[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
import "./noteMath.scss";
export { InlineMarkdownEditor } from "./InlineMarkdownEditor";

View File

@@ -0,0 +1,786 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom";
import {
annotateNoteCodeBlockDeleteButtons,
createNoteDecorationMutationScheduler,
getHostPickerTriggerRange,
getNoteDecorationMutationDelay,
getRenderedNoteHeadingText,
isNoteSmallImageWidth,
isNoteMathLanguageLabel,
isSupportedNoteExternalHref,
isPointerInsideLinkActionHoverZone,
invokeNoteEditorDialogAction,
linkActionStatesEqual,
NOTE_SMALL_IMAGE_MAX_WIDTH,
NOTE_EDIT_DECORATION_DEBOUNCE_MS,
resolveHostPickerPopupPosition,
scrollNoteHeadingIntoView,
shouldApplyExternalNoteMarkdown,
shouldRenderNoteMathFormula,
shouldInsertClipboardTextAsMarkdown,
shouldHandleHostPickerNavigationKey,
} from "./InlineMarkdownEditor.tsx";
import { getSourceEditDelta, shouldCoalesceSourceUndoStep } from "./NoteSourceEditor.tsx";
test("source undo history coalesces only adjacent edits of the same typing kind", () => {
const previous = { inputType: "insertText", at: 1_000, caret: 2 };
const adjacentInsert = getSourceEditDelta("ab", "abc");
const movedCaretInsert = getSourceEditDelta("ab", "axb");
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertText", 1_500, adjacentInsert), true);
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertText", 1_500, movedCaretInsert), false);
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertText", 1_751, adjacentInsert), false);
assert.equal(shouldCoalesceSourceUndoStep(previous, "deleteContentBackward", 1_100, adjacentInsert), false);
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertFromPaste", 1_100, adjacentInsert), false);
assert.equal(shouldCoalesceSourceUndoStep(null, "insertText", 1_100, adjacentInsert), false);
});
test("math language detection does not mistake plain text blocks for TeX", () => {
assert.equal(isNoteMathLanguageLabel("math"), false);
assert.equal(isNoteMathLanguageLabel("Math (LaTeX)"), false);
assert.equal(isNoteMathLanguageLabel("language-latex"), true);
assert.equal(isNoteMathLanguageLabel("language-tex highlighted"), true);
assert.equal(isNoteMathLanguageLabel("latex"), true);
assert.equal(isNoteMathLanguageLabel("tex"), true);
assert.equal(isNoteMathLanguageLabel("公式"), false);
assert.equal(isNoteMathLanguageLabel("text"), false);
assert.equal(isNoteMathLanguageLabel("plaintext"), false);
assert.equal(isNoteMathLanguageLabel("typescript"), false);
assert.equal(shouldRenderNoteMathFormula("Plain text"), false);
assert.equal(shouldRenderNoteMathFormula("plaintext"), false);
assert.equal(shouldRenderNoteMathFormula(""), false);
assert.equal(shouldRenderNoteMathFormula("math"), false);
assert.equal(shouldRenderNoteMathFormula("latex"), true);
assert.equal(shouldRenderNoteMathFormula("tex"), true);
});
test("live note decoration scans are debounced while preview mounts stay immediate", () => {
assert.equal(getNoteDecorationMutationDelay("edit"), NOTE_EDIT_DECORATION_DEBOUNCE_MS);
assert.equal(getNoteDecorationMutationDelay("live"), NOTE_EDIT_DECORATION_DEBOUNCE_MS);
assert.equal(getNoteDecorationMutationDelay("preview"), 0);
let nextId = 1;
let runCount = 0;
const timers = new Map<number, () => void>();
const frames = new Map<number, () => void>();
const runtime = {
requestFrame: (callback: () => void) => {
const id = nextId++;
frames.set(id, callback);
return id;
},
cancelFrame: (id: number) => { frames.delete(id); },
setTimer: (callback: () => void, delay: number) => {
assert.equal(delay, NOTE_EDIT_DECORATION_DEBOUNCE_MS);
const id = nextId++;
timers.set(id, callback);
return id;
},
clearTimer: (id: number) => { timers.delete(id); },
};
const editScheduler = createNoteDecorationMutationScheduler("edit", () => { runCount += 1; }, runtime);
editScheduler.schedule();
editScheduler.schedule();
editScheduler.schedule();
assert.equal(runCount, 0);
assert.equal(timers.size, 1);
const pendingEdit = [...timers.values()][0];
timers.clear();
pendingEdit();
assert.equal(runCount, 1);
editScheduler.schedule();
assert.equal(timers.size, 1);
const cancelledEdit = [...timers.values()][0];
editScheduler.cancel();
assert.equal(timers.size, 0);
cancelledEdit();
assert.equal(runCount, 1);
const previewScheduler = createNoteDecorationMutationScheduler("preview", () => { runCount += 1; }, runtime);
previewScheduler.schedule();
previewScheduler.schedule();
assert.equal(frames.size, 1);
const pendingPreview = [...frames.values()][0];
frames.clear();
pendingPreview();
assert.equal(runCount, 2);
});
test("note link and image actions open the editor dialogs", () => {
const opened: string[] = [];
const dialogs = {
openImageDialog: () => opened.push("image"),
openLinkDialog: () => opened.push("link"),
};
assert.equal(invokeNoteEditorDialogAction("link", dialogs), true);
assert.equal(invokeNoteEditorDialogAction("image", dialogs), true);
assert.equal(invokeNoteEditorDialogAction("bold", dialogs), false);
assert.equal(invokeNoteEditorDialogAction("link", null), false);
assert.deepEqual(opened, ["link", "image"]);
});
test("toolbar text selection is restricted to the note editor", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /if \(domText\) \{/);
assert.match(source, /domSelection && domSelection\.rangeCount > 0 && container/);
assert.match(source, /container\.contains\(range\.startContainer\)/);
assert.match(source, /container\.contains\(range\.endContainer\)/);
assert.match(source, /return domText;[\s\S]*return "";[\s\S]*querySelector\("\[contenteditable\]"\)/);
});
test("note outline jumps to the matching rendered heading", () => {
let scrollOptions: ScrollIntoViewOptions | undefined;
const first = {
tagName: "H1",
textContent: "Quoted",
scrollIntoView: () => undefined,
} as unknown as HTMLElement;
const second = {
tagName: "H2",
textContent: "Real",
scrollIntoView: (options?: ScrollIntoViewOptions) => {
scrollOptions = options;
},
} as unknown as HTMLElement;
const root = {
querySelectorAll: (selector: string) => {
assert.match(selector, /\.netcatty-mdx-content h1/);
assert.match(selector, /\.netcatty-mdx-content h6/);
return [first, second];
},
};
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Real" }), true);
assert.deepEqual(scrollOptions, {
behavior: "smooth",
block: "start",
inline: "nearest",
});
assert.equal(scrollNoteHeadingIntoView(root, { level: 3, text: "Missing" }), false);
assert.equal(scrollNoteHeadingIntoView(null, { level: 1, text: "None" }), false);
});
test("note outline matches rendered whitespace and strikethrough heading text", () => {
let scrolled = false;
const renderedHeading = {
tagName: "H2",
textContent: "Removed\n title",
scrollIntoView: () => {
scrolled = true;
},
} as unknown as HTMLElement;
const root = {
querySelectorAll: () => [renderedHeading],
};
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Removed title" }), true);
assert.equal(scrolled, true);
});
test("note outline matches image alt text inside rendered headings", () => {
const textNode = (textContent: string) => ({ nodeType: 3, textContent, childNodes: [] });
const imageNode = (alt: string) => ({
nodeType: 1,
tagName: "IMG",
textContent: "",
childNodes: [],
getAttribute: (name: string) => name === "alt" ? alt : null,
});
let scrolled = 0;
const imageHeading = {
tagName: "H2",
textContent: "",
childNodes: [imageNode("Logo")],
scrollIntoView: () => { scrolled += 1; },
} as unknown as HTMLElement;
const mixedHeading = {
tagName: "H2",
textContent: "Release notes",
childNodes: [textNode("Release "), imageNode("Logo"), textNode(" notes")],
scrollIntoView: () => { scrolled += 1; },
} as unknown as HTMLElement;
const root = { querySelectorAll: () => [imageHeading, mixedHeading] };
assert.equal(getRenderedNoteHeadingText(imageHeading), "Logo");
assert.equal(getRenderedNoteHeadingText(mixedHeading), "Release Logo notes");
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Logo" }), true);
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Release Logo notes" }), true);
assert.equal(scrolled, 2);
});
test("host picker navigation keys are handled even before a query is typed", () => {
assert.equal(shouldHandleHostPickerNavigationKey(true, "ArrowDown", 3), true);
assert.equal(shouldHandleHostPickerNavigationKey(true, "ArrowUp", 3), true);
assert.equal(shouldHandleHostPickerNavigationKey(true, "Enter", 3), true);
assert.equal(shouldHandleHostPickerNavigationKey(true, "Tab", 3), true);
});
test("host picker uses a constrained virtual list and keeps pointer selection", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /FixedSizeVirtualList/);
assert.match(source, /ref=\{hostPickerListRef\}/);
assert.match(source, /HOST_PICKER_LIST_MAX_HEIGHT/);
assert.match(
source,
/filteredHosts\.length === 0\s*\?\s*HOST_PICKER_EMPTY_HEIGHT\s*:\s*HOST_PICKER_LIST_VERTICAL_PADDING \+ filteredHosts\.length \* HOST_PICKER_ROW_HEIGHT/,
);
assert.match(source, /onMouseDown=\{\(event\) => event\.preventDefault\(\)\}/);
assert.match(source, /onClick=\{\(\) => insertHostLink\(host\)\}/);
});
test("host picker still lets ordinary trigger text continue through the editor", () => {
assert.equal(shouldHandleHostPickerNavigationKey(true, "@", 3), false);
assert.equal(shouldHandleHostPickerNavigationKey(true, "/", 3), false);
assert.equal(shouldHandleHostPickerNavigationKey(true, "a", 3), false);
});
test("host picker does not consume submit keys when there are no hosts to choose", () => {
assert.equal(shouldHandleHostPickerNavigationKey(true, "ArrowDown", 0), false);
assert.equal(shouldHandleHostPickerNavigationKey(true, "Enter", 0), false);
assert.equal(shouldHandleHostPickerNavigationKey(true, "Escape", 0), true);
});
test("link action hover zone keeps the open button reachable but not sticky", () => {
const action = { href: "https://example.com", label: "example", left: 100, top: 50 };
assert.equal(isPointerInsideLinkActionHoverZone(action, 105, 55), true);
assert.equal(isPointerInsideLinkActionHoverZone(action, 95, 45), true);
assert.equal(isPointerInsideLinkActionHoverZone(action, 160, 55), false);
assert.equal(isPointerInsideLinkActionHoverZone(null, 105, 55), false);
});
test("link action state equality skips identical hover chips", () => {
const a = { href: "https://example.com", label: "example", left: 100, top: 50 };
assert.equal(linkActionStatesEqual(a, { ...a }), true);
assert.equal(linkActionStatesEqual(a, { ...a, left: 101 }), false);
assert.equal(linkActionStatesEqual(a, null), false);
assert.equal(linkActionStatesEqual(null, null), true);
});
test("small note image width threshold matches README badge sizes", () => {
assert.equal(isNoteSmallImageWidth(32), true);
assert.equal(isNoteSmallImageWidth("96"), true);
assert.equal(isNoteSmallImageWidth(NOTE_SMALL_IMAGE_MAX_WIDTH), true);
assert.equal(isNoteSmallImageWidth(128), false);
assert.equal(isNoteSmallImageWidth(2000), false);
assert.equal(isNoteSmallImageWidth(""), false);
assert.equal(isNoteSmallImageWidth(null), false);
});
test("note image actions use a bordered toolbar shown only on hover or focus", () => {
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
assert.match(
styles,
/\[data-editor-block-type="image"\] \[class\*="_editImageToolbar_"\][\s\S]*?gap:\s*0\.0625rem;[\s\S]*?padding:\s*0\.125rem;[\s\S]*?opacity:\s*0;[\s\S]*?pointer-events:\s*none;[\s\S]*?border:\s*1px solid/s,
);
assert.match(
styles,
/\[data-editor-block-type="image"\] \[class\*="_editImageToolbar_"\] button,[\s\S]*?width:\s*1\.375rem;[\s\S]*?height:\s*1\.375rem;/s,
);
assert.match(
styles,
/\[data-editor-block-type="image"\] \[class\*="_editImageToolbar_"\] button svg,[\s\S]*?width:\s*0\.875rem;[\s\S]*?height:\s*0\.875rem;/s,
);
assert.match(
styles,
/\[data-editor-block-type="image"\]:hover \[class\*="_editImageToolbar_"\][\s\S]*?opacity:\s*1;[\s\S]*?pointer-events:\s*auto;/s,
);
assert.match(
styles,
/\[data-editor-block-type="image"\]:focus-within \[class\*="_editImageToolbar_"\]/,
);
assert.doesNotMatch(
styles,
/\[data-editor-block-type="image"\]\[data-note-img-size="sm"\] \[class\*="_editImageToolbar_"\]/,
);
});
test("host picker trigger range only covers the typed trigger and query", () => {
const text = "before\n\n@10.2.0.32";
const range = getHostPickerTriggerRange(text);
assert.deepEqual(range, {
query: "10.2.0.32",
startOffset: "before\n\n".length,
trigger: "@",
});
assert.equal(text.slice(0, range?.startOffset), "before\n\n");
});
test("host picker trigger range supports slash without stealing ordinary text", () => {
assert.deepEqual(getHostPickerTriggerRange("run /prod"), {
query: "prod",
startOffset: "run ".length,
trigger: "/",
});
assert.equal(getHostPickerTriggerRange("email foo@bar"), null);
});
test("host picker opens above the caret when the bottom edge has no room", () => {
const position = resolveHostPickerPopupPosition({
anchorRect: { left: 520, top: 910, bottom: 930, width: 1, height: 20 },
containerRect: { left: 400, top: 40, bottom: 960, width: 1200, height: 920 },
availableHostCount: 8,
viewportHeight: 960,
});
assert.equal(position.left, 120);
assert.ok(position.top < 870);
});
test("host picker stays below the caret when there is enough room", () => {
const position = resolveHostPickerPopupPosition({
anchorRect: { left: 520, top: 160, bottom: 180, width: 1, height: 20 },
containerRect: { left: 400, top: 40, bottom: 960, width: 1200, height: 920 },
availableHostCount: 4,
viewportHeight: 960,
});
assert.equal(position.left, 120);
assert.equal(position.top, 150);
});
test("pasted markdown is detected only when it has renderable structure", () => {
assert.equal(shouldInsertClipboardTextAsMarkdown("# Runbook\n\n- restart sshd"), true);
assert.equal(shouldInsertClipboardTextAsMarkdown("Open [docs](https://example.com)"), true);
assert.equal(shouldInsertClipboardTextAsMarkdown("```sh\nuptime\n```"), true);
assert.equal(shouldInsertClipboardTextAsMarkdown("plain text from clipboard"), false);
assert.equal(shouldInsertClipboardTextAsMarkdown("https://example.com/path_(x)"), false);
// Image markdown / raw HTML img must intercept so notes can render remote images.
assert.equal(shouldInsertClipboardTextAsMarkdown("![logo](https://example.com/logo.png)"), true);
assert.equal(shouldInsertClipboardTextAsMarkdown('<img alt="logo" src="https://example.com/logo.png" />'), true);
});
test("note editor registers a code block editor for pasted fenced code", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(
source,
/codeBlockPlugin\([^)]*\),\s*codeMirrorPlugin\(\{\s*codeBlockLanguages:/s,
);
assert.match(source, /codeMirrorExtensions:\s*\[\s*\.\.\.NOTE_CODE_MIRROR_EXTENSIONS,/);
assert.match(source, /syntaxHighlighting\(noteCodeHighlightStyle\)/);
// Tooltips mount on body to escape clipped code blocks, so their placement
// must be bounded to this editor instead of the whole window.
assert.match(
source,
/createNoteCodeTooltipExtensions\(\s*typeof document === "undefined" \? undefined : document\.body,/,
);
assert.match(source, /\(\) => containerRef\.current,/);
});
test("note editor enables image plugin for remote markdown images", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /imagePlugin\(\{\s*allowSetImageDimensions:\s*true/);
});
test("note editor exposes its modes from a borderless title-row dropdown", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
const managerSource = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
const toolbarSource = readFileSync(new URL("./NoteToolbar.tsx", import.meta.url), "utf8");
assert.match(source, /type NoteEditorMode/);
// The app-owned NoteToolbar (not MDXEditor's toolbarPlugin) hosts the
// formatting controls; MDXEditor must not render its own toolbar.
assert.doesNotMatch(source, /toolbarPlugin\(/);
assert.match(toolbarSource, /onAction\?\.\("undo"\)/);
assert.match(toolbarSource, /onAction\?\.\("redo"\)/);
assert.match(toolbarSource, /<Undo2 size=\{14\} \/>/);
assert.match(toolbarSource, /<Redo2 size=\{14\} \/>/);
// Preview and edit both use MDXEditor (readOnly in preview).
assert.match(source, /readOnly=\{editorMode === "preview"\}/);
assert.match(source, /key=\{editorMode\}/);
assert.match(source, /netcatty-mdx-editor--preview/);
assert.doesNotMatch(source, /NoteMarkdownPreview|react-markdown|github-markdown/);
assert.match(source, /editorMode = controlledEditorMode \?\? "edit"/);
assert.doesNotMatch(source, /data-note-mode-switch/);
assert.doesNotMatch(source, /absolute -top-9/);
assert.match(managerSource, /data-note-title-row/);
assert.match(toolbarSource, /data-note-mode-dropdown-trigger/);
assert.match(toolbarSource, /data-note-mode-option=\{option\.mode\}/);
assert.match(toolbarSource, /gap-1\.5 border-0 bg-transparent px-2/);
assert.match(toolbarSource, /<SelectContent align="end" className="w-max min-w-\[10rem\]">/);
assert.match(
toolbarSource,
/data-note-mode-option=\{option\.mode\}[\s\S]*?className="h-9 whitespace-nowrap"/,
);
assert.match(toolbarSource, /<Select value=\{normalizedMode\}/);
assert.doesNotMatch(toolbarSource, /data-note-mode-switch=/);
assert.match(managerSource, /data-note-title-row[\s\S]*?<NoteModeDropdown[\s\S]*?<NoteToolbar/);
assert.match(managerSource, /<NoteModeDropdown[\s\S]*?editorMode=\{noteEditorMode\}/);
assert.match(managerSource, /editorMode=\{noteEditorMode\}/);
assert.doesNotMatch(`${source}\n${managerSource}`, /role="tablist"|role="tab"|renderModeButton/);
assert.doesNotMatch(`${source}\n${managerSource}`, /className="mb-2 flex shrink-0 items-center justify-end"/);
});
test("note markdown toolbar remains usable in narrow panes", () => {
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
const toolbarSource = readFileSync(new URL("./NoteToolbar.tsx", import.meta.url), "utf8");
assert.doesNotMatch(source, /MoreHorizontal|data-note-toolbar-more|netcatty-note-toolbar-more/);
assert.match(
styles,
/\.netcatty-mdx-editor\s*\{[^}]*container-type:\s*inline-size;/s,
);
// Keep fixed-containing-block in sync with MDX linkDialog coordinate math
// (container-type alone is not a fixed CB in browsers).
assert.match(
styles,
/\.netcatty-mdx-editor\s*\{[^}]*transform:\s*translateZ\(0\);/s,
);
// The app-owned NoteToolbar scrolls horizontally when the pane is narrow so
// formatting buttons are never clipped; the scrollbar stays visible.
assert.match(
toolbarSource,
/overflow-x-auto [^"]*\[scrollbar-width:thin\]/,
);
assert.match(
toolbarSource,
/\[&::-webkit-scrollbar\]:h-1\.5/,
);
assert.match(
toolbarSource,
/\[&::-webkit-scrollbar-thumb\]:bg-border\/70/,
);
assert.match(
toolbarSource,
/flex flex-1 items-center gap-0\.5 min-w-0 overflow-x-auto/,
);
});
test("preview mode opens links directly without showing the edit hover action", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /const handleClickCapture = useCallback/);
assert.match(
source,
/if \(editorMode === "preview"\) \{[\s\S]*toggleTaskListItemAtIndex[\s\S]*const handled = openLink\(href, label\);/,
"preview click path handles task toggles then openable links",
);
assert.match(source, /const handled = openLink\(href, label\);[\s\S]*if \(!handled\) return;[\s\S]*event\.preventDefault\(\);/);
assert.match(source, /scheduleHostPickerUpdate\(\);\s*\n\s*\}, \[commitMarkdown, editorMode, openLink, scheduleHostPickerUpdate\]/);
assert.match(source, /onClickCapture=\{\(event\) => \{[\s\S]*blockWhileContentSwapping[\s\S]*handleClickCapture/);
assert.match(source, /if \(editorMode !== "edit"\) \{[\s\S]*setLinkActionIfChanged\(null\);[\s\S]*return;/);
assert.match(source, /\{editorMode === "edit" && linkAction && \(/);
});
test("preview mode only intercepts links netcatty can open", () => {
assert.equal(isSupportedNoteExternalHref("https://example.com/docs"), true);
assert.equal(isSupportedNoteExternalHref("http://example.com/docs"), true);
assert.equal(isSupportedNoteExternalHref("mailto:support@example.com"), true);
assert.equal(isSupportedNoteExternalHref("#section"), false);
assert.equal(isSupportedNoteExternalHref("/docs"), false);
assert.equal(isSupportedNoteExternalHref("file:///tmp/readme.md"), false);
});
test("pasting inside code blocks keeps CodeMirror in control", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /export const isNotePasteInsideCodeBlock/);
assert.match(source, /element\?\.closest/);
assert.match(source, /\.cm-editor/);
assert.match(source, /_codeMirrorWrapper_/);
assert.match(
source,
/pasteInsideCodeBlock:\s*isNotePasteInsideCodeBlock\(event\.target\)/,
);
});
test("note code block editor colors follow the app theme", () => {
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
assert.match(styles, /\.netcatty-mdx-editor\s+\.cm-editor/);
assert.match(styles, /\.netcatty-mdx-editor\s+\.cm-gutters/);
assert.match(styles, /background:\s*hsl\(var\(--secondary\)/);
assert.match(styles, /color:\s*hsl\(var\(--foreground\)/);
assert.match(styles, /--note-code-token-keyword:\s*color-mix\(in oklab,\s*hsl\(var\(--primary\)\)/);
assert.match(styles, /\.netcatty-mdx-editor\s+\.cm-content\s+\.netcatty-code-token-keyword/);
assert.match(styles, /\.netcatty-mdx-editor\s+\.cm-content\s+\.netcatty-code-token-string/);
assert.doesNotMatch(styles, /span\[class\*="ͼ"\]/);
assert.doesNotMatch(styles, /\.netcatty-mdx-editor\s+\.cm-line\s+span/);
});
test("note code block active line is highlighted only while focused", () => {
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
assert.match(
styles,
/\.netcatty-mdx-editor\s+\.cm-activeLine,\s*\.netcatty-mdx-editor\s+\.cm-activeLineGutter\s*\{[^}]*background:\s*transparent/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor\s+\.cm-editor:focus-within\s+\.cm-activeLine,\s*\.netcatty-mdx-editor\s+\.cm-editor:focus-within\s+\.cm-activeLineGutter\s*\{[^}]*background:\s*hsl\(var\(--primary\)\s*\/\s*0\.08\)/s,
);
});
test("note code block frame is borderless and language picker is compact", () => {
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
assert.match(
styles,
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*border:\s*0\s*!important;[^}]*background:\s*transparent\s*!important;[^}]*padding:\s*0\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor\s+\.cm-editor\s*\{[^}]*border:\s*0\s*!important;[^}]*background:\s*transparent\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-content\s+pre\s*\{[^}]*border:\s*0;[^}]*background:\s*transparent;[^}]*padding:\s*0;/s,
);
assert.match(
styles,
/\.netcatty-note-code-copy\s*\{[^}]*border:\s*0\s*!important;[^}]*background:\s*transparent\s*!important;[^}]*box-shadow:\s*none\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*position:\s*absolute\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor \[class\*="_codeMirrorToolbar_"\] \[class\*="_selectTrigger_"\]\s*\{[^}]*height:\s*1\.45rem\s*!important;[^}]*font-size:\s*11px\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor \[class\*="_codeMirrorToolbar_"\] \[class\*="_tooltipTrigger_"\]\s*\{[^}]*display:\s*inline-flex\s*!important;[^}]*align-items:\s*center\s*!important;[^}]*align-self:\s*center\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor \[class\*="_codeMirrorToolbar_"\]\s+\[class\*="_selectTrigger_"\]\s*\{[^}]*width:\s*auto\s*!important;[^}]*min-width:\s*0\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor \[class\*="_toolbarCodeBlockLanguageSelectContent_"\][\s\S]*width:\s*auto\s*!important;[\s\S]*min-width:\s*max-content\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor \[class\*="_toolbarCodeBlockLanguageSelectContent_"\] \[class\*="_selectItem_"\][\s\S]*font-size:\s*11px\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor\s+\.cm-editor\s*\{[^}]*font-size:\s*13px\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor\s+\.cm-line\s*\{[^}]*line-height:\s*1\.45\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor\s+\.cm-gutterElement\s*\{[^}]*font-size:\s*13px\s*!important;[^}]*line-height:\s*1\.45\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*gap:\s*0;[^}]*margin:\s*0\.25rem\s+0\s+0\.55rem;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorWrapper_"\]\s+\.cm-content\s*\{[^}]*padding:\s*0\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor\s+\.cm-gutters\s*\{[^}]*background:\s*transparent\s*!important;[^}]*padding:\s*0\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor--preview\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*display:\s*none\s*!important;/s,
);
});
test("note formulas render without framed surfaces", () => {
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
assert.doesNotMatch(styles, /data-language="math"/);
assert.match(
styles,
/\.netcatty-math-formula-preview\s*\{[^}]*background:\s*transparent;[^}]*border:\s*0;/s,
);
assert.match(
styles,
/\.netcatty-math-reading-mode\s*\{[^}]*background:\s*transparent\s*!important;[^}]*border:\s*none\s*!important;[^}]*padding:\s*0\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-math-reading-mode\s+\.netcatty-math-formula-preview\s*\{[^}]*background:\s*transparent;/s,
);
assert.match(styles, /\.netcatty-math-formula-preview\s*\{[^}]*justify-content:\s*safe center;[^}]*overflow-x:\s*auto;/s);
assert.match(
styles,
/\.netcatty-math-reading-mode\s*>\s*\.netcatty-note-code-copy\s*\{[^}]*display:\s*none\s*!important;/s,
);
});
test("getCodeMirrorBlockText reads rendered code block lines", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /export const getCodeMirrorBlockText/);
assert.match(source, /\.cm-content \.cm-line/);
assert.match(source, /\.join\("\\n"\)/);
});
test("annotateNoteCodeBlockCopyButtons adds a copy action to code blocks", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /export const annotateNoteCodeBlockCopyButtons/);
assert.match(source, /data-note-code-copy/);
assert.match(source, /getCodeMirrorBlockText\(wrapper\)/);
assert.match(source, /onCopy\(text\)/);
});
test("annotateNoteCodeBlockCopyButtons treats a CSS-hidden toolbar as absent so preview shows the copy button", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /getComputedStyle\(toolbar\)\.display !== "none"/);
assert.match(source, /toolbarVisible/);
assert.match(source, /firstButton\.parentElement !== wrapper/);
assert.match(source, /wrapper\.appendChild\(firstButton\)/);
});
test("annotateNoteCodeBlockDeleteButtons swaps the delete icon only once", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
const dom = new JSDOM(`
<div id="container">
<div class="_codeMirrorToolbar_test">
<button class="_toolbarCodeBlockLanguageSelectTrigger_test"></button>
<button id="delete"></button>
</div>
</div>
`);
const previousHTMLElement = Object.getOwnPropertyDescriptor(globalThis, "HTMLElement");
Object.defineProperty(globalThis, "HTMLElement", {
configurable: true,
value: dom.window.HTMLElement,
});
try {
const container = dom.window.document.querySelector("#container") as HTMLElement;
const deleteButton = dom.window.document.querySelector("#delete") as HTMLButtonElement;
annotateNoteCodeBlockDeleteButtons(container);
const installedIcon = deleteButton.firstElementChild;
assert.equal(deleteButton.dataset.noteCodeDelete, "true");
assert.equal(installedIcon?.nodeName.toLowerCase(), "svg");
const observer = new dom.window.MutationObserver(() => {});
observer.observe(deleteButton, { attributes: true, childList: true, subtree: true });
annotateNoteCodeBlockDeleteButtons(container);
assert.equal(observer.takeRecords().length, 0);
assert.equal(deleteButton.firstElementChild, installedIcon);
observer.disconnect();
} finally {
if (previousHTMLElement) {
Object.defineProperty(globalThis, "HTMLElement", previousHTMLElement);
} else {
delete (globalThis as { HTMLElement?: unknown }).HTMLElement;
}
dom.window.close();
}
assert.match(source, /export const annotateNoteCodeBlockDeleteButtons/);
assert.match(source, /data-note-code-delete/);
assert.match(source, /DELETE_ICON_SVG/);
assert.match(source, /stroke="currentColor"/);
assert.match(source, /stroke-width="2"/);
});
test("note preview uses MDX readOnly with code-copy chrome", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
assert.match(source, /removeNoteCodeBlockCopyButtons/);
assert.match(source, /readOnly=\{editorMode === "preview"\}/);
assert.match(source, /annotateCodeBlockCopyButtons/);
assert.match(source, /annotateNoteCodeBlockCopyButtons/);
assert.match(source, /MutationObserver/);
assert.match(source, /setAttribute\("aria-label", copiedLabel\)/);
assert.match(styles, /\.netcatty-note-code-copy/);
assert.match(
styles,
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorWrapper_"\]:hover\s+\.netcatty-note-code-copy/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*display:\s*flex;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*justify-content:\s*flex-end;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorToolbar_"\]\s+\[class\*="_selectTrigger_"\]\s*\{[^}]*font-size:\s*11px\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorToolbar_"\]\s+button[^{]*\{[^}]*height:\s*1\.4rem\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorToolbar_"\][^{]*svg\s*\{[^}]*width:\s*10px\s*!important;/s,
);
});
test("annotateMathFormulaBlocks handles empty math blocks and avoids reading toolbar text", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /export const annotateMathFormulaBlocks/);
assert.match(source, /const text = getCodeMirrorBlockText\(wrapper\)\.trim\(\);/);
assert.match(source, /if \(!formulaSource\) \{/);
assert.match(source, /existingPreview\.remove\(\)/);
});
test("NoteSourceEditor manages local draft state to prevent cursor jumping on debounced commits", () => {
const source = readFileSync(new URL("./NoteSourceEditor.tsx", import.meta.url), "utf8");
assert.match(source, /const \[localValue, setLocalValue\] = useState\(value\);/);
assert.match(source, /value=\{localValue\}/);
assert.match(source, /onChange=\{handleChange\}/);
assert.match(source, /prevNoteIdRef\.current/);
assert.match(source, /prevValueRef\.current/);
});
test("source mode compares raw markdown separately from display-normalized markdown", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /const latestSourceMarkdownRef = useRef\(value\)/);
assert.match(source, /markdown === latestSourceMarkdownRef\.current/);
assert.match(source, /latestMarkdownRef\.current = normalizeNotePublicAssetPaths\(markdown\)/);
assert.match(source, /setAcceptedSourceMarkdown\(markdown\)/);
assert.match(
source,
/<NoteSourceEditor[\s\S]*?value=\{noteId !== undefined && noteId !== noteIdRef\.current \? value : acceptedSourceMarkdown\}[\s\S]*?onChange=\{commitSourceMarkdown\}/,
);
});
test("raw source drafts are not overwritten by external values with equivalent display markdown", () => {
const base = {
latestMarkdown: "![x](/x.png)",
syncedMarkdown: "![x](/x.png)",
latestSourceMarkdown: "![x](/x.png)",
syncedSourceMarkdown: "![x](/public/x.png)",
};
assert.equal(shouldApplyExternalNoteMarkdown({
...base,
nextSourceMarkdown: "# Remote",
}), false);
assert.equal(shouldApplyExternalNoteMarkdown({
...base,
nextSourceMarkdown: "![x](/x.png)",
}), true);
assert.equal(shouldApplyExternalNoteMarkdown({
...base,
latestSourceMarkdown: "![x](/public/x.png)",
nextSourceMarkdown: "![x](/x.png)",
}), true);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,583 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { JSDOM } from "jsdom";
import { runWithAct } from "../test-support/renderReactDom.tsx";
/**
* Regression tests for the notes rich editor: markdown that MDX cannot parse
* (e.g. unbalanced angle tags such as `<host>` in prose) used to render as an
* empty editor, so an AI-written or imported note looked like it had lost its
* content. The editor must fall back to the raw markdown source view instead.
*/
const NOTE_MARKDOWN_MDX_CANNOT_PARSE = [
"# SlurmDB Agent Skill",
"",
"## Overview",
"",
"Run the exporter with mysql -h <host> -u <user> -P 3306.",
"",
"The content after the angle tags must stay visible.",
].join("\n");
const PLAIN_NOTE_MARKDOWN = ["# Steps", "", "Promote the replica, then restart the agent."].join("\n");
// Public author-supplied note from issue #3205. Commands are inert test content.
const ISSUE_3205_MARKDOWN = readFileSync(new URL("./test-fixtures/issue-3205.md", import.meta.url), "utf8");
const setupDom = () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost",
});
const window = dom.window;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
function LoadedImageStub() {
const image = window.document.createElement("img");
Object.defineProperty(image, "src", {
get: () => image.getAttribute("src") ?? "",
set: (src: string) => {
image.setAttribute("src", src);
queueMicrotask(() => image.dispatchEvent(new window.Event("load")));
},
});
return image;
}
Object.assign(window.Range.prototype, {
getClientRects: () => [],
getBoundingClientRect: () => new window.DOMRect(),
});
// CodeMirror needs these browser APIs when the full note contains code blocks.
window.matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent: () => false,
});
for (const [key, value] of Object.entries({
window,
Window: window.Window,
Image: LoadedImageStub,
document: window.document,
navigator: window.navigator,
HTMLElement: window.HTMLElement,
HTMLImageElement: window.HTMLImageElement,
HTMLInputElement: window.HTMLInputElement,
HTMLTextAreaElement: window.HTMLTextAreaElement,
HTMLSelectElement: window.HTMLSelectElement,
Element: window.Element,
SVGElement: window.SVGElement,
Node: window.Node,
DocumentFragment: window.DocumentFragment,
Range: window.Range,
NodeFilter: window.NodeFilter,
MutationObserver: window.MutationObserver,
CustomEvent: window.CustomEvent,
DOMRect: window.DOMRect,
Event: window.Event,
KeyboardEvent: window.KeyboardEvent,
MouseEvent: window.MouseEvent,
getComputedStyle: window.getComputedStyle.bind(window),
requestAnimationFrame: window.requestAnimationFrame.bind(window),
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
ResizeObserver: ResizeObserverStub,
IS_REACT_ACT_ENVIRONMENT: true,
})) {
installGlobal(key, value);
}
return {
window,
cleanup() {
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
dom.window.close();
},
};
};
type DomHarness = ReturnType<typeof setupDom>;
type ActiveFormats = {
bold: boolean;
italic: boolean;
underline: boolean;
strikethrough: boolean;
code: boolean;
};
type RenderEditorProps = {
value: string;
noteId?: string;
editorMode?: "edit" | "preview" | "source";
onActiveFormatsChange?: (formats: ActiveFormats) => void;
};
const renderEditor = async (
window: DomHarness["window"],
props: RenderEditorProps,
) => {
const { act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { I18nProvider } = await import("../../application/i18n/I18nProvider.tsx");
const { InlineMarkdownEditor } = await import("./InlineMarkdownEditor.tsx");
const rootNode = window.document.getElementById("root");
assert.ok(rootNode);
const root = createRoot(rootNode);
const changes: string[] = [];
const render = async (nextProps: RenderEditorProps) => act(async () => {
root.render(
<I18nProvider locale="en">
<InlineMarkdownEditor
noteId={nextProps.noteId ?? "note-1"}
value={nextProps.value}
placeholder="Write Markdown notes here..."
editorMode={nextProps.editorMode ?? "edit"}
onChange={(next) => changes.push(next)}
onActiveFormatsChange={nextProps.onActiveFormatsChange}
hosts={[]}
/>
</I18nProvider>,
);
});
await render(props);
// Let the deferred MDX import (and its parse-error reporting) settle.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
return {
rootNode,
changes,
rerender: render,
async unmount() {
await act(async () => {
root.unmount();
});
},
};
};
const querySourceFallback = (rootNode: HTMLElement) =>
rootNode.querySelector<HTMLTextAreaElement>("[data-note-markdown-source-fallback] textarea");
const setTextareaValue = (window: DomHarness["window"], textarea: HTMLTextAreaElement, nextValue: string) => {
const setValue = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
assert.ok(setValue);
setValue.call(textarea, nextValue);
textarea.dispatchEvent(new window.Event("input", { bubbles: true }));
};
test("literal Markdown comparisons render without changing the note source", async () => {
const { window, cleanup } = setupDom();
try {
const value = "Memory<1GB";
const { rootNode, changes, unmount } = await renderEditor(window, {
value,
editorMode: "preview",
});
try {
assert.ok(!querySourceFallback(rootNode), "a comparison must render as ordinary text");
assert.equal(rootNode.querySelector("[contenteditable]")?.textContent, value);
assert.deepEqual(changes, [], "reading the note must preserve its original source");
} finally {
await unmount();
}
} finally {
cleanup();
}
});
test("comparison variants render in paragraphs, tables and alongside HTML", async () => {
const { window, cleanup } = setupDom();
try {
const value = [
"<1GB",
"",
"Memory<1GB, x<=2, x<-1, x<+2, x<.5, x < 3, x\\<4, x&lt;5",
"",
"| Limit |",
"| --- |",
"| **Memory<1GB** |",
"",
'<a href="https://example.com/">limit<1</a><2',
"",
'<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="limit" width="80" height="40" /><3',
"",
"`literal<1 and <host>`",
"",
"```sh",
"cat <<'EOF'",
"<host> and x<1",
"EOF",
"```",
].join("\n");
const { rootNode, changes, unmount } = await renderEditor(window, { value });
try {
assert.ok(!querySourceFallback(rootNode));
const editable = rootNode.querySelector("[contenteditable]");
assert.ok(editable);
assert.match(editable.textContent ?? "", /Memory<1GB, x<=2, x<-1, x<\+2, x<\.5, x < 3, x<4, x<5/);
assert.equal(editable.querySelector("td strong")?.textContent, "Memory<1GB");
assert.equal(editable.querySelector("a")?.getAttribute("href"), "https://example.com/");
assert.equal(editable.querySelector("a")?.textContent, "limit<1");
const image = editable.querySelector("img");
assert.equal(image?.getAttribute("alt"), "limit");
assert.equal(image?.getAttribute("width"), "80");
assert.equal(image?.getAttribute("height"), "40");
assert.equal(editable.querySelector("code")?.textContent, "literal<1 and <host>");
const { getCodeMirrorBlockText } = await import("./InlineMarkdownEditor.tsx");
const codeBlock = editable.querySelector(".cm-editor");
assert.ok(codeBlock);
assert.equal(getCodeMirrorBlockText(codeBlock), "cat <<'EOF'\n<host> and x<1\nEOF");
assert.deepEqual(changes, []);
} finally {
await unmount();
}
} finally {
cleanup();
}
});
test("issue 3205 author note survives source, preview and edit without rewriting", async () => {
const { window, cleanup } = setupDom();
try {
const value = ISSUE_3205_MARKDOWN;
const { rootNode, changes, rerender, unmount } = await renderEditor(window, { value, editorMode: "source" });
try {
assert.equal(rootNode.querySelector("textarea")?.value, value);
for (const editorMode of ["preview", "edit", "preview"] as const) {
await rerender({ value, editorMode });
await runWithAct(async () => { await new Promise((resolve) => setTimeout(resolve, 30)); });
assert.ok(!querySourceFallback(rootNode), `${editorMode} must render the author's note`);
const editable = rootNode.querySelector("[contenteditable]");
assert.ok(editable);
assert.equal(editable.getAttribute("contenteditable"), editorMode === "edit" ? "true" : "false");
assert.equal(editable.querySelector("h1")?.textContent, "设备TCP栈调优操作记录");
assert.match(editable.querySelector("table")?.textContent ?? "", /内存<1GB设备/);
assert.match(editable.textContent ?? "", /配置回滚/);
assert.equal(editable.querySelectorAll(".cm-editor").length, 10);
}
await rerender({ value, editorMode: "source" });
assert.equal(rootNode.querySelector("textarea")?.value, value);
assert.deepEqual(changes, [], "view changes must not rewrite the author's source");
} finally {
await unmount();
}
} finally {
cleanup();
}
});
test("editing a comparison emits Markdown that can be reopened", async () => {
const { window, cleanup } = setupDom();
try {
const value = "Memory<1GB";
const { rootNode, changes, rerender, unmount } = await renderEditor(window, { value });
try {
assert.deepEqual(changes, []);
const editable = rootNode.querySelector("[contenteditable]");
assert.ok(editable);
const { getNearestEditorFromDOMNode, $getRoot, $isTextNode } = await import("lexical");
const editor = getNearestEditorFromDOMNode(editable);
assert.ok(editor);
await runWithAct(async () => {
editor.update(() => {
const text = $getRoot().getFirstDescendant();
assert.ok($isTextNode(text));
text.setTextContent("Memory<2GB");
}, { discrete: true });
});
assert.ok(changes.length > 0, "real edits must still be saved");
const edited = changes.at(-1)!;
await rerender({ value: edited, editorMode: "preview" });
assert.ok(!querySourceFallback(rootNode));
assert.equal(rootNode.querySelector("[contenteditable]")?.textContent, "Memory<2GB");
await rerender({ value: edited, editorMode: "source" });
assert.equal(rootNode.querySelector("textarea")?.value, edited);
} finally {
await unmount();
}
} finally {
cleanup();
}
});
test("preview task checkboxes still save explicit changes without rewriting comparisons", async () => {
const { window, cleanup } = setupDom();
try {
const value = "- [ ] Memory<1GB\n- [x] Ready";
const { rootNode, changes, unmount } = await renderEditor(window, { value, editorMode: "preview" });
try {
assert.deepEqual(changes, []);
const task = rootNode.querySelector<HTMLElement>('li[role="checkbox"]');
assert.ok(task);
await runWithAct(async () => {
task.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 0 }));
});
assert.deepEqual(changes, ["- [x] Memory<1GB\n- [x] Ready"]);
assert.equal(rootNode.querySelector('li[role="checkbox"]')?.getAttribute("aria-checked"), "true");
} finally {
await unmount();
}
} finally {
cleanup();
}
});
test("switching between a comparison note and an unsupported note keeps the fallback scoped", async () => {
const { window, cleanup } = setupDom();
try {
const value = "Memory<1GB";
const { rootNode, changes, rerender, unmount } = await renderEditor(window, { value });
try {
for (const props of [
{ noteId: "unsupported", value: NOTE_MARKDOWN_MDX_CANNOT_PARSE },
{ noteId: "comparison", value },
]) {
await rerender(props);
await runWithAct(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); });
if (props.noteId === "unsupported") {
assert.equal(querySourceFallback(rootNode)?.value, props.value);
} else {
assert.ok(!querySourceFallback(rootNode));
assert.equal(rootNode.querySelector("[contenteditable]")?.textContent, value);
}
}
assert.deepEqual(changes, []);
} finally {
await unmount();
}
} finally {
cleanup();
}
});
test("unrenderable markdown stays visible via the raw source fallback", async () => {
const { window, cleanup } = setupDom();
try {
const { rootNode, unmount } = await renderEditor(window, { value: NOTE_MARKDOWN_MDX_CANNOT_PARSE });
const fallback = querySourceFallback(rootNode);
assert.ok(fallback, "expected the raw markdown fallback to be rendered");
assert.equal(fallback.value, NOTE_MARKDOWN_MDX_CANNOT_PARSE);
const notice = rootNode.querySelector("[data-note-markdown-source-notice]");
assert.ok(notice, "expected an explanatory notice next to the fallback");
// The rich editor must not sit next to the fallback showing a blank page.
assert.equal(rootNode.querySelectorAll("[contenteditable]").length, 0);
await unmount();
} finally {
cleanup();
}
});
test("plain markdown still renders in the rich editor", async () => {
const { window, cleanup } = setupDom();
try {
const { rootNode, unmount } = await renderEditor(window, { value: PLAIN_NOTE_MARKDOWN });
assert.equal(querySourceFallback(rootNode), null);
const editable = rootNode.querySelector<HTMLElement>("[contenteditable]");
assert.ok(editable, "expected the rich editor to stay mounted");
assert.match(editable.textContent || "", /Promote the replica/);
await unmount();
} finally {
cleanup();
}
});
test("unrenderable markdown in preview mode falls back to a read-only source view", async () => {
const { window, cleanup } = setupDom();
try {
const { rootNode, changes, unmount } = await renderEditor(window, {
value: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
editorMode: "preview",
});
const fallback = querySourceFallback(rootNode);
assert.ok(fallback, "expected the raw markdown fallback in preview mode");
assert.equal(fallback.value, NOTE_MARKDOWN_MDX_CANNOT_PARSE);
assert.equal(fallback.readOnly, true);
// Read-only must also block the custom Tab insertion and history handling.
await runWithAct(async () => {
fallback.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Tab",
bubbles: true,
cancelable: true,
}));
});
assert.deepEqual(changes, [], "read-only fallback must not mutate the note");
await unmount();
} finally {
cleanup();
}
});
test("retry imports the latest fallback draft, not the stale prop value", async () => {
const { window, cleanup } = setupDom();
try {
// The host keeps the original (invalid) `value` prop, like the 300 ms
// draft debounce in NotesManager does while the user edits the fallback.
const { rootNode, changes, unmount } = await renderEditor(window, { value: NOTE_MARKDOWN_MDX_CANNOT_PARSE });
const fallback = querySourceFallback(rootNode);
assert.ok(fallback);
const fixedMarkdown = NOTE_MARKDOWN_MDX_CANNOT_PARSE.replace("mysql -h <host> -u <user>", "mysql with host and user");
await runWithAct(async () => {
setTextareaValue(window, fallback, fixedMarkdown);
});
assert.deepEqual(changes, [fixedMarkdown]);
const retry = rootNode.querySelector<HTMLButtonElement>("[data-note-markdown-source-retry]");
assert.ok(retry, "expected a retry action on the fallback notice");
await runWithAct(async () => {
retry.click();
});
await runWithAct(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
const editable = rootNode.querySelector<HTMLElement>("[contenteditable]");
assert.ok(editable, "expected the rich editor back after the retry");
assert.match(editable.textContent || "", /The content after the angle tags/);
assert.equal(querySourceFallback(rootNode), null, "the fallback must be gone after a successful retry");
await unmount();
} finally {
cleanup();
}
});
test("retry rebinds active-format listeners after the rich editor remounts", async () => {
const { window, cleanup } = setupDom();
try {
const formatChanges: ActiveFormats[] = [];
const onActiveFormatsChange = (formats: ActiveFormats) => {
formatChanges.push(formats);
};
const { rootNode, changes, unmount } = await renderEditor(window, {
value: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
onActiveFormatsChange,
});
assert.ok(querySourceFallback(rootNode), "expected the raw markdown fallback");
assert.equal(rootNode.querySelectorAll("[contenteditable]").length, 0);
const callsWhileFallback = formatChanges.length;
const fallback = querySourceFallback(rootNode);
assert.ok(fallback);
const fixedMarkdown = NOTE_MARKDOWN_MDX_CANNOT_PARSE.replace(
"mysql -h <host> -u <user>",
"mysql with host and user",
);
await runWithAct(async () => {
setTextareaValue(window, fallback, fixedMarkdown);
});
assert.deepEqual(changes, [fixedMarkdown]);
const retry = rootNode.querySelector<HTMLButtonElement>("[data-note-markdown-source-retry]");
assert.ok(retry, "expected a retry action on the fallback notice");
await runWithAct(async () => {
retry.click();
});
await runWithAct(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
const editable = rootNode.querySelector<HTMLElement>("[contenteditable]");
assert.ok(editable, "expected the rich editor back after the retry");
assert.equal(querySourceFallback(rootNode), null, "the fallback must be gone after a successful retry");
assert.ok(
formatChanges.length > callsWhileFallback,
"expected onActiveFormatsChange after the rich editor remounted",
);
await unmount();
} finally {
cleanup();
}
});
test("same-note external markdown clears an active fallback", async () => {
const { window, cleanup } = setupDom();
try {
const { rootNode, rerender, unmount } = await renderEditor(window, {
value: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
});
assert.ok(querySourceFallback(rootNode));
await rerender({ value: PLAIN_NOTE_MARKDOWN });
await runWithAct(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.equal(querySourceFallback(rootNode), null);
assert.match(rootNode.querySelector<HTMLElement>("[contenteditable]")?.textContent || "", /Promote the replica/);
await unmount();
} finally {
cleanup();
}
});
test("leading and trailing whitespace does not hide an unrenderable note", async () => {
const { window, cleanup } = setupDom();
try {
const { rootNode, unmount } = await renderEditor(window, {
value: `\n${NOTE_MARKDOWN_MDX_CANNOT_PARSE}\n`,
});
const fallback = querySourceFallback(rootNode);
assert.ok(fallback, "expected padded unrenderable markdown to use the source fallback");
assert.equal(fallback.value, `\n${NOTE_MARKDOWN_MDX_CANNOT_PARSE}\n`);
await unmount();
} finally {
cleanup();
}
});
test("a stale parse error is ignored when the same note has newer markdown", async () => {
const { shouldApplyMdxParseFailure } = await import("./InlineMarkdownEditor.tsx");
assert.equal(shouldApplyMdxParseFailure({
currentNoteId: "note-1",
failedNoteId: "note-1",
currentMarkdown: PLAIN_NOTE_MARKDOWN,
failedMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
}), false);
assert.equal(shouldApplyMdxParseFailure({
currentNoteId: "note-1",
failedNoteId: "note-1",
currentMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
failedMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
}), true);
assert.equal(shouldApplyMdxParseFailure({
currentNoteId: "note-1",
failedNoteId: "note-1",
currentMarkdown: `\n${NOTE_MARKDOWN_MDX_CANNOT_PARSE}\n`,
failedMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
}), true);
});

View File

@@ -0,0 +1,162 @@
import {
Copy,
FileText,
Share2,
} from "lucide-react";
import React, { useCallback, useState, useRef, useEffect } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import {
type VaultNote,
} from "../../domain/notes";
import { copyToClipboard } from "../keychain/utils";
import { toast } from "../ui/toast";
export interface NoteExportMenuProps {
note: VaultNote | null;
allNotes: VaultNote[];
onExportNote: (note: VaultNote) => void;
onExportAll: () => void;
className?: string;
}
export const NoteExportMenu: React.FC<NoteExportMenuProps> = ({
note,
allNotes,
onExportNote,
onExportAll,
className = "",
}) => {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeAndRestoreFocus = useCallback(() => {
setOpen(false);
requestAnimationFrame(() => triggerRef.current?.focus());
}, []);
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
closeAndRestoreFocus();
return;
}
if (e.key === "Tab") {
setOpen(false);
return;
}
const items = Array.from(
menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)') ?? [],
);
if (!items.length) return;
const current = items.indexOf(document.activeElement as HTMLButtonElement);
let next = current;
if (e.key === "ArrowDown") next = (current + 1 + items.length) % items.length;
else if (e.key === "ArrowUp") next = (current - 1 + items.length) % items.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = items.length - 1;
else return;
e.preventDefault();
items[next]?.focus();
};
window.addEventListener("mousedown", handleClickOutside);
window.addEventListener("keydown", handleKeyDown);
requestAnimationFrame(() => {
menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus();
});
return () => {
window.removeEventListener("mousedown", handleClickOutside);
window.removeEventListener("keydown", handleKeyDown);
};
}, [closeAndRestoreFocus, open]);
const handleExportSingleMarkdown = () => {
if (!note) return;
onExportNote(note);
closeAndRestoreFocus();
};
const handleCopyMarkdown = async () => {
if (!note) return;
const ok = await copyToClipboard(note.content);
if (ok) {
toast.success(t("common.copied") || "已复制到剪贴板");
}
closeAndRestoreFocus();
};
const handleExportAllZip = () => {
if (!allNotes.length) return;
onExportAll();
closeAndRestoreFocus();
};
return (
<div className={`relative inline-block ${className}`} ref={menuRef}>
<button
ref={triggerRef}
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("notes.export.share")}
onClick={() => setOpen((prev) => !prev)}
aria-haspopup="menu"
aria-expanded={open}
>
<Share2 size={16} />
</button>
{open && (
<div role="menu" className="absolute right-0 top-full mt-1.5 w-56 bg-popover border border-border rounded-lg shadow-lg py-1.5 z-50 text-sm animate-in fade-in-50 zoom-in-95">
{note && (
<>
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{t("notes.export.currentNote")}
</div>
<button
type="button"
role="menuitem"
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
onClick={handleExportSingleMarkdown}
>
<FileText size={14} className="text-primary" />
<span>{t("notes.export.exportMarkdown")}</span>
</button>
<button
type="button"
role="menuitem"
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
onClick={handleCopyMarkdown}
>
<Copy size={14} className="text-muted-foreground" />
<span>{t("notes.export.copyMarkdown")}</span>
</button>
<div className="my-1 border-t border-border" />
</>
)}
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{t("notes.export.allNotes")}
</div>
<button
type="button"
role="menuitem"
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
onClick={handleExportAllZip}
disabled={!allNotes.length}
>
<FileText size={14} className="text-emerald-500" />
<span>{t("notes.export.exportAllZip")}</span>
</button>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,26 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import { NoteOutline } from "./NoteOutline.tsx";
test("note outline renders a borderless hierarchy without heading-level labels", () => {
const markup = renderToStaticMarkup(
<I18nProvider locale="zh-CN">
<NoteOutline
content={"## 第一节\n\n### 子章节\n\n## 第二节"}
onClose={() => undefined}
onSelectHeading={() => undefined}
/>
</I18nProvider>,
);
assert.match(markup, /data-note-outline="true"/);
assert.equal(markup.match(/data-note-outline-item=/g)?.length, 3);
assert.match(markup, /padding-left:10px/);
assert.match(markup, /padding-left:22px/);
assert.doesNotMatch(markup, />H[1-6]</);
assert.doesNotMatch(markup, /border-l|border-b|uppercase|tracking-wider/);
});

View File

@@ -0,0 +1,94 @@
import { ListTree, X } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { extractNoteHeadings, type NoteHeadingItem } from "../../domain/notes";
import { useI18n } from "../../application/i18n/I18nProvider";
export interface NoteOutlineProps {
content: string;
onSelectHeading?: (heading: NoteHeadingItem, index: number) => void;
onClose?: () => void;
className?: string;
}
export const NoteOutline: React.FC<NoteOutlineProps> = ({
content,
onSelectHeading,
onClose,
className = "",
}) => {
const { t } = useI18n();
const headings = useMemo(() => extractNoteHeadings(content), [content]);
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
const minimumHeadingLevel = useMemo(
() => headings.length > 0
? Math.min(...headings.map((heading) => heading.level))
: 1,
[headings],
);
useEffect(() => {
setActiveHeadingId(null);
}, [content]);
return (
<nav
aria-label={t("notes.outline.title")}
data-note-outline="true"
className={`flex h-full flex-col bg-background select-none ${className}`}
>
<div className="flex shrink-0 items-center justify-between px-3.5 pb-2 pt-3">
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-foreground/90">
<ListTree size={14} className="shrink-0 text-muted-foreground" />
<span className="truncate">{t("notes.outline.title")}</span>
<span className="shrink-0 text-[11px] font-normal tabular-nums text-muted-foreground/60">
{headings.length}
</span>
</div>
{onClose && (
<button
type="button"
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/60"
onClick={onClose}
title={t("common.close")}
aria-label={t("common.close")}
>
<X size={14} />
</button>
)}
</div>
<div className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
{headings.length === 0 ? (
<div className="px-3 py-8 text-center text-xs text-muted-foreground">
<p>{t("notes.outline.empty")}</p>
<p className="mt-1 text-[11px] opacity-70">
{t("notes.outline.emptyHint")}
</p>
</div>
) : (
headings.map((item, index) => (
<button
key={item.id}
type="button"
data-note-outline-item={item.id}
data-heading-level={item.level}
className={`flex w-full items-center rounded-md py-1.5 pr-2 text-left text-xs leading-5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/60 ${
activeHeadingId === item.id
? "bg-secondary/60 font-medium text-foreground"
: "text-muted-foreground hover:bg-secondary/40 hover:text-foreground"
}`}
style={{ paddingLeft: `${10 + Math.min(item.level - minimumHeadingLevel, 4) * 12}px` }}
onClick={() => {
setActiveHeadingId(item.id);
onSelectHeading?.(item, index);
}}
title={item.text}
>
<span className="truncate">{item.text}</span>
</button>
))
)}
</div>
</nav>
);
};

View File

@@ -0,0 +1,265 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import {
NoteSourceEditor,
type NoteSourceEditorHandle,
} from "./NoteSourceEditor.tsx";
test("NoteSourceEditor toolbar undo restores coalesced ordinary typing", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
const previousAnimationFrame = actEnvironment.requestAnimationFrame;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
actEnvironment.requestAnimationFrame = (callback) => {
callback(0);
return 0;
};
const editorRef = React.createRef<NoteSourceEditorHandle>();
const changes: string[] = [];
const textareaNode = {
selectionStart: 1,
selectionEnd: 1,
focus: () => undefined,
setSelectionRange(start: number, end: number) {
this.selectionStart = start;
this.selectionEnd = end;
},
scrollTop: 0,
scrollTo: () => undefined,
};
let renderer: ReactTestRenderer | null = null;
try {
await act(async () => {
renderer = create(
<NoteSourceEditor
ref={editorRef}
noteId="note-1"
value="a"
onChange={(nextValue) => changes.push(nextValue)}
/>,
{
createNodeMock: (element) => element.type === "textarea"
? textareaNode
: { scrollTop: 0 },
},
);
});
const textarea = renderer!.root.findByType("textarea");
await act(async () => {
textarea.props.onChange({
target: { value: "ab" },
nativeEvent: { inputType: "insertText" },
});
});
await act(async () => {
textarea.props.onChange({
target: { value: "abc" },
nativeEvent: { inputType: "insertText" },
});
});
await act(async () => {
editorRef.current?.insertAction("undo");
});
let prevented = false;
await act(async () => {
renderer!.root.findByType("textarea").props.onKeyDown({
key: "z",
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: true,
preventDefault: () => { prevented = true; },
});
});
const composedTextarea = renderer!.root.findByType("textarea");
await act(async () => {
composedTextarea.props.onCompositionStart();
composedTextarea.props.onChange({
target: { value: "abc你", selectionStart: 4 },
nativeEvent: { inputType: "insertCompositionText" },
});
});
await act(async () => {
renderer!.root.findByType("textarea").props.onCompositionEnd();
});
await act(async () => {
renderer!.root.findByType("textarea").props.onCompositionStart();
renderer!.root.findByType("textarea").props.onChange({
target: { value: "abc你好", selectionStart: 5 },
nativeEvent: { inputType: "insertCompositionText" },
});
});
await act(async () => {
renderer!.root.findByType("textarea").props.onCompositionEnd();
});
await act(async () => editorRef.current?.insertAction("undo"));
await act(async () => editorRef.current?.insertAction("undo"));
assert.equal(prevented, true);
assert.deepEqual(changes, ["ab", "abc", "a", "abc", "abc你", "abc你好", "abc你", "abc"]);
assert.equal(renderer!.root.findByType("textarea").props.value, "abc");
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
actEnvironment.requestAnimationFrame = previousAnimationFrame;
}
});
test("NoteSourceEditor keeps formatting and later typing as separate undo steps", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
const previousAnimationFrame = actEnvironment.requestAnimationFrame;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
actEnvironment.requestAnimationFrame = (callback) => {
callback(0);
return 0;
};
const editorRef = React.createRef<NoteSourceEditorHandle>();
const changes: string[] = [];
const textareaNode = {
selectionStart: 0,
selectionEnd: 0,
focus: () => undefined,
setSelectionRange(start: number, end: number) {
this.selectionStart = start;
this.selectionEnd = end;
},
scrollTop: 0,
scrollTo: () => undefined,
};
let renderer: ReactTestRenderer | null = null;
try {
await act(async () => {
renderer = create(
<NoteSourceEditor
ref={editorRef}
noteId="note-1"
value=""
onChange={(nextValue) => changes.push(nextValue)}
/>,
{
createNodeMock: (element) => element.type === "textarea"
? textareaNode
: { scrollTop: 0 },
},
);
});
await act(async () => {
editorRef.current?.insertAction("bold");
});
const formatted = "**bold text**";
const edited = "**bold text!**";
const textarea = renderer!.root.findByType("textarea");
await act(async () => {
textarea.props.onChange({
target: { value: edited },
nativeEvent: { inputType: "insertText" },
});
});
await act(async () => {
editorRef.current?.insertAction("undo");
});
await act(async () => {
editorRef.current?.insertAction("undo");
});
assert.deepEqual(changes, [formatted, edited, formatted, ""]);
assert.equal(renderer!.root.findByType("textarea").props.value, "");
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
actEnvironment.requestAnimationFrame = previousAnimationFrame;
}
});
test("NoteSourceEditor undo and redo restore toolbar formatting selections", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
const previousAnimationFrame = actEnvironment.requestAnimationFrame;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
actEnvironment.requestAnimationFrame = (callback) => {
callback(0);
return 0;
};
const editorRef = React.createRef<NoteSourceEditorHandle>();
const textareaNode = {
selectionStart: 0,
selectionEnd: 5,
focus: () => undefined,
setSelectionRange(start: number, end: number) {
this.selectionStart = start;
this.selectionEnd = end;
},
scrollTop: 0,
scrollTo: () => undefined,
};
let renderer: ReactTestRenderer | null = null;
try {
await act(async () => {
renderer = create(
<NoteSourceEditor
ref={editorRef}
noteId="note-1"
value="hello"
onChange={() => undefined}
/>,
{
createNodeMock: (element) => element.type === "textarea"
? textareaNode
: { scrollTop: 0 },
},
);
});
await act(async () => editorRef.current?.insertAction("bold"));
assert.deepEqual(
{ start: textareaNode.selectionStart, end: textareaNode.selectionEnd },
{ start: 2, end: 7 },
);
await act(async () => editorRef.current?.insertAction("undo"));
assert.equal(renderer!.root.findByType("textarea").props.value, "hello");
assert.deepEqual(
{ start: textareaNode.selectionStart, end: textareaNode.selectionEnd },
{ start: 0, end: 5 },
);
await act(async () => editorRef.current?.insertAction("redo"));
assert.equal(renderer!.root.findByType("textarea").props.value, "**hello**");
assert.deepEqual(
{ start: textareaNode.selectionStart, end: textareaNode.selectionEnd },
{ start: 2, end: 7 },
);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
actEnvironment.requestAnimationFrame = previousAnimationFrame;
}
});

