diff --git a/web/src/styles/study-source.css b/web/src/styles/study-source.css new file mode 100644 index 0000000..d4f7278 --- /dev/null +++ b/web/src/styles/study-source.css @@ -0,0 +1,217 @@ +.study-selection-toolbar { + z-index: 9; + background: var(--main); + border: 1px solid var(--line2); + border-radius: 10px; + padding: 4px; + box-shadow: 0 8px 24px #0002; +} +.study-source-annotation { + border-bottom: 1.5px dotted var(--t3); +} +.study-source-highlight { + background: var(--sel); + color: var(--t1); + border-radius: 3px; + padding: 0 2px; + box-shadow: inset 0 -2px 0 var(--t3); +} +.source-card-popover { + z-index: 40; + background: var(--main); + border-radius: 16px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + box-shadow: + 0 16px 48px #0003, + 0 0 0 1px var(--line2); + font-size: 12.5px; + overflow: auto; + animation: source-pop-in 200ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +.source-card-popover h2 { + font-size: 14px; + font-weight: 500; + margin: 0; +} +.source-card-popover > label { + display: flex; + flex-direction: column; + gap: 5px; +} +.source-card-popover textarea { + min-height: 68px; + resize: vertical; +} +.source-card-types { + display: flex; + gap: 3px; + flex-wrap: wrap; +} +.source-card-types button { + flex: 1; + font-size: 12px; + padding: 7px; +} +.source-card-types button[aria-pressed="true"] { + background: var(--sel); + box-shadow: inset 0 0 0 1px var(--line2); +} +.source-card-preview { + background: var(--raise); + border-radius: 12px; + padding: 12px 14px; + overflow-wrap: anywhere; + max-height: 200px; + overflow: auto; +} +.source-card-preview p { + white-space: pre-wrap; + line-height: 1.6; +} +.source-card-preview small { + color: var(--t3); +} +.source-card-notice { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 20; + background: var(--main); + color: var(--t1); + border-radius: 12px; + box-shadow: + 0 10px 30px #0003, + 0 0 0 1px var(--line2); + padding: 10px 12px 10px 14px; + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; + font-size: 12.5px; + max-width: calc(100vw - 32px); + animation: source-toast-in 200ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +@keyframes source-pop-in { + from { + opacity: 0; + transform: translateY(8px) scale(0.98); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes source-toast-in { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes source-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@media (max-width: 499px) { + .source-card-popover { + border-radius: 16px 16px 0 0; + } + .source-card-notice { + left: 16px; + right: 16px; + bottom: 16px; + gap: 8px; + } +} +:root[data-motion="reduced"] .source-card-popover, +:root[data-motion="reduced"] .source-card-notice { + animation: source-fade 150ms linear !important; + transform: none !important; +} +@media (prefers-reduced-motion: reduce) { + :root:not([data-motion="on"]) .source-card-popover, + :root:not([data-motion="on"]) .source-card-notice { + animation: source-fade 150ms linear !important; + transform: none !important; + } +} + +.study-source-annotation { + animation: source-mark-in 150ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +.source-card-popover.source-closing { + animation: source-pop-out 140ms cubic-bezier(0.2, 0.8, 0.2, 1); + pointer-events: none; +} +.source-card-notice.source-closing { + animation: source-toast-out 140ms cubic-bezier(0.2, 0.8, 0.2, 1); + pointer-events: none; +} +@keyframes source-mark-in { + from { + border-bottom-color: transparent; + } + to { + border-bottom-color: var(--t3); + } +} +@keyframes source-pop-out { + from { + opacity: 1; + transform: none; + } + to { + opacity: 0; + transform: translateY(8px) scale(0.98); + } +} +@keyframes source-toast-out { + from { + opacity: 1; + transform: none; + } + to { + opacity: 0; + transform: translateY(12px); + } +} +@keyframes source-fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} +:root[data-motion="reduced"] .study-source-annotation { + animation: none !important; +} +:root[data-motion="reduced"] .source-card-popover.source-closing, +:root[data-motion="reduced"] .source-card-notice.source-closing { + animation: source-fade-out 150ms linear !important; + transform: none !important; +} +@media (prefers-reduced-motion: reduce) { + :root:not([data-motion="on"]) .study-source-annotation { + animation: none !important; + } + :root:not([data-motion="on"]) .source-card-popover.source-closing, + :root:not([data-motion="on"]) .source-card-notice.source-closing { + animation: source-fade-out 150ms linear !important; + transform: none !important; + } +} + +.source-card-popover.source-card-sheet { + border-radius: 16px 16px 0 0; +} diff --git a/web/src/ui/NoteEditor.tsx b/web/src/ui/NoteEditor.tsx index 4b71faf..e7569dd 100644 --- a/web/src/ui/NoteEditor.tsx +++ b/web/src/ui/NoteEditor.tsx @@ -1,13 +1,38 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { registerCloseGuard } from "../desktop/closeGuard"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { createPortal, flushSync } from "react-dom"; +import { registerCloseGuard, trackOperation } from "../desktop/closeGuard"; import { useDesktop } from "../desktop/DesktopShell"; +import type { Tag } from "../library/contracts"; import type { Note, NotesStore, NoteDocument } from "../domain/contracts"; +import type { EditorChangeTrace, EditorSourceCapture } from "./RichEditor"; +import type { NotesLibrary } from "../library/contracts"; +import type { + SourceAwareNoteUpdate, + SavedNoteSelection, +} from "../study/sourceContracts"; +import { + canonicalSourceDocument, + fingerprintSourceDocument, + verifySourceEdit, + SourceCodecError, +} from "../study/sourceCodec"; +import { SourceCardComposer, SourceCardNotice } from "./SourceCardComposer"; +import type { SourceCardOwners } from "./SourceCardComposer"; +import type { + MakeCardResult, + SourceAnnotation, +} from "../study/sourceContracts"; +import type { EditorSourceTarget } from "./RichEditor"; import { RichEditor } from "./RichEditor"; import { message, plainText } from "./model"; import { Dialog } from "./primitives"; import { Menu } from "./Menu"; import { Copy, Download, Pencil, Trash2 } from "lucide-react"; +import type { EditorNoteLinks } from "./noteLinkViews"; +import { NoteLinksPanel } from "./NoteLinksPanel"; +import { encodeNoteReference } from "../links/reference"; +import type { NoteLinkCopyFormatter } from "../links/navigation"; +import { NoteLinkError } from "../links/contracts"; export function NoteEditor({ note, store, @@ -15,7 +40,33 @@ export function NoteEditor({ onBack, onTrash, onExport, + readOnly = false, + pinned = false, + onPin, + onHistory, + tagCatalog, + sourceWriter, + sourceLibrary, + source, + noteLinks, + formatNoteLink, + active = true, }: { + active?: boolean; + formatNoteLink?: NoteLinkCopyFormatter; + noteLinks?: EditorNoteLinks; + source?: SourceCardOwners & { + target?: EditorSourceTarget | null; + onTargetUnavailable?: () => void; + onOpenCard: (entryId: string) => void; + }; + sourceWriter?: SourceAwareNoteUpdate; + sourceLibrary?: NotesLibrary; + tagCatalog?: Tag[]; + readOnly?: boolean; + pinned?: boolean; + onPin?: () => void; + onHistory?: () => void; note: Note; store: NotesStore; onSaved: (n: Note) => void; @@ -23,12 +74,146 @@ export function NoteEditor({ onTrash: (n: Note) => void; onExport: (n: Note) => void; }) { + const titleInput = useRef(null); + const [linksOpen, setLinksOpen] = useState(false); + const linksCanClose = useRef<() => boolean>(() => true); + const linksRequestClose = useRef(() => {}); + const linksReturnFocus = useRef(null); + const [linkCopyNotice, setLinkCopyNotice] = useState(""); + const linkCopySequence = useRef(0); + useEffect(() => { + const stop = noteLinks?.service.onInvalidate(() => { + linkCopySequence.current++; + setLinkCopyNotice(""); + }); + return () => { + linkCopySequence.current++; + stop?.(); + }; + }, [note.id, noteLinks?.service]); + async function copyNoteReference() { + if (!noteLinks) return; + const ticket = ++linkCopySequence.current; + try { + const ref = await noteLinks.service.reference(note.id); + const formatted = formatNoteLink?.(ref.target) ?? { + text: encodeNoteReference(ref.target), + kind: "local-reference" as const, + }; + if (ticket !== linkCopySequence.current) return; + await navigator.clipboard.writeText(formatted.text); + if (ticket === linkCopySequence.current) + setLinkCopyNotice( + formatted.kind === "local-reference" + ? "Local reference copied" + : "Link copied", + ); + } catch (failure) { + if (ticket === linkCopySequence.current) + setLinkCopyNotice( + failure instanceof NoteLinkError && + [ + "note_link_reference_too_large", + "note_link_reference_unencodable", + ].includes(failure.code) + ? "This note can’t be copied as a link. You can still open it here." + : "Couldn’t copy the link. Try again.", + ); + } + } + const [sourceCapture, setSourceCapture] = + useState(null), + [sourceStale, setSourceStale] = useState(false), + [sourceReselect, setSourceReselect] = useState(false), + [annotations, setAnnotations] = useState([]), + [sourceNotice, setSourceNotice] = useState(null), + [sourceError, setSourceError] = useState(""); + const sourceIntent = useRef(false); + const pendingSourceRestore = useRef<{ + capture: EditorSourceCapture; + noteId: string; + service: SourceCardOwners["service"]; + } | null>(null); + // RichEditor applies editability in its child layout effect. Restore only + // after the composer is removed in that same committed tree. + useLayoutEffect(() => { + const pending = pendingSourceRestore.current; + if (!pending) return; + pendingSourceRestore.current = null; + if ( + !sourceCapture && + active && + !readOnly && + pending.noteId === note.id && + pending.service === source?.service && + pending.capture.isCurrent() + ) + pending.capture.restore(); + }); + const sourceTargets = useMemo( + () => annotations.map((item) => ({ key: item.entryId, ...item })), + [annotations], + ); + useEffect(() => { + if (!source) { + setAnnotations([]); + return; + } + let active = true, + sequence = 0; + const read = () => { + const request = ++sequence; + void source.service + .annotations(note.id) + .then((value) => { + if (active && request === sequence) { + setAnnotations(value); + setSourceError(""); + } + }) + .catch(() => { + if (active && request === sequence) + setSourceError( + "Couldn’t load linked passages. Your note was not changed.", + ); + }); + }; + const stopInvalidation = source.service.onInvalidate(() => { + sequence++; + sourceIntent.current = false; + pendingSourceRestore.current = null; + if (active) + flushSync(() => { + setSourceCapture(null); + setSourceNotice(null); + setAnnotations([]); + setSourceError(""); + }); + }); + const stop = source.service.subscribe(read); + read(); + return () => { + active = false; + sequence++; + stopInvalidation(); + stop(); + }; + }, [source?.service, note.id]); const [draft, setDraft] = useState({ title: note.title, document: note.document, tags: note.tags, }); - const [tagInput, setTagInput] = useState(note.tags.join(", ")); + const [tagInput, setTagInput] = useState(""); + const tagDraftRef = useRef(""); + const stepBatches = useRef< + { id: number; before: NoteDocument; steps: readonly unknown[] }[] + >([]), + stepNumber = useRef(0), + traceGap = useRef(false); + const activeSave = useRef | null>(null); + const [replacementOpen, setReplacementOpen] = useState(false), + [sourceLimit, setSourceLimit] = useState(false); const base = useRef(note); const latest = useRef(draft); const generation = useRef(0); @@ -38,6 +223,7 @@ export function NoteEditor({ const copyInFlight = useRef(false); const desktop = useDesktop(); const [error, setError] = useState(""); + const [copyError, setCopyError] = useState(""); const [conflict, setConflict] = useState(null); const [busy, setBusy] = useState(false); const [retry, setRetry] = useState(0); @@ -46,19 +232,35 @@ export function NoteEditor({ const [copyBusy, setCopyBusy] = useState(false); const duplicating = useRef(false); const [duplicateBusy, setDuplicateBusy] = useState(false); - useLayoutEffect(() => registerCloseGuard(() => { - if (dirtyRef.current || saving.current || duplicating.current || copyInFlight.current) { - setNavigationNotice("Your latest changes are only in this window. Save or copy your text before leaving."); - return false; - } - return true; - }), []); + useLayoutEffect( + () => + registerCloseGuard(() => { + if ( + sourceIntent.current || + tagDraftRef.current.trim() || + dirtyRef.current || + saving.current || + duplicating.current || + copyInFlight.current + ) { + setNavigationNotice( + tagDraftRef.current.trim() + ? "Add or clear the new tag before leaving." + : "Your latest changes are only in this window. Save or copy your text before leaving.", + ); + return false; + } + return true; + }), + [], + ); useEffect(() => { if (note.revision <= base.current.revision) return; if (dirty) { setConflict(note); setError("A newer saved version is available. Your draft has been kept."); } else { + if (sourceIntent.current) setSourceStale(true); base.current = note; const next = { title: note.title, @@ -66,11 +268,31 @@ export function NoteEditor({ tags: note.tags, }; latest.current = next; + stepBatches.current = []; + traceGap.current = false; setDraft(next); - setTagInput(note.tags.join(", ")); } }, [note.revision, dirty]); - function change(patch: Partial) { + function addTag() { + if (readOnly) return; + const name = tagDraftRef.current.trim(); + if (!name) return; + tagDraftRef.current = ""; + setTagInput(""); + if (!latest.current.tags.includes(name)) + change({ tags: [...latest.current.tags, name] }); + } + function change(patch: Partial, trace?: EditorChangeTrace) { + if (readOnly) return; + if (patch.document) { + if (trace) + stepBatches.current.push({ + id: ++stepNumber.current, + before: structuredClone(trace.before), + steps: structuredClone(trace.steps), + }); + else traceGap.current = true; + } const next = { ...latest.current, ...patch }; latest.current = next; generation.current++; @@ -80,24 +302,72 @@ export function NoteEditor({ if (!conflict) setError(""); } useEffect(() => { - if (!dirty || saving.current || conflict || error) return; + if (readOnly || !dirty || saving.current || conflict || error) return; const timer = setTimeout(() => { void save(); }, 450); return () => clearTimeout(timer); }, [draft, dirty, retry, conflict, error]); - async function save() { + function save(replacement = false): Promise { + if (activeSave.current) return activeSave.current; + const task = performSave(replacement); + activeSave.current = task; + void task.finally(() => { + if (activeSave.current === task) activeSave.current = null; + }); + return task; + } + async function performSave(replacement = false) { + if (readOnly) return; if (saving.current) return; saving.current = true; const version = generation.current; - const snapshot = latest.current; + const snapshot = structuredClone(latest.current), + savedBase = structuredClone(base.current); + const batches = [...stepBatches.current], + lastBatch = batches.at(-1)?.id ?? 0; const delayed = setTimeout(() => setBusy(true), 1000); try { - const updated = await store.update( - note.id, - snapshot, - base.current.revision, + const updated = await trackOperation(async () => { + if (sourceWriter && !replacement) { + if (traceGap.current) + throw new SourceCodecError("source_codec_invalid"); + if ( + batches.length && + canonicalSourceDocument(batches[0]!.before).canonicalJSON !== + canonicalSourceDocument(savedBase.document).canonicalJSON + ) + throw new SourceCodecError("source_codec_invalid"); + const steps = batches.flatMap((batch) => [...batch.steps]); + verifySourceEdit(savedBase.document, snapshot.document, steps); + const [before, after] = await Promise.all([ + fingerprintSourceDocument(savedBase.document), + fingerprintSourceDocument(snapshot.document), + ]); + return sourceWriter(note.id, snapshot, savedBase.revision, { + baseDigest: before.digest, + finalDigest: after.digest, + steps, + }); + } + if (replacement) { + if (!sourceLibrary) + throw new Error( + "A verified replacement writer is unavailable. Copy your text before trying again.", + ); + return sourceLibrary.replaceDocument( + note.id, + snapshot, + savedBase.revision, + ); + } + return store.update(note.id, snapshot, savedBase.revision); + }); + stepBatches.current = stepBatches.current.filter( + (batch) => batch.id > lastBatch, ); + if (replacement) traceGap.current = false; + setSourceLimit(false); base.current = updated; onSaved(updated); if (version === generation.current) { @@ -108,6 +378,12 @@ export function NoteEditor({ } catch (e) { if (typeof e === "object" && e && "current" in e) setConflict(e.current as Note); + setSourceLimit( + !!e && + typeof e === "object" && + "code" in e && + (e.code === "source_codec_limit" || e.code === "source_limit"), + ); setError(message(e)); } finally { clearTimeout(delayed); @@ -115,18 +391,78 @@ export function NoteEditor({ saving.current = false; } } + async function flushSavedSelection( + capture: EditorSourceCapture, + ): Promise { + if (!sourceWriter || !sourceLibrary || !capture.isCurrent()) + throw new Error( + "This passage changed. Select it again before adding the card.", + ); + if (tagDraftRef.current.trim()) + throw new Error("Add or clear the new tag before making a card."); + if (activeSave.current) await activeSave.current; + if (!capture.isCurrent()) + throw new Error( + "This passage changed. Select it again before adding the card.", + ); + if (dirtyRef.current) await save(); + if (dirtyRef.current || saving.current) + throw new Error( + "Couldn’t save the selected note. Your card draft has been kept.", + ); + const admitted = structuredClone(base.current), + version = generation.current; + const [fingerprint, library] = await Promise.all([ + fingerprintSourceDocument(admitted.document), + sourceLibrary.read(), + ]); + if ( + !capture.isCurrent() || + version !== generation.current || + base.current.revision !== admitted.revision || + canonicalSourceDocument(capture.document).canonicalJSON !== + fingerprint.canonicalJSON + ) + throw new Error( + "This passage changed. Select it again before adding the card.", + ); + if ( + library.mode !== "ready" || + library.snapshot.notes.find((value) => value.id === admitted.id) + ?.revision !== admitted.revision + ) + throw new Error( + "The saved note changed. Reload it before making a card.", + ); + return { + noteId: admitted.id, + noteRevision: admitted.revision, + libraryEpoch: library.snapshot.token.epoch, + fingerprint, + passage: capture.selection.passage, + }; + } useEffect(() => { - if (!dirty) return; + if (!dirty && !tagInput.trim() && !sourceCapture) return; const guard = (event: BeforeUnloadEvent) => { event.preventDefault(); }; window.addEventListener("beforeunload", guard); return () => window.removeEventListener("beforeunload", guard); - }, [dirty]); + }, [dirty, tagInput, sourceCapture]); function leave(action: () => void) { - if (dirty) { + if ( + dirty || + sourceIntent.current || + tagDraftRef.current.trim() || + saving.current || + duplicating.current || + copyInFlight.current + ) { setNavigationNotice( - "Your latest changes are only in this window. Save or copy your text before leaving.", + tagDraftRef.current.trim() + ? "Add or clear the new tag before leaving." + : "Your latest changes are only in this window. Save or copy your text before leaving.", ); return; } @@ -137,43 +473,174 @@ export function NoteEditor({ await navigator.clipboard.writeText( `${draft.title}\n\n${plainText(draft.document)}`, ); + setCopyError(""); } catch { - setError("Couldn’t copy text. Select your text and copy it."); + setCopyError("Couldn’t copy text. Select your text and copy it."); } } - const chrome =
- - - {error - ? "Couldn’t save" - : busy - ? "Saving…" - : dirty - ? "Unsaved changes" - : "Saved in this browser"} - -
; + const chrome = ( +
+ + + {error + ? "Couldn’t save" + : busy + ? "Saving…" + : dirty || !!tagInput.trim() + ? "Unsaved changes" + : "Saved in this browser"} + + {onHistory && ( + + )} + {noteLinks && ( + + )} + { + linksReturnFocus.current = + document.querySelector( + '[aria-label="Note menu"]', + ); + setLinksOpen(true); + }, + }, + ] + : []), + ...(noteLinks + ? [ + { + label: formatNoteLink + ? "Copy link to note" + : "Copy local reference", + onSelect: () => { + void copyNoteReference(); + }, + }, + ] + : []), + ...(onPin + ? [ + { + label: pinned ? "Unpin" : "Pin", + disabled: dirty || !!tagInput.trim() || readOnly, + onSelect: onPin, + }, + ] + : []), + { + label: "Rename", + disabled: readOnly, + icon:
+ ); return (
- {desktop?.toolbar ? createPortal(chrome, desktop.toolbar) : chrome} + {desktop?.toolbar + ? active + ? createPortal(chrome, desktop.toolbar) + : null + : chrome} {navigationNotice && (

{navigationNotice}

)} + {linkCopyNotice && ( +

+ {linkCopyNotice} +

+ )} + {linksOpen && noteLinks && ( + { + setLinksOpen(false); + requestAnimationFrame(() => { + if (linksReturnFocus.current?.isConnected) + linksReturnFocus.current.focus(); + }); + }} + /> + )} + {copyError && ( +

+ {copyError} +

+ )} {note.trashedAt && (

This note was moved to Trash in another tab. Keep a copy of your draft @@ -189,10 +656,15 @@ export function NoteEditor({

{error}

+ {sourceLimit && !conflict && ( + + )} {conflict ? ( <> +

+ )}