From c5bda0f11bb441e0d0784e4a1386ed9e09ad5e36 Mon Sep 17 00:00:00 2001 From: Luan Nguyen Date: Wed, 11 Nov 2026 21:35:38 +1100 Subject: [PATCH] feat(editor): add protected links and selection controls --- web/src/styles/app.css | 36 +- web/src/styles/note-links.css | 300 +++++ web/src/ui/LinkPopover.tsx | 235 ++++ web/src/ui/Menu.tsx | 8 +- web/src/ui/NoteLinkPicker.tsx | 394 ++++++ web/src/ui/RichEditor.tsx | 1106 ++++++++++++++++- web/src/ui/editorPreferences.ts | 108 ++ web/src/ui/model.ts | 2 + web/src/ui/noteLinkClipboard.ts | 86 ++ web/src/ui/noteLinkViews.ts | 145 +++ .../journal-noop-source-regression.test.tsx | 102 ++ web/tests/ui/link-popover.test.tsx | 270 ++++ web/tests/ui/menu.test.tsx | 20 +- web/tests/ui/slash-commands.test.tsx | 20 +- 14 files changed, 2760 insertions(+), 72 deletions(-) create mode 100644 web/src/styles/note-links.css create mode 100644 web/src/ui/LinkPopover.tsx create mode 100644 web/src/ui/NoteLinkPicker.tsx create mode 100644 web/src/ui/editorPreferences.ts create mode 100644 web/src/ui/noteLinkClipboard.ts create mode 100644 web/src/ui/noteLinkViews.ts create mode 100644 web/tests/ui/journal-noop-source-regression.test.tsx create mode 100644 web/tests/ui/link-popover.test.tsx diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 803ee3c..5117800 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -94,8 +94,10 @@ button { gap: 6px; align-items: center; justify-content: center; - transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1); + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1), box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1); } +button:not(:disabled):active { transform: scale(.97); } +.desktop-caption-controls button:not(:disabled):active { transform: none; } button:hover { background: var(--sel); } @@ -423,7 +425,7 @@ small { } .editor-page { width: 100%; - max-width: 800px; + max-width: var(--notes-editor-width, 640px); margin: 0 auto; padding: 36px 44px 80px; overflow: auto; @@ -465,10 +467,18 @@ small { min-height: 30px; padding: 4px 8px; } +.format[inert] { opacity: .5; } +.link-popover { position: fixed; z-index: 110; width: min(320px, calc(100vw - 16px)); max-height: calc(100dvh - 16px); overflow: auto; display: flex; flex-direction: column; gap: 8px; padding: 14px; background: var(--main); color: var(--t1); border: 1px solid var(--line2); border-radius: 12px; box-shadow: 0 10px 30px #0002; font-size: 13px; } +.link-popover small { color: var(--t3); } +.link-popover p { margin: 0; } +.link-popover-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 6px; padding-top: 4px; } +.settings-row input[type="checkbox"] { width: 18px; height: 18px; flex-shrink: 0; accent-color: var(--t1); } +.settings-toggle { flex-direction: row; cursor: pointer; } +.settings-toggle > span { display: flex; flex-direction: column; gap: 4px; } .tiptap { min-height: 320px; line-height: 1.75; - font-size: 15px; + font-size: var(--notes-editor-size, 14px); overflow-wrap: anywhere; } .tiptap p { @@ -528,12 +538,6 @@ small { @keyframes menu-in { from { opacity: 0; transform: translateY(-4px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes menu-out { from { opacity: 1; } to { opacity: 0; transform: translateY(-4px) scale(.98); } } @keyframes menu-fade { from { opacity: 0; } to { opacity: 1; } } -@media (prefers-reduced-motion: reduce) { - :root:not([data-motion="on"]) .action-menu { animation: menu-fade 150ms !important; } - :root:not([data-motion="on"]) .action-menu-closing { animation: menu-fade-out 150ms forwards !important; } -} -:root[data-motion="reduced"] .action-menu { animation: menu-fade 150ms !important; } -:root[data-motion="reduced"] .action-menu-closing { animation: menu-fade-out 150ms forwards !important; } @keyframes menu-fade-out { from { opacity: 1; } to { opacity: 0; } } .action-menu button:not(:disabled):hover, .action-menu button:not(:disabled):focus-visible { background: var(--sel); } .account-page { @@ -721,11 +725,22 @@ dialog p { animation: none !important; transition: none !important; } + :root:not([data-motion="on"]) button { transition: background-color 150ms cubic-bezier(.4,0,.2,1), box-shadow 150ms cubic-bezier(.4,0,.2,1) !important; } + :root:not([data-motion="on"]) button:active { transform: none !important; } } :root[data-motion="reduced"] * { animation: none !important; transition: none !important; } +:root[data-motion="reduced"] button:active { transform: none !important; } +:root[data-motion="reduced"] button { transition: background-color 150ms cubic-bezier(.4,0,.2,1), box-shadow 150ms cubic-bezier(.4,0,.2,1) !important; } +/* Menus retain opacity feedback under the reduced-motion reset. */ +@media (prefers-reduced-motion: reduce) { + :root:not([data-motion="on"]) .action-menu { animation: menu-fade 150ms !important; } + :root:not([data-motion="on"]) .action-menu-closing { animation: menu-fade-out 150ms forwards !important; } +} +:root[data-motion="reduced"] .action-menu { animation: menu-fade 150ms !important; } +:root[data-motion="reduced"] .action-menu-closing { animation: menu-fade-out 150ms forwards !important; } .slash-menu { z-index: 100; box-sizing: border-box; @@ -954,3 +969,6 @@ dialog p { padding: 6px 20px 24px; } } + +.link-popover-lock { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line2); } +.link-popover-lock small { flex: 1; } diff --git a/web/src/styles/note-links.css b/web/src/styles/note-links.css new file mode 100644 index 0000000..f4a8c0d --- /dev/null +++ b/web/src/styles/note-links.css @@ -0,0 +1,300 @@ +.note-reference-chip { + display: inline-flex; + align-items: center; + vertical-align: middle; + max-width: min(100%, 30em); + min-height: 24px; + padding: 0 9px; + border: 0; + border-radius: 7px; + background: var(--raise); + color: var(--t1); + font: inherit; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.note-reference-chip:disabled { + color: var(--t3); + opacity: 1; + cursor: default; +} +.note-link-picker { + z-index: 70; + display: flex; + flex-direction: column; + gap: 1px; + padding: 5px; + border-radius: 12px; + background: var(--main); + color: var(--t1); + box-shadow: + 0 10px 30px #0002, + 0 0 0 1px var(--line2); + font-size: 12.5px; + overflow: auto; + animation: note-link-in 200ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +.note-link-picker-head { + display: flex; + gap: 4px; +} +.note-link-picker-head input { + min-width: 0; + flex: 1; + height: 34px; + background: var(--raise); + color: var(--t1); + border: 0; + padding: 0 9px; + border-radius: 8px; +} +.note-link-picker button { + color: inherit; + background: transparent; + border: 0; + border-radius: 8px; + min-height: 30px; + padding: 0 9px; + text-align: left; +} +.note-link-picker [role="option"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: 9px; + width: 100%; +} +.note-link-picker [role="option"] span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.note-link-picker [role="option"][aria-selected="true"] { + background: var(--sel); +} +.note-link-picker small, +.note-link-group, +.note-link-hint { + color: var(--t3); + font-size: 11px; +} +.note-link-group { + padding: 6px 9px 3px; +} +.note-link-hint { + margin: 6px 9px; +} +.note-link-picker p { + padding: 0 9px; +} +.note-link-picker :focus-visible, +.note-reference-chip:focus-visible { + outline: 2px solid var(--t2); + outline-offset: 2px; +} +@keyframes note-link-in { + from { + opacity: 0; + transform: translateY(-4px) scale(0.98); + } + to { + opacity: 1; + transform: none; + } +} +@media (max-width: 540px) { + .note-link-picker-head input { + font-size: 16px; + } + .note-link-picker { + border-radius: 16px; + } +} +@media (prefers-reduced-motion: reduce) { + :root:not([data-motion="on"]) .note-link-picker { + animation: note-link-fade 150ms linear !important; + transform: none !important; + } +} +[data-motion="reduced"] .note-link-picker { + animation: note-link-fade 150ms linear !important; + transform: none !important; +} +@keyframes note-link-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +.note-links-panel { + width: 360px; + max-width: 100%; + background: var(--main); + border-left: 1px solid var(--line); + padding: 0 14px; + display: flex; + flex-direction: column; + gap: 8px; + overflow: auto; + min-height: 0; +} +.note-links-panel header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 0 10px; +} +.note-links-panel button { + color: var(--t1); + background: transparent; + border: 0; + border-radius: 8px; + min-height: 30px; + padding: 4px 9px; + text-align: left; +} +.note-links-panel h3 { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--t3); + padding: 12px 0 6px; +} +.note-links-panel .note-backlink { + display: flex; + flex-direction: column; + gap: 3px; + width: 100%; + padding: 10px 12px; + border-radius: 10px; +} +.note-backlink span { + color: var(--t2); + font-size: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.note-links-panel p { + font-size: 12px; + color: var(--t2); +} +.note-links-panel small { + font-size: 11px; + color: var(--t3); +} +.note-links-panel button:hover { + background: var(--sel); +} + +.editor-pane { + position: relative; +} +.editor-pane > .note-links-panel { + position: absolute; + right: 0; + top: 44px; + bottom: 0; + z-index: 25; + box-shadow: -8px 0 24px #0001; + animation: note-panel-in 300ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +@keyframes note-panel-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: none; + opacity: 1; + } +} +@media (prefers-reduced-motion: reduce) { + :root:not([data-motion="on"]) .note-links-panel, + :root:not([data-motion="on"]) .note-link-picker { + animation: note-link-fade 150ms linear !important; + transform: none !important; + } +} +:root[data-motion="reduced"] .note-links-panel { + animation: note-link-fade 150ms linear !important; + transform: none !important; +} + +.note-link-picker[data-exiting="true"] { + animation: note-link-out 140ms cubic-bezier(0.4, 0, 0.2, 1) forwards; +} +.note-links-panel[data-exiting="true"] { + animation: note-panel-out 210ms cubic-bezier(0.4, 0, 0.2, 1) forwards; +} +@keyframes note-link-out { + to { + opacity: 0; + transform: translateY(-4px) scale(0.98); + } +} +@keyframes note-panel-out { + to { + opacity: 0; + transform: translateX(100%); + } +} +@keyframes note-link-fade-out { + to { + opacity: 0; + } +} +@media (prefers-reduced-motion: reduce) { + :root:not([data-motion="on"]) .note-link-picker[data-exiting="true"], + :root:not([data-motion="on"]) .note-links-panel[data-exiting="true"] { + animation: note-link-fade-out 150ms forwards !important; + transform: none !important; + } +} +:root[data-motion="reduced"] .note-link-picker[data-exiting="true"], +:root[data-motion="reduced"] .note-links-panel[data-exiting="true"] { + animation: note-link-fade-out 150ms forwards !important; + transform: none !important; +} +dialog .note-links-panel { + width: 100%; + border: 0; + padding: 0; + max-height: calc(100dvh - 130px); + animation: note-link-in 300ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.note-link-picker[data-sheet="true"] { + border-radius: 16px 16px 0 0; +} +.note-link-picker[data-sheet="true"] input { + font-size: 16px; +} + +.note-links-panel .note-backlink { + align-items: flex-start; + text-align: left; +} +@media (max-width: 640px) { + .note-links-toggle { + display: none !important; + } +} + +.note-reference-chip svg { + width: 12px; + height: 12px; + flex: none; + margin-right: 6px; +} +.note-reference-chip > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/web/src/ui/LinkPopover.tsx b/web/src/ui/LinkPopover.tsx new file mode 100644 index 0000000..0791abc --- /dev/null +++ b/web/src/ui/LinkPopover.tsx @@ -0,0 +1,235 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { registerCloseGuard } from "../desktop/closeGuard"; +import { safeLink } from "./model"; + +export function LinkPopover({ + initialUrl, + originalUrl = initialUrl, + onChange, + unavailable, + onLock, + anchor, + onApply, + onRemove, + onCancel, +}: { + initialUrl: string; + originalUrl?: string; + onChange?: (url: string) => void; + unavailable?: string; + onLock?: () => void; + anchor: () => { left: number; top: number; bottom: number }; + onApply: (url: string) => string | null; + onRemove?: () => string | null; + onCancel: (restoreFocus: boolean) => void; +}) { + const [url, setUrl] = useState(initialUrl), + [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [position, setPosition] = useState({ left: 8, top: 8 }); + const root = useRef(null), + input = useRef(null); + const dirty = useRef(initialUrl !== originalUrl), + anchorRef = useRef(anchor), + cancelRef = useRef(onCancel); + anchorRef.current = anchor; + cancelRef.current = onCancel; + const id = useId(); + useLayoutEffect( + () => + registerCloseGuard(() => { + if (!dirty.current) return true; + setNotice("Apply or cancel this link before leaving."); + return false; + }), + [], + ); + useLayoutEffect(() => { + const place = () => { + try { + const rect = anchorRef.current(), + height = root.current?.offsetHeight ?? 180, + width = root.current?.offsetWidth ?? 320; + const below = innerHeight - rect.bottom - 8; + setPosition({ + left: Math.max(8, Math.min(rect.left, innerWidth - width - 8)), + top: Math.max( + 8, + Math.min( + below >= height ? rect.bottom + 6 : rect.top - height - 6, + innerHeight - height - 8, + ), + ), + }); + } catch { + setPosition({ left: 8, top: 8 }); + } + }; + place(); + input.current?.focus(); + input.current?.select(); + const resize = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(place); + if (root.current) resize?.observe(root.current); + window.addEventListener("resize", place); + window.addEventListener("scroll", place, true); + return () => { + resize?.disconnect(); + window.removeEventListener("resize", place); + window.removeEventListener("scroll", place, true); + }; + }, []); + useEffect(() => { + const outside = (event: PointerEvent) => { + if (root.current?.contains(event.target as Node)) return; + if ( + event.target instanceof Element && + event.target.closest('[data-preserves-editor-draft="true"]') + ) + return; + if (dirty.current) setNotice("Apply or cancel this link before leaving."); + else cancelRef.current(false); + }; + const outsideClick = (event: MouseEvent) => { + if (root.current?.contains(event.target as Node)) return; + if ( + event.target instanceof Element && + event.target.closest('[data-preserves-editor-draft="true"]') + ) + return; + if (!dirty.current) { + cancelRef.current(false); + return; + } + // Preserve the form/guard before route or caption handlers can close it. + event.preventDefault(); + event.stopPropagation(); + setNotice("Apply or cancel this link before leaving."); + input.current?.focus(); + }; + const unload = (event: BeforeUnloadEvent) => { + if (dirty.current) { + event.preventDefault(); + event.returnValue = ""; + } + }; + document.addEventListener("pointerdown", outside); + document.addEventListener("click", outsideClick, true); + window.addEventListener("beforeunload", unload); + return () => { + document.removeEventListener("pointerdown", outside); + document.removeEventListener("click", outsideClick, true); + window.removeEventListener("beforeunload", unload); + }; + }, []); + return ( +
{ + event.preventDefault(); + event.stopPropagation(); + if (unavailable) return; + const value = url.trim(); + if (!safeLink(value)) { + setError("Enter a valid web or email link."); + return; + } + const failure = onApply(value); + if (failure) setError(failure); + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onCancel(true); + } + if (event.key === "Tab") { + const controls = Array.from( + root.current?.querySelectorAll( + "input,button:not(:disabled)", + ) ?? [], + ); + const first = controls[0], + last = controls.at(-1); + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } + } + }} + > + Web link + + { + try { + onChange?.(event.target.value); + } catch (failure) { + setError( + failure instanceof Error + ? failure.message + : "Could not retain this link draft.", + ); + return; + } + dirty.current = event.target.value !== originalUrl; + setUrl(event.target.value); + setError(""); + setNotice(""); + }} + /> + https://, http:// or mailto: + {unavailable &&

