diff --git a/src/app/components/CalendarEventForm.svelte b/src/app/components/CalendarEventForm.svelte index 6ccf1d48..fb1473a8 100644 --- a/src/app/components/CalendarEventForm.svelte +++ b/src/app/components/CalendarEventForm.svelte @@ -20,6 +20,7 @@ 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" @@ -28,16 +29,32 @@ h?: string header: Snippet initialValues?: { - d: string - title: string - content: string - location: string - start: number - end: number + d?: string + title?: string + content?: string + location?: string + start?: number + end?: number } } - const {url, h, header, initialValues}: Props = $props() + let {url, h, header, initialValues}: Props = $props() + + type CalendarDraft = { + editorContent?: unknown + d?: string + title?: string + content?: string + location?: string + start?: number + end?: number + } + + const draftKey = new DraftKey(`calendar:${url}:${h ?? ""}`) + const draft = draftKey.get() + if (!initialValues) { + initialValues = draft + } const shouldProtect = canEnforceNip70(url) @@ -95,17 +112,36 @@ 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}) + const onChange = (json: unknown) => { + draftKey.set({editorContent: json, title, location, start, end}) + } + const editor = makeEditor({ + url, + submit, + uploading, + onChange, + content: draft?.editorContent ?? initialValues?.content ?? "", + }) - let title = $state(initialValues?.title || "") - let location = $state(initialValues?.location || "") + 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({ + ...draftKey.get(), + title, + location, + start, + end, + }) + }) $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..2502bc82 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.set({editorContent: json, content, amount}) - 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.set({...draftKey.get(), content, amount}) + }) diff --git a/src/app/components/PollCreate.svelte b/src/app/components/PollCreate.svelte index 33d3cc3e..c3f16f97 100644 --- a/src/app/components/PollCreate.svelte +++ b/src/app/components/PollCreate.svelte @@ -22,6 +22,7 @@ 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 Props = { @@ -31,6 +32,14 @@ const {url, h}: Props = $props() + const draftKey = new DraftKey<{ + title?: string + pollType?: PollType + endsAt?: number + options?: DraftOption[] + }>(`poll:${url}:${h ?? ""}`) + const draft = draftKey.get() + const shouldProtect = canEnforceNip70(url) type DraftOption = { @@ -129,17 +138,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..98f88cea 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.set({content: json, title}) - 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.set({...draftKey.get(), 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..178ce033 --- /dev/null +++ b/src/app/util/drafts.ts @@ -0,0 +1,17 @@ +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) + } + + clear(): void { + store.delete(this.key) + } +}