qnote/web/src/lib/components/NoteEditor.svelte

287 lines
8.0 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte"
import { Marked } from "marked"
import { markedHighlight } from "marked-highlight"
import hljs from "highlight.js"
import { cError, versionHistory } from "$lib/logic/client"
import ChevronDown from "$lib/icons/ChevronDown.svelte"
import ChevronUp from "$lib/icons/ChevronUp.svelte"
import EditPen from "$lib/icons/editor/EditPen.svelte"
import ViewEye from "$lib/icons/editor/ViewEye.svelte"
import Save from "$lib/icons/editor/Save.svelte"
import type { FullNote } from "$lib/logic/model"
import { formatTitleWithHighlight } from "$lib/util/contentVisual"
// props
export let note: FullNote
export let isEditing = false
export let isVersionDropdownOpen = false
export let saveNote: (title: string, content: string) => Promise<void>
export let selectVersion: (versionID: string, isActiveVersion: boolean) => Promise<void>
// constants
const TITLE_MAX_LENGTH = 150
const ZEROWIDTH_CHARACTERS = /^[\u200B\u200C\u200D\u200E\u200F\uFEFF]/
// local state
let editableTitle = note.title
let editableContent = note.content
let displayTitle = formatTitleWithHighlight(note.title)
// markdown parser with syntax highlight support
const marked = new Marked(
markedHighlight({
emptyLangClass: "hljs",
langPrefix: "hljs language-",
highlight(code, lang, info) {
const language = hljs.getLanguage(lang) ? lang : "plaintext"
// console.log(`syntax highlight language: ${language}`)
return hljs.highlight(code, { language }).value
}
})
)
// update the local copy when the note changes
$: if (note && note.id) {
editableTitle = note.title
editableContent = note.content
displayTitle = formatTitleWithHighlight(note.title)
}
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 isLatestVersion = (note: FullNote | null) => {
if (!note) {
return true
}
return note.isActiveVersion
}
const toggleEditMode = () => {
if (isLatestVersion(note)) {
isEditing = !isEditing
isVersionDropdownOpen = false
} else {
$cError = "Editing historical versions is prohibited."
}
}
const exitEditMode = () => {
isEditing = false
}
const toggleVersionDropdown = () => {
isVersionDropdownOpen = !isVersionDropdownOpen
}
const handleKeyDown = (event: KeyboardEvent) => {
// ctrl+enter or cmd+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 = () => {
// only update the display title once the user saves the edits
displayTitle = formatTitleWithHighlight(editableTitle)
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 ""
}
// github flavored markdown rendering
marked.setOptions({
breaks: false,
gfm: true,
pedantic: false
})
// remove the most common zerowidth characters from the start of the file
marked.parse(markdown.replace(ZEROWIDTH_CHARACTERS, ""))
return await marked.parse(markdown)
}
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()
}
// close the versioning dropdown menu if it has been left open in the previously viewed notes
isVersionDropdownOpen = false
})
$: 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-2">
{editableTitle.length}/{TITLE_MAX_LENGTH} characters
</div>
{:else}
<h1 class="note-title-display">
{@html displayTitle}
</h1>
{/if}
<!-- TODO: add pagination support for versions dropdown -->
<!-- Note action buttons -->
<div class="note-action-container">
<!-- Editor mode toggle button -->
{#if isLatestVersion(note)}
<button on:click={toggleEditMode} class="note-action-button w-28">
{#if isEditing}
<EditPen classString="mr-3 h-4 w-4" />
{:else}
<ViewEye classString="mr-3 h-4 w-4" />
{/if}
{isEditing ? "Editing" : "Viewing"}
</button>
{:else}
<button disabled class="note-action-button w-28 cursor-not-allowed opacity-40"
>Viewing</button
>
{/if}
<!-- Versioning dropdown -->
<div class="relative flex flex-col">
<button on:click={toggleVersionDropdown} class="note-action-button min-w-32">
Version {note.versionNumber - 1}
{#if isVersionDropdownOpen}
<ChevronUp classString="ml-1.5 h-4 w-4" />
{:else}
<ChevronDown classString="ml-1.5 h-4 w-4" />
{/if}
</button>
{#if isVersionDropdownOpen && $versionHistory && $versionHistory.length > 0}
<div class="note-versions-dropdown">
{#each $versionHistory as version, index}
<button
on:click={() => selectVersion(version.versionID, version.isActive)}
class="sidebar-action-button w-full flex-col rounded-none {index === 0
? 'rounded-t-lg'
: ''} {index === $versionHistory.length - 1 ? 'rounded-b-lg' : ''}
{version.isActive ? 'versions-dropdown-active-version' : ''}"
>
<p class="versions-dropdown-item-text">{version.title}</p>
<span class="versions-dropdown-item-meta"
>{version.createdAt.toLocaleString()}, v.{version.versionNumber}</span
>
</button>
{/each}
</div>
{/if}
</div>
{#if isEditing}
<button
on:click={() => saveNote(editableTitle, editableContent)}
class="note-action-icon-button"
>
<Save classString="h-5 w-5" />
</button>
{/if}
</div>
</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>
<button
on:click={() => saveNote(editableTitle, editableContent)}
class="note-mobile-save-button"
>
<Save classString="h-6 w-6" />
</button>
</div>
{:else}
<!-- Rendered markdown preview -->
<div class="prose markdown-preview max-w-none">
{#await parseMarkdown(note.content) then html}
{@html html}
{/await}
</div>
{/if}
</div>
</div>