{unavailable}

} + {error && ( +

+ {error} +

+ )} + {notice &&

{notice}

} +
+ {onRemove && ( + + )} + + +
+ {onLock && ( +
+ + Your unfinished link will be kept for unlock. +
+ )} + + ); +} diff --git a/web/src/ui/Menu.tsx b/web/src/ui/Menu.tsx index f255e31..e523a30 100644 --- a/web/src/ui/Menu.tsx +++ b/web/src/ui/Menu.tsx @@ -44,7 +44,13 @@ export function Menu({ label, actions }: { label: string; actions: MenuAction[] }, [open]); useEffect(() => { if (phase !== "closing") return; - const timer = window.setTimeout(() => setPhase("closed"), 150); + // Match the rendered exit animation, including the opacity-only reduced mode. + const style = menu.current ? getComputedStyle(menu.current) : null; + const milliseconds = (value: string) => value.trim().endsWith("ms") ? parseFloat(value) : parseFloat(value) * 1000; + const durations = style?.animationDuration.split(",").map(milliseconds) ?? []; + const delays = style?.animationDelay.split(",").map(milliseconds) ?? []; + const ends = durations.map((duration, i) => duration + (delays[i % Math.max(1, delays.length)] || 0)).filter(Number.isFinite); + const timer = window.setTimeout(() => setPhase("closed"), ends.length ? Math.max(0, ...ends) : 140); return () => window.clearTimeout(timer); }, [phase]); function keyDown(event: React.KeyboardEvent) { diff --git a/web/src/ui/NoteLinkPicker.tsx b/web/src/ui/NoteLinkPicker.tsx new file mode 100644 index 0000000..3c63dbf --- /dev/null +++ b/web/src/ui/NoteLinkPicker.tsx @@ -0,0 +1,394 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { + LinkCandidate, + LinkSearchCursor, + NoteTarget, +} from "../links/contracts"; +import type { EditorNoteLinks } from "./noteLinkViews"; +import "../styles/note-links.css"; +import { registerCloseGuard, trackOperation } from "../desktop/closeGuard"; +export function NoteLinkPicker({ + owner, + query: inlineQuery, + anchor, + isCurrent, + onChoose, + onClose, + onQuery, +}: { + owner: EditorNoteLinks; + query?: string; + anchor: () => DOMRect; + isCurrent: () => boolean; + onChoose: (target: NoteTarget) => void; + onClose: (restoreFocus?: boolean) => void; + onQuery?: (query: string) => void; +}) { + const [closing, setClosing] = useState(false); + const [sheet, setSheet] = useState(false); + const [preparing, setPreparing] = useState(false); + const closingRef = useRef(false); + const restoreFocus = useRef(true); + function finish(restore = true) { + if (closingRef.current) return; + restoreFocus.current = restore; + closingRef.current = true; + setClosing(true); + } + const [query, setQuery] = useState(inlineQuery ?? ""), + [items, setItems] = useState([]), + [next, setNext] = useState(null), + [active, setActive] = useState(0), + [busy, setBusy] = useState(false), + [error, setError] = useState(""), + [position, setPosition] = useState({ + visibility: "hidden", + }); + const root = useRef(null), + sequence = useRef(0), + alive = useRef(true), + pending = useRef(false), + composing = useRef(false), + cursor = useRef(null), + token = useRef(""); + useEffect(() => registerCloseGuard(() => !pending.current), []); + const current = useRef({ isCurrent, onChoose, onClose }); + current.current = { isCurrent, onChoose, onClose }; + useEffect(() => { + if (inlineQuery !== undefined) setQuery(inlineQuery); + }, [inlineQuery]); + useEffect(() => { + alive.current = true; + const stop = owner.service.onInvalidate(() => { + sequence.current++; + current.current.onClose(false); + }); + return () => { + alive.current = false; + sequence.current++; + stop(); + }; + }, [owner.service]); + useEffect(() => { + if (!closing) return; + const element = root.current; + let done = false; + const end = () => { + if (done) return; + done = true; + if (alive.current) current.current.onClose(restoreFocus.current); + }; + const listener = (event: AnimationEvent) => { + if (event.target === element) end(); + }; + element?.addEventListener("animationend", listener); + const css = element ? getComputedStyle(element) : null; + const duration = Math.max( + ...(css?.animationDuration ?? "0s") + .split(",") + .map((value) => parseFloat(value) * 1000), + 0, + ); + const timer = setTimeout(end, duration + 34); + return () => { + clearTimeout(timer); + element?.removeEventListener("animationend", listener); + }; + }, [closing]); + async function read(more = false) { + const ticket = ++sequence.current; + setBusy(true); + setError(""); + setPreparing(false); + if (!more) { + setItems([]); + setNext(null); + cursor.current = null; + setActive(0); + } + if (query.length > 200) { + setError("Search with 200 characters or fewer."); + setBusy(false); + return; + } + try { + const admission = await owner.service.admission(); + if (admission.mode === "index-recovery") + throw Error("Repair note links before adding a reference."); + if (admission.mode === "legacy" && admission.premise !== "verified") { + if (alive.current && ticket === sequence.current) setPreparing(true); + throw Error( + "Preparing note links. Continue to check the remaining notes.", + ); + } + const result = await owner.service.search( + query, + owner.noteId, + more ? (cursor.current ?? undefined) : undefined, + ); + if (!alive.current || ticket !== sequence.current) return; + const key = JSON.stringify(result.token); + if (more && token.current !== key) + throw Error("Notes changed. Search again."); + token.current = key; + cursor.current = result.next; + setNext(result.next); + setItems((old) => (more ? [...old, ...result.items] : result.items)); + } catch (failure) { + if (alive.current && ticket === sequence.current) + setError( + failure instanceof Error + ? failure.message + : "Couldn’t load notes. Try again.", + ); + } finally { + if (alive.current && ticket === sequence.current) setBusy(false); + } + } + useEffect(() => { + void read(); + const stop = owner.service.subscribe(() => void read()); + return () => { + sequence.current++; + stop(); + }; + }, [owner.service, owner.noteId, query]); + useLayoutEffect(() => { + const place = () => { + const node = root.current; + if (!node) return; + const r = anchor(), + v = window.visualViewport, + left = v?.offsetLeft ?? 0, + top = v?.offsetTop ?? 0, + w = v?.width ?? innerWidth, + h = v?.height ?? innerHeight; + const pane = node.ownerDocument + .querySelector(".editor-pane") + ?.getBoundingClientRect(); + const narrow = Math.min(w, pane?.width ?? w) < 416; + const paneLeft = pane?.width ? Math.max(left, pane.left) : left, + paneRight = pane?.width ? Math.min(left + w, pane.right) : left + w; + setSheet(narrow); + setPosition({ + position: "fixed", + width: narrow + ? Math.max(1, paneRight - paneLeft) + : Math.min(400, w - 16), + left: narrow + ? paneLeft + : Math.max(left + 8, Math.min(r.left, left + w - 408)), + top: narrow + ? undefined + : Math.max(top + 8, Math.min(r.bottom + 4, top + h - 180)), + maxHeight: narrow + ? h - 8 + : Math.max(120, top + h - Math.max(top + 8, r.bottom + 4) - 8), + bottom: narrow ? Math.max(0, innerHeight - top - h) : undefined, + }); + }; + place(); + const pane = document.querySelector(".editor-pane"), + observer = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(place) + : null; + if (pane) observer?.observe(pane); + window.addEventListener("resize", place); + window.addEventListener("scroll", place, true); + const v = window.visualViewport; + v?.addEventListener("resize", place); + v?.addEventListener("scroll", place); + return () => { + observer?.disconnect(); + window.removeEventListener("resize", place); + window.removeEventListener("scroll", place, true); + v?.removeEventListener("resize", place); + v?.removeEventListener("scroll", place); + }; + }, [anchor]); + async function choose(item: LinkCandidate) { + if (pending.current || closingRef.current) return; + if (!current.current.isCurrent()) { + setError("This insertion point changed. Choose where to link again."); + return; + } + pending.current = true; + setBusy(true); + const ticket = sequence.current; + try { + const result = await owner.service.reference(item.target.noteId); + if (!alive.current || ticket !== sequence.current) return; + if ( + !current.current.isCurrent() || + JSON.stringify(result.target) !== JSON.stringify(item.target) + ) + throw Error( + "This insertion point changed. Choose where to link again.", + ); + current.current.onChoose(item.target); + finish(); + } catch (failure) { + if (alive.current && ticket === sequence.current) + setError( + failure instanceof Error + ? failure.message + : "Couldn’t link this note. Try again.", + ); + } finally { + pending.current = false; + if (alive.current) setBusy(false); + } + } + useEffect(() => { + const outside = (event: PointerEvent) => { + if (!pending.current && !root.current?.contains(event.target as Node)) + finish(false); + }; + document.addEventListener("pointerdown", outside); + return () => document.removeEventListener("pointerdown", outside); + }, []); + useEffect(() => { + root.current + ?.querySelector('[aria-selected="true"]') + ?.scrollIntoView({ block: "nearest" }); + }, [active]); + return createPortal( +
{ + if (composing.current || event.nativeEvent.isComposing) return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + if (!pending.current) finish(); + } else if (event.key === "Tab") { + if (!pending.current) finish(false); + } else if ( + ["ArrowUp", "ArrowDown"].includes(event.key) && + items.length + ) { + event.preventDefault(); + setActive( + (old) => + (old + (event.key === "ArrowDown" ? 1 : -1) + items.length) % + items.length, + ); + } else if (event.key === "Enter") { + event.preventDefault(); + if (items[active] && !busy) void choose(items[active]); + } + }} + > +
+ { + if (onQuery) onQuery(event.target.value); + else setQuery(event.target.value); + }} + onCompositionStart={() => { + composing.current = true; + }} + onCompositionEnd={() => { + composing.current = false; + }} + /> + +
+
Notes
+ + {busy &&

Searching notes…

} + {error && ( +

+ {error}{" "} + +

+ )} + {!busy && !error && !items.length && ( +

No matching notes

+ )} + {next && ( + + )} +

