diff --git a/src/app/components/CalendarEventForm.svelte b/src/app/components/CalendarEventForm.svelte index 6ccf1d48..fadc1a70 100644 --- a/src/app/components/CalendarEventForm.svelte +++ b/src/app/components/CalendarEventForm.svelte @@ -20,24 +20,33 @@ import EditorContent from "@app/editor/EditorContent.svelte" import {PROTECTED} from "@app/core/state" import {makeEditor} from "@app/editor" + import {DraftKey} from "@app/util/drafts" import {pushToast} from "@app/util/toast" import {canEnforceNip70} from "@app/core/commands" + type CalendarValues = { + d?: string + title: string + content: unknown + location: string + start: number | undefined + end: number | undefined + } + type Props = { url: string h?: string header: Snippet - initialValues?: { - d: string - title: string - content: string - location: string - start: number - end: number - } + initialValues?: CalendarValues } - const {url, h, header, initialValues}: Props = $props() + let {url, h, header, initialValues}: Props = $props() + + const draftKey = new DraftKey(`calendar:${url}:${h ?? ""}`) + const draft = draftKey.get() + if (!initialValues) { + initialValues = draft + } const shouldProtect = canEnforceNip70(url) @@ -95,17 +104,33 @@ pushToast({message: "Your event has been saved!"}) publishThunk({event, relays: [url]}) + draftKey.clear() history.back() } - const content = initialValues?.content || "" - const editor = makeEditor({url, submit, uploading, content}) + let editorContent = $state(initialValues?.content ?? "") - let title = $state(initialValues?.title || "") - let location = $state(initialValues?.location || "") + const onChange = (json: unknown) => { + editorContent = json + } + + const editor = makeEditor({ + url, + submit, + uploading, + onChange, + content: initialValues?.content ?? "", + }) + + let title = $state(initialValues?.title ?? "") + let location = $state(initialValues?.location ?? "") let start: number | undefined = $state(initialValues?.start) let end: number | undefined = $state(initialValues?.end) - let endDirty = Boolean(initialValues?.end) + let endDirty = $state(Boolean(initialValues?.end)) + + $effect(() => { + draftKey.set({d: initialValues?.d, title, location, start, end, content: editorContent}) + }) $effect(() => { if (!endDirty && start) { diff --git a/src/app/components/Chat.svelte b/src/app/components/Chat.svelte index 6cc312b2..47a8dd73 100644 --- a/src/app/components/Chat.svelte +++ b/src/app/components/Chat.svelte @@ -55,6 +55,7 @@ import ThunkToast from "@app/components/ThunkToast.svelte" import {userSettingsValues, deriveChat} from "@app/core/state" import {pushModal} from "@app/util/modal" + import {DraftKey} from "@app/util/drafts" import {makeDelete, prependParent} from "@app/core/commands" import {pushToast} from "@app/util/toast" @@ -66,6 +67,7 @@ const {pubkeys, info}: Props = $props() const chat = deriveChat(pubkeys) + const draftKey = new DraftKey<{content?: unknown}>(`dm:${$chat?.id}`) const others = remove($pubkey!, pubkeys) const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk))) @@ -337,6 +339,7 @@ {onEscape} {onEditPrevious} content={eventToEdit?.content} + draftKey={eventToEdit ? undefined : draftKey} disabled={Boolean(missingRelayLists.length)} /> {/key} diff --git a/src/app/components/ChatCompose.svelte b/src/app/components/ChatCompose.svelte index 68b62447..b941c1a3 100644 --- a/src/app/components/ChatCompose.svelte +++ b/src/app/components/ChatCompose.svelte @@ -10,16 +10,20 @@ import Button from "@lib/components/Button.svelte" import EditorContent from "@app/editor/EditorContent.svelte" import {makeEditor} from "@app/editor" + import {type DraftKey} from "@app/util/drafts" type Props = { content?: string disabled?: boolean + draftKey?: DraftKey<{content?: unknown}> onEscape?: () => void onEditPrevious?: () => void onSubmit: (event: EventContent) => void } - const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props() + const {content, disabled = false, draftKey, onEscape, onEditPrevious, onSubmit}: Props = $props() + + const draft = draftKey?.get() const autofocus = !isMobile && !disabled @@ -59,14 +63,20 @@ onSubmit({content, tags}) + draftKey?.clear() ed.chain().clearContent().run() } + const onChange = (json: unknown) => { + draftKey?.set({content: json}) + } + const editor = makeEditor({ - content, + content: content ?? (draft?.content as string | object | undefined), autofocus, submit, uploading, + onChange, aggressive: true, encryptFiles: true, }) diff --git a/src/app/components/ClassifiedForm.svelte b/src/app/components/ClassifiedForm.svelte index 4e9fcbf6..51911895 100644 --- a/src/app/components/ClassifiedForm.svelte +++ b/src/app/components/ClassifiedForm.svelte @@ -20,6 +20,7 @@ import {pushToast} from "@app/util/toast" import {PROTECTED} from "@app/core/state" import {makeEditor} from "@app/editor" + import {DraftKey} from "@app/util/drafts" import {canEnforceNip70, uploadFile} from "@app/core/commands" type Props = { @@ -32,13 +33,31 @@ content?: string price?: number currency?: string - images?: string[] + images?: (string | File)[] status?: string topics?: string[] } } - const {url, h, header, initialValues}: Props = $props() + let {url, h, header, initialValues}: Props = $props() + + type ClassifiedDraft = { + editorContent?: unknown + d?: string + title?: string + content?: string + price?: number + currency?: string + images?: (string | File)[] + status?: string + topics?: string[] + } + + const draftKey = new DraftKey(`classified:${url}:${h ?? ""}`) + const draft = draftKey.get() + if (!initialValues) { + initialValues = draft + } const shouldProtect = canEnforceNip70(url) @@ -110,22 +129,35 @@ event: makeEvent(CLASSIFIED, {content, tags}), }) + draftKey.clear() history.back() } finally { loading = false } } - const content = initialValues?.content || "" - const editor = makeEditor({url, submit, content}) + const initialContent = initialValues?.content || "" + const onChange = (json: unknown) => { + draftKey.set({editorContent: json, title, price, currency, topics, status, images}) + } + const editor = makeEditor({ + url, + submit, + onChange, + content: draft?.editorContent ?? initialContent, + }) let loading = $state(false) - let title = $state(initialValues?.title || "") - let status = $state(initialValues?.status || "active") - let price = $state(Number(initialValues?.price || 0)) - let currency = $state(initialValues?.currency || "SAT") - let images = $state<(string | File)[]>(initialValues?.images || []) - let topics = $state(uniq(removeUndefined((initialValues?.topics || []).map(normalizeTopic)))) + let title = $state(initialValues?.title ?? "") + let status = $state(initialValues?.status ?? "active") + let price = $state(Number(initialValues?.price ?? 0)) + let currency = $state(initialValues?.currency ?? "SAT") + let images = $state<(string | File)[]>(initialValues?.images ?? []) + let topics = $state(uniq(removeUndefined((initialValues?.topics ?? []).map(normalizeTopic)))) + + $effect(() => { + draftKey.set({...draftKey.get(), title, price, currency, topics, status, images}) + }) diff --git a/src/app/components/EventReply.svelte b/src/app/components/EventReply.svelte index 31b412a3..8924d12d 100644 --- a/src/app/components/EventReply.svelte +++ b/src/app/components/EventReply.svelte @@ -10,10 +10,14 @@ import {publishComment, canEnforceNip70} from "@app/core/commands" import {PROTECTED} from "@app/core/state" import {makeEditor} from "@app/editor" + import {DraftKey} from "@app/util/drafts" import {pushToast} from "@app/util/toast" const {url, event, onClose, onSubmit} = $props() + const draftKey = new DraftKey<{content?: unknown}>(`reply:${event.id}`) + const draft = draftKey.get() + const shouldProtect = canEnforceNip70(url) const uploading = writable(false) @@ -38,10 +42,20 @@ }) } + draftKey.clear() onSubmit(publishComment({event, content, tags, relays: [url]})) } - const editor = makeEditor({url, submit, uploading, autofocus: !isMobile}) + const onChange = (json: unknown) => draftKey.set({content: json}) + + const editor = makeEditor({ + url, + submit, + uploading, + autofocus: !isMobile, + content: draft?.content as string | object | undefined, + onChange, + }) let form: HTMLElement let spacer: HTMLElement diff --git a/src/app/components/GoalCreate.svelte b/src/app/components/GoalCreate.svelte index 1db8ded5..7a5ce978 100644 --- a/src/app/components/GoalCreate.svelte +++ b/src/app/components/GoalCreate.svelte @@ -20,14 +20,31 @@ import {pushToast} from "@app/util/toast" import {PROTECTED} from "@app/core/state" import {makeEditor} from "@app/editor" + import {DraftKey} from "@app/util/drafts" import {canEnforceNip70} from "@app/core/commands" type Props = { url: string h?: string + initialValues?: { + content?: string + amount?: number + } } - const {url, h}: Props = $props() + let {url, h, initialValues}: Props = $props() + + type GoalDraft = { + editorContent?: unknown + content?: string + amount?: number + } + + const draftKey = new DraftKey(`goal:${url}:${h ?? ""}`) + const draft = draftKey.get() + if (!initialValues) { + initialValues = draft + } const shouldProtect = canEnforceNip70(url) @@ -77,13 +94,27 @@ event: makeEvent(ZAP_GOAL, {content, tags}), }) + draftKey.clear() history.back() } - const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"}) + const onChange = (json: unknown) => draftKey.update({editorContent: json}) - let content = $state("") - let amount = $state(1000) + const editor = makeEditor({ + url, + submit, + uploading, + onChange, + placeholder: "What's on your mind?", + content: draft?.editorContent as string | object | undefined, + }) + + let content = $state(initialValues?.content ?? "") + let amount = $state(initialValues?.amount ?? 1000) + + $effect(() => { + draftKey.update({content, amount}) + }) diff --git a/src/app/components/PollCreate.svelte b/src/app/components/PollCreate.svelte index 33d3cc3e..f35f2e07 100644 --- a/src/app/components/PollCreate.svelte +++ b/src/app/components/PollCreate.svelte @@ -22,8 +22,21 @@ import {pushToast} from "@app/util/toast" import {PROTECTED} from "@app/core/state" import {canEnforceNip70} from "@app/core/commands" + import {DraftKey} from "@app/util/drafts" import type {PollType} from "@app/util/polls" + type DraftOption = { + id: string + value: string + } + + type PollDraft = { + title?: string + pollType?: PollType + endsAt?: number + options?: DraftOption[] + } + type Props = { url: string h?: string @@ -31,12 +44,10 @@ const {url, h}: Props = $props() - const shouldProtect = canEnforceNip70(url) + const draftKey = new DraftKey(`poll:${url}:${h ?? ""}`) + const draft = draftKey.get() - type DraftOption = { - id: string - value: string - } + const shouldProtect = canEnforceNip70(url) const back = () => history.back() @@ -129,17 +140,24 @@ event: makeEvent(Poll, {content: title.trim(), tags}), }) + draftKey.clear() history.back() } - let title = $state("") - let pollType = $state("singlechoice") - let endsAt = $state() - let options = $state([ - {id: randomId(), value: "Yes"}, - {id: randomId(), value: "No"}, - ]) + let title = $state(draft?.title ?? "") + let pollType = $state(draft?.pollType ?? "singlechoice") + let endsAt = $state(draft?.endsAt) + let options = $state( + draft?.options ?? [ + {id: randomId(), value: "Yes"}, + {id: randomId(), value: "No"}, + ], + ) let draggedOptionId = $state() + + $effect(() => { + draftKey.set({title, pollType, endsAt, options}) + }) diff --git a/src/app/components/RoomCompose.svelte b/src/app/components/RoomCompose.svelte index 28bb18ab..e5f05f1f 100644 --- a/src/app/components/RoomCompose.svelte +++ b/src/app/components/RoomCompose.svelte @@ -12,6 +12,7 @@ import ComposeMenu from "@app/components/ComposeMenu.svelte" import EditorContent from "@app/editor/EditorContent.svelte" import {makeEditor} from "@app/editor" + import {DraftKey} from "@app/util/drafts" import {onDestroy, onMount} from "svelte" type Props = { @@ -25,6 +26,11 @@ const {url, h, content, onEscape, onEditPrevious, onSubmit}: Props = $props() + type ComposeDraft = {content?: unknown} + + const draftKey = url || h ? new DraftKey(`room:${url ?? ""}:${h ?? ""}`) : undefined + const draft = draftKey?.get() + const autofocus = !isMobile const uploading = writable(false) @@ -61,10 +67,23 @@ onSubmit({content, tags}) + draftKey?.clear() ed.chain().clearContent().run() } - const editor = makeEditor({url, content, autofocus, submit, uploading, aggressive: true}) + const onChange = (json: unknown) => { + draftKey?.set({content: json}) + } + + const editor = makeEditor({ + url, + content: content ?? (draft?.content as string | object | undefined), + autofocus, + submit, + uploading, + onChange, + aggressive: true, + }) let popover: Instance | undefined = $state() diff --git a/src/app/components/ThreadCreate.svelte b/src/app/components/ThreadCreate.svelte index 5f947e0d..97e74fa7 100644 --- a/src/app/components/ThreadCreate.svelte +++ b/src/app/components/ThreadCreate.svelte @@ -18,6 +18,7 @@ import {pushToast} from "@app/util/toast" import {PROTECTED} from "@app/core/state" import {makeEditor} from "@app/editor" + import {DraftKey} from "@app/util/drafts" import {canEnforceNip70} from "@app/core/commands" type Props = { @@ -27,6 +28,9 @@ const {url, h}: Props = $props() + const draftKey = new DraftKey<{content?: unknown; title?: string}>(`thread:${url}:${h ?? ""}`) + const draft = draftKey.get() + const shouldProtect = canEnforceNip70(url) const uploading = writable(false) @@ -70,12 +74,26 @@ event: makeEvent(THREAD, {content, tags}), }) + draftKey.clear() history.back() } - const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"}) + const onChange = (json: unknown) => draftKey.update({content: json}) - let title: string = $state("") + const editor = makeEditor({ + url, + submit, + uploading, + onChange, + placeholder: "What's on your mind?", + content: draft?.content as string | object | undefined, + }) + + let title: string = $state(draft?.title ?? "") + + $effect(() => { + draftKey.update({title}) + }) diff --git a/src/app/editor/EditorContent.svelte b/src/app/editor/EditorContent.svelte index 6b56f9c6..cdf764fe 100644 --- a/src/app/editor/EditorContent.svelte +++ b/src/app/editor/EditorContent.svelte @@ -11,13 +11,17 @@ let element: HTMLElement onMount(() => { - editor.then(({options}) => { - if (options.element) { - element?.append(options.element) + editor.then(ed => { + if (ed.options.element) { + element?.append(ed.options.element) } - if (options.autofocus) { - ;(element?.querySelector("[contenteditable]") as HTMLElement)?.focus() + if ((ed as any)._shouldAutofocus) { + const hasContent = ed.getText().trim().length > 0 + + requestAnimationFrame(() => { + ed.commands.focus(hasContent ? "end" : "start") + }) } }) }) diff --git a/src/app/editor/index.ts b/src/app/editor/index.ts index 8ea78606..ba9b0589 100644 --- a/src/app/editor/index.ts +++ b/src/app/editor/index.ts @@ -28,6 +28,7 @@ export const makeEditor = async ({ autofocus = false, charCount, content = "", + onChange, placeholder = "", url, submit, @@ -38,7 +39,8 @@ export const makeEditor = async ({ aggressive?: boolean autofocus?: boolean charCount?: Writable - content?: string + content?: string | object + onChange?: (json: unknown) => void placeholder?: string url?: string submit: () => void @@ -82,9 +84,9 @@ export const makeEditor = async ({ }, ) - return new Editor({ - content: escapeHtml(content), - autofocus, + const ed = new Editor({ + content: typeof content === "string" ? escapeHtml(content) : content, + autofocus: false, editorProps, element: document.createElement("div"), extensions: [ @@ -142,6 +144,11 @@ export const makeEditor = async ({ onUpdate({editor}) { wordCount?.set(editor.storage.wordCount.words) charCount?.set(editor.storage.wordCount.chars) + onChange?.(editor.getJSON()) }, }) + + ;(ed as any)._shouldAutofocus = autofocus + + return ed } diff --git a/src/app/util/drafts.ts b/src/app/util/drafts.ts new file mode 100644 index 00000000..4a1f591c --- /dev/null +++ b/src/app/util/drafts.ts @@ -0,0 +1,21 @@ +const store = new Map() + +export class DraftKey { + constructor(private key: string) {} + + get(): T | undefined { + return store.get(this.key) as T | undefined + } + + set(value: T): void { + store.set(this.key, value) + } + + update(value: Partial): void { + this.set({...this.get(), ...value} as T) + } + + clear(): void { + store.delete(this.key) + } +}