190 lines
5.0 KiB
Svelte
190 lines
5.0 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte"
|
|
import { marked } from "marked"
|
|
import { TITLE_MAX_LENGTH } from "$lib/const"
|
|
import { apiClient, type FullNote } from "$lib/client"
|
|
|
|
// Props
|
|
export let note: FullNote
|
|
export let isEditing = false
|
|
export let saveNote: (title: string, content: string) => Promise<void>
|
|
|
|
// Local copy for editing (to prevent uploading every single keypress as unique version)
|
|
let editableTitle = note.title
|
|
let editableContent = note.content
|
|
|
|
// Update the local copy when the note changes
|
|
$: if (note && note.id) {
|
|
editableTitle = note.title
|
|
editableContent = note.content
|
|
}
|
|
|
|
const handleContentChange = (
|
|
event: Event & { currentTarget: EventTarget & HTMLTextAreaElement }
|
|
) => {
|
|
if (!event.target) {
|
|
return
|
|
}
|
|
|
|
const { value } = event.target as HTMLTextAreaElement
|
|
editableContent = value
|
|
|
|
// Update title based on the first line if it starts with #
|
|
const firstLine = editableContent.split("\n")[0]
|
|
if (firstLine && firstLine.startsWith("# ")) {
|
|
editableTitle = firstLine.substring(2).slice(0, TITLE_MAX_LENGTH)
|
|
} else if (!editableTitle || editableTitle === "Untitled Note") {
|
|
// Try to extract a title from the first non-empty line if there's no title yet
|
|
const firstNonEmptyLine = editableContent.split("\n").find((line) => line.trim().length > 0)
|
|
|
|
if (firstNonEmptyLine) {
|
|
editableTitle =
|
|
firstNonEmptyLine.length > TITLE_MAX_LENGTH
|
|
? firstNonEmptyLine.substring(0, TITLE_MAX_LENGTH - 3) + "..."
|
|
: firstNonEmptyLine
|
|
}
|
|
}
|
|
}
|
|
|
|
const exitEditMode = () => {
|
|
isEditing = false
|
|
}
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
// Ctrl+Enter or Command+Enter keyboard shortcut for saving the current note
|
|
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
|
event.preventDefault()
|
|
handleSave()
|
|
}
|
|
// Esc keyboard shortcut for quitting edit mode
|
|
else if (event.key === "Escape") {
|
|
event.preventDefault()
|
|
exitEditMode()
|
|
}
|
|
}
|
|
|
|
const handleSave = () => {
|
|
saveNote(editableTitle, editableContent)
|
|
}
|
|
|
|
const handleTitleChange = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
|
|
if (!event.target) {
|
|
return
|
|
}
|
|
|
|
const { value } = event.target as HTMLInputElement
|
|
editableTitle = value.slice(0, TITLE_MAX_LENGTH)
|
|
}
|
|
|
|
const parseMarkdown = async (markdown: string) => {
|
|
if (!markdown) {
|
|
return ""
|
|
}
|
|
|
|
// Enable Github flavored markdown rendering
|
|
marked.setOptions({
|
|
gfm: true,
|
|
breaks: true
|
|
})
|
|
|
|
let html = await marked(markdown)
|
|
|
|
// Add spans to regular list items
|
|
html = html.replaceAll(/\<li\>([^\<].*)\<\/li\>/g, '<li><span class="list-text">$1</span></li>')
|
|
|
|
// Add spans to nested ordered/unordered lists
|
|
html = html.replaceAll(
|
|
/<li>([^<]+)(?=.*?<(?:ul|ol)[^>]*>)/g,
|
|
'<li><span class="list-text">$1</span></li>'
|
|
)
|
|
|
|
return html
|
|
}
|
|
|
|
let textarea: HTMLTextAreaElement | null
|
|
const autoResize = () => {
|
|
if (textarea) {
|
|
textarea.style.height = "auto"
|
|
textarea.style.height = textarea.scrollHeight + "px"
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
if (isEditing && textarea) {
|
|
// Scrollbar is hidden in global CSS so flickering during resizing of the
|
|
// textarea shouldn't be an issue anymore
|
|
autoResize()
|
|
}
|
|
})
|
|
|
|
$: if (isEditing && textarea) {
|
|
setTimeout(autoResize, 0)
|
|
}
|
|
</script>
|
|
|
|
<div class="note-editor-content">
|
|
<!-- Note title -->
|
|
<div class="note-title-container">
|
|
{#if isEditing}
|
|
<input
|
|
type="text"
|
|
bind:value={editableTitle}
|
|
on:input={handleTitleChange}
|
|
on:keydown={handleKeyDown}
|
|
placeholder="Title"
|
|
class="note-title-input"
|
|
/>
|
|
<div class="note-char-count ml-3">
|
|
{editableTitle.length}/{TITLE_MAX_LENGTH} characters
|
|
</div>
|
|
<div class="note-char-count ml-3">
|
|
Last updated: {new Date(note.versionCreatedAt).toLocaleString()}
|
|
{#if !isEditing}
|
|
<!-- Minus 1 due to versioning beginning at 2 in the DB -->
|
|
• Version: {note.versionNumber - 1}
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<h1 class="border-[var(--light-text)]/20 border-b pb-2 text-2xl font-bold">
|
|
{note.title || "Untitled Note"}
|
|
</h1>
|
|
<div class="note-char-count">
|
|
Last updated: {new Date(note.versionCreatedAt).toLocaleString()}
|
|
{#if !isEditing}
|
|
<!-- Minus 1 due to versioning beginning at 2 in the DB -->
|
|
• Version: {note.versionNumber - 1}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Note content (takes up remaining vertical space) -->
|
|
<div class="note-editor-container">
|
|
{#if isEditing}
|
|
<div class="note-editor-wrapper">
|
|
<textarea
|
|
bind:this={textarea}
|
|
bind:value={editableContent}
|
|
on:input={handleContentChange}
|
|
on:keydown={handleKeyDown}
|
|
placeholder="Markdown contents"
|
|
class="note-textarea"
|
|
></textarea>
|
|
</div>
|
|
{:else}
|
|
<!-- Rendered markdown preview -->
|
|
<div class="prose markdown-preview max-w-none p-4">
|
|
{#await parseMarkdown(note.content) then html}
|
|
{@html html}
|
|
{/await}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if isEditing}
|
|
<div class="note-save-button">
|
|
<button on:click={handleSave} class="btn-primary rounded-full"> Save </button>
|
|
</div>
|
|
{/if}
|
|
</div>
|