View File

@@ -0,0 +1,383 @@
import React, { useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { type MarkdownActionType, wrapMarkdownSyntax } from "../../domain/notes";
const SOURCE_EDIT_UNDO_COALESCE_MS = 750;
interface SourceHistorySnapshot {
value: string;
selectionStart: number;
selectionEnd: number;
}
export const shouldCoalesceSourceUndoStep = (
previous: { inputType: string; at: number; caret: number } | null,
inputType: string,
now: number,
edit: { start: number; removedLength: number; insertedLength: number },
): boolean => {
if (!previous || previous.inputType !== inputType || now - previous.at > SOURCE_EDIT_UNDO_COALESCE_MS) {
return false;
}
if (inputType === "insertText") {
return edit.removedLength === 0 && edit.start === previous.caret;
}
if (inputType === "deleteContentBackward") {
return edit.insertedLength === 0 && edit.start + edit.removedLength === previous.caret;
}
if (inputType === "deleteContentForward") {
return edit.insertedLength === 0 && edit.start === previous.caret;
}
return false;
};
export const getSourceEditDelta = (
previousValue: string,
nextValue: string,
): { start: number; removedLength: number; insertedLength: number } => {
let start = 0;
const sharedLength = Math.min(previousValue.length, nextValue.length);
while (start < sharedLength && previousValue[start] === nextValue[start]) start += 1;
let previousEnd = previousValue.length;
let nextEnd = nextValue.length;
while (
previousEnd > start
&& nextEnd > start
&& previousValue[previousEnd - 1] === nextValue[nextEnd - 1]
) {
previousEnd -= 1;
nextEnd -= 1;
}
return {
start,
removedLength: previousEnd - start,
insertedLength: nextEnd - start,
};
};
export interface NoteSourceEditorHandle {
insertAction: (action: MarkdownActionType) => void;
focus: () => void;
scrollToLine: (line: number) => boolean;
}
export interface NoteSourceEditorProps {
noteId?: string;
value: string;
placeholder?: string;
onChange: (value: string) => void;
className?: string;
noteFontFamily?: string;
noteFontSize?: number;
/** Blocks edits (used when the rich editor cannot render the markdown). */
readOnly?: boolean;
}
export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSourceEditorProps>(
({ noteId, value, placeholder = "", onChange, className = "", noteFontFamily, noteFontSize, readOnly = false }, ref) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const lineNumbersRef = useRef<HTMLDivElement>(null);
const [localValue, setLocalValue] = useState(value);
const prevNoteIdRef = useRef(noteId);
const prevValueRef = useRef(value);
// Undo/redo history for the source textarea (native textarea undo is
// unreliable once the value is controlled by React).
const undoStackRef = useRef<SourceHistorySnapshot[]>([]);
const redoStackRef = useRef<SourceHistorySnapshot[]>([]);
const lastUserEditRef = useRef<{ inputType: string; at: number; caret: number } | null>(null);
const compositionBaselineRef = useRef<SourceHistorySnapshot | null>(null);
const skipNextCompositionCommitRef = useRef(false);
const resetUserEditCoalescing = () => {
lastUserEditRef.current = null;
};
const createSnapshot = (
snapshotValue = localValue,
selectionStart = textareaRef.current?.selectionStart ?? snapshotValue.length,
selectionEnd = textareaRef.current?.selectionEnd ?? selectionStart,
): SourceHistorySnapshot => ({
value: snapshotValue,
selectionStart: Math.min(selectionStart, snapshotValue.length),
selectionEnd: Math.min(selectionEnd, snapshotValue.length),
});
// Adopt the external value only for genuine external changes:
// - noteId switch → always adopt (new note).
// - prop value changed AND differs from localValue → external edit.
// The parent debounces our own edits and echoes them back with
// value === localValue; those echoes must NOT reset the textarea, or the
// keystroke would be reverted and the caret would jump to the end.
useEffect(() => {
if (noteId !== prevNoteIdRef.current) {
prevNoteIdRef.current = noteId;
prevValueRef.current = value;
setLocalValue(value);
undoStackRef.current = [];
redoStackRef.current = [];
compositionBaselineRef.current = null;
skipNextCompositionCommitRef.current = false;
resetUserEditCoalescing();
return;
}
if (value !== prevValueRef.current) {
prevValueRef.current = value;
if (value !== localValue) {
setLocalValue(value);
undoStackRef.current = [];
redoStackRef.current = [];
compositionBaselineRef.current = null;
skipNextCompositionCommitRef.current = false;
resetUserEditCoalescing();
}
}
}, [noteId, value, localValue]);
const lineCount = (localValue.match(/\n/g)?.length || 0) + 1;
const lineNumbers = useMemo(
() => Array.from({ length: lineCount }, (_, i) => i + 1).join("\n"),
[lineCount],
);
const gutterWidth = Math.max(48, String(lineCount).length * 9 + 24);
const handleScroll = () => {
if (textareaRef.current && lineNumbersRef.current) {
lineNumbersRef.current.scrollTop = textareaRef.current.scrollTop;
}
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (readOnly) return;
const nextValue = e.target.value;
if (nextValue === localValue) return;
const nativeInputType = (e.nativeEvent as InputEvent | undefined)?.inputType;
const inputType = typeof nativeInputType === "string" && nativeInputType
? nativeInputType
: "input";
const isCompositionInput = inputType === "insertCompositionText"
|| inputType === "insertFromComposition";
if (isCompositionInput) {
if (inputType === "insertFromComposition" && skipNextCompositionCommitRef.current) {
skipNextCompositionCommitRef.current = false;
setLocalValue(nextValue);
onChange(nextValue);
return;
}
if (compositionBaselineRef.current === null) {
const edit = getSourceEditDelta(localValue, nextValue);
const baseline = createSnapshot(
localValue,
edit.start,
edit.start + edit.removedLength,
);
compositionBaselineRef.current = baseline;
undoStackRef.current.push(baseline);
redoStackRef.current = [];
}
if (inputType === "insertFromComposition") compositionBaselineRef.current = null;
resetUserEditCoalescing();
setLocalValue(nextValue);
onChange(nextValue);
return;
}
compositionBaselineRef.current = null;
const now = Date.now();
const edit = getSourceEditDelta(localValue, nextValue);
if (!shouldCoalesceSourceUndoStep(lastUserEditRef.current, inputType, now, edit)) {
const previousSelectionStart = inputType === "deleteContentBackward"
? edit.start + edit.removedLength
: edit.start;
undoStackRef.current.push(createSnapshot(
localValue,
previousSelectionStart,
edit.start + edit.removedLength,
));
}
const caret = typeof e.target.selectionStart === "number"
? e.target.selectionStart
: edit.start + edit.insertedLength;
lastUserEditRef.current = { inputType, at: now, caret };
redoStackRef.current = [];
setLocalValue(nextValue);
onChange(nextValue);
};
const applyHistoryAction = (action: "undo" | "redo"): boolean => {
const textarea = textareaRef.current;
if (!textarea) return false;
const stack = action === "undo" ? undoStackRef.current : redoStackRef.current;
const target = stack.pop();
if (target === undefined) return false;
resetUserEditCoalescing();
compositionBaselineRef.current = null;
const current = createSnapshot();
if (action === "undo") {
redoStackRef.current.push(current);
} else {
undoStackRef.current.push(current);
}
setLocalValue(target.value);
onChange(target.value);
requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(target.selectionStart, target.selectionEnd);
});
return true;
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const textarea = textareaRef.current;
if (!textarea) return;
// Read-only fallback (preview mode): the DOM readOnly attribute blocks
// typing but not our custom Tab/undo handling, which would still mutate
// and persist the note.
if (readOnly) return;
const key = e.key.toLowerCase();
const commandModifier = e.metaKey || e.ctrlKey;
const historyAction = commandModifier && !e.altKey
? key === "z"
? (e.shiftKey ? "redo" : "undo")
: key === "y" && e.ctrlKey
? "redo"
: null
: null;
if (historyAction) {
e.preventDefault();
applyHistoryAction(historyAction);
return;
}
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(e.key)) {
resetUserEditCoalescing();
}
// Handle Tab insertion
if (e.key === "Tab") {
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const nextValue = `${localValue.substring(0, start)} ${localValue.substring(end)}`;
undoStackRef.current.push(createSnapshot(localValue, start, end));
redoStackRef.current = [];
compositionBaselineRef.current = null;
resetUserEditCoalescing();
setLocalValue(nextValue);
onChange(nextValue);
requestAnimationFrame(() => {
textarea.selectionStart = start + 2;
textarea.selectionEnd = start + 2;
});
}
};
useImperativeHandle(ref, () => ({
insertAction: (action: MarkdownActionType) => {
const textarea = textareaRef.current;
if (!textarea || readOnly) return;
if (action === "undo" || action === "redo") {
applyHistoryAction(action);
return;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const result = wrapMarkdownSyntax(localValue, start, end, action);
if (result.text !== localValue) {
undoStackRef.current.push(createSnapshot(localValue, start, end));
redoStackRef.current = [];
compositionBaselineRef.current = null;
resetUserEditCoalescing();
}
setLocalValue(result.text);
onChange(result.text);
requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(result.selectionStart, result.selectionEnd);
});
},
focus: () => {
textareaRef.current?.focus();
},
scrollToLine: (line: number) => {
const textarea = textareaRef.current;
if (!textarea) return false;
const top = Math.max(0, (Math.max(1, line) - 1) * 24 - 12);
if (typeof textarea.scrollTo === "function") {
textarea.scrollTo({ top, behavior: "smooth" });
} else {
textarea.scrollTop = top;
}
if (lineNumbersRef.current) {
lineNumbersRef.current.scrollTop = top;
}
return true;
},
}));
return (
<div
className={`relative flex h-full w-full bg-background font-mono text-sm select-text overflow-hidden ${className}`}
>
{/* Line numbers gutter */}
<div
ref={lineNumbersRef}
style={{ width: `${gutterWidth}px` }}
className="shrink-0 py-3 select-none text-right pr-3 text-muted-foreground/40 border-r border-border/50 overflow-hidden font-mono text-sm leading-6"
onWheel={(e) => {
if (textareaRef.current) {
textareaRef.current.scrollTop += e.deltaY;
}
}}
>
<pre className="m-0 whitespace-pre font-inherit leading-6">{lineNumbers}</pre>
</div>
{/* Source Textarea */}
<div className="relative flex-1 h-full min-w-0">
<textarea
ref={textareaRef}
value={localValue}
onChange={handleChange}
onScroll={handleScroll}
onKeyDown={handleKeyDown}
onPointerDown={resetUserEditCoalescing}
onCompositionStart={() => {
skipNextCompositionCommitRef.current = false;
if (compositionBaselineRef.current === null) {
const baseline = createSnapshot();
compositionBaselineRef.current = baseline;
undoStackRef.current.push(baseline);
redoStackRef.current = [];
}
resetUserEditCoalescing();
}}
onCompositionEnd={() => {
const baseline = compositionBaselineRef.current;
if (baseline !== null && baseline.value === localValue) {
undoStackRef.current.pop();
}
skipNextCompositionCommitRef.current = baseline !== null && baseline.value !== localValue;
compositionBaselineRef.current = null;
resetUserEditCoalescing();
}}
placeholder={placeholder}
spellCheck={false}
readOnly={readOnly || undefined}
aria-readonly={readOnly || undefined}
style={{
fontFamily: noteFontFamily || undefined,
fontSize: noteFontSize ? `${noteFontSize}px` : undefined,
}}
className="w-full h-full py-3 px-4 bg-transparent text-foreground resize-none outline-none font-mono text-sm leading-6 whitespace-pre overflow-auto"
/>
</div>
</div>
);
},
);
NoteSourceEditor.displayName = "NoteSourceEditor";

