mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c81e42167 | ||
|
|
f03c9f4e49 | ||
|
|
12e8f58496 | ||
|
|
ff13a2da66 | ||
|
|
039e636784 | ||
|
|
f8f4ded120 |
No files matched your search
@@ -10,7 +10,7 @@ export type NotesSnapshot = {
|
||||
};
|
||||
|
||||
export type NotesActions = {
|
||||
updateNotes: (notes: VaultNote[]) => void;
|
||||
updateNotes: (notes: VaultNote[]) => boolean | void;
|
||||
updateNoteGroups: (groups: string[]) => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -69,8 +69,10 @@ import {
|
||||
publishNotesSnapshot,
|
||||
registerNotesActions,
|
||||
} from "./notesStore";
|
||||
import { commitVaultNotesWrite } from "./vaultNotesPersistence";
|
||||
import { publishShellHistorySnapshot } from "./shellHistoryStore";
|
||||
import { setVaultInitialized } from "./vaultInitStore";
|
||||
import { notify } from "../notification";
|
||||
import {
|
||||
decryptGroupConfigs,
|
||||
decryptHosts,
|
||||
@@ -286,6 +288,7 @@ export const useVaultState = () => {
|
||||
const hostsRef = useRef<Host[]>([]);
|
||||
const notesRef = useRef<VaultNote[]>([]);
|
||||
const noteGroupsRef = useRef<string[]>([]);
|
||||
const notesPersistFailureNotifiedAtRef = useRef(0);
|
||||
customGroupsRef.current = customGroups;
|
||||
managedSourcesRef.current = managedSources;
|
||||
hostsRef.current = hosts;
|
||||
@@ -598,13 +601,29 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
|
||||
const cleaned = normalizeVaultNotes(data);
|
||||
const { notes: cleaned, persisted } = commitVaultNotesWrite({
|
||||
data,
|
||||
write: (key, value) => localStorageAdapter.write(key, value),
|
||||
});
|
||||
// Keep the in-session catalog updated so an autosave quota failure does not
|
||||
// snap the editor back to stale props and discard the user's draft. Disk
|
||||
// may still be behind — surface that explicitly.
|
||||
notesRef.current = cleaned;
|
||||
setNotes(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_NOTES, cleaned);
|
||||
// Publish synchronously so Notes→AI handoff does not read a stale catalog
|
||||
// while React state is still committing (useLayoutEffect republishes too).
|
||||
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
|
||||
if (!persisted) {
|
||||
const now = Date.now();
|
||||
// Debounced autosave can hit quota repeatedly; avoid toast spam.
|
||||
if (now - notesPersistFailureNotifiedAtRef.current > 10_000) {
|
||||
notesPersistFailureNotifiedAtRef.current = now;
|
||||
notify.error(
|
||||
"Notes could not be saved. Free some local storage space and try again.",
|
||||
"Notes",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const updateNoteGroups = useCallback((data: unknown) => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { STORAGE_KEY_NOTES } from "../../infrastructure/config/storageKeys.ts";
|
||||
import { commitVaultNotesWrite } from "./vaultNotesPersistence.ts";
|
||||
|
||||
test("commitVaultNotesWrite reports failure when the storage write returns false", () => {
|
||||
const writes: Array<{ key: string; value: unknown }> = [];
|
||||
const result = commitVaultNotesWrite({
|
||||
data: [{
|
||||
id: "note-1",
|
||||
title: "Draft",
|
||||
content: "body",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
}],
|
||||
write: (key, value) => {
|
||||
writes.push({ key, value });
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.persisted, false);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0]?.key, STORAGE_KEY_NOTES);
|
||||
assert.equal(result.notes[0]?.title, "Draft");
|
||||
});
|
||||
|
||||
test("commitVaultNotesWrite returns persisted notes when the write succeeds", () => {
|
||||
const result = commitVaultNotesWrite({
|
||||
data: [{
|
||||
id: "note-1",
|
||||
title: "Saved",
|
||||
content: "ok",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
}],
|
||||
write: () => true,
|
||||
});
|
||||
|
||||
assert.equal(result.persisted, true);
|
||||
assert.equal(result.notes[0]?.title, "Saved");
|
||||
});
|
||||
|
||||
test("useVaultState updateNotes surfaces storage write failures", () => {
|
||||
const source = readFileSync(new URL("./useVaultState.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /commitVaultNotesWrite/);
|
||||
assert.match(source, /notify\.error/);
|
||||
assert.match(source, /!persisted|persisted === false/);
|
||||
assert.match(source, /return false/);
|
||||
assert.match(source, /notesPersistFailureNotifiedAtRef/);
|
||||
assert.match(source, /10_000/);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { normalizeVaultNotes } from "../../domain/notes";
|
||||
import type { VaultNote } from "../../domain/models";
|
||||
import { STORAGE_KEY_NOTES } from "../../infrastructure/config/storageKeys";
|
||||
|
||||
export type VaultNotesWriteResult = {
|
||||
notes: VaultNote[];
|
||||
persisted: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize and attempt to persist vault notes. Callers must check `persisted`
|
||||
* before treating the update as durable (QuotaExceededError returns false).
|
||||
*/
|
||||
export function commitVaultNotesWrite(input: {
|
||||
data: Partial<VaultNote>[];
|
||||
write: (key: string, value: VaultNote[]) => boolean;
|
||||
}): VaultNotesWriteResult {
|
||||
const notes = normalizeVaultNotes(input.data);
|
||||
const persisted = input.write(STORAGE_KEY_NOTES, notes);
|
||||
return { notes, persisted };
|
||||
}
|
||||
@@ -209,7 +209,10 @@ test("pasting inside code blocks keeps CodeMirror in control", () => {
|
||||
assert.match(source, /element\?\.closest/);
|
||||
assert.match(source, /\.cm-editor/);
|
||||
assert.match(source, /_codeMirrorWrapper_/);
|
||||
assert.match(source, /if \(isNotePasteInsideCodeBlock\(event\.target\)\) return;/);
|
||||
assert.match(
|
||||
source,
|
||||
/pasteInsideCodeBlock:\s*isNotePasteInsideCodeBlock\(event\.target\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("note code block editor colors follow the app theme", () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ExternalLink } from "lucide-react";
|
||||
import {
|
||||
$createRangeSelection,
|
||||
$getNearestNodeFromDOMNode,
|
||||
$getSelection,
|
||||
$isTextNode,
|
||||
$setSelection,
|
||||
getNearestEditorFromDOMNode,
|
||||
@@ -223,6 +224,41 @@ export const isNotePasteInsideCodeBlock = (target: EventTarget | null): boolean
|
||||
return Boolean(element?.closest(".cm-editor, [class*=\"_codeMirrorWrapper_\"]"));
|
||||
};
|
||||
|
||||
/** True when Lexical currently has a selection that insertMarkdown can target. */
|
||||
export const hasActiveLexicalTextSelection = (target: EventTarget | null): boolean => {
|
||||
if (typeof Element === "undefined" || typeof Node === "undefined") return false;
|
||||
const element = target instanceof Element
|
||||
? target
|
||||
: target instanceof Node
|
||||
? target.parentElement
|
||||
: null;
|
||||
if (!element) return false;
|
||||
const lexicalEditor = getNearestEditorFromDOMNode(element);
|
||||
if (!lexicalEditor) return false;
|
||||
let hasSelection = false;
|
||||
lexicalEditor.getEditorState().read(() => {
|
||||
hasSelection = $getSelection() !== null;
|
||||
});
|
||||
return hasSelection;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide whether markdown paste should call preventDefault + insertMarkdown.
|
||||
* When selection is missing, return false so the browser can paste plain text
|
||||
* instead of swallowing the clipboard with a no-op insert.
|
||||
*/
|
||||
export const shouldInterceptNoteMarkdownPaste = (input: {
|
||||
editorMode: NoteEditorMode;
|
||||
pasteInsideCodeBlock: boolean;
|
||||
clipboardText: string;
|
||||
canInsertMarkdownAtSelection: boolean;
|
||||
}): boolean => {
|
||||
if (input.editorMode !== "edit") return false;
|
||||
if (input.pasteInsideCodeBlock) return false;
|
||||
if (!shouldInsertClipboardTextAsMarkdown(input.clipboardText)) return false;
|
||||
return input.canInsertMarkdownAtSelection;
|
||||
};
|
||||
|
||||
export const resolveHostPickerPopupPosition = ({
|
||||
anchorRect,
|
||||
containerRect,
|
||||
@@ -846,12 +882,19 @@ export function InlineMarkdownEditor({
|
||||
}, []);
|
||||
|
||||
const handlePasteCapture = useCallback((event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
if (editorMode !== "edit") return;
|
||||
if (isNotePasteInsideCodeBlock(event.target)) return;
|
||||
const markdown = event.clipboardData.getData("text/plain");
|
||||
if (!shouldInsertClipboardTextAsMarkdown(markdown)) return;
|
||||
|
||||
const editor = editorRef.current;
|
||||
if (
|
||||
!shouldInterceptNoteMarkdownPaste({
|
||||
editorMode,
|
||||
pasteInsideCodeBlock: isNotePasteInsideCodeBlock(event.target),
|
||||
clipboardText: markdown,
|
||||
canInsertMarkdownAtSelection: Boolean(editor)
|
||||
&& hasActiveLexicalTextSelection(event.target),
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!editor) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
markVaultInsideDropIndicator,
|
||||
} from "../vault/vaultReorderDrag";
|
||||
import type { NoteEditorMode } from "./InlineMarkdownEditor";
|
||||
import { NoteTitleInput } from "./NoteTitleInput";
|
||||
|
||||
const InlineMarkdownEditor = lazy(() =>
|
||||
import("./InlineMarkdownEditor").then((module) => ({ default: module.InlineMarkdownEditor })),
|
||||
@@ -630,6 +631,18 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
updateNoteDraft(note.id, { title });
|
||||
};
|
||||
|
||||
/** Ref-only title stash for IME composition — avoids controlled value rewrite. */
|
||||
const stashNoteTitleDraft = (note: VaultNote, title: string) => {
|
||||
if (draftNoteIdRef.current && draftNoteIdRef.current !== note.id) {
|
||||
flushNoteDraft();
|
||||
}
|
||||
// Cancel any idle-commit debounce so a prior ASCII commit cannot flush
|
||||
// mid-composition and rewrite the controlled title.
|
||||
clearDraftTimer();
|
||||
draftNoteIdRef.current = note.id;
|
||||
draftTitleRef.current = title;
|
||||
};
|
||||
|
||||
const saveNoteContentDraft = (note: VaultNote, content: string) => {
|
||||
updateNoteDraft(note.id, { content });
|
||||
};
|
||||
@@ -1666,11 +1679,13 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
<>
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
<NoteTitleInput
|
||||
noteId={selectedNoteView.id}
|
||||
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
|
||||
value={selectedNoteView.title}
|
||||
placeholder={t("notes.title.placeholder")}
|
||||
onChange={(event) => saveNoteTitleDraft(selectedNoteView, event.target.value)}
|
||||
onCommit={(title) => saveNoteTitleDraft(selectedNoteView, title)}
|
||||
onLiveDraft={(title) => stashNoteTitleDraft(selectedNoteView, title)}
|
||||
onBlur={() => flushNoteDraft()}
|
||||
/>
|
||||
</div>
|
||||
@@ -1755,11 +1770,13 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-4 pt-5 pb-1" data-note-title-row>
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
<NoteTitleInput
|
||||
noteId={overlayNoteView.id}
|
||||
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
|
||||
value={overlayNoteView.title}
|
||||
placeholder={t("notes.title.placeholder")}
|
||||
onChange={(event) => saveNoteTitleDraft(overlayNoteView, event.target.value)}
|
||||
onCommit={(title) => saveNoteTitleDraft(overlayNoteView, title)}
|
||||
onLiveDraft={(title) => stashNoteTitleDraft(overlayNoteView, title)}
|
||||
onBlur={() => flushNoteDraft()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { shouldInterceptNoteMarkdownPaste } from "./InlineMarkdownEditor.tsx";
|
||||
|
||||
test("markdown paste intercept requires a live 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,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInterceptNoteMarkdownPaste({
|
||||
editorMode: "preview",
|
||||
pasteInsideCodeBlock: false,
|
||||
clipboardText: "# Heading\n\n- item",
|
||||
canInsertMarkdownAtSelection: true,
|
||||
}),
|
||||
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, /shouldInterceptNoteMarkdownPaste/);
|
||||
assert.match(source, /hasActiveLexicalTextSelection/);
|
||||
assert.match(
|
||||
source,
|
||||
/shouldInterceptNoteMarkdownPaste\([\s\S]*?\)[\s\S]*?event\.preventDefault\(\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(\s*!shouldInterceptNoteMarkdownPaste\([\s\S]*?\)\s*\{\s*return;\s*\}/,
|
||||
);
|
||||
});
|
||||
@@ -424,7 +424,7 @@ export interface VaultAgentApiDeps {
|
||||
unreadable: boolean;
|
||||
}>;
|
||||
removeKeyPassphrases: (keyPaths: string[]) => Promise<void> | void;
|
||||
updateNotes: (notes: VaultNote[]) => void;
|
||||
updateNotes: (notes: VaultNote[]) => boolean | void;
|
||||
updateSnippets: (snippets: Snippet[]) => void;
|
||||
startTunnel: (
|
||||
rule: PortForwardingRule,
|
||||
|
||||
Reference in new issue
Block a user