[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
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:
3
components/notes/InlineMarkdownEditor.lazy.tsx
Normal file
3
components/notes/InlineMarkdownEditor.lazy.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import "./noteMath.scss";
|
||||
|
||||
export { InlineMarkdownEditor } from "./InlineMarkdownEditor";
|
||||
786
components/notes/InlineMarkdownEditor.test.tsx
Normal file
786
components/notes/InlineMarkdownEditor.test.tsx
Normal 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(""), 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: "",
|
||||
syncedMarkdown: "",
|
||||
latestSourceMarkdown: "",
|
||||
syncedSourceMarkdown: "",
|
||||
};
|
||||
|
||||
assert.equal(shouldApplyExternalNoteMarkdown({
|
||||
...base,
|
||||
nextSourceMarkdown: "# Remote",
|
||||
}), false);
|
||||
assert.equal(shouldApplyExternalNoteMarkdown({
|
||||
...base,
|
||||
nextSourceMarkdown: "",
|
||||
}), true);
|
||||
assert.equal(shouldApplyExternalNoteMarkdown({
|
||||
...base,
|
||||
latestSourceMarkdown: "",
|
||||
nextSourceMarkdown: "",
|
||||
}), true);
|
||||
});
|
||||
2251
components/notes/InlineMarkdownEditor.tsx
Normal file
2251
components/notes/InlineMarkdownEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<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);
|
||||
});
|
||||
162
components/notes/NoteExportMenu.tsx
Normal file
162
components/notes/NoteExportMenu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
26
components/notes/NoteOutline.test.tsx
Normal file
26
components/notes/NoteOutline.test.tsx
Normal 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/);
|
||||
});
|
||||
94
components/notes/NoteOutline.tsx
Normal file
94
components/notes/NoteOutline.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
265
components/notes/NoteSourceEditor.test.tsx
Normal file
265
components/notes/NoteSourceEditor.test.tsx
Normal 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;
|
||||
}
|
||||
});
|
||||
383
components/notes/NoteSourceEditor.tsx
Normal file
383
components/notes/NoteSourceEditor.tsx
Normal 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";
|
||||
70
components/notes/NoteTitleInput.test.tsx
Normal file
70
components/notes/NoteTitleInput.test.tsx
Normal 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/);
|
||||
});
|
||||
156
components/notes/NoteTitleInput.tsx
Normal file
156
components/notes/NoteTitleInput.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
523
components/notes/NoteToolbar.tsx
Normal file
523
components/notes/NoteToolbar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
212
components/notes/NotesManager.perf.test.ts
Normal file
212
components/notes/NotesManager.perf.test.ts
Normal 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"\]/);
|
||||
});
|
||||
430
components/notes/NotesManager.test.tsx
Normal file
430
components/notes/NotesManager.test.tsx
Normal 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\](?:>|>)div\]:!block/,
|
||||
);
|
||||
assert.match(
|
||||
markup,
|
||||
/data-radix-scroll-area-viewport\](?:>|>)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\]/);
|
||||
});
|
||||
2202
components/notes/NotesManager.tsx
Normal file
2202
components/notes/NotesManager.tsx
Normal file
File diff suppressed because it is too large
Load Diff
29
components/notes/noteClipboardPaste.test.ts
Normal file
29
components/notes/noteClipboardPaste.test.ts
Normal 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);
|
||||
});
|
||||
31
components/notes/noteClipboardPaste.ts
Normal file
31
components/notes/noteClipboardPaste.ts
Normal 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";
|
||||
153
components/notes/noteCodeTooltips.test.ts
Normal file
153
components/notes/noteCodeTooltips.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
128
components/notes/noteCodeTooltips.ts
Normal file
128
components/notes/noteCodeTooltips.ts
Normal 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))",
|
||||
},
|
||||
})),
|
||||
];
|
||||
26
components/notes/noteEditorTypes.ts
Normal file
26
components/notes/noteEditorTypes.ts
Normal 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,
|
||||
};
|
||||
47
components/notes/noteImageLayout.ts
Normal file
47
components/notes/noteImageLayout.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
143
components/notes/noteMarkdownPaste.test.ts
Normal file
143
components/notes/noteMarkdownPaste.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
5
components/notes/noteMath.scss
Normal file
5
components/notes/noteMath.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
@use "katex/src/styles/katex.scss" with (
|
||||
$font-folder: "katex/dist/fonts",
|
||||
$use-woff: false,
|
||||
$use-ttf: false,
|
||||
);
|
||||
62
components/notes/noteMathRenderer.test.ts
Normal file
62
components/notes/noteMathRenderer.test.ts
Normal 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);
|
||||
});
|
||||
27
components/notes/noteMathRenderer.ts
Normal file
27
components/notes/noteMathRenderer.ts
Normal 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,
|
||||
);
|
||||
121
components/notes/test-fixtures/issue-3205.md
Normal file
121
components/notes/test-fixtures/issue-3205.md
Normal 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 # 或重启设备
|
||||
```
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user