View File

@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./NoteTitleInput.tsx", import.meta.url), "utf8");
const managerSource = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
test("NoteTitleInput keeps a local draft and only commits when IME composition is idle", () => {
assert.match(source, /shouldCommitImeControlledChange/);
assert.match(source, /shouldAdoptExternalImeControlledValue/);
assert.match(source, /onCompositionStart=\{/);
assert.match(source, /onCompositionEnd=\{/);
assert.match(source, /value=\{draft\}/);
assert.doesNotMatch(
source,
/onChange=\{\(event\) => onCommit\(event\.target\.value\)\}/,
);
});
test("NoteTitleInput stashes live drafts during composition without idle-only commits", () => {
assert.match(source, /onLiveDraft\?\.\(next\)/);
assert.match(source, /onLiveDraft\?: \(title: string\) => void/);
});
test("NoteTitleInput commits the local draft on blur before parent flush", () => {
assert.match(source, /onBlur=\{\(event\) => \{/);
assert.match(source, /onCommit\(next\)/);
assert.match(source, /onCommit\(value\)/);
assert.match(source, /onBlur\?\.\(\)/);
});
test("NoteTitleInput clears live-stashed title when composition is externally superseded", () => {
assert.match(
source,
/supersededRef\.current = true;[\s\S]*?setDraft\(value\);[\s\S]*?onLiveDraft\?\.\(value\)/,
);
assert.match(source, /adoptedExternal/);
assert.match(source, /onLiveDraftRef\.current\?\.\(adoptedExternal\)/);
});
test("NotesManager cancels debounced flush while stashing IME title drafts", () => {
assert.match(
managerSource,
/clearDraftTimer\(\);[\s\S]*?draftNoteIdRef\.current = note\.id;[\s\S]*?draftTitleRef\.current = title/,
);
});
test("NoteTitleInput resets IME guards when the active note changes", () => {
assert.match(source, /noteId/);
assert.match(
source,
/composingRef\.current = false;[\s\S]*?supersededRef\.current = false;[\s\S]*?setDraft\(value\)/,
);
});
test("NotesManager title rows use NoteTitleInput instead of raw controlled saves", () => {
assert.match(managerSource, /NoteTitleInput/);
assert.match(managerSource, /data-note-title-row/);
assert.match(managerSource, /onCommit=\{\(title\) => saveNoteTitleDraft/);
assert.doesNotMatch(
managerSource,
/data-note-title-row[\s\S]{0,500}<input[\s\S]{0,250}onChange=\{\(event\) => saveNoteTitleDraft/,
);
});
test("NotesManager title rows stash live IME drafts into refs", () => {
assert.match(managerSource, /stashNoteTitleDraft/);
assert.match(managerSource, /onLiveDraft=\{\(title\) => stashNoteTitleDraft/);
assert.match(managerSource, /draftTitleRef\.current = title/);
});

View File

@@ -0,0 +1,156 @@
import React, { useEffect, useRef, useState } from "react";
import {
resolveSupersededImeInputEvent,
shouldAdoptExternalImeControlledValue,
shouldCommitImeControlledChange,
} from "../../domain/imeControlledInput";
type NoteTitleInputProps = {
noteId: string;
value: string;
placeholder?: string;
className?: string;
/** Commit into parent draft state (may rewrite controlled value). Idle IME only. */
onCommit: (title: string) => void;
/**
* Stash title for crash/teardown flush without updating controlled React state.
* Called during IME composition so pagehide/note-switch can persist without
* fighting the composition buffer.
*/
onLiveDraft?: (title: string) => void;
onBlur?: () => void;
};
/**
* Controlled note-title field that does not push parent updates during CJK IME
* composition. Immediate `value={external}` writes mid-composition break Windows
* IMEs such as Sogou Wubi (candidate dismiss / no committed text).
*/
export const NoteTitleInput: React.FC<NoteTitleInputProps> = ({
noteId,
value,
placeholder,
className,
onCommit,
onLiveDraft,
onBlur,
}) => {
const [draft, setDraft] = useState(value);
const composingRef = useRef(false);
const valueAtComposeStartRef = useRef(value);
const supersededRef = useRef(false);
const noteIdRef = useRef(noteId);
const onLiveDraftRef = useRef(onLiveDraft);
onLiveDraftRef.current = onLiveDraft;
useEffect(() => {
if (noteIdRef.current !== noteId) {
noteIdRef.current = noteId;
composingRef.current = false;
supersededRef.current = false;
setDraft(value);
return;
}
let adoptedExternal: string | null = null;
setDraft((draftValue) => {
const composing = composingRef.current;
const shouldAdopt = shouldAdoptExternalImeControlledValue({
isComposingSession: composing,
draftValue,
externalValue: value,
valueAtComposeStart: composing ? valueAtComposeStartRef.current : undefined,
});
if (shouldAdopt && composing && value !== valueAtComposeStartRef.current) {
supersededRef.current = true;
adoptedExternal = value;
}
return shouldAdopt ? value : draftValue;
});
if (adoptedExternal !== null) {
onLiveDraftRef.current?.(adoptedExternal);
}
}, [noteId, value]);
const commit = (next: string) => {
composingRef.current = false;
supersededRef.current = false;
setDraft(next);
onCommit(next);
};
return (
<input
data-note-title-input="true"
className={className}
value={draft}
placeholder={placeholder}
onBlur={(event) => {
// Blur finalizes IME. Sync parent before flushNoteDraft so composition-only
// titles and superseded external adoptions both land in draftTitleRef.
composingRef.current = false;
if (supersededRef.current) {
supersededRef.current = false;
setDraft(value);
onCommit(value);
} else {
const next = event.currentTarget.value;
setDraft(next);
onCommit(next);
}
onBlur?.();
}}
onChange={(event) => {
const superseded = resolveSupersededImeInputEvent({
compositionExternallySuperseded: supersededRef.current,
isComposingSession: composingRef.current,
nativeEventIsComposing: event.nativeEvent.isComposing,
});
if (superseded.ignoreEventValue) {
if (superseded.clearSupersedeLatch) {
supersededRef.current = false;
}
setDraft(value);
return;
}
const next = event.target.value;
setDraft(next);
// Always stash for teardown/note-switch flush; do not fight IME via onCommit.
onLiveDraft?.(next);
if (
shouldCommitImeControlledChange({
isComposingSession: composingRef.current,
nativeEventIsComposing: event.nativeEvent.isComposing,
compositionExternallySuperseded: supersededRef.current,
})
) {
onCommit(next);
}
}}
onCompositionStart={() => {
composingRef.current = true;
supersededRef.current = false;
valueAtComposeStartRef.current = value;
}}
onCompositionEnd={(event) => {
composingRef.current = false;
if (value !== valueAtComposeStartRef.current || supersededRef.current) {
supersededRef.current = true;
setDraft(value);
// Drop any live-stashed composed text so teardown flush cannot persist
// the rejected IME draft over the authoritative external title.
onLiveDraft?.(value);
window.setTimeout(() => {
if (supersededRef.current && !composingRef.current) {
supersededRef.current = false;
}
}, 0);
return;
}
commit(event.currentTarget.value);
}}
/>
);
};

View File

@@ -0,0 +1,523 @@
import {
Bold,
Check,
CheckSquare,
Code,
Eye,
FileCode,
Heading1,
Heading2,
Heading3,
Heading4,
Heading,
Image as ImageIcon,
Italic,
Link as LinkIcon,
List,
ListOrdered,
Minus,
PencilLine,
Quote,
Redo2,
Search,
Sigma,
SquareCode,
Strikethrough,
Table as TableIcon,
Type,
Underline,
Undo2,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { type MarkdownActionType } from "../../domain/notes";
import { useAvailableFonts } from "../../application/state/fontStore";
import { useI18n } from "../../application/i18n/I18nProvider";
import type { ActiveTextFormats, NoteEditorMode } from "./noteEditorTypes";
import { EMPTY_ACTIVE_FORMATS } from "./noteEditorTypes";
import { Dropdown, DropdownContent, DropdownTrigger } from "../ui/dropdown";
import { Select, SelectContent, SelectItem, SelectTrigger } from "../ui/select";
import { cn } from "../../lib/utils";
export interface NoteToolbarProps {
editorMode: NoteEditorMode;
onAction?: (action: MarkdownActionType) => void;
onOpenHostPicker?: () => void;
className?: string;
noteFontFamily?: string;
onChangeNoteFontFamily?: (font: string) => void;
noteFontSize?: number;
onChangeNoteFontSize?: (size: number) => void;
noteCodeFontSize?: number;
onChangeNoteCodeFontSize?: (size: number) => void;
/** Active text-format toggles at the current selection (button highlight). */
activeFormats?: ActiveTextFormats;
}
export interface NoteModeDropdownProps {
editorMode: NoteEditorMode;
onChangeMode: (mode: NoteEditorMode) => void;
className?: string;
}
const FONT_SIZES = [12, 13, 14, 15, 16, 18, 20];
const CODE_FONT_SIZES = [11, 12, 13, 14, 15, 16, 18];
export const NoteModeDropdown: React.FC<NoteModeDropdownProps> = ({
editorMode,
onChangeMode,
className = "",
}) => {
const { t } = useI18n();
const normalizedMode: "edit" | "source" | "preview" =
editorMode === "live" ? "edit" : editorMode;
const options = [
{
mode: "edit" as const,
label: t("notes.toolbar.modeLive"),
title: t("notes.toolbar.modeLiveTitle"),
icon: PencilLine,
},
{
mode: "source" as const,
label: t("notes.toolbar.modeSource"),
title: t("notes.toolbar.modeSourceTitle"),
icon: SquareCode,
},
{
mode: "preview" as const,
label: t("notes.toolbar.modePreview"),
title: t("notes.toolbar.modePreviewTitle"),
icon: Eye,
},
];
const activeOption = options.find((option) => option.mode === normalizedMode) ?? options[0];
const ActiveIcon = activeOption.icon;
return (
<Select value={normalizedMode} onValueChange={(mode) => onChangeMode(mode as NoteEditorMode)}>
<SelectTrigger
data-note-mode-dropdown-trigger
aria-label={activeOption.title}
className={cn(
"app-no-drag h-8 w-auto shrink-0 gap-1.5 border-0 bg-transparent px-2 text-xs text-muted-foreground shadow-none hover:bg-secondary/70 hover:text-foreground focus:ring-0",
className,
)}
>
<ActiveIcon size={15} />
<span>{activeOption.label}</span>
</SelectTrigger>
<SelectContent align="end" className="w-max min-w-[10rem]">
{options.map((option) => {
const Icon = option.icon;
return (
<SelectItem
key={option.mode}
value={option.mode}
data-note-mode-option={option.mode}
className="h-9 whitespace-nowrap"
>
<span className="flex items-center gap-2 whitespace-nowrap"><Icon size={14} />{option.label}</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
};
export const NoteToolbar: React.FC<NoteToolbarProps> = ({
editorMode,
onAction,
className = "",
noteFontFamily = "",
onChangeNoteFontFamily,
noteFontSize = 14,
onChangeNoteFontSize,
noteCodeFontSize = 13,
onChangeNoteCodeFontSize,
activeFormats = EMPTY_ACTIVE_FORMATS,
}) => {
const { t } = useI18n();
const [fontSearch, setFontSearch] = useState("");
// The font tool only controls code block / inline code fonts, so it lists
// system monospace fonts (fontStore) rather than the UI font set.
const availableSystemFonts = useAvailableFonts();
const systemFontList = useMemo(() => {
const defaultOption = { label: t("notes.toolbar.defaultFont"), value: "" };
const list = availableSystemFonts.map((f) => ({
label: f.name,
value: f.family,
}));
return [defaultOption, ...list];
}, [availableSystemFonts, t]);
const filteredFonts = useMemo(() => {
if (!fontSearch.trim()) return systemFontList;
const query = fontSearch.trim().toLowerCase();
return systemFontList.filter(
(f) => f.label.toLowerCase().includes(query) || f.value.toLowerCase().includes(query),
);
}, [fontSearch, systemFontList]);
const isEditing = editorMode === "edit" || editorMode === "live" || editorMode === "source";
if (!isEditing) return null;
// Highlight style for toggles that are active at the current selection.
const formatButtonClass = (active: boolean) =>
cn(
"p-1.5 rounded-md transition-colors",
active
? "bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary"
: "hover:bg-muted text-muted-foreground hover:text-foreground",
);
return (
<div
className={`flex items-center gap-1.5 px-3 py-1.5 border-b border-border/70 bg-card/40 text-xs select-none min-w-0 ${className}`}
>
{/* Formatting Tools (Available in Live Preview & Source Mode) */}
<div className="flex flex-1 items-center gap-0.5 min-w-0 overflow-x-auto [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/70 [&::-webkit-scrollbar-track]:bg-transparent">
{/* Undo / Redo */}
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("undo")}
title={t("notes.toolbar.undo")}
>
<Undo2 size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("redo")}
title={t("notes.toolbar.redo")}
>
<Redo2 size={14} />
</button>
<div className="h-4 w-px bg-border mx-1 shrink-0" />
{/* Code Font & Typography Settings Dropdown */}
<Dropdown>
<DropdownTrigger asChild>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
title={t("notes.toolbar.typography")}
onMouseDown={(e) => e.preventDefault()}
>
<Type size={14} />
</button>
</DropdownTrigger>
<DropdownContent align="start" className="w-64 p-2.5 space-y-2.5 z-50 text-xs shadow-lg">
<div className="space-y-1.5">
<div className="flex items-center justify-between text-[11px] font-medium text-muted-foreground">
<span>{t("notes.toolbar.customCodeFont")}</span>
<span className="text-[10px] opacity-70">
{t("notes.toolbar.fontsAvailable", { count: systemFontList.length })}
</span>
</div>
{/* Search Bar */}
<div className="relative flex items-center">
<Search size={12} className="absolute left-2 text-muted-foreground pointer-events-none" />
<input
type="text"
placeholder={t("notes.toolbar.searchFont")}
value={fontSearch}
onChange={(e) => setFontSearch(e.target.value)}
className="w-full pl-6 pr-2 py-1 rounded border border-border bg-background text-[11px] text-foreground outline-none focus:border-primary placeholder:text-muted-foreground/60"
/>
</div>
{/* Scrollable Font List */}
<div className="space-y-0.5 max-h-52 overflow-y-auto pr-1">
{filteredFonts.length === 0 ? (
<div className="py-2 text-center text-muted-foreground text-[11px]">
{t("notes.toolbar.noFontsFound")}
</div>
) : (
filteredFonts.map((f) => (
<button
key={f.value}
type="button"
style={{ fontFamily: f.value || undefined }}
className={cn(
"w-full px-2 py-1 rounded text-left text-xs transition-colors flex items-center justify-between gap-1.5",
(noteFontFamily || "") === f.value
? "bg-primary text-primary-foreground font-medium"
: "hover:bg-secondary text-foreground",
)}
onClick={() => onChangeNoteFontFamily?.(f.value)}
>
<span className="truncate">{f.label}</span>
{(noteFontFamily || "") === f.value && <Check size={12} className="shrink-0" />}
</button>
))
)}
</div>
</div>
<div className="border-t border-border/60 pt-2">
<div className="text-[11px] font-medium text-muted-foreground mb-1">{t("notes.toolbar.bodyFontSize")}</div>
<div className="flex flex-wrap gap-1">
{FONT_SIZES.map((sz) => (
<button
key={sz}
type="button"
className={cn(
"px-2 py-0.5 rounded text-xs border transition-colors",
(noteFontSize || 14) === sz
? "bg-primary text-primary-foreground border-primary font-medium"
: "border-border hover:bg-secondary text-foreground",
)}
onClick={() => onChangeNoteFontSize?.(sz)}
>
{sz}px
</button>
))}
</div>
</div>
<div className="border-t border-border/60 pt-2">
<div className="text-[11px] font-medium text-muted-foreground mb-1">{t("notes.toolbar.codeFontSize")}</div>
<div className="flex flex-wrap gap-1">
{CODE_FONT_SIZES.map((sz) => (
<button
key={sz}
type="button"
className={cn(
"px-2 py-0.5 rounded text-xs border transition-colors font-mono",
(noteCodeFontSize || 13) === sz
? "bg-primary text-primary-foreground border-primary font-medium"
: "border-border hover:bg-secondary text-foreground",
)}
onClick={() => onChangeNoteCodeFontSize?.(sz)}
>
{sz}px
</button>
))}
</div>
</div>
</DropdownContent>
</Dropdown>
{/* Heading Dropdown (Using Portal Dropdown to avoid clipping) */}
<Dropdown>
<DropdownTrigger asChild>
<button
type="button"
className="flex items-center gap-1 p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
title={t("notes.toolbar.headingLevel")}
>
<Heading size={14} />
</button>
</DropdownTrigger>
<DropdownContent align="start" className="w-32 py-1 z-50 text-xs">
<button
type="button"
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("h1")}
>
<Heading1 size={14} className="text-primary" />
<span>{t("notes.toolbar.h1")}</span>
</button>
<button
type="button"
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("h2")}
>
<Heading2 size={14} className="text-primary" />
<span>{t("notes.toolbar.h2")}</span>
</button>
<button
type="button"
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("h3")}
>
<Heading3 size={14} className="text-primary" />
<span>{t("notes.toolbar.h3")}</span>
</button>
<button
type="button"
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("h4")}
>
<Heading4 size={14} className="text-primary" />
<span>{t("notes.toolbar.h4")}</span>
</button>
</DropdownContent>
</Dropdown>
{/* Inline Styles */}
<button
type="button"
className={formatButtonClass(activeFormats.bold)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("bold")}
title={t("notes.toolbar.bold")}
>
<Bold size={14} />
</button>
<button
type="button"
className={formatButtonClass(activeFormats.italic)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("italic")}
title={t("notes.toolbar.italic")}
>
<Italic size={14} />
</button>
<button
type="button"
className={formatButtonClass(activeFormats.strikethrough)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("strikethrough")}
title={t("notes.toolbar.strikethrough")}
>
<Strikethrough size={14} />
</button>
<button
type="button"
className={formatButtonClass(activeFormats.underline)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("underline")}
title={t("notes.toolbar.underline")}
>
<Underline size={14} />
</button>
<button
type="button"
className={formatButtonClass(activeFormats.code)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("code")}
title={t("notes.toolbar.inlineCode")}
>
<Code size={14} />
</button>
<div className="h-4 w-px bg-border mx-1 shrink-0" />
{/* Lists */}
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("bullet")}
title={t("notes.toolbar.bulletList")}
>
<List size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("number")}
title={t("notes.toolbar.orderedList")}
>
<ListOrdered size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("task")}
title={t("notes.toolbar.taskList")}
>
<CheckSquare size={14} />
</button>
<div className="h-4 w-px bg-border mx-1 shrink-0" />
{/* Blocks */}
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("quote")}
title={t("notes.toolbar.quote")}
>
<Quote size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("codeblock")}
title={t("notes.toolbar.codeBlock")}
>
<FileCode size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("math")}
title={t("notes.toolbar.mathFormula")}
>
<Sigma size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("table")}
title={t("notes.toolbar.table")}
>
<TableIcon size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("divider")}
title={t("notes.toolbar.divider")}
>
<Minus size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("link")}
title={t("notes.toolbar.link")}
>
<LinkIcon size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("image")}
title={t("notes.toolbar.image")}
>
<ImageIcon size={14} />
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,212 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const managerSource = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
const layoutSource = readFileSync(
new URL("../vault/VaultViewLayout.tsx", import.meta.url),
"utf8",
);
test("notes tree width resize avoids React setState on every pointermove", () => {
// Dragging the sidebar used to call setTreeWidth per pixel and re-render the
// whole NotesManager (including MDXEditor). Live width must stay on the DOM
// until pointerup commits state + localStorage.
assert.match(managerSource, /treeAsideRef/);
assert.match(managerSource, /requestAnimationFrame/);
assert.match(managerSource, /aside\.style\.width = `\$\{width\}px`/);
assert.match(
managerSource,
/const handlePointerMove = \(moveEvent: PointerEvent\) => \{[\s\S]*?requestAnimationFrame/,
);
assert.doesNotMatch(
managerSource,
/const handlePointerMove = \(moveEvent: PointerEvent\) => \{\s*\n\s*setTreeWidth\(/,
);
assert.match(managerSource, /persistTreeWidth\(nextWidth\)/);
assert.match(managerSource, /isTreeResizing && "pointer-events-none"/);
});
test("note content drafts stay in refs so MDX keystrokes do not rebuild the shell", () => {
assert.doesNotMatch(
managerSource,
/const \[draftContent, setDraftContent\]/,
"draftContent React state causes a full NotesManager render per keystroke",
);
assert.match(
managerSource,
/draftContentRef\.current = fields\.content;/,
);
assert.doesNotMatch(
managerSource,
/setDraftContent\(fields\.content\)/,
);
});
test("NotesManager teardown flush uses a stable ref under StrictMode", () => {
assert.match(managerSource, /flushNoteDraftRef\.current = flushNoteDraft/);
assert.match(
managerSource,
/useEffect\(\(\) => \(\) => \{\s*\n\s*flushNoteDraftRef\.current\(\);\s*\n\s*\}, \[\]\)/,
);
});
test("Vault notes section is memoized against unrelated VaultView churn", () => {
assert.match(layoutSource, /const MemoVaultNotesSection = React\.memo/);
assert.match(layoutSource, /<MemoVaultNotesSection\b/);
assert.match(layoutSource, /const handleNotesOpenHost = useCallback/);
assert.match(layoutSource, /onOpenHost=\{handleNotesOpenHost\}/);
assert.match(
layoutSource,
/useNotesStore\(\{\s*\n\s*enabled:\s*isActive,\s*\n\s*\}\)/,
);
assert.match(
layoutSource,
/if \(next\.isActive && prev\.hosts !== next\.hosts\) return false;/,
"hidden retained notes must ignore hosts identity churn",
);
});
test("hidden terminal notes side panel does not subscribe to notes publishes", () => {
const slotsSource = readFileSync(
new URL("../terminalLayer/terminalLayerSidePanelSlots.tsx", import.meta.url),
"utf8",
);
assert.match(slotsSource, /useNotesStore\(\{\s*enabled:\s*isVisible\s*\}\)/);
});
test("notes manager prefetches the MDXEditor chunk when becoming active", () => {
assert.match(managerSource, /prefetchInlineMarkdownEditor/);
assert.match(
managerSource,
/if \(!isActive\) return;\s*\n\s*prefetchInlineMarkdownEditor\(\);/,
);
});
test("mode toggle flushes ref-only content drafts before remounting the editor", () => {
assert.match(
managerSource,
/flushNoteDraft\(\);\s*\n\s*setNoteEditorMode/,
"preview/edit remount must see the in-progress body",
);
});
test("note switches reuse MDX instance instead of key=noteId remount", () => {
assert.match(managerSource, /noteId=\{selectedNoteView\.id\}/);
assert.doesNotMatch(
managerSource,
/<InlineMarkdownEditor[\s\S]{0,200}key=\{selectedNoteView\.id\}/,
"key=noteId forces full Lexical teardown on every note switch",
);
const editorSource = readFileSync(
new URL("./InlineMarkdownEditor.tsx", import.meta.url),
"utf8",
);
assert.match(editorSource, /noteId !== noteIdRef\.current/);
assert.match(editorSource, /setMarkdown\(scheduled\.markdown\)/);
assert.match(
editorSource,
/contentSwapFramesRef\.current\.outer = window\.requestAnimationFrame/,
"outer rAF yields a paint so the tree selection updates before Lexical import",
);
assert.match(
editorSource,
/contentSwapFramesRef\.current\.inner = window\.requestAnimationFrame/,
"inner rAF completes the double-yield before setMarkdown",
);
assert.match(editorSource, /data-notes-content-swapping="true"/);
assert.match(editorSource, /setIsContentSwapping\(true\)/);
assert.match(editorSource, /CLEAR_HISTORY_COMMAND/);
assert.match(editorSource, /clearLexicalHistory/);
assert.match(editorSource, /attributeFilter:\s*\[\s*"width",\s*"height"/);
assert.match(editorSource, /onPointerDownCapture=\{blockWhileContentSwapping\}/);
assert.match(editorSource, /startTransition\(\(\) => setIsContentSwapping\(false\)\)/);
assert.match(
editorSource,
/latestMarkdownRef\.current !== scheduled\.markdown/,
"deferred setMarkdown must not clobber edits typed after the switch",
);
assert.match(
editorSource,
/if \(contentSwapPendingRef\.current\) return;/,
"stale onChange during the swap yield must not write the previous note into the new draft",
);
assert.match(
editorSource,
/contentSwapScheduledRef/,
"deferred import must refresh when the same note's value changes during the yield",
);
assert.match(
editorSource,
/syncedPropValueRef\.current = markdown;/,
"draft-clobber guard compares display-normalized values after note switch",
);
assert.match(
editorSource,
/runDecorations\(true\)/,
"edit-mode note swaps must re-annotate host links after setMarkdown",
);
});
test("host-link annotation does not re-run on every markdown value keystroke", () => {
const editorSource = readFileSync(
new URL("./InlineMarkdownEditor.tsx", import.meta.url),
"utf8",
);
assert.doesNotMatch(
editorSource,
/annotateHostLinks,\s*value\s*\]/,
"value in annotateHostLinks effect deps walks the DOM on every keystroke",
);
assert.doesNotMatch(
editorSource,
/annotateCodeBlockCopyButtons,\s*editorMode,\s*value\s*\]/,
"value in code-copy effect deps re-walks every code block on each draft identity change",
);
assert.match(
editorSource,
/\[annotateCodeBlockCopyButtons, annotateHostLinks, editorMode\]/,
"DOM decoration is independent of markdown value identity",
);
assert.match(
editorSource,
/readOnly=\{editorMode === "preview"\}/,
"preview reuses MDXEditor in read-only mode",
);
assert.match(
editorSource,
/syncedPropValueRef/,
"external note publishes must not clobber an in-progress local draft",
);
assert.match(
editorSource,
/shouldApplyExternalNoteMarkdown/,
);
assert.match(
editorSource,
/syncedSourceMarkdownRef/,
);
});
test("link hover and small-image CSS avoid render thrash", () => {
const editorSource = readFileSync(
new URL("./InlineMarkdownEditor.tsx", import.meta.url),
"utf8",
);
const cssSource = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
const imageLayoutSource = readFileSync(
new URL("./noteImageLayout.ts", import.meta.url),
"utf8",
);
assert.match(editorSource, /linkActionStatesEqual/);
assert.match(editorSource, /setLinkActionIfChanged/);
assert.match(editorSource, /annotateNoteImageSizes/);
assert.match(imageLayoutSource, /data-note-img-size/);
// No combinatorial :has(img[width="N"]) matrix for small icons.
assert.doesNotMatch(
cssSource,
/:has\(img\[width="16"\]\).*?:has\(img\[width="20"\]/s,
);
assert.match(cssSource, /img\[data-note-img-size="sm"\]/);
});

View File

@@ -0,0 +1,430 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import { readStoredStringValue } from "../../application/state/useStoredString.ts";
import { STORAGE_KEY_VAULT_NOTES_EDITOR_MODE } from "../../infrastructure/config/storageKeys.ts";
import type { VaultNote } from "../../types.ts";
import { TooltipProvider } from "../ui/tooltip.tsx";
import {
clampNotesTreeWidth,
getFallbackNoteSelectionState,
getNoteActionTargetGroup,
getNoteGroupSelectionState,
getNotesGroupDropAction,
getNoteSelectionState,
getValidatedNoteSelectionState,
getSelectedVaultNote,
isNoteFolderTreeSelected,
isNoteEditorMode,
normalizeNoteEditorMode,
NotesManager,
} from "./NotesManager.tsx";
const note = (overrides: Partial<VaultNote> = {}): VaultNote => ({
id: "note-1",
title: "Postgres failover checklist",
content: "# Steps\n\nPromote replica",
group: "Ops",
createdAt: 1,
updatedAt: 1,
order: 1000,
...overrides,
});
const renderNotes = (
notes: VaultNote[] = [note()],
displayMode: React.ComponentProps<typeof NotesManager>["displayMode"] = "full",
noteGroups: string[] = ["Ops"],
openNoteId: string | null = null,
) => renderToStaticMarkup(
<I18nProvider locale="en">
<TooltipProvider>
<NotesManager
notes={notes}
noteGroups={noteGroups}
hosts={[]}
onUpdateNotes={() => undefined}
onUpdateNoteGroups={() => undefined}
displayMode={displayMode}
openNoteId={openNoteId}
/>
</TooltipProvider>
</I18nProvider>,
);
test("NotesManager renders notes tree and selected markdown editor", () => {
const markup = renderNotes();
assert.match(markup, /Ops/);
assert.match(markup, /Postgres failover checklist/);
// React.lazy may show Suspense fallback or the resolved editor depending on
// whether a prior test already settled the MDX chunk.
assert.ok(
markup.includes('data-notes-editor-loading="true"')
|| markup.includes("editable markdown")
|| markup.includes("Promote replica"),
"full mode should mount the note editor (loading fallback or resolved MDX)",
);
});
test("NotesManager marks selected notebook rows with shared tree state", () => {
const markup = renderNotes();
assert.match(markup, /data-vault-tree-row="group"/);
assert.match(markup, /data-vault-tree-row="item"/);
assert.equal(markup.match(/data-selected="true"/g)?.length, 1);
assert.match(markup, /data-vault-tree-row="group"[^>]*data-selected="false"/);
assert.match(markup, /data-vault-tree-row="item"[^>]*data-selected="true"/);
});
test("NotesManager balances folder and note tree icon sizes", () => {
const markup = renderNotes();
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-folder/);
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-file-text/);
assert.match(markup, /<div class="flex shrink-0 items-center[^"]*mr-1">/);
assert.doesNotMatch(markup, /lucide lucide-file-text[^"]*mr-2/);
});
test("NotesManager gives folder and tag metadata pills the same compact style", () => {
const markup = renderNotes([note({ tags: ["inspection"] })]);
const document = new JSDOM(markup).window.document;
const folderPill = document.querySelector<HTMLElement>('[data-note-metadata-pill="folder"]');
const tagPill = document.querySelector<HTMLElement>('[data-note-metadata-pill="tag"]');
const addTagPill = document.querySelector<HTMLElement>('[data-note-metadata-pill="add-tag"]');
assert.ok(folderPill);
assert.ok(tagPill);
assert.ok(addTagPill);
for (const className of [
"inline-flex",
"h-5",
"items-center",
"gap-1",
"rounded-md",
"bg-muted/70",
"px-2",
"text-[11px]",
"font-medium",
"leading-none",
]) {
assert.ok(folderPill.classList.contains(className), `folder pill should include ${className}`);
assert.ok(tagPill.classList.contains(className), `tag pill should include ${className}`);
assert.ok(addTagPill.classList.contains(className), `add-tag pill should include ${className}`);
}
assert.ok(tagPill.classList.contains("text-foreground"));
assert.ok(addTagPill.classList.contains("text-foreground"));
assert.equal(tagPill.classList.contains("border"), false);
assert.equal(tagPill.classList.contains("bg-primary/10"), false);
assert.equal(folderPill.querySelector("svg")?.classList.contains("-translate-y-px"), false);
assert.equal(tagPill.querySelector("svg")?.classList.contains("-translate-y-px"), false);
assert.ok([...folderPill.querySelectorAll("span")].some((node) => node.classList.contains("translate-y-px")));
assert.ok([...tagPill.querySelectorAll("span")].some((node) => node.classList.contains("translate-y-px")));
assert.ok([...addTagPill.querySelectorAll("span")].some((node) => node.classList.contains("translate-y-px")));
});
test("NotesManager tree scroll area constrains width so titles can truncate", () => {
const markup = renderNotes();
// Radix ScrollArea viewport child is display:table by default; force block +
// min-w-0 so narrowing the notes sidebar ellipsizes long titles instead of
// clipping them. React encodes `&` / `>` in class attributes.
assert.match(
markup,
/data-radix-scroll-area-viewport\](?:&gt;|>)div\]:!block/,
);
assert.match(
markup,
/data-radix-scroll-area-viewport\](?:&gt;|>)div\]:!min-w-0/,
);
assert.match(markup, /data-notes-drop-zone="root"/);
assert.match(markup, /min-h-full min-w-0 space-y-1 overflow-hidden/);
// Aside stays unclipped so the resize handle's outer half remains hit-testable;
// truncation is enforced by the inner tree shell + ScrollArea constraints.
assert.match(markup, /aside class="[^"]*min-w-0[^"]*flex-col bg-background/);
assert.match(markup, /flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden/);
assert.match(markup, /role="separator"/);
assert.match(markup, /translate-x-1\/2 cursor-col-resize/);
});
test("clampNotesTreeWidth keeps the sidebar within the design range", () => {
assert.equal(clampNotesTreeWidth(100), 160);
assert.equal(clampNotesTreeWidth(300), 300);
assert.equal(clampNotesTreeWidth(900), 520);
});
test("normalizeNoteEditorMode migrates the legacy live mode to edit", () => {
assert.equal(normalizeNoteEditorMode("live"), "edit");
assert.equal(isNoteEditorMode("live"), false);
assert.equal(isNoteEditorMode("edit"), true);
assert.equal(normalizeNoteEditorMode("preview"), "preview");
assert.equal(normalizeNoteEditorMode("invalid"), null);
});
test("stored legacy live mode falls back to edit on the real read path", (t) => {
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => key === STORAGE_KEY_VAULT_NOTES_EDITOR_MODE ? "live" : null,
} as Storage,
});
t.after(() => {
if (previousLocalStorage) {
Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
} else {
Reflect.deleteProperty(globalThis, "localStorage");
}
});
assert.equal(
readStoredStringValue(STORAGE_KEY_VAULT_NOTES_EDITOR_MODE, "edit", isNoteEditorMode),
"edit",
);
});
test("NotesManager selection helpers keep note and folder selection exclusive", () => {
const notes = [note(), note({ id: "note-2", title: "Deploy", group: "Deploy" })];
const noteSelection = getNoteSelectionState(notes[0], false);
const sidebarNoteSelection = getNoteSelectionState(notes[0], true);
const groupSelection = getNoteGroupSelectionState("Ops");
assert.equal(getSelectedVaultNote(notes, "note-1")?.title, "Postgres failover checklist");
assert.equal(getSelectedVaultNote(notes, null), null);
assert.deepEqual(noteSelection, {
selectedNoteId: "note-1",
selectedGroup: null,
overlayNoteId: null,
});
assert.deepEqual(sidebarNoteSelection, {
selectedNoteId: "note-1",
selectedGroup: null,
overlayNoteId: "note-1",
});
assert.deepEqual(groupSelection, {
selectedNoteId: null,
selectedGroup: "Ops",
overlayNoteId: null,
});
assert.equal(isNoteFolderTreeSelected("Ops", "note-1", "Ops"), false);
assert.equal(isNoteFolderTreeSelected("Ops", null, "Ops"), true);
assert.equal(getNoteActionTargetGroup(notes[0], "Deploy"), "Ops");
assert.equal(getNoteActionTargetGroup(null, "Deploy"), "Deploy");
});
test("NotesManager note creation, duplicate, and delete fallback selections stay exclusive", () => {
const notes = [
note(),
note({ id: "note-2", title: "Deploy", group: "Deploy", order: 2000 }),
];
const createdOrDuplicated = note({ id: "new-note", group: "Ops" });
assert.deepEqual(getNoteSelectionState(createdOrDuplicated, false), {
selectedNoteId: "new-note",
selectedGroup: null,
overlayNoteId: null,
});
assert.deepEqual(getNoteSelectionState(createdOrDuplicated, true), {
selectedNoteId: "new-note",
selectedGroup: null,
overlayNoteId: "new-note",
});
assert.deepEqual(getFallbackNoteSelectionState(notes.slice(1), false), {
selectedNoteId: "note-2",
selectedGroup: null,
overlayNoteId: null,
});
assert.deepEqual(getFallbackNoteSelectionState(notes.slice(1), true), {
selectedNoteId: null,
selectedGroup: null,
overlayNoteId: null,
});
});
test("NotesManager selects the first loaded note when full mode has no selection", () => {
const notes = [
note({ id: "note-2", title: "Loaded note", group: "Ops", order: 2000 }),
];
assert.deepEqual(getValidatedNoteSelectionState(notes, null, null, false), {
selectedNoteId: "note-2",
selectedGroup: null,
overlayNoteId: null,
});
assert.equal(getValidatedNoteSelectionState(notes, null, "Ops", false), null);
assert.equal(getValidatedNoteSelectionState(notes, null, null, true), null);
});
test("NotesManager group drop helper separates reorder, inside, and ignored drops", () => {
assert.equal(getNotesGroupDropAction("Ops", "Deploy", "before"), "reorder");
assert.equal(getNotesGroupDropAction("Ops", "Deploy", "after"), "reorder");
assert.equal(getNotesGroupDropAction("Ops", "Deploy", "inside"), "inside");
assert.equal(getNotesGroupDropAction("Ops", "Ops", "before"), "ignore");
assert.equal(getNotesGroupDropAction("Ops", "Ops/DB", "inside"), "ignore");
assert.equal(getNotesGroupDropAction(null, "Deploy", "before"), "ignore");
});
test("NotesManager exposes shared tree drag targets and context menus", () => {
const markup = renderNotes();
assert.match(markup, /data-notes-drop-zone="root"/);
assert.match(markup, /data-notes-drag-kind="group"/);
assert.match(markup, /data-notes-drag-kind="note"/);
assert.match(markup, /data-notes-context-menu="group"/);
assert.match(markup, /data-notes-context-menu="note"/);
assert.match(markup, /draggable="true"/);
assert.match(markup, /data-open="false"/);
assert.match(markup, /role="separator"/);
});
test("NotesManager restores and persists the note editor mode without resetting on note switch", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /STORAGE_KEY_VAULT_NOTES_EDITOR_MODE/);
assert.match(source, /useStoredString<NoteEditorMode>\(\s*STORAGE_KEY_VAULT_NOTES_EDITOR_MODE,\s*"edit",\s*isNoteEditorMode/s);
assert.doesNotMatch(source, /localStorageAdapter/);
assert.doesNotMatch(source, /setNoteEditorMode\("edit"\)/);
});
test("NotesManager renders nested notebook folders", () => {
const markup = renderNotes([
note({
group: "Ops/DB/Failover",
title: "Replica promotion",
content: "Promote replica",
}),
]);
assert.match(markup, /Ops/);
assert.match(markup, /DB/);
assert.match(markup, /Failover/);
assert.match(markup, /Replica promotion/);
});
test("NotesManager keeps saved notebook folder order", () => {
const markup = renderNotes(
[
note({ id: "alpha-note", title: "Alpha note", group: "Alpha" }),
note({ id: "beta-note", title: "Beta note", group: "Beta" }),
],
"full",
["Beta", "Alpha"],
);
assert.ok(markup.indexOf("Beta") < markup.indexOf("Alpha"));
});
test("NotesManager renders empty state", () => {
const markup = renderNotes([]);
assert.match(markup, /No notes yet/);
assert.match(markup, /New Note/);
assert.match(markup, /Import Markdown/);
assert.doesNotMatch(markup, /data-notes-drop-zone="root"/);
});
test("NotesManager exposes markdown import controls", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /notes\.action\.importMarkdown/);
assert.match(source, /importMarkdownPayloadsToVaultNotes/);
assert.match(source, /accept="\.md,\.markdown,\.txt"/);
assert.match(source, /importTargetGroupRef/);
assert.match(source, /openImportMarkdownPicker/);
assert.match(source, /notes\.action\.importMarkdown[\s\S]*openImportMarkdownPicker\(groupPath\)/);
assert.match(source, /multiple/);
});
test("NotesManager exposes markdown export controls", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /notes\.action\.exportNote/);
assert.match(source, /notes\.action\.exportGroup/);
assert.match(source, /notes\.action\.exportAll/);
assert.match(source, /buildVaultNoteMarkdownExportFiles/);
assert.match(source, /buildTextFilesZipBlob/);
assert.match(source, /downloadNotesBlob/);
assert.match(source, /text\/markdown;charset=utf-8/);
});
test("NotesManager shows placeholder label for notes without titles", () => {
const markup = renderNotes([note({ title: "" })]);
assert.match(markup, /Note title/);
});
test("NotesManager flushes drafts on pagehide/beforeunload", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /addEventListener\("pagehide"/);
assert.match(source, /addEventListener\("beforeunload"/);
assert.match(source, /flushNoteDraft/);
});
test("NotesManager flushes drafts when retained mounts become inactive", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /isActive\?: boolean/);
assert.match(source, /if \(isActive\) return;/);
assert.match(source, /useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;/);
assert.match(source, /visibilitychange/);
});
test("NotesManager tree rename allows clearing note titles", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
const renameBlock = source.match(/onRenameCommit=\{\(name\) => \{[\s\S]*?\}\}/)?.[0] ?? "";
assert.match(renameBlock, /renameNoteFromTree\(note\.id, name\)/);
assert.doesNotMatch(renameBlock, /if \(!title\) return/);
assert.match(source, /note\.id === noteId \? \{ \.\.\.note, title: nextTitle, updatedAt \}/);
});
test("NotesManager sidebar mode renders list without editor by default", () => {
const markup = renderNotes([note()], "sidebar");
assert.match(markup, /Ops/);
assert.match(markup, /Postgres failover checklist/);
assert.doesNotMatch(markup, /editable markdown/);
assert.doesNotMatch(markup, /data-notes-editor-loading="true"/);
});
test("NotesManager sidebar mode opens the requested note without selecting its folder", () => {
const markup = renderNotes(
[
note(),
note({
id: "note-2",
title: "Deploy overlay",
content: "Deploy overlay content",
group: "Ops",
order: 2000,
}),
],
"sidebar",
["Ops"],
"note-2",
);
assert.match(markup, /Deploy overlay/);
assert.ok(
markup.includes('data-notes-editor-loading="true"')
|| markup.includes("Deploy overlay content")
|| markup.includes("editable markdown"),
"openNoteId should mount the sidebar note overlay editor",
);
assert.equal(markup.match(/data-selected="true"/g)?.length, 1);
assert.match(markup, /data-vault-tree-row="group"[^>]*data-selected="false"/);
assert.match(markup, /data-vault-tree-row="item"[^>]*data-selected="true"[^>]*data-note-id="note-2"/);
});
test("NotesManager can re-open the same sidebar note when the request id changes", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /openNoteRequestId\?: number \| null/);
assert.match(source, /\[isSidebarMode, onOpenNoteIdHandled, openNoteId, openNoteRequestId, sortedNotes\]/);
assert.match(source, /if \(isSidebarMode && overlayNoteView\)/);
assert.match(source, /\[isSidebarMode, overlayNoteView\]/);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
/**
* Smoke: UI re-export surface stays wired to domain paste policy.
* Full policy coverage lives in domain/notes/clipboardPaste.test.ts.
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
convertClipboardHtmlToMarkdown,
resolveNoteClipboardPaste,
shouldInsertClipboardTextAsMarkdown,
} from "./noteClipboardPaste.ts";
test("noteClipboardPaste re-exports domain resolve + convert helpers", () => {
assert.equal(shouldInsertClipboardTextAsMarkdown("# Title\n\n- item"), true);
assert.equal(shouldInsertClipboardTextAsMarkdown("plain only"), false);
const payload = resolveNoteClipboardPaste({
plainText: "# From re-export\n\n- a",
htmlText: "",
});
assert.equal(payload.kind, "markdown");
assert.match(payload.text, /# From re-export/);
const md = convertClipboardHtmlToMarkdown(
"<html><body><!--StartFragment--><h1>Hi</h1><!--EndFragment--></body></html>",
);
assert.match(md, /^# Hi/m);
});

View File

@@ -0,0 +1,31 @@
/**
* UI re-export of note clipboard paste policy.
* Implementation lives in domain/notes/clipboardPaste.ts (pure, no React).
*/
export {
type NoteClipboardPasteKind,
type NoteClipboardPastePayload,
shouldInsertClipboardTextAsMarkdown,
looksLikeClipboardHtml,
plainMarkdownContainsHtml,
isPrimarilyHtmlDocument,
isCenteredBlockElement,
htmlOpenTagIsCentered,
wrapCenteredMarkdown,
decodeHtmlEntities,
normalizeImageSrc,
normalizeNotePublicAssetPaths,
serializeSafeHtmlImage,
trimBlankLinesOutsideCode,
findHtmlTagEnd,
convertHtmlImgTagToMarkdownOrHtml,
normalizeLinkedBadgeImages,
maskCodeRegions,
unmaskCodeRegions,
normalizePastedNoteMarkdown,
convertClipboardHtmlToMarkdown,
extractBalancedHtmlElement,
convertHtmlIslandsInMarkdown,
resolveNoteClipboardPaste,
shouldInterceptResolvedNotePaste,
} from "../../domain/notes/clipboardPaste";

