qnote/web/src/lib/components/NoteEditor.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>