287 lines
8.0 KiB
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>
|