View File

@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import { EditorState } from "@codemirror/state";
import { EditorView, showTooltip } from "@codemirror/view";
import { syncNoteCodeTooltipStyle, createNoteCodeTooltipExtensions, getNoteTooltipSpace } from "./noteCodeTooltips";
const stubRect = (element: Element, top: number, left: number, right: number, bottom: number) => {
element.getBoundingClientRect = () => ({
top,
left,
right,
bottom,
width: right - left,
height: bottom - top,
x: left,
y: top,
toJSON: () => ({}),
} as DOMRect);
};
test("note tooltips escape clipped code blocks and disappear with their editor", () => {
const dom = new JSDOM('<div id="note" style="overflow:hidden;height:20px"></div>', {
pretendToBeVisual: true,
});
const keys = ["window", "document", "MutationObserver", "requestAnimationFrame", "cancelAnimationFrame"] as const;
const previous = keys.map((key) => Object.getOwnPropertyDescriptor(globalThis, key));
for (const key of keys) {
const value = dom.window[key];
Object.defineProperty(globalThis, key, {
configurable: true,
value: typeof value === "function" && key.includes("AnimationFrame") ? value.bind(dom.window) : value,
});
}
let view: EditorView | undefined;
try {
const parent = dom.window.document.querySelector("#note") as HTMLElement;
view = new EditorView({
parent,
state: EditorState.create({
doc: "con",
extensions: [
...createNoteCodeTooltipExtensions(dom.window.document.body),
showTooltip.of({
pos: 0,
create() {
const tooltip = dom.window.document.createElement("div");
tooltip.textContent = "const";
return { dom: tooltip };
},
}),
],
}),
});
const tooltip = dom.window.document.querySelector(".cm-tooltip")!;
assert.ok(tooltip);
assert.equal(parent.contains(tooltip), false);
assert.equal(tooltip.parentElement?.parentElement, dom.window.document.body);
for (const themeClass of view.themeClasses.split(" ")) {
assert.ok(tooltip.parentElement?.classList.contains(themeClass));
}
view.dom.style.setProperty("--popover", "120 50% 20%");
view.dom.style.setProperty("--accent", "120 70% 40%");
syncNoteCodeTooltipStyle(view, { top: 0, left: 100, right: 300, bottom: 400 });
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--popover"), "120 50% 20%");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--accent"), "120 70% 40%");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--note-tooltip-width"), "200px");
view.dom.style.setProperty("--popover", "240 50% 20%");
view.dom.style.removeProperty("--accent");
syncNoteCodeTooltipStyle(view, { top: 0, left: 100, right: 250, bottom: 400 });
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--popover"), "240 50% 20%");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--accent"), "");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--note-tooltip-width"), "150px");
view.destroy();
view = undefined;
assert.equal(dom.window.document.querySelector(".cm-tooltip"), null);
} finally {
view?.destroy();
keys.forEach((key, index) => {
const descriptor = previous[index];
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
});
dom.window.close();
}
});
test("note tooltip space is bounded by clipping pane ancestors", () => {
const dom = new JSDOM('<div id="pane" style="overflow:hidden"><div id="note"></div></div>');
const pane = dom.window.document.querySelector("#pane") as HTMLElement;
const note = dom.window.document.querySelector("#note") as HTMLElement;
stubRect(pane, 100, 200, 600, 500);
// The notes editor extends below its pane; a tooltip must not follow it out.
stubRect(note, 150, 250, 550, 700);
const realGetComputedStyle = dom.window.getComputedStyle.bind(dom.window);
dom.window.getComputedStyle = ((element: Element) => ({
...realGetComputedStyle(element),
overflowX: element === pane ? "hidden" : "visible",
overflowY: element === pane ? "hidden" : "visible",
})) as typeof dom.window.getComputedStyle;
try {
assert.deepEqual(getNoteTooltipSpace(note, dom.window.document), {
top: 150,
left: 250,
right: 550,
bottom: 500,
});
} finally {
dom.window.close();
}
});
test("note tooltip space honors paint containment without overflow clipping", () => {
const dom = new JSDOM(
'<style>#pane{contain:strict}</style><div id="pane"><div id="note"></div></div>',
);
const pane = dom.window.document.querySelector("#pane") as HTMLElement;
const note = dom.window.document.querySelector("#note") as HTMLElement;
stubRect(pane, 0, 0, 800, 600);
stubRect(note, 50, -50, 850, 900);
const realGetComputedStyle = dom.window.getComputedStyle.bind(dom.window);
dom.window.getComputedStyle = ((element: Element) => ({
...realGetComputedStyle(element),
overflowX: "visible",
overflowY: "visible",
contain: element === pane ? "strict" : "none",
})) as typeof dom.window.getComputedStyle;
try {
assert.deepEqual(getNoteTooltipSpace(note, dom.window.document), {
top: 50,
left: 0,
right: 800,
bottom: 600,
});
} finally {
dom.window.close();
}
});
test("note tooltip space falls back to window bounds when the editor is unmounted", () => {
const dom = new JSDOM("<div></div>");
const detached = dom.window.document.createElement("div");
try {
assert.deepEqual(getNoteTooltipSpace(detached, dom.window.document), {
top: 0,
left: 0,
right: dom.window.document.documentElement.clientWidth,
bottom: dom.window.document.documentElement.clientHeight,
});
} finally {
dom.window.close();
}
});