↑ ↓ to move · ⏎ to link · Esc to close

+
, + document.body, + ); +} diff --git a/web/src/ui/RichEditor.tsx b/web/src/ui/RichEditor.tsx index 59df9d3..47e0b58 100644 --- a/web/src/ui/RichEditor.tsx +++ b/web/src/ui/RichEditor.tsx @@ -1,12 +1,31 @@ import { useEditor, EditorContent } from "@tiptap/react"; -import StarterKit from "@tiptap/starter-kit"; -import TaskList from "@tiptap/extension-task-list"; -import TaskItem from "@tiptap/extension-task-item"; +import { createNoteExtensions } from "../editor/schema"; +import { + captureSourceSelection, + fingerprintSourceDocument, +} from "../study/sourceCodec"; +import type { SourceSelection } from "../study/sourceCodec"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; import type { NoteDocument } from "../domain/contracts"; import { safeLink } from "./model"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import type { Editor } from "@tiptap/react"; import { closeHistory } from "@tiptap/pm/history"; +import { Selection, TextSelection, Plugin, PluginKey } from "@tiptap/pm/state"; +import { useEditorPreferences } from "./editorPreferences"; +import { LinkPopover } from "./LinkPopover"; +import { validateDocument, validateNoteDocument } from "../domain/validation"; +import { useMemo } from "react"; +import { createNoteLinkViews } from "./noteLinkViews"; +import type { EditorNoteLinks } from "./noteLinkViews"; +import { NoteLinkPicker } from "./NoteLinkPicker"; +import { Slice } from "@tiptap/pm/model"; +import { + NOTE_FRAGMENT_MIME, + encodeNoteFragment, + decodeNoteFragment, + referencePlainText, +} from "./noteLinkClipboard"; const commands = [ "Paragraph", @@ -15,8 +34,70 @@ const commands = [ "Numbered list", "To-do list", "Web link", + "Another note", ] as const; +export type EditorChangeTrace = { + before: NoteDocument; + steps: readonly unknown[]; +}; +export type EditorSourceCapture = { + document: NoteDocument; + selection: SourceSelection; + anchor: () => { left: number; top: number; bottom: number }; + isCurrent: () => boolean; + restore: () => void; +}; +export type EditorSourceTarget = { + key: string; + noteId: string; + noteRevision: number; + documentDigest: string; + from: number; + to: number; + quote: string; +}; +export type EditorSourceControl = { + noteId: string; + noteRevision: number; + annotations: readonly EditorSourceTarget[]; + target?: EditorSourceTarget | null; + onCapture: (capture: EditorSourceCapture) => void; + onInvalidated?: () => void; + onTargetUnavailable?: () => void; +}; +function noteDocument(value: NoteDocument): NoteDocument { + return Array.isArray(value.content) ? value : { ...value, content: [] }; +} type Slash = { from: number; to: number; query: string }; +export type EditorLinkDraft = { + from: number; + to: number; + source: "toolbar" | "slash"; + selection: + | { type: "all" } + | { type: "node"; anchor: number } + | { type: "text"; anchor: number; head: number }; + initialUrl: string; + url: string; +}; +export interface EditorLinkControl { + draft: EditorLinkDraft | null; + valid: boolean; + open(draft: EditorLinkDraft): void; + change(url: string): void; + cancel(): void; + beginApply(): (document: NoteDocument, changed: boolean) => void; + lock?(): void; +} +type LinkRequest = { + from: number; + to: number; + href: string; + slash: boolean; + value: string; + selection: EditorLinkDraft["selection"]; + document: Editor["state"]["doc"]; +}; function currentSlash(editor: Editor): Slash | null { const { selection } = editor.state; @@ -35,10 +116,202 @@ function currentSlash(editor: Editor): Slash | null { export function RichEditor({ document, onChange, + ariaLabel = "Note content", + readOnly = false, + linkControl, + traceTransactions = false, + sourceControl, + documentPolicy = "plain", + noteLinks, }: { + noteLinks?: EditorNoteLinks; + documentPolicy?: "notes" | "journal" | "plain"; document: NoteDocument; - onChange: (value: NoteDocument) => void; + onChange: (value: NoteDocument, trace?: EditorChangeTrace) => void; + traceTransactions?: boolean; + sourceControl?: EditorSourceControl; + ariaLabel?: string; + readOnly?: boolean; + linkControl?: EditorLinkControl; }) { + const policy = useRef(documentPolicy); + const pasteReferences = useRef(false); + const noteLinksRef = useRef(noteLinks); + noteLinksRef.current = documentPolicy === "notes" ? noteLinks : undefined; + useEffect(() => { + pasteReferences.current = false; + if (documentPolicy !== "notes" || !noteLinks || readOnly) return; + let active = true, + sequence = 0; + const read = () => { + const ticket = ++sequence; + void noteLinks.service + .admission() + .then((value) => { + if (active && ticket === sequence) + pasteReferences.current = + value.mode === "current" || + (value.mode === "legacy" && value.premise === "verified"); + }) + .catch(() => { + if (active && ticket === sequence) pasteReferences.current = false; + }); + }; + const stop = noteLinks.service.subscribe(read), + invalid = noteLinks.service.onInvalidate(() => { + sequence++; + pasteReferences.current = false; + }); + read(); + return () => { + active = false; + sequence++; + pasteReferences.current = false; + stop(); + invalid(); + }; + }, [noteLinks?.service, documentPolicy, readOnly]); + const noteViews = useMemo( + () => createNoteLinkViews(() => noteLinksRef.current), + [], + ); + useEffect(() => noteViews.refresh(), [noteViews, noteLinks]); + policy.current = documentPolicy; + const checkedDocument = useMemo(() => { + try { + return { + value: (documentPolicy === "notes" + ? validateNoteDocument + : validateDocument)(document), + error: "", + }; + } catch { + return { + value: { type: "doc" as const, content: [] }, + error: + "This content isn’t supported in this editor. Your saved document was not changed.", + }; + } + }, [document, documentPolicy]); + const policyBlocked = useRef(false); + policyBlocked.current = Boolean(checkedDocument.error); + const sourceRef = useRef(sourceControl); + sourceRef.current = sourceControl; + const changeRef = useRef(onChange); + changeRef.current = onChange; + const traceRef = useRef(traceTransactions); + traceRef.current = traceTransactions; + const editorVersion = useRef(0), + sourceDecorations = useRef(DecorationSet.empty), + sourceKey = useRef(new PluginKey("note-source-passages")); + const [sourceSelection, setSourceSelection] = + useState(null); + const sourceToolbar = useRef(null); + const [toolbarPosition, setToolbarPosition] = useState< + import("react").CSSProperties + >({ visibility: "hidden" }); + function selectionFor(instance: Editor) { + if ( + !sourceRef.current || + !instance.isEditable || + composing.current || + readOnlyRef.current || + instance.state.selection.empty + ) { + setSourceSelection(null); + return; + } + try { + const { from, to } = instance.state.selection, + document = noteDocument(instance.getJSON() as NoteDocument), + selection = captureSourceSelection(document, from, to), + node = instance.state.doc, + version = editorVersion.current; + const isCurrent = () => + !instance.isDestroyed && + admission.current?.alive === true && + admission.current.editor === instance && + editorVersion.current === version && + instance.state.doc.eq(node); + const capture: EditorSourceCapture = { + document, + selection, + isCurrent, + anchor: () => { + if (!isCurrent()) throw new Error("This passage changed."); + const start = instance.view.coordsAtPos(from), + end = instance.view.coordsAtPos(to); + return { left: start.left, top: start.top, bottom: end.bottom }; + }, + restore: () => { + if (isCurrent()) { + instance.view.dispatch( + instance.state.tr.setSelection( + TextSelection.create(instance.state.doc, from, to), + ), + ); + instance.view.focus(); + } + }, + }; + setSourceSelection(capture); + } catch { + setSourceSelection(null); + } + } + const { preferences } = useEditorPreferences(); + const [linkRequest, setLinkRequest] = useState(null); + const [linkError, setLinkError] = useState(""); + const [contentNotice, setContentNotice] = useState(""); + const [noteRequest, setNoteRequest] = useState<{ + from: number; + to: number; + query?: string; + node: Editor["state"]["doc"]; + version: number; + } | null>(null); + const noteRequestRef = useRef(noteRequest); + noteRequestRef.current = noteRequest; + const dismissedNote = useRef(null); + function openNotePicker( + instance: Editor, + range?: { from: number; to: number; query?: string }, + ) { + if ( + !noteLinksRef.current || + readOnlyRef.current || + linkRequest || + composing.current || + !instance.isEditable + ) + return; + const selection = instance.state.selection; + if ( + !selection.$from.parent.isTextblock || + instance.isActive("codeBlock") || + instance.isActive("code") || + instance.isActive("link") + ) + return; + setNoteRequest({ + from: range?.from ?? selection.from, + to: range?.to ?? selection.to, + query: range?.query, + node: instance.state.doc, + version: editorVersion.current, + }); + } + const linkControlRef = useRef(linkControl); + linkControlRef.current = linkControl; + const preferencesRef = useRef(preferences); + const readOnlyRef = useRef(readOnly); + preferencesRef.current = preferences; + readOnlyRef.current = readOnly; + const admission = useRef<{ editor: Editor; alive: boolean } | null>(null); + const suspendedRules = useRef<{ + editor: Editor; + slots: { plugin: Plugin; next?: Plugin }[]; + } | null>(null); const [slash, setSlash] = useState(null); const [active, setActive] = useState(0); const [closing, setClosing] = useState(null); @@ -58,8 +331,10 @@ export function RichEditor({ const id = useRef(`slash-${Math.random().toString(36).slice(2)}`); const shown = slash ?? closing; const matches = shown - ? commands.filter((label) => - label.toLowerCase().includes(shown.query.toLowerCase()), + ? commands.filter( + (label) => + (label !== "Another note" || Boolean(noteLinksRef.current)) && + label.toLowerCase().includes(shown.query.toLowerCase()), ) : []; const matchesRef = useRef([]); @@ -68,6 +343,34 @@ export function RichEditor({ activeRef.current = active; sync.current = (instance) => { if (composing.current || replacing.current || !instance.isFocused) return; + if ( + noteLinksRef.current && + !linkRequest && + !noteRequestRef.current && + instance.state.selection.empty && + !instance.isActive("codeBlock") && + !instance.isActive("code") && + !instance.isActive("link") + ) { + const { $from, from } = instance.state.selection; + const before = $from.parent.textBetween( + 0, + $from.parentOffset, + "\n", + "\ufffc", + ); + const match = before.match(/\[\[([^\[\]\n]*)$/); + if (match) { + const start = from - match[0].length; + const key = `${start}:${from}:${match[1]}`; + if (dismissedNote.current !== key) { + close(); + openNotePicker(instance, { from: start, to: from, query: match[1] }); + return; + } + } else dismissedNote.current = null; + } + if (noteRequestRef.current) return; const next = currentSlash(instance); const visible = next && dismissed.current?.from === next.from ? null : next; if (visible?.query !== slashRef.current?.query) setActive(0); @@ -77,27 +380,43 @@ export function RichEditor({ setSlash(visible); } else close(); }; + function copySelection(view: Editor["view"], event: ClipboardEvent) { + if ( + policy.current !== "notes" || + !noteLinksRef.current || + !event.clipboardData + ) + return false; + try { + const slice = view.state.selection.content(), + document = validateNoteDocument({ + type: "doc", + content: slice.content.toJSON() ?? [], + }); + const payload = encodeNoteFragment( + document.content, + slice.openStart, + slice.openEnd, + ); + event.clipboardData.setData("text/plain", referencePlainText(document)); + event.clipboardData.setData(NOTE_FRAGMENT_MIME, payload); + event.preventDefault(); + return true; + } catch { + return false; + } + } const editor = useEditor({ - extensions: [ - StarterKit.configure({ - // Focusing a stored document must not append content or trigger a write. - trailingNode: false, - link: { - openOnClick: false, - autolink: false, - isAllowedUri: (url) => safeLink(url), - }, - }), - TaskList, - TaskItem.configure({ nested: true }), - ], - content: document, + editable: !readOnly, + extensions: createNoteExtensions({ noteLinkNodeView: noteViews.renderer }), + content: checkedDocument.value, editorProps: { attributes: { role: "textbox", - "aria-label": "Note content", + "aria-label": ariaLabel, "aria-multiline": "true", - spellcheck: "true", + spellcheck: String(preferences.spellcheck), + "aria-readonly": String(readOnly), }, handleKeyDown: (_view, event) => { if (composing.current || event.isComposing || !slashRef.current) @@ -127,6 +446,13 @@ export function RichEditor({ return false; }, handleDOMEvents: { + copy: copySelection, + cut: (view, event) => { + const copied = copySelection(view, event); + if (copied && !readOnlyRef.current && view.editable) + view.dispatch(view.state.tr.deleteSelection().scrollIntoView()); + return copied; + }, compositionstart: () => { composing.current = true; close(); @@ -145,6 +471,46 @@ export function RichEditor({ }, }, handlePaste: (view, event) => { + if (readOnlyRef.current) { + event.preventDefault(); + return true; + } + const fragment = event.clipboardData?.getData(NOTE_FRAGMENT_MIME); + if ( + fragment && + policy.current === "notes" && + noteLinksRef.current && + pasteReferences.current + ) { + try { + const parsed = decodeNoteFragment(fragment), + node = view.state.schema.nodeFromJSON(parsed.document); + node.check(); + const depth = (first: boolean) => { + let current = first ? node.firstChild : node.lastChild, + result = 0; + while (current && !current.isLeaf) { + result++; + current = first ? current.firstChild : current.lastChild; + } + return result; + }; + if (parsed.openStart > depth(true) || parsed.openEnd > depth(false)) + throw Error("Invalid open selection."); + const slice = new Slice( + node.content, + parsed.openStart, + parsed.openEnd, + ); + event.preventDefault(); + view.dispatch( + view.state.tr.replaceSelection(slice).scrollIntoView(), + ); + return true; + } catch { + /* Invalid application payload falls back to its literal text, never HTML. */ + } + } const text = event.clipboardData?.getData("text/plain"); if (text !== undefined && text !== "") { event.preventDefault(); @@ -159,8 +525,30 @@ export function RichEditor({ return false; }, }, - onSelectionUpdate: ({ editor }) => sync.current(editor), - onFocus: ({ editor }) => sync.current(editor), + onSelectionUpdate: ({ editor }) => { + sync.current(editor); + selectionFor(editor); + }, + onFocus: ({ editor }) => { + sync.current(editor); + selectionFor(editor); + }, + onTransaction: ({ editor, transaction, appendedTransactions }) => { + const batch = [transaction, ...appendedTransactions]; + if (!batch.some((item) => item.docChanged)) return; + setContentNotice(""); + editorVersion.current++; + sourceDecorations.current = DecorationSet.empty; + if (sourceRef.current) + editor.view.dispatch(editor.state.tr.setMeta(sourceKey.current, true)); + setSourceSelection(null); + sourceRef.current?.onInvalidated?.(); + if (replacing.current || !traceRef.current) return; + changeRef.current(noteDocument(editor.getJSON() as NoteDocument), { + before: noteDocument(transaction.before.toJSON() as NoteDocument), + steps: batch.flatMap((item) => item.steps.map((step) => step.toJSON())), + }); + }, onUpdate: ({ editor, transaction }) => { const previous = dismissed.current; if (previous) { @@ -172,22 +560,226 @@ export function RichEditor({ ? { ...previous, from, to } : null; } - onChange(editor.getJSON() as NoteDocument); + if (!traceRef.current) + changeRef.current(noteDocument(editor.getJSON() as NoteDocument)); sync.current(editor); }, }); + useLayoutEffect(() => { + if (!sourceSelection || readOnly || linkRequest || slash) return; + const place = () => { + const node = sourceToolbar.current; + if (!node) return; + try { + const rect = sourceSelection.anchor(), + viewport = window.visualViewport, + left = viewport?.offsetLeft ?? 0, + top = viewport?.offsetTop ?? 0, + width = viewport?.width ?? innerWidth, + height = viewport?.height ?? innerHeight; + const ownWidth = node.offsetWidth, + ownHeight = node.offsetHeight; + setToolbarPosition({ + position: "fixed", + left: Math.max( + left + 8, + Math.min(rect.left, left + width - ownWidth - 8), + ), + top: Math.max( + top + 8, + Math.min( + rect.top - ownHeight - 10 >= top + 8 + ? rect.top - ownHeight - 10 + : rect.bottom + 10, + top + height - ownHeight - 8, + ), + ), + }); + } catch { + setToolbarPosition({ display: "none" }); + } + }; + place(); + window.addEventListener("resize", place); + window.addEventListener("scroll", place, true); + const viewport = window.visualViewport; + viewport?.addEventListener("resize", place); + viewport?.addEventListener("scroll", place); + return () => { + window.removeEventListener("resize", place); + window.removeEventListener("scroll", place, true); + viewport?.removeEventListener("resize", place); + viewport?.removeEventListener("scroll", place); + }; + }, [sourceSelection, readOnly, !!linkRequest, !!slash]); + useEffect(() => { + if (!editor) return; + const plugin = new Plugin({ + key: sourceKey.current, + props: { decorations: () => sourceDecorations.current }, + }); + editor.registerPlugin(plugin); + return () => { + if (!editor.isDestroyed) editor.unregisterPlugin(sourceKey.current); + sourceDecorations.current = DecorationSet.empty; + }; + }, [editor]); + useEffect(() => { + if (editor) selectionFor(editor); + }, [editor, !!sourceControl]); + useEffect(() => { + if (!editor || !sourceControl) return; + let active = true; + const version = editorVersion.current, + node = editor.state.doc, + control = sourceControl; + const draw = () => { + if (!editor.isDestroyed) + editor.view.dispatch(editor.state.tr.setMeta(sourceKey.current, true)); + }; + sourceDecorations.current = DecorationSet.empty; + draw(); + void fingerprintSourceDocument( + noteDocument(editor.getJSON() as NoteDocument), + ) + .then((fingerprint) => { + if ( + !active || + editor.isDestroyed || + version !== editorVersion.current || + !editor.state.doc.eq(node) + ) + return; + const checked = (target: EditorSourceTarget) => { + if ( + target.noteId !== control.noteId || + target.noteRevision !== control.noteRevision || + target.documentDigest !== fingerprint.digest + ) + return false; + try { + if ( + !Number.isSafeInteger(target.from) || + !Number.isSafeInteger(target.to) || + target.from < 0 || + target.from >= target.to || + target.to > node.content.size + ) + return false; + const start = node.resolve(target.from), + end = node.resolve(target.to); + if (!start.parent.isTextblock || !end.parent.isTextblock) + return false; + const scalar = (position: number) => { + const resolved = node.resolve(position), + before = resolved.nodeBefore, + after = resolved.nodeAfter, + left = before?.isText + ? before.text?.charCodeAt(before.text.length - 1) + : undefined, + right = after?.isText ? after.text?.charCodeAt(0) : undefined; + return !( + left !== undefined && + right !== undefined && + left >= 0xd800 && + left <= 0xdbff && + right >= 0xdc00 && + right <= 0xdfff + ); + }; + if (!scalar(target.from) || !scalar(target.to)) return false; + let supported = true; + node.nodesBetween(target.from, target.to, (child) => { + if ( + child.isLeaf && + !child.isText && + child.type.name !== "hardBreak" + ) + supported = false; + }); + return ( + supported && + node.textBetween(target.from, target.to, "\n", (child) => + child.type.name === "hardBreak" ? "\n" : "\ufffc", + ) === target.quote + ); + } catch { + return false; + } + }; + const decorations = control.annotations.filter(checked).map((target) => + Decoration.inline(target.from, target.to, { + class: "study-source-annotation", + "data-source-entry": target.key, + }), + ); + if (control.target) { + if (checked(control.target)) { + const target = control.target; + decorations.push( + Decoration.inline(target.from, target.to, { + class: "study-source-highlight", + }), + ); + editor.view.dispatch( + editor.state.tr + .setSelection( + TextSelection.create( + editor.state.doc, + target.from, + target.to, + ), + ) + .scrollIntoView(), + ); + editor.view.focus(); + } else control.onTargetUnavailable?.(); + } + sourceDecorations.current = DecorationSet.create( + editor.state.doc, + decorations, + ); + draw(); + }) + .catch(() => { + if ( + active && + !editor.isDestroyed && + version === editorVersion.current + ) { + sourceDecorations.current = DecorationSet.empty; + draw(); + if (control.target) control.onTargetUnavailable?.(); + } + }); + return () => { + active = false; + }; + }, [ + editor, + sourceControl?.noteId, + sourceControl?.noteRevision, + sourceControl?.annotations, + sourceControl?.target, + ]); useEffect(() => { if ( editor && - JSON.stringify(editor.getJSON()) !== JSON.stringify(document) + JSON.stringify(editor.getJSON()) !== JSON.stringify(checkedDocument.value) ) { close(); + if (!linkControlRef.current) setLinkRequest(null); dismissed.current = null; replacing.current = true; - editor.commands.setContent(document, { emitUpdate: false }); - replacing.current = false; + try { + editor.commands.setContent(checkedDocument.value, { + emitUpdate: false, + }); + } finally { + replacing.current = false; + } } - }, [document, editor]); + }, [checkedDocument.value, editor]); useLayoutEffect(() => { if (!editor || !slash) return; const update = () => { @@ -260,6 +852,36 @@ export function RichEditor({ menu.scrollTop = row.offsetTop + row.offsetHeight - menu.clientHeight; } }, [slash, active, matches.join("|")]); + function requestFromDraft(draft: EditorLinkDraft): LinkRequest { + return { + from: draft.from, + to: draft.to, + href: draft.initialUrl, + value: draft.url, + slash: draft.source === "slash", + selection: draft.selection, + document: editor!.state.doc, + }; + } + function openLink(draft: EditorLinkDraft) { + if (!editor || noteRequestRef.current) return; + try { + linkControlRef.current?.open(draft); + setLinkRequest(requestFromDraft(draft)); + setLinkError(""); + } catch (failure) { + setLinkError( + failure instanceof Error + ? failure.message + : "Could not open this link draft.", + ); + } + } + useEffect(() => { + if (editor && !linkRequest && linkControl?.draft) { + setLinkRequest(requestFromDraft(linkControl.draft)); + } + }, [editor, linkControl?.draft, linkRequest]); action.current = (label) => { if (!editor) return; const range = currentSlash(editor); @@ -271,16 +893,25 @@ export function RichEditor({ range.query !== slashRef.current.query ) return; - let url: string | null = null; if (label === "Web link") { - url = window.prompt("Link URL (https://, http:// or mailto:)"); - if (!url || !safeLink(url)) { - if (url) window.alert("Use an https://, http:// or mailto: link."); - dismissed.current = range; - close(); - editor.commands.focus(); - return; - } + dismissed.current = range; + openLink({ + from: range.from, + to: range.to, + initialUrl: "", + url: "", + source: "slash", + selection: + editor.state.selection.toJSON() as EditorLinkDraft["selection"], + }); + close(); + return; + } + if (label === "Another note") { + dismissed.current = range; + close(); + openNotePicker(editor, { from: range.from, to: range.to }); + return; } const chain = editor .chain() @@ -295,19 +926,134 @@ export function RichEditor({ else if (label === "Bulleted list") chain.toggleBulletList(); else if (label === "Numbered list") chain.toggleOrderedList(); else if (label === "To-do list") chain.toggleTaskList(); - else if (url) - chain.insertContent({ - type: "text", - text: url, - marks: [{ type: "link", attrs: { href: url } }], - }); close(); chain.run(); }; + useLayoutEffect(() => { + if (!editor) return; + editor.setEditable(!readOnly && !checkedDocument.error, false); + editor.setOptions({ + editorProps: { + ...editor.options.editorProps, + attributes: { + role: "textbox", + "aria-label": ariaLabel, + "aria-multiline": "true", + "aria-readonly": String(readOnly), + spellcheck: String(preferences.spellcheck), + }, + }, + }); + if (readOnly) { + close(); + if (!linkControlRef.current) setLinkRequest(null); + } + }, [ + editor, + readOnly, + ariaLabel, + preferences.spellcheck, + checkedDocument.error, + ]); + useLayoutEffect(() => { + let record = admission.current; + if (record?.editor !== editor) { + if (!editor) return; + record = { editor, alive: true }; + admission.current = record; + const owner = record; + editor.registerPlugin( + new Plugin({ + key: new PluginKey("notes-editor-admission"), + filterTransaction(transaction) { + if (!transaction.docChanged) return true; + if (!owner.alive) return false; + if (policyBlocked.current && !replacing.current) return false; + if (readOnlyRef.current && !replacing.current) return false; + try { + (policy.current === "notes" + ? validateNoteDocument + : validateDocument)( + noteDocument(transaction.doc.toJSON() as NoteDocument), + ); + } catch { + setContentNotice( + "This edit isn’t supported or is too large. Your previous writing is unchanged.", + ); + return false; + } + return ( + preferencesRef.current.markdown || + !transaction.getMeta("inputRule") + ); + }, + }), + ); + } + record.alive = true; + // The editor owns plugin disposal. Keep this fence during deferred destroy so + // already queued IME callbacks cannot mutate a detached/locked editor. + return () => { + record.alive = false; + }; + }, [editor]); + useLayoutEffect(() => { + if (!editor) return; + if (!preferences.markdown) { + const plugins = editor.state.plugins; + if (!plugins.some((plugin) => plugin.spec.isInputRules)) return; + suspendedRules.current = { + editor, + slots: plugins.flatMap((plugin, index) => + plugin.spec.isInputRules + ? [ + { + plugin, + next: plugins + .slice(index + 1) + .find((next) => !next.spec.isInputRules), + }, + ] + : [], + ), + }; + editor.view.updateState( + editor.state.reconfigure({ + plugins: plugins.filter((plugin) => !plugin.spec.isInputRules), + }), + ); + } else if (suspendedRules.current?.editor === editor) { + // Restore the same rule plugins around their original non-rule anchors. + // History and any subsequently registered plugins keep their existing state. + const plugins = [...editor.state.plugins]; + for (const { plugin, next } of suspendedRules.current.slots) { + if (plugins.includes(plugin)) continue; + const index = next ? plugins.indexOf(next) : -1; + plugins.splice(index < 0 ? plugins.length : index, 0, plugin); + } + editor.view.updateState(editor.state.reconfigure({ plugins })); + suspendedRules.current = null; + } + }, [editor, preferences.markdown]); if (!editor) return null; return ( <> -
+
+ {noteLinks && documentPolicy === "notes" && ( + + )}
+ {sourceControl && ( + + )} + {sourceControl && + sourceSelection && + !linkRequest && + !noteRequest && + !slash && + !readOnly && ( +
+ +
+ )} + {noteRequest && noteLinks && documentPolicy === "notes" && ( + { + const rect = editor.view.coordsAtPos( + Math.min(noteRequest.to, editor.state.doc.content.size), + ); + return new DOMRect(rect.left, rect.top, 1, rect.bottom - rect.top); + }} + isCurrent={() => + !editor.isDestroyed && + admission.current?.alive === true && + !readOnlyRef.current && + editorVersion.current === noteRequest.version && + editor.state.doc.eq(noteRequest.node) + } + onQuery={ + noteRequest.query !== undefined + ? (value) => { + if ( + editorVersion.current !== noteRequest.version || + !editor.state.doc.eq(noteRequest.node) + ) + return; + editor.view.dispatch( + editor.state.tr.insertText( + value, + noteRequest.from + 2, + noteRequest.to, + ), + ); + setNoteRequest({ + ...noteRequest, + to: noteRequest.from + 2 + value.length, + query: value, + node: editor.state.doc, + version: editorVersion.current, + }); + } + : undefined + } + onChoose={(target) => { + const chain = editor + .chain() + .focus() + .command(({ tr }) => { + closeHistory(tr); + return true; + }) + .insertContentAt( + { from: noteRequest.from, to: noteRequest.to }, + { type: "noteLink", attrs: { version: 1, target } }, + ); + chain.run(); + }} + onClose={(restoreFocus = true) => { + dismissedNote.current = + noteRequest.query === undefined + ? null + : `${noteRequest.from}:${noteRequest.to}:${noteRequest.query}`; + setNoteRequest(null); + if (restoreFocus && !editor.isDestroyed) editor.commands.focus(); + }} + /> + )} + {checkedDocument.error &&

{checkedDocument.error}

} + {contentNotice &&

{contentNotice}

} + {linkError &&

{linkError}

} + {linkRequest && (!readOnly || !linkControl) && ( + { + linkControlRef.current?.change(url); + setLinkRequest((current) => + current ? { ...current, value: url } : current, + ); + }} + onLock={linkControl?.lock} + unavailable={ + readOnly + ? "This editor is read-only. You can copy or cancel this link." + : linkControl && !linkControl.valid + ? "This text changed. Cancel this link and select the text again." + : undefined + } + anchor={() => editor.view.coordsAtPos(linkRequest.from)} + onCancel={(restore) => { + try { + linkControlRef.current?.cancel(); + } catch (failure) { + setLinkError(String(failure)); + return; + } + setLinkRequest(null); + if (restore && !editor.isDestroyed) { + if (editor.state.doc.eq(linkRequest.document)) { + try { + editor.view.dispatch( + editor.state.tr.setSelection( + Selection.fromJSON( + editor.state.doc, + linkRequest.selection, + ), + ), + ); + } catch { + /* Ignore a selection invalidated by a replacement document. */ + } + } + editor.view.focus(); + } + }} + onApply={(url) => { + if (!editor.isEditable || readOnlyRef.current) + return "This editor is read-only."; + if (!editor.state.doc.eq(linkRequest.document)) + return "This text changed. Cancel and select it again."; + let accept: + ((document: NoteDocument, changed: boolean) => void) | undefined; + try { + accept = linkControlRef.current?.beginApply(); + } catch (failure) { + return String(failure); + } + const before = editor.state.doc; + const chain = editor + .chain() + .focus() + .command(({ tr }) => { + closeHistory(tr); + return true; + }); + if ( + linkRequest.slash || + (!linkRequest.href && linkRequest.from === linkRequest.to) + ) + chain.insertContentAt( + { from: linkRequest.from, to: linkRequest.to }, + { + type: "text", + text: url, + marks: [{ type: "link", attrs: { href: url } }], + }, + ); + else + chain + .setTextSelection({ + from: linkRequest.from, + to: linkRequest.to, + }) + .extendMarkRange("link") + .setLink({ href: url }); + if (!chain.run()) + return "Could not apply this link. Your input is still here."; + try { + accept?.( + noteDocument(editor.getJSON() as NoteDocument), + !before.eq(editor.state.doc), + ); + } catch (failure) { + return String(failure); + } + editor.view.focus(); + setLinkRequest(null); + return null; + }} + onRemove={ + linkRequest.href + ? () => { + if (!editor.isEditable || readOnlyRef.current) + return "This editor is read-only."; + if (!editor.state.doc.eq(linkRequest.document)) + return "This text changed. Cancel and select it again."; + let accept: + | ((document: NoteDocument, changed: boolean) => void) + | undefined; + try { + accept = linkControlRef.current?.beginApply(); + } catch (failure) { + return String(failure); + } + const before = editor.state.doc; + const applied = editor + .chain() + .focus() + .setTextSelection({ + from: linkRequest.from, + to: linkRequest.to, + }) + .extendMarkRange("link") + .unsetLink() + .run(); + if (!applied) + return "Could not remove this link. Your input is still here."; + try { + accept?.( + noteDocument(editor.getJSON() as NoteDocument), + !before.eq(editor.state.doc), + ); + } catch (failure) { + return String(failure); + } + editor.view.focus(); + setLinkRequest(null); + return null; + } + : undefined + } + /> + )} {shown && (
; +const KEY = "notes.editor.v1"; +const defaults: EditorPreferences = Object.freeze({ + textSize: "medium", + lineWidth: "narrow", + markdown: true, + spellcheck: true, +}); +let cached: EditorPreferences = defaults; +let observed: string | null | undefined; +let memoryOnly = false; +const listeners = new Set<() => void>(); +function parse(raw: string | null): EditorPreferences { + if (!raw || raw.length > 1000) return defaults; + try { + const value: unknown = JSON.parse(raw); + if (!value || typeof value !== "object" || Array.isArray(value)) + return defaults; + const item = value as Record; + if ( + Object.keys(item).length !== 4 || + !["small", "medium", "large"].includes(String(item.textSize)) || + !["narrow", "wide"].includes(String(item.lineWidth)) || + typeof item.markdown !== "boolean" || + typeof item.spellcheck !== "boolean" + ) + return defaults; + return Object.freeze({ + textSize: item.textSize as EditorPreferences["textSize"], + lineWidth: item.lineWidth as EditorPreferences["lineWidth"], + markdown: item.markdown, + spellcheck: item.spellcheck, + }); + } catch { + return defaults; + } +} +function snapshot(): EditorPreferences { + if (!memoryOnly) { + try { + const raw = localStorage.getItem(KEY); + if (raw !== observed) { + observed = raw; + cached = parse(raw); + } + } catch { + memoryOnly = true; + } + } + return cached; +} +function changed() { + for (const listener of listeners) listener(); +} +function storage(event: StorageEvent) { + if (event.key !== null && event.key !== KEY) return; + memoryOnly = false; + observed = undefined; + snapshot(); + changed(); +} +function subscribe(listener: () => void): () => void { + if (!listeners.size) window.addEventListener("storage", storage); + listeners.add(listener); + return () => { + listeners.delete(listener); + if (!listeners.size) window.removeEventListener("storage", storage); + }; +} +function update(patch: Partial) { + const next = { ...snapshot(), ...patch }, + raw = JSON.stringify(next); + const checked = parse(raw); + if (JSON.stringify(checked) !== raw) + throw new Error("Unsupported editor preference."); + cached = checked; + try { + localStorage.setItem(KEY, raw); + observed = raw; + memoryOnly = false; + } catch { + memoryOnly = true; + } + changed(); +} +/** Device-only presentation choices. Never includes note or Journal content. */ +export function useEditorPreferences() { + const preferences = useSyncExternalStore(subscribe, snapshot, () => defaults); + useLayoutEffect(() => { + const root = document.documentElement; + root.style.setProperty( + "--notes-editor-size", + `${{ small: 12, medium: 14, large: 16 }[preferences.textSize]}px`, + ); + root.style.setProperty( + "--notes-editor-width", + preferences.lineWidth === "narrow" ? "640px" : "800px", + ); + }, [preferences]); + return { preferences, setPreferences: update }; +} diff --git a/web/src/ui/model.ts b/web/src/ui/model.ts index 89fc504..31968b3 100644 --- a/web/src/ui/model.ts +++ b/web/src/ui/model.ts @@ -1,4 +1,5 @@ import type { Note, RichNode } from "../domain/contracts"; +import { safeHref } from "../domain/validation"; export function plainText(node: RichNode): string { return [node.text ?? "", ...(node.content ?? []).map(plainText)].join(" "); } @@ -27,6 +28,7 @@ export function projectNotes( ); } export function safeLink(value: string): boolean { + if (!safeHref(value)) return false; try { return ["https:", "http:", "mailto:"].includes(new URL(value).protocol); } catch { diff --git a/web/src/ui/noteLinkClipboard.ts b/web/src/ui/noteLinkClipboard.ts new file mode 100644 index 0000000..df85249 --- /dev/null +++ b/web/src/ui/noteLinkClipboard.ts @@ -0,0 +1,86 @@ +import type { NoteDocument } from "../domain/contracts"; +import { validateNoteDocument } from "../domain/validation"; +import { encodeNoteReference } from "../links/reference"; +import type { NoteTarget } from "../links/contracts"; +export const NOTE_FRAGMENT_MIME = "application/x-orbyn-notes-fragment-v1"; +const MAX_FRAGMENT = 8 * 1024 * 1024; +function boundedUtf8(value: string) { + if (value.length > MAX_FRAGMENT) + throw Error("The note selection is too large."); + let bytes = 0; + for (let index = 0; index < value.length; index++) { + const unit = value.charCodeAt(index); + if (unit < 0x80) bytes++; + else if (unit < 0x800) bytes += 2; + else if ( + unit >= 0xd800 && + unit <= 0xdbff && + index + 1 < value.length && + value.charCodeAt(index + 1) >= 0xdc00 && + value.charCodeAt(index + 1) <= 0xdfff + ) { + bytes += 4; + index++; + } else bytes += 3; + if (bytes > MAX_FRAGMENT) throw Error("The note selection is too large."); + } +} +export function encodeNoteFragment( + content: unknown, + openStart: number, + openEnd: number, +) { + const document = validateNoteDocument({ + type: "doc", + content: content ?? [], + }); + const value = JSON.stringify({ version: 1, document, openStart, openEnd }); + boundedUtf8(value); + return value; +} +export function decodeNoteFragment(value: string) { + boundedUtf8(value); + const parsed: unknown = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + throw Error("Invalid note selection."); + const raw = parsed as Record; + if (JSON.stringify(raw) !== value) throw Error("Invalid note selection."); + if ( + Object.keys(raw).sort().join(",") !== + "document,openEnd,openStart,version" || + raw.version !== 1 || + !Number.isSafeInteger(raw.openStart) || + !Number.isSafeInteger(raw.openEnd) || + (raw.openStart as number) < 0 || + (raw.openStart as number) > 32 || + (raw.openEnd as number) < 0 || + (raw.openEnd as number) > 32 + ) + throw Error("Invalid note selection."); + return { + document: validateNoteDocument(raw.document), + openStart: raw.openStart as number, + openEnd: raw.openEnd as number, + }; +} +export function referencePlainText(document: NoteDocument): string { + const visit = (node: Record): string => { + if (node.type === "text") return String(node.text ?? ""); + if (node.type === "hardBreak") return "\n"; + if (node.type === "noteLink") { + const target = (node.attrs as { target: NoteTarget }).target; + let value: string; + try { + value = encodeNoteReference(target); + } catch { + value = JSON.stringify(target); + } + return `Note reference ${value}`; + } + const children = Array.isArray(node.content) + ? (node.content as Record[]) + : []; + return children.map(visit).join(node.type === "doc" ? "\n" : ""); + }; + return visit(document as unknown as Record); +} diff --git a/web/src/ui/noteLinkViews.ts b/web/src/ui/noteLinkViews.ts new file mode 100644 index 0000000..c5980f6 --- /dev/null +++ b/web/src/ui/noteLinkViews.ts @@ -0,0 +1,145 @@ +import type { NodeViewRenderer, NodeViewRendererProps } from "@tiptap/core"; +import type { NoteLinkService, NoteTarget } from "../links/contracts"; +export type EditorNoteLinks = { + service: NoteLinkService; + noteId: string; + onOpen: (target: NoteTarget) => void; + interactive?: boolean; +}; +export function createNoteLinkViews( + control: () => EditorNoteLinks | undefined, +) { + const readers = new Set<() => void>(); + const renderer: NodeViewRenderer = ({ node }: NodeViewRendererProps) => { + let current = node, + active = true, + sequence = 0, + stop: (() => void) | undefined, + revoke: (() => void) | undefined; + let subscribed: NoteLinkService | undefined; + const dom = document.createElement("span"); + dom.className = "note-reference"; + dom.contentEditable = "false"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "note-reference-chip"; + const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + icon.setAttribute("viewBox", "0 0 24 24"); + icon.setAttribute("aria-hidden", "true"); + icon.setAttribute("fill", "none"); + icon.setAttribute("stroke", "currentColor"); + icon.setAttribute("stroke-width", "1.5"); + for (const d of ["M6 4h9l3 3v13H6z", "M9 11h6M9 14.5h4"]) { + const path = document.createElementNS( + "http://www.w3.org/2000/svg", + "path", + ); + path.setAttribute("d", d); + icon.append(path); + } + const labelNode = document.createElement("span"); + button.append(icon, labelNode); + dom.append(button); + function redact(label = "Note reference", actionable = false) { + labelNode.textContent = label; + button.setAttribute("aria-label", label); + button.disabled = !actionable; + button.removeAttribute("title"); + } + const refresh = () => { + const owner = control(), + ticket = ++sequence; + redact(); + if (subscribed !== owner?.service) { + stop?.(); + revoke?.(); + subscribed = owner?.service; + stop = subscribed?.subscribe(refresh); + revoke = subscribed?.onInvalidate(() => { + sequence++; + redact("Unavailable note"); + }); + } + if (!owner) return; + const target = current.attrs.target as NoteTarget; + void owner.service + .resolve(target) + .then((result) => { + if ( + !active || + ticket !== sequence || + control()?.service !== owner.service + ) + return; + if (result.status === "available") { + const title = result.title || "Untitled note"; + labelNode.textContent = title; + button.setAttribute( + "aria-label", + owner.interactive === false + ? `Note reference: ${title}` + : `Open note: ${title}`, + ); + button.disabled = owner.interactive === false; + } else + redact( + result.reason === "trashed" + ? "Note in Trash" + : "Unavailable note", + owner.interactive !== false, + ); + }) + .catch(() => { + if (active && ticket === sequence) redact("Unavailable note"); + }); + }; + button.addEventListener("click", (event) => { + if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) + return; + event.preventDefault(); + event.stopPropagation(); + const owner = control(); + if (!button.disabled && owner) + owner.onOpen(current.attrs.target as NoteTarget); + }); + readers.add(refresh); + refresh(); + return { + dom, + update(next) { + if (next.type !== current.type) return false; + current = next; + refresh(); + return true; + }, + stopEvent: (event) => { + if (!button.contains(event.target as Node)) return false; + if (event instanceof KeyboardEvent) + return ( + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + ["Enter", " "].includes(event.key) + ); + return ( + event instanceof MouseEvent && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + /^(click|mousedown|mouseup)$/.test(event.type) + ); + }, + ignoreMutation: () => true, + destroy() { + active = false; + sequence++; + stop?.(); + revoke?.(); + readers.delete(refresh); + }, + }; + }; + return { renderer, refresh: () => readers.forEach((read) => read()) }; +} diff --git a/web/tests/ui/journal-noop-source-regression.test.tsx b/web/tests/ui/journal-noop-source-regression.test.tsx new file mode 100644 index 0000000..eb13f19 --- /dev/null +++ b/web/tests/ui/journal-noop-source-regression.test.tsx @@ -0,0 +1,102 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { RichEditor, type EditorLinkControl } from "../../src/ui/RichEditor"; +import type { NoteDocument } from "../../src/domain/contracts"; + +beforeEach(() => { + Object.defineProperty(Range.prototype, "getClientRects", { + configurable: true, + value: () => [], + }); + Object.defineProperty(Range.prototype, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(10, 10, 1, 16), + }); +}); + +it("keeps a clean Journal link Apply free of document changes after source tracing was added", async () => { + const href = "https://example.test/existing"; + const document: NoteDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Existing link", + marks: [{ type: "link", attrs: { href } }], + }, + ], + }, + ], + }; + // Journal binds Apply to its unchanged draft generation. An equal-document + // transaction must not notify ordinary onChange and invalidate that token. + let draftGeneration = 0; + const change = vi.fn(() => { + draftGeneration++; + }); + const accept = vi.fn(); + const beginApply = vi.fn(() => { + const admittedGeneration = draftGeneration; + return (value: NoteDocument, changed: boolean) => { + if (draftGeneration !== admittedGeneration) + throw new Error("The protected Journal draft changed during Apply."); + accept(value, changed); + }; + }); + const control: EditorLinkControl = { + draft: null, + valid: true, + open: vi.fn(), + change: vi.fn(), + cancel: vi.fn(), + beginApply, + }; + render( + , + ); + const editor = await screen.findByRole("textbox", { name: "Journal entry" }); + await act(async () => { + editor.focus(); + const range = window.document.createRange(); + range.setStart(editor.querySelector("a")!.firstChild!, 3); + range.collapse(true); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + window.document.dispatchEvent(new Event("selectionchange")); + }); + fireEvent.click(screen.getByRole("button", { name: /^Link$/ })); + expect(await screen.findByLabelText("URL")).toHaveValue(href); + expect(change).not.toHaveBeenCalled(); + fireEvent.submit(screen.getByRole("dialog", { name: "Web link" })); + await waitFor(() => + expect( + screen.queryByRole("dialog", { + name: "Web link", + }), + ).not.toBeInTheDocument(), + ); + expect(beginApply).toHaveBeenCalledOnce(); + expect(accept).toHaveBeenCalledOnce(); + expect(accept.mock.calls[0]![1]).toBe(false); + expect(change).not.toHaveBeenCalled(); + expect(control.cancel).not.toHaveBeenCalled(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(editor.querySelector("a")).toHaveAttribute("href", href); + expect(editor).toHaveTextContent("Existing link"); + expect(editor).toHaveFocus(); +}); diff --git a/web/tests/ui/link-popover.test.tsx b/web/tests/ui/link-popover.test.tsx new file mode 100644 index 0000000..172e9de --- /dev/null +++ b/web/tests/ui/link-popover.test.tsx @@ -0,0 +1,270 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, expect, it, vi } from "vitest"; +import { LinkPopover } from "../../src/ui/LinkPopover"; +import { RichEditor, type EditorLinkControl } from "../../src/ui/RichEditor"; +import { checkClose } from "../../src/desktop/closeGuard"; + +beforeEach(() => { + Object.defineProperty(Range.prototype, "getClientRects", { + configurable: true, + value: () => [], + }); + Object.defineProperty(Range.prototype, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(10, 10, 1, 16), + }); +}); +it("keeps dirty URL input and its guard when a route or caption button is clicked", () => { + const cancel = vi.fn(), + close = vi.fn(); + render( + <> + ({ left: 10, top: 10, bottom: 26 })} + onApply={() => null} + onCancel={cancel} + /> + + , + ); + fireEvent.change(screen.getByLabelText("URL"), { + target: { value: "https://example.test/unfinished" }, + }); + const outside = screen.getByRole("button", { name: "Close window" }); + fireEvent.pointerDown(outside); + fireEvent.click(outside); + expect(close).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); + expect(screen.getByLabelText("URL")).toHaveValue( + "https://example.test/unfinished", + ); + let state = ""; + act(() => { + state = checkClose(); + }); + expect(state).toBe("draft"); +}); +it("rejects URLs that the persistence validator would reject", () => { + const apply = vi.fn(() => null); + render( + ({ left: 10, top: 10, bottom: 26 })} + onApply={apply} + onCancel={() => {}} + />, + ); + fireEvent.change(screen.getByLabelText("URL"), { + target: { value: "https://example.test/a b" }, + }); + fireEvent.submit(screen.getByRole("dialog", { name: "Web link" })); + expect(screen.getByText("Enter a valid web or email link.")).toBeVisible(); + expect(apply).not.toHaveBeenCalled(); +}); +it("validates inline, applies email URLs and keeps unsubmitted input behind the close guard", async () => { + const apply = vi.fn(() => null), + cancel = vi.fn(); + const mounted = render( + ({ left: 10, top: 10, bottom: 26 })} + onApply={apply} + onCancel={cancel} + />, + ); + fireEvent.change(screen.getByLabelText("URL"), { + target: { value: "javascript:bad" }, + }); + let state: string = ""; + act(() => { + state = checkClose(); + }); + expect(state).toBe("draft"); + fireEvent.submit(screen.getByRole("dialog", { name: "Web link" })); + expect(screen.getByText("Enter a valid web or email link.")).toBeVisible(); + expect(apply).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText("URL"), { + target: { value: "mailto:synthetic@example.test" }, + }); + fireEvent.submit(screen.getByRole("dialog", { name: "Web link" })); + expect(apply).toHaveBeenCalledWith("mailto:synthetic@example.test"); + fireEvent.keyDown(screen.getByLabelText("URL"), { key: "Escape" }); + expect(cancel).toHaveBeenCalledWith(true); + mounted.unmount(); + expect(checkClose()).toBe("allowed"); +}); +it("removes an existing link while keeping its text", async () => { + const user = userEvent.setup(); + const change = vi.fn(); + render( + , + ); + const editor = await screen.findByRole("textbox", { name: "Note content" }); + await act(async () => editor.focus()); + await act(async () => { + const range = document.createRange(); + range.setStart(editor.querySelector("a")!.firstChild!, 3); + range.collapse(true); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + document.dispatchEvent(new Event("selectionchange")); + }); + await user.click(screen.getByRole("button", { name: /^Link$/ })); + expect(screen.getByLabelText("URL")).toHaveValue("https://example.test"); + await user.click(screen.getByRole("button", { name: "Remove link" })); + expect(editor.querySelector("a")).toBeNull(); + expect(editor.textContent).toBe("Keep this text"); + expect(editor).toHaveFocus(); + expect(change).toHaveBeenCalled(); +}); + +it("guards restored dirty input immediately and permits only draft-preserving caption actions", () => { + const cancel = vi.fn(), + minimize = vi.fn(), + maximize = vi.fn(), + close = vi.fn(); + render( + <> + ({ left: 0, top: 0, bottom: 20 })} + onApply={() => null} + onCancel={cancel} + /> + + + + , + ); + let state = ""; + act(() => { + state = checkClose(); + }); + expect(state).toBe("draft"); + for (const name of ["Minimize window", "Maximize window", "Close window"]) { + const button = screen.getByRole("button", { name }); + fireEvent.pointerDown(button); + fireEvent.click(button); + } + expect(minimize).toHaveBeenCalledOnce(); + expect(maximize).toHaveBeenCalledOnce(); + expect(close).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); + expect(screen.getByLabelText("URL")).toHaveValue( + "https://example.test/retained", + ); +}); +it("keeps copyable stale input and allows Journal locking without applying it", () => { + const lock = vi.fn(), + apply = vi.fn(() => null), + cancel = vi.fn(); + render( + ({ left: 0, top: 0, bottom: 20 })} + onApply={apply} + onCancel={cancel} + onLock={lock} + />, + ); + expect(screen.getByRole("button", { name: "Apply link" })).toBeDisabled(); + expect(screen.getByLabelText("URL")).not.toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Lock Journal" })); + expect(lock).toHaveBeenCalledOnce(); + expect(apply).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); +}); +it("rejects an unretainable change before displaying it", () => { + render( + { + throw new Error("Synthetic retention limit"); + }} + anchor={() => ({ left: 0, top: 0, bottom: 20 })} + onApply={() => null} + onCancel={() => {}} + />, + ); + fireEvent.change(screen.getByLabelText("URL"), { + target: { value: "unretainable" }, + }); + expect(screen.getByLabelText("URL")).toHaveValue("retained"); + expect(screen.getByRole("alert")).toHaveTextContent( + "Synthetic retention limit", + ); +}); + +it("restores the accepted URL after a protected editor becomes read-only and editable again", async () => { + const control: EditorLinkControl = { + draft: { + from: 1, + to: 1, + source: "toolbar", + selection: { type: "text", anchor: 1, head: 1 }, + initialUrl: "", + url: "", + }, + valid: true, + open: vi.fn(), + change: vi.fn(), + cancel: vi.fn(), + beginApply: vi.fn(() => () => {}), + }; + const doc = { + type: "doc" as const, + content: [{ type: "paragraph" as const, content: [] }], + }; + const fixture = (readOnly: boolean) => ( + {}} + readOnly={readOnly} + linkControl={control} + /> + ); + const view = render(fixture(false)); + await screen.findByLabelText("URL"); + fireEvent.change(screen.getByLabelText("URL"), { + target: { value: "https://example.test/accepted" }, + }); + expect(control.change).toHaveBeenCalledWith("https://example.test/accepted"); + view.rerender(fixture(true)); + expect( + screen.queryByRole("dialog", { name: "Web link" }), + ).not.toBeInTheDocument(); + expect(control.cancel).not.toHaveBeenCalled(); + view.rerender(fixture(false)); + expect(await screen.findByLabelText("URL")).toHaveValue( + "https://example.test/accepted", + ); +}); diff --git a/web/tests/ui/menu.test.tsx b/web/tests/ui/menu.test.tsx index 6e5737b..9a10df3 100644 --- a/web/tests/ui/menu.test.tsx +++ b/web/tests/ui/menu.test.tsx @@ -1,8 +1,26 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { Menu } from "../../src/ui/Menu"; describe("action menu", () => { + it.each([140, 150])("keeps the inert closing surface for its rendered %ims exit", (duration) => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const computed = vi.spyOn(window, "getComputedStyle").mockImplementation(() => { + const style = document.createElement("div").style; + style.animationDuration = `${duration}ms`; style.animationDelay = "0s"; + return style; + }); + try { + const { container } = render(); + fireEvent.click(screen.getByRole("button", { name: "Timed actions" })); + fireEvent.keyDown(screen.getByRole("menuitem", { name: "Run" }), { key: "Escape" }); + expect(container.querySelector(".action-menu-closing")).toHaveAttribute("inert"); + act(() => vi.advanceTimersByTime(duration - 1)); + expect(container.querySelector(".action-menu-closing")).not.toBeNull(); + act(() => vi.advanceTimersByTime(1)); + expect(container.querySelector(".action-menu")).toBeNull(); + } finally { computed.mockRestore(); vi.useRealTimers(); } + }); it("navigates enabled items and dismisses with Escape or outside pointer", async () => { render(<> { it("keeps a canceled or invalid link query and inserts a valid link", async () => { const { editor, user } = await setup(); - const prompt = vi - .spyOn(window, "prompt") - .mockReturnValueOnce(null) - .mockReturnValueOnce("javascript:bad") - .mockReturnValueOnce("https://example.com"); - vi.spyOn(window, "alert").mockImplementation(() => {}); + const prompt = vi.spyOn(window, "prompt"); + const alert = vi.spyOn(window, "alert"); await user.type(editor, "/l"); fireEvent.mouseDown(screen.getByRole("option", { name: "Web link" })); fireEvent.click(screen.getByRole("option", { name: "Web link" })); + expect(screen.getByRole("dialog", { name: "Web link" })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Cancel" })); expect(editor.textContent).toBe("/l"); await user.type(editor, "i"); fireEvent.mouseDown(screen.getByRole("option", { name: "Web link" })); fireEvent.click(screen.getByRole("option", { name: "Web link" })); + await user.type(screen.getByLabelText("URL"), "javascript:bad"); + await user.click(screen.getByRole("button", { name: "Apply link" })); + expect(screen.getByText("Enter a valid web or email link.")).toBeVisible(); expect(editor.textContent).toBe("/li"); - await user.type(editor, "n{Enter}"); + await user.clear(screen.getByLabelText("URL")); + await user.type(screen.getByLabelText("URL"), "https://example.com{Enter}"); expect(editor.querySelector("a")?.getAttribute("href")).toBe( "https://example.com", ); - expect(prompt).toHaveBeenCalledTimes(3); + expect(editor.textContent).toBe("https://example.com"); + expect(prompt).not.toHaveBeenCalled(); + expect(alert).not.toHaveBeenCalled(); vi.restoreAllMocks(); });