View File

@@ -0,0 +1,128 @@
import { Prec } from "@codemirror/state";
import { EditorView, getTooltip, showTooltip, tooltips } from "@codemirror/view";
export interface NoteTooltipSpace {
top: number;
left: number;
right: number;
bottom: number;
}
/** True when the element clips horizontally overflowing descendants. */
const clipsHorizontally = (style: CSSStyleDeclaration): boolean =>
style.overflowX !== "visible" ||
/(^|\s)(strict|content|paint)(\s|$)/.test(style.contain || "");
/** True when the element clips vertically overflowing descendants. Paint
* containment (strict/content/paint) also clips and captures fixed
* descendants even when overflow is visible. */
const clipsVertically = (style: CSSStyleDeclaration): boolean =>
style.overflowY !== "visible" ||
/(^|\s)(strict|content|paint)(\s|$)/.test(style.contain || "");
/**
* Viewport-space bounds CodeMirror may place note tooltips in: the owning
* notes region (`boundsRoot`) shrunk by every clipping ancestor (terminal
* side-panel pane hosts, ScrollArea viewports). Tooltips are mounted on
* `document.body` so they escape clipped code blocks; bounding their space
* to the notes pane keeps a completion from rendering over an adjacent
* split pane. Without a mounted `boundsRoot`, falls back to the window
* bounds CodeMirror would use by default.
*/
export const getNoteTooltipSpace = (
boundsRoot: HTMLElement | null | undefined,
doc: Document,
): NoteTooltipSpace => {
if (!boundsRoot || !boundsRoot.isConnected) {
const docElt = doc.documentElement;
return { top: 0, left: 0, right: docElt.clientWidth, bottom: docElt.clientHeight };
}
const rootRect = boundsRoot.getBoundingClientRect();
let space: NoteTooltipSpace = {
top: rootRect.top,
left: rootRect.left,
right: rootRect.right,
bottom: rootRect.bottom,
};
const defaultView = boundsRoot.ownerDocument.defaultView;
if (!defaultView) return space;
for (let node: Element | null = boundsRoot; node; node = node.parentElement) {
const style = defaultView.getComputedStyle(node);
const rect = node.getBoundingClientRect();
if (clipsHorizontally(style)) {
space = {
...space,
left: Math.max(space.left, rect.left),
right: Math.min(space.right, rect.right),
};
}
if (clipsVertically(style)) {
space = {
...space,
top: Math.max(space.top, rect.top),
bottom: Math.min(space.bottom, rect.bottom),
};
}
}
return space;
};
export const syncNoteCodeTooltipStyle = (view: EditorView, space: NoteTooltipSpace): void => {
const width = `${Math.max(0, space.right - space.left)}px`;
const paneStyle = view.dom.ownerDocument.defaultView?.getComputedStyle(view.dom);
const tokens = ["--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground"];
for (const tooltip of view.state.facet(showTooltip)) {
if (!tooltip) continue;
const dom = getTooltip(view, tooltip)?.dom;
if (!dom) continue;
dom.style.setProperty("--note-tooltip-width", width);
// Detached tooltips must retain terminal-side-panel theme overrides.
for (const token of tokens) {
const value = paneStyle?.getPropertyValue(token).trim();
if (value) dom.style.setProperty(token, value);
else dom.style.removeProperty(token);
}
}
};
export const createNoteCodeTooltipExtensions = (
parent: HTMLElement | undefined,
getBoundsRoot?: () => HTMLElement | null,
) => [
// Code blocks and their enclosing note panes can clip editor descendants.
Prec.highest(
tooltips({
parent,
// Tooltips escape those clips via the global parent, so constrain their
// placement to the owning notes pane instead of the whole window.
tooltipSpace: (view: EditorView) => {
const space = getNoteTooltipSpace(getBoundsRoot?.(), view.dom.ownerDocument);
// CodeMirror constrains coordinates and height, but not width. Set
// the available width; its ResizeObserver remeasures after resizing.
syncNoteCodeTooltipStyle(view, space);
return space;
},
}),
),
// CodeMirror carries this theme's scope onto its detached tooltip container.
// Keep these rules local to Notes and use the application's existing colors.
Prec.highest(EditorView.theme({
".cm-tooltip": {
backgroundColor: "hsl(var(--popover))",
color: "hsl(var(--popover-foreground))",
border: "1px solid hsl(var(--border))",
},
".cm-tooltip-autocomplete": {
maxWidth: "var(--note-tooltip-width, 95vw)",
boxSizing: "border-box",
},
".cm-tooltip.cm-tooltip-autocomplete > ul": {
minWidth: "min(250px, max(0px, calc(var(--note-tooltip-width, 95vw) - 2px)))",
maxWidth: "min(700px, max(0px, calc(var(--note-tooltip-width, 95vw) - 2px)))",
},
".cm-tooltip-autocomplete > ul > li[aria-selected]": {
backgroundColor: "hsl(var(--accent))",
color: "hsl(var(--accent-foreground))",
},
})),
];

View File

@@ -0,0 +1,26 @@
import type { MarkdownActionType, NoteHeadingItem } from "../../domain/notes";
export interface InlineMarkdownEditorHandle {
executeAction: (action: MarkdownActionType) => void;
focus: () => void;
scrollToHeading: (heading: NoteHeadingItem, headingIndex: number) => boolean;
}
export type NoteEditorMode = "edit" | "preview" | "source" | "live";
/** Active text-format toggles at the current selection (toolbar highlight). */
export type ActiveTextFormats = {
bold: boolean;
italic: boolean;
underline: boolean;
strikethrough: boolean;
code: boolean;
};
export const EMPTY_ACTIVE_FORMATS: ActiveTextFormats = {
bold: false,
italic: false,
underline: false,
strikethrough: false,
code: false,
};

View File

@@ -0,0 +1,47 @@
/** Compact README-style icons (width ≤ 96). CSS uses data-note-img-size instead of :has(). */
export const NOTE_SMALL_IMAGE_MAX_WIDTH = 96;
export const isNoteSmallImageWidth = (widthRaw: string | number | null | undefined): boolean => {
const width = typeof widthRaw === "number" ? widthRaw : Number(String(widthRaw ?? "").trim());
return Number.isFinite(width) && width > 0 && width <= NOTE_SMALL_IMAGE_MAX_WIDTH;
};
/**
* Mark compact images so CSS can lay out badge rows without hundreds of :has()
* width selectors. Also enable lazy loading for remote images.
*/
export const annotateNoteImageSizes = (container: HTMLElement): void => {
container.querySelectorAll("img").forEach((node) => {
if (!(node instanceof HTMLImageElement)) return;
const isSmall = isNoteSmallImageWidth(node.getAttribute("width"));
if (isSmall) {
node.dataset.noteImgSize = "sm";
} else {
delete node.dataset.noteImgSize;
}
// Prefer browser-native lazy decode for remote screenshots / badges.
if (node.getAttribute("src") && !node.getAttribute("loading")) {
node.loading = "lazy";
}
if (!node.getAttribute("decoding")) {
node.decoding = "async";
}
const wrappers: HTMLElement[] = [];
const block = node.closest<HTMLElement>("[data-editor-block-type=\"image\"]");
if (block) wrappers.push(block);
const imageWrapper = node.closest<HTMLElement>("[class*=\"_imageWrapper_\"]");
if (imageWrapper && imageWrapper !== block) wrappers.push(imageWrapper);
const parent = node.parentElement;
if (parent?.tagName === "P" && parent.childElementCount === 1) {
wrappers.push(parent);
}
for (const wrapper of wrappers) {
if (isSmall) wrapper.dataset.noteImgSize = "sm";
else delete wrapper.dataset.noteImgSize;
}
});
};

View File

@@ -0,0 +1,143 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
insertClipboardTextAtActiveLexicalSelection,
mergeNoteMarkdownDocumentPaste,
NOTE_MARKDOWN_PASTE_INSERT_MAX_CHARS,
resolveNoteMarkdownPasteSettleAttempts,
resolveNoteMarkdownPasteStrategy,
shouldInterceptNoteMarkdownPaste,
} from "./InlineMarkdownEditor.tsx";
test("markdown paste intercepts structured clipboard text in edit mode even without a Lexical selection", () => {
assert.equal(
shouldInterceptNoteMarkdownPaste({
editorMode: "edit",
pasteInsideCodeBlock: false,
clipboardText: "# Heading\n\n- item",
canInsertMarkdownAtSelection: true,
}),
true,
);
assert.equal(
shouldInterceptNoteMarkdownPaste({
editorMode: "edit",
pasteInsideCodeBlock: false,
clipboardText: "# Heading\n\n- item",
canInsertMarkdownAtSelection: false,
}),
true,
);
assert.equal(
shouldInterceptNoteMarkdownPaste({
editorMode: "preview",
pasteInsideCodeBlock: false,
clipboardText: "# Heading\n\n- item",
canInsertMarkdownAtSelection: true,
}),
false,
);
});
test("document paste merge preserves first-line indentation and appends after current body", () => {
assert.equal(
mergeNoteMarkdownDocumentPaste("Existing note", "# Pasted\n\n- item"),
"Existing note\n\n# Pasted\n\n- item",
);
assert.equal(
mergeNoteMarkdownDocumentPaste(" ", "# Only paste"),
"# Only paste",
);
assert.equal(
mergeNoteMarkdownDocumentPaste("- parent", " - child"),
"- parent\n\n - child",
);
assert.equal(
mergeNoteMarkdownDocumentPaste("Existing", "\n\n# Pasted\n"),
"Existing\n\n# Pasted",
);
});
test("paste strategy preserves caret: long paste with selection still inserts at selection", () => {
assert.equal(
resolveNoteMarkdownPasteStrategy({
canInsertMarkdownAtSelection: false,
clipboardText: "# short",
}),
"document-merge",
);
assert.equal(
resolveNoteMarkdownPasteStrategy({
canInsertMarkdownAtSelection: true,
clipboardText: "# short body",
}),
"insert-at-selection",
);
const longMarkdown = `# Title\n\n${"paragraph text ".repeat(400)}`;
assert.ok(longMarkdown.length >= NOTE_MARKDOWN_PASTE_INSERT_MAX_CHARS);
// Long paste must NOT force document-merge when a caret exists (would append at EOF).
assert.equal(
resolveNoteMarkdownPasteStrategy({
canInsertMarkdownAtSelection: true,
clipboardText: longMarkdown,
}),
"insert-at-selection",
);
});
test("paste settle attempts scale with clipboard size", () => {
assert.equal(resolveNoteMarkdownPasteSettleAttempts(100), 6);
assert.equal(resolveNoteMarkdownPasteSettleAttempts(3_000), 6);
assert.equal(resolveNoteMarkdownPasteSettleAttempts(12_000), 10);
assert.ok(resolveNoteMarkdownPasteSettleAttempts(100_000) <= 40);
assert.ok(resolveNoteMarkdownPasteSettleAttempts(100_000) >= 6);
});
test("selection paste recovery helper rejects empty text or missing target", () => {
assert.equal(insertClipboardTextAtActiveLexicalSelection(null, "# Heading"), false);
assert.equal(insertClipboardTextAtActiveLexicalSelection(null, ""), false);
});
test("InlineMarkdownEditor only preventDefaults markdown paste after a successful intercept guard", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /resolveNoteClipboardPaste/);
assert.match(source, /shouldInterceptResolvedNotePaste/);
assert.match(source, /hasActiveLexicalTextSelection/);
assert.match(source, /mergeNoteMarkdownDocumentPaste/);
assert.match(source, /setMarkdown\(/);
assert.match(source, /resolveNoteMarkdownPasteStrategy/);
assert.match(source, /text\/html/);
assert.match(
source,
/shouldInterceptResolvedNotePaste\([\s\S]*?\)[\s\S]*?event\.preventDefault\(\)/,
);
assert.match(
source,
/if \(\s*!shouldInterceptResolvedNotePaste\([\s\S]*?\)\s*\{\s*return;\s*\}/,
);
assert.match(source, /strategy === "document-merge"/);
assert.match(source, /editor\.insertMarkdown\(markdown\)/);
assert.match(source, /pasteRecoveryGenerationRef/);
assert.match(source, /tryCommitSettledPaste/);
assert.match(source, /editor\.getMarkdown\(\)/);
// With caret: settle failure must not blindly append (emptyDoc gate).
assert.match(source, /if \(emptyDoc\) applyDocumentPaste\(\)/);
assert.match(
source,
/const currentMarkdown = editor\.getMarkdown\(\);[\s\S]*mergeNoteMarkdownDocumentPaste\(currentMarkdown, markdown\)/,
);
// Do not re-queue insertMarkdown at the settle midpoint (double-insert risk).
assert.doesNotMatch(
source,
/attempt === Math\.floor\(maxAttempts \/ 2\)/,
);
// Non-empty settle failure must recover at the selection, not discard after preventDefault.
assert.match(source, /recoverInterceptedPasteAtSelection/);
assert.match(
source,
/if \(attempt >= maxAttempts\)[\s\S]*emptyDoc[\s\S]*applyDocumentPaste[\s\S]*recoverInterceptedPasteAtSelection/,
);
});

View File

@@ -0,0 +1,5 @@
@use "katex/src/styles/katex.scss" with (
$font-folder: "katex/dist/fonts",
$use-woff: false,
$use-ttf: false,
);

View File

@@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
normalizeNoteMathSource,
NOTE_MATH_KATEX_OPTIONS,
renderNoteMathFormula,
} from "./noteMathRenderer.ts";
test("note math renderer uses accessible, untrusted KaTeX output", () => {
assert.equal(NOTE_MATH_KATEX_OPTIONS.displayMode, true);
assert.equal(NOTE_MATH_KATEX_OPTIONS.output, "htmlAndMathml");
assert.equal(NOTE_MATH_KATEX_OPTIONS.throwOnError, false);
assert.equal(NOTE_MATH_KATEX_OPTIONS.trust, false);
const html = renderNoteMathFormula(String.raw`E = mc^2`);
assert.match(html, /class="katex-display"/);
assert.match(html, /class="katex-mathml"/);
assert.match(html, /<msup>/);
});
test("note math renderer handles prime shorthand and matrices", () => {
const primes = renderNoteMathFormula(String.raw`f'(x) + x''`);
assert.match(primes, /<msup>/);
assert.match(primes, //);
const matrix = renderNoteMathFormula(String.raw`\begin{pmatrix} a & b \\ c & d \end{pmatrix}^{-1}`);
assert.match(matrix, /<mtable(?:\s|>)/);
assert.match(matrix, /<msup>/);
});
test("note math renderer supports common LaTeX constructs without custom parsing", () => {
const html = renderNoteMathFormula(
String.raw`\sum_{i=1}^n \frac{\alpha_i}{\sqrt[3]{x_i}} \times \left\lVert v \right\rVert`,
);
assert.match(html, /∑/);
assert.match(html, /<mfrac>/);
assert.match(html, /<mroot>/);
assert.match(html, /α/);
assert.match(html, /∥/);
});
test("note math renderer accepts balanced outer display delimiters only", () => {
assert.equal(normalizeNoteMathSource(" $$ x^2 $$ "), "x^2");
assert.equal(normalizeNoteMathSource(String.raw`\[ x^2 \]`), "x^2");
assert.equal(normalizeNoteMathSource("$$ x^2"), "$$ x^2");
assert.equal(normalizeNoteMathSource("x^2 $$"), "x^2 $$");
});
test("note math renderer shows invalid input without throwing", () => {
const html = renderNoteMathFormula(String.raw`\frac{`);
assert.match(html, /class="katex-error"/);
assert.match(html, /\\frac/);
});
test("note math renderer blocks untrusted links and external images", () => {
const link = renderNoteMathFormula(String.raw`\href{javascript:alert(1)}{click}`);
assert.doesNotMatch(link, /href=/i);
const image = renderNoteMathFormula(String.raw`\includegraphics{https://example.com/x.png}`);
assert.doesNotMatch(image, /<img/i);
});

View File

@@ -0,0 +1,27 @@
import katex, { type KatexOptions } from "katex";
export const NOTE_MATH_KATEX_OPTIONS: KatexOptions = Object.freeze({
displayMode: true,
output: "htmlAndMathml",
throwOnError: false,
strict: "warn",
trust: false,
maxExpand: 1_000,
maxSize: 20,
});
export const normalizeNoteMathSource = (source: string): string => {
const trimmed = source.trim();
if (trimmed.length >= 4 && trimmed.startsWith("$$") && trimmed.endsWith("$$")) {
return trimmed.slice(2, -2).trim();
}
if (trimmed.length >= 4 && trimmed.startsWith("\\[") && trimmed.endsWith("\\]")) {
return trimmed.slice(2, -2).trim();
}
return trimmed;
};
export const renderNoteMathFormula = (source: string): string => katex.renderToString(
normalizeNoteMathSource(source),
NOTE_MATH_KATEX_OPTIONS,
);

View File

@@ -0,0 +1,121 @@
# 设备TCP栈调优操作记录
> **目标**根据本地带宽动态调整TCP接收/发送缓冲区使其能容纳带宽延迟积BDP
## 1. 环境信息收集
### 1.1 基础硬件与系统信息
```bash
# 设备型号
cat /tmp/sysinfo/model
# 内核版本
uname -r
# 总内存
grep MemTotal /proc/meminfo
```
### 1.2 当前TCP参数快照
```bash
sysctl net.ipv4.tcp_rmem \
net.ipv4.tcp_wmem \
net.core.rmem_max \
net.core.wmem_max \
net.ipv4.tcp_congestion_control \
net.core.netdev_max_backlog
```
### 1.3 网络链路与带宽测试
- **查看WAN口速率**(替换`<wan口>`为实际接口名,如`eth0`
```bash
ethtool <wan口> | grep -i speed
```
- **实测带宽**(确保无代理干扰):
```bash
# 检查代理进程
ps | grep -E "openclash|mihomo"
# 若存在代理,请先关闭;然后测速
speedtest --accept-license --format=json
```
### 1.4 检查BBR拥塞控制算法支持
```bash
cat /proc/sys/net/ipv4/tcp_available_congestion_control
```
> **注意**:若输出不包含`bbr`,则后续配置保持`cubic`。
---
## 2. BDP计算与参数确定
### 2.1 计算公式
`最大缓冲区(MB) ≈ 带宽(Mbps) × 0.125 × 最大RTT(秒)`
即:`带宽(Mbps) × 0.125 × (RTT_ms / 1000)`
### 2.2 参数参考
| 场景 | 预估RTT | 推荐`max`值 |
| :--- | :--- | :--- |
| 本地网络 | ~30ms | 根据实测带宽计算 |
| 代理/国际链路 | ~150-200ms | 根据实测带宽计算 |
| **内存<1GB设备** | - | **固定16MB** |
| 大内存服务器 | - | 可参考GCP建议最大64MB |
### 2.3 配置示例16MB
若计算后决定使用16MB上限
```bash
cat > /etc/sysctl.d/90-tcp-tuning.conf <<'EOF'
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 131072 16777216
net.ipv4.tcp_wmem = 4096 16384 16777216
net.core.netdev_max_backlog = 8192
net.ipv4.tcp_mtu_probing = 1
net.ipv4.tcp_fastopen = 3
net.ipv4.tcp_slow_start_after_idle = 0
EOF
```
---
## 3. 应用与验证
### 3.1 加载配置
```bash
# 应用配置文件BusyBox使用-p参数
sysctl -p /etc/sysctl.d/90-tcp-tuning.conf
```
### 3.2 验证生效
```bash
# 检查关键参数
sysctl net.ipv4.tcp_rmem net.core.rmem_max
```
### 3.3 本地环回吞吐测试
```bash
# 启动临时服务端运行1次后退出
iperf3 -s -1 &
# 客户端连接本机测试3秒
iperf3 -c 127.0.0.1 -t 3
```
> **目的**验证内核TCP栈本身是否正常。
---
## 4. 重要说明
- **开机自动加载**OpenWrt系统下`/etc/init.d/sysctl`会自动遍历`/etc/sysctl.d/*.conf`,无需额外设置。
- **BBR支持**若内核不支持BBR不添加`net.ipv4.tcp_congestion_control=bbr`保留默认cubic。
- **故障排查**:外网测速慢时,请先排除:
- 代理软件openclash/mihomo劫持流量。
- 上游运营商或国际链路瓶颈。
- **配置回滚**:如需恢复默认设置,只需:
```bash
rm /etc/sysctl.d/90-tcp-tuning.conf
sysctl -p /etc/sysctl.d/90-tcp-tuning.conf # 或重启设备
```
---