diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d1504b45f..a2116421e 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -45,7 +45,7 @@ jobs: with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_args: "--model opus" - direct_prompt: | + prompt: | Fix the audit issue described in this GitHub issue. 1. Read issue #$ISSUE_NUMBER body with `gh issue view $ISSUE_NUMBER` diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 19f1f47cf..545213707 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5720,7 +5720,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vmark" -version = "0.5.27" +version = "0.5.29" dependencies = [ "block2 0.6.2", "chrono", diff --git a/src/hooks/useAutoSave.test.ts b/src/hooks/useAutoSave.test.ts index 690a0905e..e810a6474 100644 --- a/src/hooks/useAutoSave.test.ts +++ b/src/hooks/useAutoSave.test.ts @@ -37,6 +37,7 @@ vi.mock("@/utils/reentryGuard", () => ({ vi.mock("@/utils/debug", () => ({ autoSaveLog: vi.fn(), + saveError: vi.fn(), })); import { useAutoSave } from "./useAutoSave"; @@ -62,7 +63,6 @@ describe("useAutoSave", () => { }); vi.mocked(useTabStore.getState).mockReturnValue({ - activeTabId: { main: "tab-1" }, tabs: { main: [{ id: "tab-1" }] }, } as unknown as ReturnType); @@ -197,7 +197,6 @@ describe("useAutoSave", () => { it("skips when no tabs exist", async () => { vi.mocked(useTabStore.getState).mockReturnValue({ - activeTabId: { main: null }, tabs: { main: [] }, } as unknown as ReturnType); @@ -310,7 +309,6 @@ describe("useAutoSave", () => { it("saves multiple dirty tabs in the same window", async () => { vi.mocked(useTabStore.getState).mockReturnValue({ - activeTabId: { main: "tab-1" }, tabs: { main: [{ id: "tab-1" }, { id: "tab-2" }] }, } as unknown as ReturnType); @@ -341,9 +339,114 @@ describe("useAutoSave", () => { expect(saveToPath).toHaveBeenCalledWith("tab-2", "/tmp/doc2.md", "Content 2", "auto"); }); + it("prevents overlapping save cycles (reentry guard)", async () => { + // Make saveToPath take a long time (longer than the interval) + let resolveSave: (() => void) | null = null; + vi.mocked(saveToPath).mockImplementation( + () => new Promise((resolve) => { + resolveSave = () => resolve(true); + }) + ); + + renderHook(() => useAutoSave()); + + // First interval fires — starts saving + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(saveToPath).toHaveBeenCalledTimes(1); + + // Second interval fires while first is still pending + await act(async () => { + vi.advanceTimersByTime(1000); + }); + // Should still be 1 — second call was blocked by reentry guard + expect(saveToPath).toHaveBeenCalledTimes(1); + + // Complete the first save + await act(async () => { + resolveSave?.(); + }); + + // Advance past debounce (5s) so next save can fire + await act(async () => { + vi.advanceTimersByTime(5000); + }); + // Now a new save should have started + expect(saveToPath).toHaveBeenCalledTimes(2); + }); + + it("re-reads document state per tab (not stale snapshot)", async () => { + let callCount = 0; + const getDocMock = vi.fn(() => { + callCount++; + // Simulate content changing between calls (first tab save modifies state) + return { + isDirty: true, + filePath: "/tmp/doc.md", + content: `Content v${callCount}`, + isMissing: false, + isDivergent: false, + }; + }); + + vi.mocked(useDocumentStore.getState).mockReturnValue({ + getDocument: getDocMock, + } as unknown as ReturnType); + + vi.mocked(useTabStore.getState).mockReturnValue({ + tabs: { main: [{ id: "tab-1" }, { id: "tab-2" }] }, + } as unknown as ReturnType); + + renderHook(() => useAutoSave()); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + // getDocument should be called once per tab (2 calls total) + expect(getDocMock).toHaveBeenCalledTimes(2); + // Each save should get fresh content + expect(saveToPath).toHaveBeenCalledWith("tab-1", "/tmp/doc.md", "Content v1", "auto"); + expect(saveToPath).toHaveBeenCalledWith("tab-2", "/tmp/doc.md", "Content v2", "auto"); + }); + + it("continues saving remaining tabs when one tab throws", async () => { + vi.mocked(useTabStore.getState).mockReturnValue({ + tabs: { main: [{ id: "tab-1" }, { id: "tab-2" }] }, + } as unknown as ReturnType); + + const getDocMock = vi.fn((tabId: string) => ({ + isDirty: true, + filePath: tabId === "tab-1" ? "/tmp/bad.md" : "/tmp/good.md", + content: "Content", + isMissing: false, + isDivergent: false, + })); + + vi.mocked(useDocumentStore.getState).mockReturnValue({ + getDocument: getDocMock, + } as unknown as ReturnType); + + // First tab throws, second succeeds + vi.mocked(saveToPath) + .mockRejectedValueOnce(new Error("disk full")) + .mockResolvedValueOnce(true); + + renderHook(() => useAutoSave()); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + // Both tabs should have been attempted + expect(saveToPath).toHaveBeenCalledTimes(2); + expect(saveToPath).toHaveBeenCalledWith("tab-1", "/tmp/bad.md", "Content", "auto"); + expect(saveToPath).toHaveBeenCalledWith("tab-2", "/tmp/good.md", "Content", "auto"); + }); + it("handles window with no tabs entry (undefined)", async () => { vi.mocked(useTabStore.getState).mockReturnValue({ - activeTabId: { main: null }, tabs: {}, } as unknown as ReturnType); diff --git a/src/hooks/useAutoSave.ts b/src/hooks/useAutoSave.ts index e49bde386..ca7bda4e4 100644 --- a/src/hooks/useAutoSave.ts +++ b/src/hooks/useAutoSave.ts @@ -14,6 +14,8 @@ * - Checks isOperationInProgress() to avoid conflicting with manual save * - Interval restarts when autoSaveInterval setting changes * - Skips save if document is currently in the middle of an operation + * - Reentry guard prevents overlapping save cycles on slow filesystems + * - Re-reads doc state per tab (not a snapshot) so content is fresh before each save * * @coordinates-with saveToPath.ts — shared save logic with line ending handling * @coordinates-with reentryGuard.ts — prevents concurrent save operations @@ -28,20 +30,30 @@ import { useTabStore } from "@/stores/tabStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { saveToPath } from "@/utils/saveToPath"; import { isOperationInProgress } from "@/utils/reentryGuard"; -import { autoSaveLog } from "@/utils/debug"; +import { autoSaveLog, saveError } from "@/utils/debug"; + +const MIN_INTERVAL_MS = 1000; export function useAutoSave() { const windowLabel = useWindowLabel(); const autoSaveEnabled = useSettingsStore((s) => s.general.autoSaveEnabled); const autoSaveInterval = useSettingsStore((s) => s.general.autoSaveInterval); const lastSaveRef = useRef(0); + const isSavingRef = useRef(false); useEffect(() => { if (!autoSaveEnabled) return; - const intervalMs = autoSaveInterval * 1000; + // Clamp interval to a safe minimum + const intervalMs = Math.max( + Number.isFinite(autoSaveInterval) ? autoSaveInterval * 1000 : MIN_INTERVAL_MS, + MIN_INTERVAL_MS + ); const checkAndSave = async () => { + // Reentry guard: prevent overlapping save cycles on slow filesystems + if (isSavingRef.current) return; + // Skip if manual save is in progress (prevents race condition) if (isOperationInProgress(windowLabel, "save")) { autoSaveLog("Skipping - manual save in progress"); @@ -50,29 +62,37 @@ export function useAutoSave() { // Debounce: Prevent saves within 5 seconds of each other. const DEBOUNCE_MS = 5000; - const now = Date.now(); - if (now - lastSaveRef.current < DEBOUNCE_MS) return; + if (Date.now() - lastSaveRef.current < DEBOUNCE_MS) return; - // Iterate ALL tabs for this window — not just the active one - const tabs = useTabStore.getState().tabs[windowLabel] ?? []; - const documentStore = useDocumentStore.getState(); - let anySaved = false; + isSavingRef.current = true; + try { + // Iterate ALL tabs for this window — not just the active one + const tabs = useTabStore.getState().tabs[windowLabel] ?? []; + let anySaved = false; - for (const tab of tabs) { - const doc = documentStore.getDocument(tab.id); - // Skip if no document, not dirty, no file path (untitled), file was deleted, - // or user chose "keep my changes" after external change (divergent) - if (!doc || !doc.isDirty || !doc.filePath || doc.isMissing || doc.isDivergent) continue; + for (const tab of tabs) { + // Re-read doc state per tab to get fresh content (avoids stale snapshot) + const doc = useDocumentStore.getState().getDocument(tab.id); + // Skip if no document, not dirty, no file path (untitled), file was deleted, + // or user chose "keep my changes" after external change (divergent) + if (!doc || !doc.isDirty || !doc.filePath || doc.isMissing || doc.isDivergent) continue; - const success = await saveToPath(tab.id, doc.filePath, doc.content, "auto"); - if (success) { - anySaved = true; - autoSaveLog("Saved:", doc.filePath); + try { + const success = await saveToPath(tab.id, doc.filePath, doc.content, "auto"); + if (success) { + anySaved = true; + autoSaveLog("Saved:", doc.filePath); + } + } catch (error) { + saveError("Auto-save failed for", doc.filePath, error); + } } - } - if (anySaved) { - lastSaveRef.current = now; + if (anySaved) { + lastSaveRef.current = Date.now(); + } + } finally { + isSavingRef.current = false; } }; diff --git a/src/plugins/autoPair/__tests__/handlersExtra.test.ts b/src/plugins/autoPair/__tests__/handlersExtra.test.ts index 09e5bf23e..e52c140c6 100644 --- a/src/plugins/autoPair/__tests__/handlersExtra.test.ts +++ b/src/plugins/autoPair/__tests__/handlersExtra.test.ts @@ -14,9 +14,9 @@ import { handleTabJump, handleClosingBracket, handleBackspacePair, - createKeyHandler, type AutoPairConfig, } from "../handlers"; +import { createKeyHandler } from "../keyHandler"; /* ------------------------------------------------------------------ */ /* Minimal schema & helpers */ @@ -354,9 +354,21 @@ describe("createKeyHandler", () => { expect(event.preventDefault).toHaveBeenCalled(); }); - it("does not handle Shift+Tab", () => { + it("handles Shift+Tab to jump before opening bracket", () => { const handler = createKeyHandler(() => ENABLED); - const state = createState("()", 1); + const state = createState("()", 1); // cursor after ( + const view = createMockView(state); + const event = createKeyEvent("Tab", { shiftKey: true }); + + const result = handler(view, event); + expect(result).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(getCursorOffset(view.state)).toBe(0); + }); + + it("returns false for Shift+Tab when not after opening bracket", () => { + const handler = createKeyHandler(() => ENABLED); + const state = createState("hello", 3); // cursor in plain text const view = createMockView(state); const event = createKeyEvent("Tab", { shiftKey: true }); diff --git a/src/plugins/autoPair/__tests__/shiftTabJump.test.ts b/src/plugins/autoPair/__tests__/shiftTabJump.test.ts new file mode 100644 index 000000000..aa9226073 --- /dev/null +++ b/src/plugins/autoPair/__tests__/shiftTabJump.test.ts @@ -0,0 +1,270 @@ +/** + * Shift+Tab Jump — Bracket Pair Left-Skip Tests + * + * When cursor is right after an opening bracket/quote, Shift+Tab moves + * cursor one position backward (before the opening char). + * Mirrors handleTabJump (right-skip over closing char). + */ + +import { describe, it, expect, vi } from "vitest"; +import { Schema } from "@tiptap/pm/model"; +import { EditorState, TextSelection } from "@tiptap/pm/state"; +import { EditorView } from "@tiptap/pm/view"; +import { + handleShiftTabJump, + type AutoPairConfig, +} from "../handlers"; + +/* ------------------------------------------------------------------ */ +/* Schema & helpers */ +/* ------------------------------------------------------------------ */ + +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { content: "text*", group: "block" }, + text: { inline: true }, + }, +}); + +function createState(text: string, cursorOffset?: number): EditorState { + const textNode = text ? schema.text(text) : undefined; + const para = schema.node("paragraph", null, textNode ? [textNode] : []); + const doc = schema.node("doc", null, [para]); + const state = EditorState.create({ doc, schema }); + + if (cursorOffset !== undefined) { + const pos = 1 + cursorOffset; + return state.apply( + state.tr.setSelection(TextSelection.create(state.doc, pos)), + ); + } + return state; +} + +function createMockView(state: EditorState) { + const view = { + state, + dispatch: vi.fn((tr: ReturnType) => { + view.state = view.state.apply(tr); + }), + } as unknown as EditorView & { dispatch: ReturnType }; + return view; +} + +function getCursorOffset(state: EditorState): number { + return state.selection.from - 1; +} + +const ENABLED: AutoPairConfig = { + enabled: true, + includeCJK: true, + includeCurlyQuotes: true, + normalizeRightDoubleQuote: false, +}; + +const DISABLED: AutoPairConfig = { + enabled: false, + includeCJK: false, + includeCurlyQuotes: false, + normalizeRightDoubleQuote: false, +}; + +const CJK_OFF: AutoPairConfig = { + enabled: true, + includeCJK: false, + includeCurlyQuotes: false, + normalizeRightDoubleQuote: false, +}; + +/* ------------------------------------------------------------------ */ +/* ASCII pairs */ +/* ------------------------------------------------------------------ */ + +describe("handleShiftTabJump — ASCII pairs", () => { + it("jumps before opening parenthesis", () => { + const state = createState("(text)", 1); // cursor after ( + const view = createMockView(state); + + expect(handleShiftTabJump(view, ENABLED)).toBe(true); + expect(getCursorOffset(view.state)).toBe(0); + }); + + it("jumps before opening square bracket", () => { + const state = createState("[text]", 1); + const view = createMockView(state); + + expect(handleShiftTabJump(view, ENABLED)).toBe(true); + expect(getCursorOffset(view.state)).toBe(0); + }); + + it("jumps before opening curly brace", () => { + const state = createState("{text}", 1); + const view = createMockView(state); + + expect(handleShiftTabJump(view, ENABLED)).toBe(true); + expect(getCursorOffset(view.state)).toBe(0); + }); + + it("jumps before opening double quote", () => { + const state = createState('"text"', 1); + const view = createMockView(state); + + expect(handleShiftTabJump(view, ENABLED)).toBe(true); + expect(getCursorOffset(view.state)).toBe(0); + }); + + it("jumps before opening single quote", () => { + const state = createState("'text'", 1); + const view = createMockView(state); + + expect(handleShiftTabJump(view, ENABLED)).toBe(true); + expect(getCursorOffset(view.state)).toBe(0); + }); + + it("jumps before opening backtick", () => { + const state = createState("`text`", 1); + const view = createMockView(state); + + expect(handleShiftTabJump(view, ENABLED)).toBe(true); + expect(getCursorOffset(view.state)).toBe(0); + }); +}); + +/* ------------------------------------------------------------------ */ +/* CJK pairs */ +/* ------------------------------------------------------------------ */ + +describe("handleShiftTabJump — CJK pairs", () => { + it("jumps before CJK fullwidth parenthesis when enabled", () => { + const state = createState("(text)", 1); + const view = createMockView(state); + + expect(handleShiftTabJump(view, ENABLED)).toBe(true); + expect(getCursorOffset(view.state)).toBe(0); + }); + + it("jumps before CJK lenticular bracket when enabled", () => { + const state = createState("【text】", 1); + const view = createMockView(state); + + expect(handleShiftTabJump(view, ENABLED)).toBe(true); + expect(getCursorOffset(view.state)).toBe(0); + }); + + it("jumps before CJK corner bracket when enabled", () => { + const state = createState("「text」", 1); + const view = createMockView(state); + + expect(handleShiftTabJump(view, ENABLED)).toBe(true); + expect(getCursorOffset(view.state)).toBe(0); + }); + + it("does not jump CJK brackets when CJK disabled", () => { + const state = createState("(text)", 1); + const view = createMockView(state); + + expect(handleShiftTabJump(view, CJK_OFF)).toBe(false); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Curly quotes */ +/* ------------------------------------------------------------------ */ + +describe("handleShiftTabJump — curly quotes", () => { + it("jumps before left double curly quote when enabled", () => { + const state = createState("\u201Ctext\u201D", 1); + const view = createMockView(state); + + expect(handleShiftTabJump(view, ENABLED)).toBe(true); + expect(getCursorOffset(view.state)).toBe(0); + }); + + it("jumps before left single curly quote when enabled", () => { + const state = createState("\u2018text\u2019", 1); + const view = createMockView(state); + + expect(handleShiftTabJump(view, ENABLED)).toBe(true); + expect(getCursorOffset(view.state)).toBe(0); + }); + + it("does not jump curly quotes when curly quotes disabled", () => { + const state = createState("\u201Ctext\u201D", 1); + const view = createMockView(state); + + expect(handleShiftTabJump(view, CJK_OFF)).toBe(false); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Guards */ +/* ------------------------------------------------------------------ */ + +describe("handleShiftTabJump — guards", () => { + it("returns false when disabled", () => { + const state = createState("(text)", 1); + const view = createMockView(state); + + expect(handleShiftTabJump(view, DISABLED)).toBe(false); + }); + + it("returns false when char before cursor is not an opening char", () => { + const state = createState("hello", 3); + const view = createMockView(state); + + expect(handleShiftTabJump(view, ENABLED)).toBe(false); + }); + + it("returns false when there is a selection", () => { + const state = createState("(text)", 0); + const withSel = state.apply( + state.tr.setSelection(TextSelection.create(state.doc, 1, 3)), + ); + const view = createMockView(withSel); + + expect(handleShiftTabJump(view, ENABLED)).toBe(false); + }); + + it("returns false at start of document (pos <= 1)", () => { + const state = createState("(text)", 0); // cursor at paragraph start + const view = createMockView(state); + + // pos = 1, getCharBefore returns "" since parentOffset = 0 + expect(handleShiftTabJump(view, ENABLED)).toBe(false); + }); + + it("does not add to history", () => { + const state = createState("(text)", 1); + const view = createMockView(state); + + handleShiftTabJump(view, ENABLED); + + const tr = (view.dispatch as ReturnType).mock.calls[0][0]; + expect(tr.getMeta("addToHistory")).toBe(false); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Edge cases */ +/* ------------------------------------------------------------------ */ + +describe("handleShiftTabJump — edge cases", () => { + it("skips only innermost opening bracket in nested brackets", () => { + // ({[|text]}) — cursor after [, should skip [ only + const state = createState("({[text]})", 3); + const view = createMockView(state); + + expect(handleShiftTabJump(view, ENABLED)).toBe(true); + expect(getCursorOffset(view.state)).toBe(2); // moved before [ + }); + + it("handles opening bracket at start of paragraph", () => { + // (|text) — cursor after ( at pos 2, ( is at pos 1 + const state = createState("(text)", 1); + const view = createMockView(state); + + expect(handleShiftTabJump(view, ENABLED)).toBe(true); + expect(getCursorOffset(view.state)).toBe(0); + }); +}); diff --git a/src/plugins/autoPair/__tests__/tiptap.test.ts b/src/plugins/autoPair/__tests__/tiptap.test.ts index 4f244e015..6d6469ea2 100644 --- a/src/plugins/autoPair/__tests__/tiptap.test.ts +++ b/src/plugins/autoPair/__tests__/tiptap.test.ts @@ -38,6 +38,9 @@ const mockCreateKeyHandler = vi.fn(() => vi.fn(() => false)); vi.mock("../handlers", () => ({ handleTextInput: (...args: unknown[]) => mockHandleTextInput(...args), +})); + +vi.mock("../keyHandler", () => ({ createKeyHandler: (...args: unknown[]) => mockCreateKeyHandler(...args), })); diff --git a/src/plugins/autoPair/backtickToggle.ts b/src/plugins/autoPair/backtickToggle.ts new file mode 100644 index 000000000..e85c125b0 --- /dev/null +++ b/src/plugins/autoPair/backtickToggle.ts @@ -0,0 +1,108 @@ +/** + * Backtick Code Mark Toggle + * + * Purpose: Handles backtick (`) input as a code mark toggle in WYSIWYG mode. + * - Outside code: activate code mark (or wrap selection) + * - Inside code: escape to end of code mark + * + * Split from handlers.ts to keep files under ~300 lines. + * + * @coordinates-with handlers.ts — called from handleTextInput for backtick input + * @module plugins/autoPair/backtickToggle + */ + +import type { EditorView } from "@tiptap/pm/view"; +import type { EditorState } from "@tiptap/pm/state"; +import { TextSelection } from "@tiptap/pm/state"; +import { isInCodeBlock } from "./utils"; + +/** + * Handle backtick as code mark toggle in WYSIWYG mode. + * - Outside code: activate code mark (or wrap selection) + * - Inside code: escape to end of code mark + * Returns true if handled. + */ +export function handleBacktickCodeToggle( + view: EditorView, + from: number, + to: number +): boolean { + const { state, dispatch } = view; + + // Don't handle if preceded by backslash (escaped) + /* v8 ignore next -- @preserve reason: from is always >= 1 in ProseMirror (cursor is inside the doc node); the else branch (from === 0) is unreachable during normal editing */ + if (from > 0) { + const $pos = state.doc.resolve(from); + const textBefore = $pos.parent.textBetween( + Math.max(0, $pos.parentOffset - 1), + $pos.parentOffset, + "" + ); + if (textBefore === "\\") return false; + } + + // Don't handle in code blocks + if (isInCodeBlock(state)) return false; + + const codeMarkType = state.schema.marks.code; + if (!codeMarkType) return false; + + // Check if cursor is in inline code + const $from = state.doc.resolve(from); + const inCode = $from.marks().some((m) => m.type === codeMarkType); + + if (inCode) { + // Escape: move cursor to end of code mark + const endPos = findCodeMarkEnd(state, from, codeMarkType); + // findCodeMarkEnd always returns non-null when inCode is true — use non-null assertion + // (the null guard above exists as a type-safety guarantee only) + /* v8 ignore next -- @preserve reason: endPos is always non-null when inCode is true; the ?? from fallback is structurally unreachable */ + const pos = endPos ?? from; // fallback to from keeps selection stable if null (unreachable) + const tr = state.tr.setSelection(TextSelection.create(state.doc, pos)); + tr.removeStoredMark(codeMarkType); + dispatch(tr); + return true; + } + + // Outside code: toggle code mark + if (from !== to) { + // Selection: wrap with code mark + const tr = state.tr.addMark(from, to, codeMarkType.create()); + dispatch(tr); + return true; + } + + // No selection: activate code mark for subsequent typing + const tr = state.tr.addStoredMark(codeMarkType.create()); + dispatch(tr); + return true; +} + +/** + * Find the end position of the code mark containing the given position. + */ +function findCodeMarkEnd( + state: EditorState, + pos: number, + codeMarkType: ReturnType["type"] +): number | null { + const $pos = state.doc.resolve(pos); + const parent = $pos.parent; + const parentStart = $pos.start(); + + let offset = 0; + for (let i = 0; i < parent.childCount; i++) { + const child = parent.child(i); + const childStart = parentStart + offset; + const childEnd = childStart + child.nodeSize; + + if (pos >= childStart && pos <= childEnd) { + /* v8 ignore next 3 -- @preserve reason: when inCode is true the cursor lies inside a code-marked text node; any sibling that satisfies the range check but lacks the code mark is only reachable at a mark boundary where $from.marks() already returns [] (inCode=false), making this else path structurally unreachable */ + if (child.marks.some((m) => m.type === codeMarkType)) { + return childEnd; + } + } + offset += child.nodeSize; + } + return null; +} diff --git a/src/plugins/autoPair/handlers.ts b/src/plugins/autoPair/handlers.ts index 3b8f6e2fc..0e31dce6b 100644 --- a/src/plugins/autoPair/handlers.ts +++ b/src/plugins/autoPair/handlers.ts @@ -3,15 +3,18 @@ * * Purpose: Core logic for auto-pairing — handles text input (insert closing char), * closing bracket skip-over (type `)` when already there), and backspace pair deletion. + * Shift+Tab jump-past-closing and backtick toggle are extracted to keyHandler.ts and + * backtickToggle.ts respectively. * * Key decisions: - * - Backtick is special-cased as a code mark toggle in WYSIWYG (not a simple pair) * - Right double quote normalization converts `\u201D` to `\u201C` at line start for * Chinese typographic convention where both open/close quotes look the same * - Config is passed in rather than read from store to keep handlers pure/testable * * @coordinates-with pairs.ts — pair definitions and lookup functions * @coordinates-with utils.ts — context checks (code block, word boundary) + * @coordinates-with keyHandler.ts — Shift+Tab jump and key event dispatch + * @coordinates-with backtickToggle.ts — backtick code mark toggle logic * @coordinates-with tiptap.ts — wires these handlers into the ProseMirror plugin * @module plugins/autoPair/handlers */ @@ -21,14 +24,13 @@ import { TextSelection } from "@tiptap/pm/state"; import type { EditorState } from "@tiptap/pm/state"; import { getClosingChar, - isClosingChar, getOpeningChar, normalizeForPairing, straightToCurlyOpening, - straightToCurlyClosing, type PairConfig, } from "./pairs"; -import { shouldAutoPair, isInCodeBlock, getCharAt, getCharBefore } from "./utils"; +import { shouldAutoPair, getCharAt, getCharBefore } from "./utils"; +import { handleBacktickCodeToggle } from "./backtickToggle"; export interface AutoPairConfig { enabled: boolean; @@ -56,99 +58,6 @@ function isAllowedClosingChar(char: string, config: AutoPairConfig): boolean { return getClosingChar(openingChar, toPairConfig(config)) === char; } -/** - * Handle backtick as code mark toggle in WYSIWYG mode. - * - Outside code: activate code mark (or wrap selection) - * - Inside code: escape to end of code mark - * Returns true if handled. - */ -function handleBacktickCodeToggle( - view: EditorView, - from: number, - to: number -): boolean { - const { state, dispatch } = view; - - // Don't handle if preceded by backslash (escaped) - /* v8 ignore next -- @preserve reason: from is always >= 1 in ProseMirror (cursor is inside the doc node); the else branch (from === 0) is unreachable during normal editing */ - if (from > 0) { - const $pos = state.doc.resolve(from); - const textBefore = $pos.parent.textBetween( - Math.max(0, $pos.parentOffset - 1), - $pos.parentOffset, - "" - ); - if (textBefore === "\\") return false; - } - - // Don't handle in code blocks - if (isInCodeBlock(state)) return false; - - const codeMarkType = state.schema.marks.code; - if (!codeMarkType) return false; - - // Check if cursor is in inline code - const $from = state.doc.resolve(from); - const inCode = $from.marks().some((m) => m.type === codeMarkType); - - if (inCode) { - // Escape: move cursor to end of code mark - const endPos = findCodeMarkEnd(state, from, codeMarkType); - // findCodeMarkEnd returns null only if no code-marked child contains `from`, - // which is structurally impossible when inCode is true (marks come from text nodes). - // findCodeMarkEnd always returns non-null when inCode is true — use non-null assertion - // (the null guard above exists as a type-safety guarantee only) - /* v8 ignore next -- @preserve reason: endPos is always non-null when inCode is true; the ?? from fallback is structurally unreachable */ - const pos = endPos ?? from; // fallback to from keeps selection stable if null (unreachable) - const tr = state.tr.setSelection(TextSelection.create(state.doc, pos)); - tr.removeStoredMark(codeMarkType); - dispatch(tr); - return true; - } - - // Outside code: toggle code mark - if (from !== to) { - // Selection: wrap with code mark - const tr = state.tr.addMark(from, to, codeMarkType.create()); - dispatch(tr); - return true; - } - - // No selection: activate code mark for subsequent typing - const tr = state.tr.addStoredMark(codeMarkType.create()); - dispatch(tr); - return true; -} - -/** - * Find the end position of the code mark containing the given position. - */ -function findCodeMarkEnd( - state: EditorState, - pos: number, - codeMarkType: ReturnType["type"] -): number | null { - const $pos = state.doc.resolve(pos); - const parent = $pos.parent; - const parentStart = $pos.start(); - - let offset = 0; - for (let i = 0; i < parent.childCount; i++) { - const child = parent.child(i); - const childStart = parentStart + offset; - const childEnd = childStart + child.nodeSize; - - if (pos >= childStart && pos <= childEnd) { - /* v8 ignore next 3 -- @preserve reason: when inCode is true the cursor lies inside a code-marked text node; any sibling that satisfies the range check but lacks the code mark is only reachable at a mark boundary where $from.marks() already returns [] (inCode=false), making this else path structurally unreachable */ - if (child.marks.some((m) => m.type === codeMarkType)) { - return childEnd; - } - } - offset += child.nodeSize; - } - return null; -} - /** * Handle text input - auto-pair opening characters. * Returns true if the input was handled. @@ -281,81 +190,66 @@ export function handleBackspacePair( } /** - * Handle Tab key - jump over closing bracket if cursor is right before one. - * Returns true if jumped, false to allow normal Tab behavior. + * Check if an opening character is allowed by the current config. + * Verifies the char has a known closing pair and that pair is enabled. */ -export function handleTabJump( +function isAllowedOpeningChar(char: string, config: AutoPairConfig): boolean { + return getClosingChar(char, toPairConfig(config)) !== null; +} + +/** + * Directional bracket jump — shared logic for Tab (forward) and Shift+Tab (backward). + * Checks the adjacent character and jumps over it if it matches the predicate. + * + * Tab: checks char at cursor (closing bracket), jumps forward (+1). + * Shift+Tab: checks char before cursor (opening bracket), jumps backward (-1). + * + * Both are navigation-only (not content changes), so addToHistory is false. + * Neither verifies full pair context (matching counterpart) — this mirrors + * the auto-pair convention where bracket skip operates on individual characters. + */ +function handleDirectionalJump( view: EditorView, - config: AutoPairConfig + config: AutoPairConfig, + getChar: (state: EditorState, pos: number) => string, + isAllowed: (char: string, config: AutoPairConfig) => boolean, + offset: 1 | -1, ): boolean { if (!config.enabled) return false; const { state } = view; const { from, to } = state.selection; - // Only handle when no selection if (from !== to) return false; - // Check if next character is an allowed closing bracket - const nextChar = getCharAt(state, from); - if (!isAllowedClosingChar(nextChar, config)) return false; + const char = getChar(state, from); + if (!char || !isAllowed(char, config)) return false; - // Jump over the closing bracket (navigation, not content change) - const tr = state.tr.setSelection(TextSelection.create(state.doc, from + 1)); + const tr = state.tr.setSelection(TextSelection.create(state.doc, from + offset)); view.dispatch(tr.setMeta("addToHistory", false)); return true; } /** - * Create keyboard event handler. - * Accepts a config getter so the handler always reads fresh settings - * without allocating a new closure on every keydown. + * Handle Tab key — jump over closing bracket if cursor is right before one. + * Returns true if jumped, false to allow normal Tab behavior. */ -export function createKeyHandler(getConfig: () => AutoPairConfig) { - return function handleKeyDown(view: EditorView, event: KeyboardEvent): boolean { - // Skip if modifiers are pressed (except Shift) - if (event.ctrlKey || event.altKey || event.metaKey) { - return false; - } - - const config = getConfig(); - - // Handle Tab to jump over closing bracket - if (event.key === "Tab" && !event.shiftKey) { - if (handleTabJump(view, config)) { - event.preventDefault(); - return true; - } - // Let normal Tab behavior happen (indent) - return false; - } - - // Handle backspace for pair deletion - if (event.key === "Backspace") { - if (handleBackspacePair(view, config)) { - event.preventDefault(); - return true; - } - return false; - } - - // Handle closing bracket skip - if (event.key.length === 1 && isClosingChar(event.key)) { - if (handleClosingBracket(view, event.key, config)) { - event.preventDefault(); - return true; - } - // When curly quotes are enabled, also try the curly closing equivalent. - // event.key is always the physical key (" straight) even when the document - // contains curly quotes from auto-pair or macOS Smart Quotes. - const pairConfig = toPairConfig(config); - const curlyClosing = straightToCurlyClosing(event.key, pairConfig); - if (curlyClosing !== event.key && handleClosingBracket(view, curlyClosing, config)) { - event.preventDefault(); - return true; - } - } - - return false; - }; +export function handleTabJump( + view: EditorView, + config: AutoPairConfig +): boolean { + return handleDirectionalJump(view, config, getCharAt, isAllowedClosingChar, 1); +} + +/** + * Handle Shift+Tab key — jump before opening bracket if cursor is right after one. + * Mirrors handleTabJump (which jumps over closing brackets). + * Returns true if jumped, false to allow normal Shift+Tab behavior. + */ +export function handleShiftTabJump( + view: EditorView, + config: AutoPairConfig +): boolean { + return handleDirectionalJump(view, config, getCharBefore, isAllowedOpeningChar, -1); } + diff --git a/src/plugins/autoPair/keyHandler.ts b/src/plugins/autoPair/keyHandler.ts new file mode 100644 index 000000000..f04eaf893 --- /dev/null +++ b/src/plugins/autoPair/keyHandler.ts @@ -0,0 +1,98 @@ +/** + * Auto-Pair Keyboard Handler + * + * Purpose: Keyboard event dispatch for auto-pair — routes Tab, Shift+Tab, + * Backspace, and closing-bracket keydown events to the appropriate handler. + * + * Split from handlers.ts to keep files under ~300 lines. + * + * @coordinates-with handlers.ts — core auto-pair logic (text input, bracket skip, backspace) + * @coordinates-with pairs.ts — pair definitions and lookup functions + * @coordinates-with tiptap.ts — wires this handler into the ProseMirror plugin + * @module plugins/autoPair/keyHandler + */ + +import type { EditorView } from "@tiptap/pm/view"; +import { + isClosingChar, + straightToCurlyClosing, + type PairConfig, +} from "./pairs"; +import { + type AutoPairConfig, + handleTabJump, + handleShiftTabJump, + handleBackspacePair, + handleClosingBracket, +} from "./handlers"; + +/** Convert AutoPairConfig to PairConfig for pair lookup functions */ +function toPairConfig(config: AutoPairConfig): PairConfig { + return { + includeCJK: config.includeCJK, + includeCurlyQuotes: config.includeCurlyQuotes, + }; +} + +/** + * Create keyboard event handler. + * Accepts a config getter so the handler always reads fresh settings + * without allocating a new closure on every keydown. + */ +export function createKeyHandler(getConfig: () => AutoPairConfig) { + return function handleKeyDown(view: EditorView, event: KeyboardEvent): boolean { + // Skip if modifiers are pressed (except Shift) + if (event.ctrlKey || event.altKey || event.metaKey) { + return false; + } + + const config = getConfig(); + + // Handle Tab to jump over closing bracket + if (event.key === "Tab" && !event.shiftKey) { + if (handleTabJump(view, config)) { + event.preventDefault(); + return true; + } + // Let normal Tab behavior happen (indent) + return false; + } + + // Handle Shift+Tab to jump before opening bracket + if (event.key === "Tab" && event.shiftKey) { + if (handleShiftTabJump(view, config)) { + event.preventDefault(); + return true; + } + return false; + } + + // Handle backspace for pair deletion + if (event.key === "Backspace") { + if (handleBackspacePair(view, config)) { + event.preventDefault(); + return true; + } + return false; + } + + // Handle closing bracket skip + if (event.key.length === 1 && isClosingChar(event.key)) { + if (handleClosingBracket(view, event.key, config)) { + event.preventDefault(); + return true; + } + // When curly quotes are enabled, also try the curly closing equivalent. + // event.key is always the physical key (" straight) even when the document + // contains curly quotes from auto-pair or macOS Smart Quotes. + const pairConfig = toPairConfig(config); + const curlyClosing = straightToCurlyClosing(event.key, pairConfig); + if (curlyClosing !== event.key && handleClosingBracket(view, curlyClosing, config)) { + event.preventDefault(); + return true; + } + } + + return false; + }; +} diff --git a/src/plugins/autoPair/tiptap.ts b/src/plugins/autoPair/tiptap.ts index 601a61bfb..83a29ece0 100644 --- a/src/plugins/autoPair/tiptap.ts +++ b/src/plugins/autoPair/tiptap.ts @@ -2,15 +2,19 @@ * Auto-Pair Tiptap Extension * * Purpose: Automatically inserts matching closing brackets/quotes when the user types - * an opening character in WYSIWYG mode. Also handles skip-over and backspace-delete. + * an opening character in WYSIWYG mode. Also handles skip-over, backspace-delete, and + * Shift+Tab jump past closing characters. * * Key decisions: * - Uses handleDOMEvents.keydown (not handleKeyDown) to intercept Tab/Backspace before * Tiptap's built-in keyboard shortcuts (e.g., list indent) + * - Key dispatch is delegated to keyHandler.ts for Shift+Tab and backtick handling * - Config is read lazily from settingsStore so changes take effect immediately * - IME composition is fully guarded to avoid corrupting CJK input * * @coordinates-with handlers.ts — core auto-pair logic (text input, key handling) + * @coordinates-with keyHandler.ts — Shift+Tab jump and key event dispatch + * @coordinates-with backtickToggle.ts — backtick code mark toggle logic * @coordinates-with pairs.ts — character pair definitions (ASCII, CJK, curly quotes) * @coordinates-with utils.ts — context detection (code block, inline code, word boundary) * @module plugins/autoPair/tiptap @@ -25,7 +29,8 @@ import { markProseMirrorCompositionEnd, isImeKeyEvent, } from "@/utils/imeGuard"; -import { handleTextInput, createKeyHandler, type AutoPairConfig } from "./handlers"; +import { handleTextInput, type AutoPairConfig } from "./handlers"; +import { createKeyHandler } from "./keyHandler"; const autoPairPluginKey = new PluginKey("autoPair"); diff --git a/src/plugins/tabIndent/__tests__/shiftTabEscape.test.ts b/src/plugins/tabIndent/__tests__/shiftTabEscape.test.ts new file mode 100644 index 000000000..e313692b9 --- /dev/null +++ b/src/plugins/tabIndent/__tests__/shiftTabEscape.test.ts @@ -0,0 +1,531 @@ +/** + * Shift+Tab Left-Escape Detection Tests + * + * Tests for mark and link left-escape — the reverse of Tab right-escape. + * Cursor anywhere inside a mark/link → Shift+Tab jumps to start position. + * + * Position reference for doc(p("text")): + * parentStart = 1 (start of paragraph content) + * First text node starts at parentStart + 0 = 1 + * Characters at positions 1, 2, 3, ... + */ + +import { describe, it, expect } from "vitest"; +import { Schema } from "@tiptap/pm/model"; +import { EditorState, TextSelection, SelectionRange } from "@tiptap/pm/state"; +import { MultiSelection } from "@/plugins/multiCursor/MultiSelection"; +import { + getMarkStartPos, + getLinkStartPos, + canShiftTabEscape, + canShiftTabEscapeMulti, +} from "../shiftTabEscape"; + +/* ------------------------------------------------------------------ */ +/* Schema */ +/* ------------------------------------------------------------------ */ + +const schema = new Schema({ + nodes: { + doc: { content: "paragraph+" }, + paragraph: { content: "text*", group: "block" }, + text: { inline: true }, + }, + marks: { + bold: { + parseDOM: [{ tag: "strong" }], + toDOM() { return ["strong", 0]; }, + }, + italic: { + parseDOM: [{ tag: "em" }], + toDOM() { return ["em", 0]; }, + }, + code: { + excludes: "_", + parseDOM: [{ tag: "code" }], + toDOM() { return ["code", 0]; }, + }, + strike: { + parseDOM: [{ tag: "s" }], + toDOM() { return ["s", 0]; }, + }, + link: { + attrs: { href: {} }, + parseDOM: [{ tag: "a[href]" }], + toDOM(mark) { return ["a", { href: mark.attrs.href }, 0]; }, + }, + }, +}); + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function createStateAtPos(doc: ReturnType, pos: number): EditorState { + const state = EditorState.create({ doc }); + return state.apply(state.tr.setSelection(TextSelection.create(doc, pos))); +} + +function makeDoc(children: Parameters[2]) { + return schema.node("doc", null, [ + schema.node("paragraph", null, children), + ]); +} + +/* ------------------------------------------------------------------ */ +/* getMarkStartPos */ +/* ------------------------------------------------------------------ */ + +describe("getMarkStartPos", () => { + it("returns start of bold text node when cursor is in middle", () => { + // doc(p(bold("hello"))) — parentStart=1, bold text at offset 0 + // Bold text node starts at pos 1 + const boldMark = schema.marks.bold.create(); + const doc = makeDoc([schema.text("hello", [boldMark])]); + const state = createStateAtPos(doc, 4); // inside "hello" + + expect(getMarkStartPos(state)).toBe(1); + }); + + it("returns start pos when cursor is at start of mark (escape-in-place)", () => { + const boldMark = schema.marks.bold.create(); + const doc = makeDoc([schema.text("hello", [boldMark])]); + const state = createStateAtPos(doc, 1); // at start of bold + + expect(getMarkStartPos(state)).toBe(1); + }); + + it("returns start pos with plain text before mark", () => { + // doc(p("plain ", bold("hello"), " end")) + // "plain " = 6 chars → bold starts at 1 + 6 = 7 + const boldMark = schema.marks.bold.create(); + const doc = makeDoc([ + schema.text("plain "), + schema.text("hello", [boldMark]), + schema.text(" end"), + ]); + const state = createStateAtPos(doc, 9); // inside bold "hello" + + expect(getMarkStartPos(state)).toBe(7); + }); + + it("returns start pos for italic mark", () => { + const italicMark = schema.marks.italic.create(); + const doc = makeDoc([schema.text("hello", [italicMark])]); + const state = createStateAtPos(doc, 4); + + expect(getMarkStartPos(state)).toBe(1); + }); + + it("returns start pos for code mark", () => { + const codeMark = schema.marks.code.create(); + const doc = makeDoc([schema.text("hello", [codeMark])]); + const state = createStateAtPos(doc, 4); + + expect(getMarkStartPos(state)).toBe(1); + }); + + it("returns start pos for strike mark", () => { + const strikeMark = schema.marks.strike.create(); + const doc = makeDoc([schema.text("hello", [strikeMark])]); + const state = createStateAtPos(doc, 4); + + expect(getMarkStartPos(state)).toBe(1); + }); + + it("returns null when cursor has no marks", () => { + const doc = makeDoc([schema.text("hello")]); + const state = createStateAtPos(doc, 3); + + expect(getMarkStartPos(state)).toBeNull(); + }); + + it("returns null when cursor has only link mark (not an escapable mark)", () => { + const linkMark = schema.marks.link.create({ href: "https://example.com" }); + const doc = makeDoc([schema.text("hello", [linkMark])]); + const state = createStateAtPos(doc, 3); + + expect(getMarkStartPos(state)).toBeNull(); + }); + + it("returns null with range selection", () => { + const boldMark = schema.marks.bold.create(); + const doc = makeDoc([schema.text("hello", [boldMark])]); + const state = EditorState.create({ doc }); + const withSel = state.apply( + state.tr.setSelection(TextSelection.create(doc, 2, 5)), + ); + + expect(getMarkStartPos(withSel)).toBeNull(); + }); + + it("handles nested marks (bold+italic on same text node)", () => { + const boldMark = schema.marks.bold.create(); + const italicMark = schema.marks.italic.create(); + const doc = makeDoc([schema.text("hello", [boldMark, italicMark])]); + const state = createStateAtPos(doc, 4); + + // Returns start of the shared text node — escapes both at once + expect(getMarkStartPos(state)).toBe(1); + }); + + it("handles mark at very start of paragraph", () => { + const boldMark = schema.marks.bold.create(); + const doc = makeDoc([ + schema.text("hello", [boldMark]), + schema.text(" world"), + ]); + const state = createStateAtPos(doc, 4); + + expect(getMarkStartPos(state)).toBe(1); + }); + + it("handles CJK text inside mark", () => { + const boldMark = schema.marks.bold.create(); + const doc = makeDoc([schema.text("你好世界", [boldMark])]); + const state = createStateAtPos(doc, 3); + + expect(getMarkStartPos(state)).toBe(1); + }); +}); + +/* ------------------------------------------------------------------ */ +/* getLinkStartPos */ +/* ------------------------------------------------------------------ */ + +describe("getLinkStartPos", () => { + it("returns start of link text node when cursor is in middle", () => { + const linkMark = schema.marks.link.create({ href: "https://example.com" }); + const doc = makeDoc([schema.text("click here", [linkMark])]); + const state = createStateAtPos(doc, 6); + + expect(getLinkStartPos(state)).toBe(1); + }); + + it("returns start pos when cursor is at start of link", () => { + const linkMark = schema.marks.link.create({ href: "https://example.com" }); + const doc = makeDoc([schema.text("link", [linkMark])]); + const state = createStateAtPos(doc, 1); + + expect(getLinkStartPos(state)).toBe(1); + }); + + it("returns start pos with text before link", () => { + const linkMark = schema.marks.link.create({ href: "https://example.com" }); + const doc = makeDoc([ + schema.text("see "), + schema.text("link text", [linkMark]), + schema.text(" here"), + ]); + // "see " = 4 chars, link starts at pos 1 + 4 = 5 + const state = createStateAtPos(doc, 8); + + expect(getLinkStartPos(state)).toBe(5); + }); + + it("returns current position at link boundary (cursor at end of link text node)", () => { + const linkMark = schema.marks.link.create({ href: "https://example.com" }); + const doc = makeDoc([ + schema.text("link", [linkMark]), + schema.text(" after"), + ]); + // "link" = 4 chars, cursor at pos 5 is at the boundary (after the link text node) + // At this boundary, $from.marks() may still include the link mark + // but the cursor is at childEnd — getLinkStartPos returns `from` for stored-mark clearing + const state = createStateAtPos(doc, 5); + + const result = getLinkStartPos(state); + // At boundary, returns current pos (5) since no child range matches with strict < childEnd + expect(result).toBe(5); + }); + + it("returns null when cursor has no link mark", () => { + const doc = makeDoc([schema.text("hello")]); + const state = createStateAtPos(doc, 3); + + expect(getLinkStartPos(state)).toBeNull(); + }); + + it("returns null with range selection", () => { + const linkMark = schema.marks.link.create({ href: "https://example.com" }); + const doc = makeDoc([schema.text("hello", [linkMark])]); + const state = EditorState.create({ doc }); + const withSel = state.apply( + state.tr.setSelection(TextSelection.create(doc, 2, 5)), + ); + + expect(getLinkStartPos(withSel)).toBeNull(); + }); +}); + +/* ------------------------------------------------------------------ */ +/* canShiftTabEscape — single cursor */ +/* ------------------------------------------------------------------ */ + +describe("canShiftTabEscape", () => { + it("returns mark escape when cursor is inside bold", () => { + const boldMark = schema.marks.bold.create(); + const doc = makeDoc([ + schema.text("plain "), + schema.text("bold", [boldMark]), + schema.text(" end"), + ]); + const state = createStateAtPos(doc, 9); // inside "bold" + + const result = canShiftTabEscape(state); + expect(result).not.toBeNull(); + expect(result).toHaveProperty("type", "mark"); + expect(result).toHaveProperty("targetPos", 7); // start of bold text node + }); + + it("returns mark escape at start of mark (escape-in-place)", () => { + const boldMark = schema.marks.bold.create(); + const doc = makeDoc([schema.text("bold", [boldMark])]); + const state = createStateAtPos(doc, 1); // at very start + + const result = canShiftTabEscape(state); + expect(result).not.toBeNull(); + expect(result).toHaveProperty("type", "mark"); + expect(result).toHaveProperty("targetPos", 1); // same position + }); + + it("returns link escape when cursor is inside link (no mark)", () => { + const linkMark = schema.marks.link.create({ href: "https://example.com" }); + const doc = makeDoc([ + schema.text("see "), + schema.text("link", [linkMark]), + schema.text(" end"), + ]); + const state = createStateAtPos(doc, 7); // inside "link" + + const result = canShiftTabEscape(state); + expect(result).not.toBeNull(); + expect(result).toHaveProperty("type", "link"); + expect(result).toHaveProperty("targetPos", 5); + }); + + it("returns mark escape when cursor has both mark and link (mark priority)", () => { + const boldMark = schema.marks.bold.create(); + const linkMark = schema.marks.link.create({ href: "https://example.com" }); + const doc = makeDoc([ + schema.text("bold link", [boldMark, linkMark]), + ]); + const state = createStateAtPos(doc, 5); + + const result = canShiftTabEscape(state); + expect(result).not.toBeNull(); + expect(result).toHaveProperty("type", "mark"); + expect(result).toHaveProperty("targetPos", 1); + }); + + it("returns null when cursor has no marks", () => { + const doc = makeDoc([schema.text("plain text")]); + const state = createStateAtPos(doc, 5); + + expect(canShiftTabEscape(state)).toBeNull(); + }); + + it("returns null with range selection", () => { + const boldMark = schema.marks.bold.create(); + const doc = makeDoc([schema.text("hello", [boldMark])]); + const state = EditorState.create({ doc }); + const withSel = state.apply( + state.tr.setSelection(TextSelection.create(doc, 2, 5)), + ); + + expect(canShiftTabEscape(withSel)).toBeNull(); + }); + + it("delegates to canShiftTabEscapeMulti for MultiSelection", () => { + const boldMark = schema.marks.bold.create(); + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, [ + schema.text("bold", [boldMark]), + ]), + ]); + const state = EditorState.create({ doc }); + + const $pos = state.doc.resolve(3); + const multiSel = new MultiSelection([ + new SelectionRange($pos, $pos), + ], 0); + const stateWithMulti = state.apply(state.tr.setSelection(multiSel)); + + const result = canShiftTabEscape(stateWithMulti); + expect(result).toBeInstanceOf(MultiSelection); + }); + + it("returns null in empty document (no text)", () => { + const doc = schema.node("doc", null, [ + schema.node("paragraph", null), + ]); + const state = EditorState.create({ doc }); + + expect(canShiftTabEscape(state)).toBeNull(); + }); +}); + +/* ------------------------------------------------------------------ */ +/* canShiftTabEscapeMulti — multi-cursor */ +/* ------------------------------------------------------------------ */ + +describe("canShiftTabEscapeMulti", () => { + it("moves escapable cursors and keeps others in place", () => { + const boldMark = schema.marks.bold.create(); + // Two paragraphs: first has plain+bold, second has plain + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, [ + schema.text("plain "), + schema.text("bold", [boldMark]), + ]), + schema.node("paragraph", null, [ + schema.text("normal"), + ]), + ]); + const state = EditorState.create({ doc }); + + // p1 content: "plain " (6) + "bold" (4) = 10 chars, positions 1..10 + // Bold starts at offset 6 → pos 1+6 = 7 + // Cursor 1: pos 9 inside bold ("bo|ld") + // p2 starts at pos 12 (10 + p1 close + p2 open = 12) + // Cursor 2: pos 14 in "normal" ("no|rmal") + const $pos1 = state.doc.resolve(9); + const $pos2 = state.doc.resolve(14); + const multiSel = new MultiSelection([ + new SelectionRange($pos1, $pos1), + new SelectionRange($pos2, $pos2), + ], 0); + const stateWithMulti = state.apply(state.tr.setSelection(multiSel)); + + const result = canShiftTabEscapeMulti(stateWithMulti); + expect(result).toBeInstanceOf(MultiSelection); + if (result instanceof MultiSelection) { + // First cursor should have moved to bold start (pos 7) + expect(result.ranges[0].$from.pos).toBe(7); + // Second cursor should stay (plain text, no marks) + expect(result.ranges[1].$from.pos).toBe(14); + } + }); + + it("moves cursor out of link in multi-cursor mode", () => { + const linkMark = schema.marks.link.create({ href: "https://example.com" }); + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, [ + schema.text("see "), + schema.text("link", [linkMark]), + schema.text(" end"), + ]), + schema.node("paragraph", null, [ + schema.text("normal"), + ]), + ]); + const state = EditorState.create({ doc }); + + // Cursor 1: inside link at pos 7 ("li|nk") + // Cursor 2: in plain text in second paragraph + const $pos1 = state.doc.resolve(7); + const $pos2 = state.doc.resolve(16); + const multiSel = new MultiSelection([ + new SelectionRange($pos1, $pos1), + new SelectionRange($pos2, $pos2), + ], 0); + const stateWithMulti = state.apply(state.tr.setSelection(multiSel)); + + const result = canShiftTabEscapeMulti(stateWithMulti); + expect(result).toBeInstanceOf(MultiSelection); + if (result instanceof MultiSelection) { + // First cursor should have moved to link start (pos 5) + expect(result.ranges[0].$from.pos).toBe(5); + // Second cursor should stay + expect(result.ranges[1].$from.pos).toBe(16); + } + }); + + it("handles cursor at link boundary in multi-cursor mode (fallback to pos)", () => { + const linkMark = schema.marks.link.create({ href: "https://example.com" }); + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, [ + schema.text("link", [linkMark]), + schema.text(" after"), + ]), + ]); + const state = EditorState.create({ doc }); + + // Cursor at pos 5 = boundary (end of "link" text node) + // "link" occupies positions 1..4, so pos 5 is right after it + const $pos1 = state.doc.resolve(5); + const multiSel = new MultiSelection([ + new SelectionRange($pos1, $pos1), + ], 0); + const stateWithMulti = state.apply(state.tr.setSelection(multiSel)); + + const result = canShiftTabEscapeMulti(stateWithMulti); + // At boundary, link mark may or may not be active — if active, returns pos (5) + // If not active, returns null (cursor is in plain text after link) + if (result) { + expect(result).toBeInstanceOf(MultiSelection); + } + // Either outcome is valid — the key is no crash at boundary + }); + + it("returns null when no cursor can escape", () => { + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, [schema.text("plain")]), + schema.node("paragraph", null, [schema.text("text")]), + ]); + const state = EditorState.create({ doc }); + + const $pos1 = state.doc.resolve(3); + const $pos2 = state.doc.resolve(9); + const multiSel = new MultiSelection([ + new SelectionRange($pos1, $pos1), + new SelectionRange($pos2, $pos2), + ], 0); + const stateWithMulti = state.apply(state.tr.setSelection(multiSel)); + + expect(canShiftTabEscapeMulti(stateWithMulti)).toBeNull(); + }); + + it("returns null for non-MultiSelection", () => { + const doc = makeDoc([schema.text("hello")]); + const state = createStateAtPos(doc, 3); + + expect(canShiftTabEscapeMulti(state)).toBeNull(); + }); + + it("preserves range selections (from !== to) in multi-cursor", () => { + const boldMark = schema.marks.bold.create(); + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, [ + schema.text("bold text", [boldMark]), + ]), + ]); + const state = EditorState.create({ doc }); + + // First cursor: range selection inside bold (pos 2..4) + const $from1 = state.doc.resolve(2); + const $to1 = state.doc.resolve(4); + // Second cursor: point cursor inside bold (pos 5) + const $pos2 = state.doc.resolve(5); + const multiSel = new MultiSelection([ + new SelectionRange($from1, $to1), + new SelectionRange($pos2, $pos2), + ], 1); + const stateWithMulti = state.apply(state.tr.setSelection(multiSel)); + + const result = canShiftTabEscapeMulti(stateWithMulti); + expect(result).toBeInstanceOf(MultiSelection); + if (result instanceof MultiSelection) { + // MultiSelection may reorder ranges by position. + // Escaped cursor (pos 1) may come before range (2,4). + const positions = result.ranges.map((r) => ({ + from: r.$from.pos, + to: r.$to.pos, + })); + // Should contain the unchanged range selection + expect(positions).toContainEqual({ from: 2, to: 4 }); + // Should contain the escaped cursor at pos 1 + expect(positions).toContainEqual({ from: 1, to: 1 }); + } + }); +}); diff --git a/src/plugins/tabIndent/shiftTabEscape.ts b/src/plugins/tabIndent/shiftTabEscape.ts new file mode 100644 index 000000000..9c8c7affa --- /dev/null +++ b/src/plugins/tabIndent/shiftTabEscape.ts @@ -0,0 +1,243 @@ +/** + * Shift+Tab Left-Escape for WYSIWYG Mode + * + * Purpose: Detects when cursor is inside an inline mark (bold, italic, code, strike) + * or a link, and provides target position for Shift+Tab to jump to the start. + * Mirrors tabEscape.ts (right-escape) in reverse direction. + * + * Pipeline: Shift+Tab pressed → check marks first (innermost-first) → then links + * → return start position of the text node containing the cursor + * + * Key decisions: + * - Marks checked before links (innermost-first principle — opposite of Tab) + * - Works from anywhere inside a mark/link (not just boundary) + * - Multi-cursor support: each cursor processed independently + * - Shares ESCAPABLE_MARKS set with tabEscape.ts + * - Mark boundary uses `<=` (cursor at mark end still has mark active) + * - Link boundary uses `<` (cursor at link end is at boundary where link may not be active; + * falls back to returning current pos for stored-mark clearing) + * + * @coordinates-with tabEscape.ts — shares ESCAPABLE_MARKS, mirrors structure + * @coordinates-with tiptap.ts — wired into Shift+Tab handler chain + * @module plugins/tabIndent/shiftTabEscape + */ + +import type { EditorState } from "@tiptap/pm/state"; +import type { Node as PMNode, MarkType, ResolvedPos } from "@tiptap/pm/model"; +import { SelectionRange } from "@tiptap/pm/state"; +import { MultiSelection } from "@/plugins/multiCursor/MultiSelection"; + +/** Mark types that Shift+Tab can escape from (shared with tabEscape.ts) */ +const ESCAPABLE_MARKS = new Set(["bold", "italic", "code", "strike"]); + +export interface ShiftTabEscapeResult { + type: "mark" | "link"; + targetPos: number; +} + +/** + * Find the start position of the inline child node at `pos` that matches `predicate`. + * + * @param parent - The parent node containing inline children + * @param parentStart - Absolute position of the parent's content start + * @param pos - Cursor position to locate + * @param inclusive - If true, use `<= childEnd` (marks); if false, use `< childEnd` (links) + * @param predicate - Test whether the child's marks qualify + * @returns childStart if found, null otherwise + */ +function findChildStartAtPos( + parent: PMNode, + parentStart: number, + pos: number, + inclusive: boolean, + predicate: (child: PMNode) => boolean, +): number | null { + let offset = 0; + for (let i = 0; i < parent.childCount; i++) { + const child = parent.child(i); + const childStart = parentStart + offset; + const childEnd = childStart + child.nodeSize; + + const inRange = inclusive + ? pos >= childStart && pos <= childEnd + : pos >= childStart && pos < childEnd; + + if (inRange && predicate(child)) { + return childStart; + } + + offset += child.nodeSize; + } + return null; +} + +/** Predicate: child has a mark matching the given MarkType */ +function hasMarkType(markType: MarkType): (child: PMNode) => boolean { + /* v8 ignore next -- @preserve inline predicate always called when mark is present */ + return (child) => child.marks.some((m) => m.type === markType); +} + +/** Predicate: child has a link mark */ +function hasLinkMark(child: PMNode): boolean { + /* v8 ignore next -- @preserve inline predicate always called when link is present */ + return child.marks.some((m) => m.type.name === "link"); +} + +/** Resolve a position and return parent + parentStart for child scanning */ +function resolveParent($pos: ResolvedPos): { parent: PMNode; parentStart: number } { + return { parent: $pos.parent, parentStart: $pos.start() }; +} + +/** + * Get the start position of the text node containing the cursor + * that has an escapable mark. Returns null if cursor has no escapable mark. + */ +export function getMarkStartPos(state: EditorState): number | null { + const { selection } = state; + const { from, to, $from } = selection; + + if (from !== to) return null; + + const escapableMark = $from.marks().find((m) => ESCAPABLE_MARKS.has(m.type.name)); + if (!escapableMark) return null; + + const { parent, parentStart } = resolveParent($from); + return findChildStartAtPos(parent, parentStart, from, true, hasMarkType(escapableMark.type)); +} + +/** + * Get the start position of the text node containing the cursor + * that has a link mark. Returns null if cursor has no link mark. + */ +export function getLinkStartPos(state: EditorState): number | null { + const { selection } = state; + const { from, to, $from } = selection; + + if (from !== to) return null; + + const linkMark = $from.marks().find((m) => m.type.name === "link"); + if (!linkMark) return null; + + const { parent, parentStart } = resolveParent($from); + const result = findChildStartAtPos(parent, parentStart, from, false, hasLinkMark); + + // Cursor at link boundary — return current position for stored-mark clearing + return result ?? from; +} + +/** + * Calculate left-escape position for a single cursor position. + * Checks marks first (innermost-first), then links. + */ +function calculateLeftEscapeForPosition( + state: EditorState, + pos: number, +): number | null { + const $pos = state.doc.resolve(pos); + + // Check for escapable mark first (innermost-first — opposite of Tab) + const escapableMark = $pos.marks().find((m) => ESCAPABLE_MARKS.has(m.type.name)); + if (escapableMark) { + const { parent, parentStart } = resolveParent($pos); + const result = findChildStartAtPos(parent, parentStart, pos, true, hasMarkType(escapableMark.type)); + if (result !== null) return result; + } + + // Check for link + const linkMark = $pos.marks().find((m) => m.type.name === "link"); + if (linkMark) { + const { parent, parentStart } = resolveParent($pos); + const result = findChildStartAtPos(parent, parentStart, pos, false, hasLinkMark); + // Cursor at link boundary — return pos for stored-mark clearing + return result ?? pos; + } + + return null; +} + +/** + * Determine if Shift+Tab can left-escape from current position. + * + * For single cursor: + * 1. If in an escapable mark (bold/italic/code/strike), escape the mark + * 2. If in a link, escape the link + * + * For multi-cursor: use canShiftTabEscapeMulti instead. + * + * @returns ShiftTabEscapeResult or null if Shift+Tab shouldn't escape + */ +export function canShiftTabEscape(state: EditorState): ShiftTabEscapeResult | MultiSelection | null { + const { selection } = state; + + // Handle multi-cursor + if (selection instanceof MultiSelection) { + return canShiftTabEscapeMulti(state); + } + + const { from, to, $from } = selection; + + if (from !== to) return null; + + // Check for escapable mark first (marks before links — innermost first) + const escapableMark = $from.marks().find((m) => ESCAPABLE_MARKS.has(m.type.name)); + if (escapableMark) { + const startPos = getMarkStartPos(state); + /* v8 ignore next -- @preserve false branch unreachable: when escapableMark is found, getMarkStartPos always returns non-null */ + if (startPos !== null) { + return { type: "mark", targetPos: startPos }; + } + } + + // Check for link + const linkMark = $from.marks().find((m) => m.type.name === "link"); + if (linkMark) { + const startPos = getLinkStartPos(state); + /* v8 ignore next -- @preserve false branch unreachable: when linkMark is found, getLinkStartPos always returns non-null */ + if (startPos !== null) { + return { type: "link", targetPos: startPos }; + } + } + + return null; +} + +/** + * Handle multi-cursor Shift+Tab left-escape. + * Each cursor processed independently — only escapable cursors move. + */ +export function canShiftTabEscapeMulti(state: EditorState): MultiSelection | null { + const { selection } = state; + + if (!(selection instanceof MultiSelection)) { + return null; + } + + const newRanges: SelectionRange[] = []; + let hasAnyEscape = false; + + for (const range of selection.ranges) { + const { $from, $to } = range; + + // Only process cursors, not selections + if ($from.pos !== $to.pos) { + newRanges.push(range); + continue; + } + + const escapePos = calculateLeftEscapeForPosition(state, $from.pos); + + if (escapePos !== null) { + const $newPos = state.doc.resolve(escapePos); + newRanges.push(new SelectionRange($newPos, $newPos)); + hasAnyEscape = true; + } else { + newRanges.push(range); + } + } + + if (!hasAnyEscape) { + return null; + } + + return new MultiSelection(newRanges, selection.primaryIndex); +} diff --git a/src/plugins/tabIndent/tiptap.test.ts b/src/plugins/tabIndent/tiptap.test.ts index f0284f480..68919880f 100644 --- a/src/plugins/tabIndent/tiptap.test.ts +++ b/src/plugins/tabIndent/tiptap.test.ts @@ -40,6 +40,12 @@ vi.mock("./tabEscape", () => ({ canTabEscape: (...args: unknown[]) => mockCanTabEscape(...args), })); +// Mock shiftTabEscape +const mockCanShiftTabEscape = vi.fn(() => null); +vi.mock("./shiftTabEscape", () => ({ + canShiftTabEscape: (...args: unknown[]) => mockCanShiftTabEscape(...args), +})); + // Mock multiCursor vi.mock("@/plugins/multiCursor/MultiSelection", () => ({ MultiSelection: class MockMultiSelection { @@ -108,6 +114,7 @@ describe("tabIndentExtension", () => { mockIsInTable.mockReturnValue(false); mockGetTableInfo.mockReturnValue(null); mockCanTabEscape.mockReturnValue(null); + mockCanShiftTabEscape.mockReturnValue(null); vi.clearAllMocks(); }); @@ -454,6 +461,7 @@ describe("tabIndent plugin handler integration", () => { mockIsInTable.mockReturnValue(false); mockGetTableInfo.mockReturnValue(null); mockCanTabEscape.mockReturnValue(null); + mockCanShiftTabEscape.mockReturnValue(null); vi.clearAllMocks(); // Extract the actual plugin from the extension @@ -626,6 +634,87 @@ describe("tabIndent plugin handler integration", () => { expect(mockCanTabEscape).not.toHaveBeenCalled(); }); + it("delegates to canShiftTabEscape on Shift+Tab and dispatches escape", () => { + mockCanShiftTabEscape.mockReturnValue({ type: "mark", targetPos: 1 }); + const state = createState("hello", 3); + const view = createMockView(state); + const event = makeTabEvent({ shiftKey: true }); + const result = keydownHandler(view, event); + expect(result).toBe(true); + expect(mockCanShiftTabEscape).toHaveBeenCalledWith(state); + expect(view.dispatch).toHaveBeenCalledTimes(1); + }); + + it("dispatches Shift+Tab link escape and clears link stored mark", () => { + mockCanShiftTabEscape.mockReturnValue({ type: "link", targetPos: 1 }); + const state = createState("hello", 3); + const view = createMockView(state); + const event = makeTabEvent({ shiftKey: true }); + const result = keydownHandler(view, event); + expect(result).toBe(true); + expect(view.dispatch).toHaveBeenCalledTimes(1); + }); + + it("handles MultiSelection from canShiftTabEscape", async () => { + const { MultiSelection } = await import("@/plugins/multiCursor/MultiSelection"); + const ms = new MultiSelection([], 0); + mockCanShiftTabEscape.mockReturnValue(ms); + + const schemaWithMarks = new Schema({ + nodes: { + doc: { content: "paragraph+" }, + paragraph: { content: "inline*" }, + text: { group: "inline", inline: true }, + }, + marks: { + bold: {}, + link: { attrs: { href: { default: "" } } }, + }, + }); + const doc = schemaWithMarks.node("doc", null, [ + schemaWithMarks.node("paragraph", null, [schemaWithMarks.text("hello")]), + ]); + const state = EditorState.create({ doc, schema: schemaWithMarks }); + + const mockTr = { + setSelection: vi.fn().mockReturnThis(), + removeStoredMark: vi.fn().mockReturnThis(), + }; + const view = { + state: { ...state, tr: mockTr, schema: schemaWithMarks }, + dispatch: vi.fn(), + dom: document.createElement("div"), + }; + + const result = keydownHandler(view, makeTabEvent({ shiftKey: true })); + expect(result).toBe(true); + expect(mockTr.setSelection).toHaveBeenCalledWith(ms); + // Should clear all escapable mark types present in schema + expect(mockTr.removeStoredMark).toHaveBeenCalledWith(schemaWithMarks.marks.bold); + expect(mockTr.removeStoredMark).toHaveBeenCalledWith(schemaWithMarks.marks.link); + expect(view.dispatch).toHaveBeenCalled(); + }); + + it("does not call canShiftTabEscape on forward Tab", () => { + mockCanShiftTabEscape.mockReturnValue({ type: "mark", targetPos: 1 }); + const state = createState("hello", 3); + const view = createMockView(state); + const event = makeTabEvent(); + keydownHandler(view, event); + expect(mockCanShiftTabEscape).not.toHaveBeenCalled(); + }); + + it("falls through to table/list/outdent when Shift+Tab escape returns null", () => { + mockCanShiftTabEscape.mockReturnValue(null); + const state = createState(" hello", 3); + const view = createMockView(state); + const event = makeTabEvent({ shiftKey: true }); + const result = keydownHandler(view, event); + expect(result).toBe(true); + // Should have handled as outdent since escape returned null + expect(view.dispatch).toHaveBeenCalledTimes(1); + }); + it("delegates to table navigation when in table", () => { mockIsInTable.mockReturnValue(true); const state = createState("hello", 3); diff --git a/src/plugins/tabIndent/tiptap.ts b/src/plugins/tabIndent/tiptap.ts index c75c80b2d..e6a9fa8f9 100644 --- a/src/plugins/tabIndent/tiptap.ts +++ b/src/plugins/tabIndent/tiptap.ts @@ -26,16 +26,68 @@ */ import { Extension } from "@tiptap/core"; -import { Plugin, PluginKey, type EditorState, TextSelection } from "@tiptap/pm/state"; +import { Plugin, PluginKey, type EditorState, TextSelection, type Transaction } from "@tiptap/pm/state"; import { goToNextCell, addRowAfter } from "@tiptap/pm/tables"; import { liftListItem, sinkListItem } from "@tiptap/pm/schema-list"; import { useSettingsStore } from "@/stores/settingsStore"; import { isInTable, getTableInfo } from "@/plugins/tableUI/tableActions.tiptap"; -import { canTabEscape } from "./tabEscape"; +import { canTabEscape, type TabEscapeResult } from "./tabEscape"; +import { canShiftTabEscape, type ShiftTabEscapeResult } from "./shiftTabEscape"; import { MultiSelection } from "@/plugins/multiCursor/MultiSelection"; const tabIndentPluginKey = new PluginKey("tabIndent"); +/** Escapable mark type names — only these are cleared from stored marks */ +const ESCAPABLE_MARK_NAMES = new Set(["bold", "italic", "code", "strike", "link"]); + +/** + * Apply an escape result: set selection and clear stored marks. + * Shared by both Tab (forward) and Shift+Tab (backward) escape paths. + */ +function applyEscapeResult( + state: EditorState, + dispatch: (tr: Transaction) => void, + escapeResult: TabEscapeResult | ShiftTabEscapeResult | MultiSelection, +): void { + if (escapeResult instanceof MultiSelection) { + const tr = state.tr.setSelection(escapeResult); + // Clear all escapable mark types present in the schema. + // Different cursors may be in different marks, so we can't rely + // on the primary cursor's marks alone (#10). + for (const name of ESCAPABLE_MARK_NAMES) { + const markType = state.schema.marks[name]; + if (markType) { + tr.removeStoredMark(markType); + } + } + dispatch(tr); + return; + } + + const tr = state.tr.setSelection( + TextSelection.create(state.doc, escapeResult.targetPos) + ); + + if (escapeResult.type === "link") { + const linkMarkType = state.schema.marks.link; + if (linkMarkType) { + tr.removeStoredMark(linkMarkType); + } + } + + if (escapeResult.type === "mark") { + // Clear only escapable marks from stored marks + const { $from } = state.selection; + for (const mark of $from.marks()) { + if (ESCAPABLE_MARK_NAMES.has(mark.type.name)) { + tr.removeStoredMark(mark.type); + } + } + } + + dispatch(tr); +} + /** * Get the configured tab size (number of spaces). */ @@ -57,6 +109,75 @@ function isInListItem(state: EditorState): boolean { return false; } +/** + * Handle Tab/Shift+Tab in table context. + * Returns true if handled (always — Tab is consumed in tables). + */ +function handleTableTab(view: import("@tiptap/pm/view").EditorView, shiftKey: boolean): boolean { + const direction = shiftKey ? -1 : 1; + const moved = goToNextCell(direction)(view.state, view.dispatch, view); + + // If Tab (not Shift+Tab) couldn't move, we're at last cell — add new row + if (!moved && direction === 1) { + const info = getTableInfo(view); + if (info && info.rowIndex === info.numRows - 1 && info.colIndex === info.numCols - 1) { + addRowAfter(view.state, view.dispatch); + goToNextCell(1)(view.state, view.dispatch, view); + } + } + return true; +} + +/** + * Handle Tab/Shift+Tab in list item context. + * Returns true if handled (always — Tab is consumed in lists). + */ +function handleListTab(state: EditorState, dispatch: (tr: Transaction) => void, shiftKey: boolean): boolean { + const listItemType = state.schema.nodes.listItem; + /* v8 ignore next -- @preserve reason: schema always defines listItem in test environment */ + if (listItemType) { + if (shiftKey) { + liftListItem(listItemType)(state, dispatch); + } else { + sinkListItem(listItemType)(state, dispatch); + } + } + return true; +} + +/** + * Handle Shift+Tab outdent: remove up to tabSize leading spaces. + */ +function handleShiftTabOutdent(state: EditorState, dispatch: (tr: Transaction) => void): boolean { + const { from } = state.selection; + const $from = state.doc.resolve(from); + const lineStart = $from.start(); + const textBefore = state.doc.textBetween(lineStart, from, "\n"); + + /* v8 ignore start -- leading-space regex always matches (never null); optional chain is defensive */ + const leadingSpaces = textBefore.match(/^[ ]*/)?.[0].length ?? 0; + /* v8 ignore stop */ + if (leadingSpaces === 0) return true; + + const spacesToRemove = Math.min(leadingSpaces, getTabSize()); + dispatch(state.tr.delete(lineStart, lineStart + spacesToRemove)); + return true; +} + +/** + * Handle Tab: insert spaces (or replace selection with spaces). + */ +function handleTabInsertSpaces(state: EditorState, dispatch: (tr: Transaction) => void): boolean { + const spaces = " ".repeat(getTabSize()); + + if (!state.selection.empty) { + dispatch(state.tr.replaceSelectionWith(state.schema.text(spaces), true)); + } else { + dispatch(state.tr.insertText(spaces)); + } + return true; +} + export const tabIndentExtension = Extension.create({ name: "tabIndent", // Low priority - runs after all other Tab handlers @@ -78,126 +199,45 @@ export const tabIndentExtension = Extension.create({ const { state, dispatch } = view; - // Tab escape from marks/links (only for forward Tab, not Shift+Tab) + // 1. Tab/Shift+Tab escape from marks/links if (!event.shiftKey) { const escapeResult = canTabEscape(state); if (escapeResult) { event.preventDefault(); - - // Handle multi-cursor - if (escapeResult instanceof MultiSelection) { - const tr = state.tr.setSelection(escapeResult); - // Clear link from stored marks for all cursors - const linkMarkType = state.schema.marks.link; - if (linkMarkType) { - tr.removeStoredMark(linkMarkType); - } - dispatch(tr); - return true; - } - - // Handle single cursor - const tr = state.tr.setSelection( - TextSelection.create(state.doc, escapeResult.targetPos) - ); - // When escaping a link, clear the link from stored marks - // so subsequent typing produces normal (unlinked) text. - // This is essential when the link is at end of paragraph - // where there's no un-marked position to jump to. - if (escapeResult.type === "link") { - const linkMarkType = state.schema.marks.link; - if (linkMarkType) { - tr.removeStoredMark(linkMarkType); - } - } - // When escaping an inline mark, clear it from stored marks - // so subsequent typing produces unmarked text. - if (escapeResult.type === "mark") { - const { $from } = state.selection; - for (const mark of $from.marks()) { - tr.removeStoredMark(mark.type); - } - } - dispatch(tr); + applyEscapeResult(state, dispatch, escapeResult); + return true; + } + } + if (event.shiftKey) { + const escapeResult = canShiftTabEscape(state); + if (escapeResult) { + event.preventDefault(); + applyEscapeResult(state, dispatch, escapeResult); return true; } } - // In table: delegate to table cell navigation + // 2. Table navigation if (isInTable(view)) { event.preventDefault(); - const direction = event.shiftKey ? -1 : 1; - const moved = goToNextCell(direction)(view.state, view.dispatch, view); - - // If Tab (not Shift+Tab) couldn't move, we're at last cell - add new row - if (!moved && direction === 1) { - const info = getTableInfo(view); - if (info && info.rowIndex === info.numRows - 1 && info.colIndex === info.numCols - 1) { - // Add row below, then move to first cell of new row - addRowAfter(view.state, view.dispatch); - // After adding row, move to first cell - goToNextCell(1)(view.state, view.dispatch, view); - } - } - return true; + return handleTableTab(view, event.shiftKey); } - const { selection } = state; - - // In list item: indent/outdent + // 3. List indent/outdent if (isInListItem(state)) { event.preventDefault(); - const listItemType = state.schema.nodes.listItem; - /* v8 ignore next -- @preserve reason: schema always defines listItem in test environment */ - if (listItemType) { - if (event.shiftKey) { - liftListItem(listItemType)(state, dispatch); - } else { - sinkListItem(listItemType)(state, dispatch); - } - } - return true; + return handleListTab(state, dispatch, event.shiftKey); } - // Handle Shift+Tab: outdent (remove up to tabSize spaces before cursor) + // 4. Shift+Tab outdent (remove leading spaces) if (event.shiftKey) { event.preventDefault(); - const { from } = selection; - const $from = state.doc.resolve(from); - const lineStart = $from.start(); - const textBefore = state.doc.textBetween(lineStart, from, "\n"); - - // Count leading spaces - /* v8 ignore start -- leading-space regex always matches (never null); optional chain is defensive */ - const leadingSpaces = textBefore.match(/^[ ]*/)?.[0].length ?? 0; - /* v8 ignore stop */ - if (leadingSpaces === 0) return true; - - // Remove up to tabSize spaces - const spacesToRemove = Math.min(leadingSpaces, getTabSize()); - const tr = state.tr.delete(lineStart, lineStart + spacesToRemove); - dispatch(tr); - return true; + return handleShiftTabOutdent(state, dispatch); } - // Handle Tab: insert spaces + // 5. Tab: insert spaces event.preventDefault(); - const spaces = " ".repeat(getTabSize()); - - // If there's a selection, replace it with spaces - if (!selection.empty) { - const tr = state.tr.replaceSelectionWith( - state.schema.text(spaces), - true - ); - dispatch(tr); - return true; - } - - // Insert spaces at cursor - const tr = state.tr.insertText(spaces); - dispatch(tr); - return true; + return handleTabInsertSpaces(state, dispatch); }, }, }, diff --git a/src/stores/documentStore.test.ts b/src/stores/documentStore.test.ts index f7fdb23ba..440e3748f 100644 --- a/src/stores/documentStore.test.ts +++ b/src/stores/documentStore.test.ts @@ -51,6 +51,28 @@ describe("documentStore", () => { expect(doc?.content).toBe("# Test"); expect(doc?.filePath).toBe("/path/to/file.md"); }); + + it("sets lastDiskContent to savedContent when savedContent is provided", () => { + const { initDocument, getDocument } = useDocumentStore.getState(); + + initDocument(WINDOW_LABEL, "Current edits", "/path.md", "Disk baseline"); + + const doc = getDocument(WINDOW_LABEL); + expect(doc?.content).toBe("Current edits"); + expect(doc?.savedContent).toBe("Disk baseline"); + expect(doc?.lastDiskContent).toBe("Disk baseline"); + expect(doc?.isDirty).toBe(true); + }); + + it("marks clean when savedContent matches content", () => { + const { initDocument, getDocument } = useDocumentStore.getState(); + + initDocument(WINDOW_LABEL, "Same", "/path.md", "Same"); + + const doc = getDocument(WINDOW_LABEL); + expect(doc?.isDirty).toBe(false); + expect(doc?.lastDiskContent).toBe("Same"); + }); }); describe("setContent", () => { @@ -156,6 +178,35 @@ describe("documentStore", () => { expect(doc?.isDirty).toBe(false); expect(doc?.savedContent).toBe("Modified"); }); + + it("keeps isDirty true when content diverged during save (TOCTOU)", () => { + const { initDocument, setContent, markSaved, getDocument } = useDocumentStore.getState(); + + initDocument(WINDOW_LABEL, "Original"); + // User edits to "Version B" + setContent(WINDOW_LABEL, "Version B"); + // But the save wrote "Version A" (normalized content from before edit) + markSaved(WINDOW_LABEL, "Version A"); + + const doc = getDocument(WINDOW_LABEL); + expect(doc?.isDirty).toBe(true); + expect(doc?.lastDiskContent).toBe("Version A"); + // savedContent should be preserved from before the save + expect(doc?.savedContent).toBe("Original"); + }); + + it("clears isDirty when content matches disk content", () => { + const { initDocument, setContent, markSaved, getDocument } = useDocumentStore.getState(); + + initDocument(WINDOW_LABEL, "Original"); + setContent(WINDOW_LABEL, "Saved content"); + markSaved(WINDOW_LABEL, "Saved content"); + + const doc = getDocument(WINDOW_LABEL); + expect(doc?.isDirty).toBe(false); + expect(doc?.savedContent).toBe("Saved content"); + expect(doc?.lastDiskContent).toBe("Saved content"); + }); }); describe("markAutoSaved", () => { @@ -174,6 +225,132 @@ describe("documentStore", () => { expect(doc?.lastAutoSave).toBeGreaterThanOrEqual(beforeTime); expect(doc?.lastAutoSave).toBeLessThanOrEqual(afterTime); }); + + it("keeps isDirty true when content diverged during auto-save (TOCTOU)", () => { + const { initDocument, setContent, markAutoSaved, getDocument } = useDocumentStore.getState(); + + initDocument(WINDOW_LABEL, "Original"); + setContent(WINDOW_LABEL, "Edited during save"); + // Auto-save wrote the pre-edit content + markAutoSaved(WINDOW_LABEL, "Pre-edit content"); + + const doc = getDocument(WINDOW_LABEL); + expect(doc?.isDirty).toBe(true); + expect(doc?.lastDiskContent).toBe("Pre-edit content"); + expect(doc?.lastAutoSave).not.toBeNull(); + }); + + it("clears isDirty when content matches disk content", () => { + const { initDocument, setContent, markAutoSaved, getDocument } = useDocumentStore.getState(); + + initDocument(WINDOW_LABEL, "Original"); + setContent(WINDOW_LABEL, "Auto-saved content"); + markAutoSaved(WINDOW_LABEL, "Auto-saved content"); + + const doc = getDocument(WINDOW_LABEL); + expect(doc?.isDirty).toBe(false); + expect(doc?.savedContent).toBe("Auto-saved content"); + }); + }); + + describe("markMissing / clearMissing", () => { + it("sets isMissing to true", () => { + const { initDocument, markMissing, getDocument } = useDocumentStore.getState(); + initDocument(WINDOW_LABEL, "content", "/file.md"); + + markMissing(WINDOW_LABEL); + expect(getDocument(WINDOW_LABEL)?.isMissing).toBe(true); + }); + + it("clears isMissing back to false", () => { + const { initDocument, markMissing, clearMissing, getDocument } = useDocumentStore.getState(); + initDocument(WINDOW_LABEL, "content", "/file.md"); + + markMissing(WINDOW_LABEL); + expect(getDocument(WINDOW_LABEL)?.isMissing).toBe(true); + + clearMissing(WINDOW_LABEL); + expect(getDocument(WINDOW_LABEL)?.isMissing).toBe(false); + }); + + it("no-ops for non-existent document", () => { + const { markMissing, getDocument } = useDocumentStore.getState(); + markMissing("non-existent"); + expect(getDocument("non-existent")).toBeUndefined(); + }); + }); + + describe("markDivergent", () => { + it("sets isDivergent to true", () => { + const { initDocument, markDivergent, getDocument } = useDocumentStore.getState(); + initDocument(WINDOW_LABEL, "content", "/file.md"); + + markDivergent(WINDOW_LABEL); + expect(getDocument(WINDOW_LABEL)?.isDivergent).toBe(true); + }); + + it("markSaved clears isDivergent", () => { + const { initDocument, markDivergent, markSaved, getDocument } = useDocumentStore.getState(); + initDocument(WINDOW_LABEL, "content", "/file.md"); + + markDivergent(WINDOW_LABEL); + expect(getDocument(WINDOW_LABEL)?.isDivergent).toBe(true); + + markSaved(WINDOW_LABEL, "content"); + expect(getDocument(WINDOW_LABEL)?.isDivergent).toBe(false); + }); + }); + + describe("setLineMetadata", () => { + it("updates lineEnding", () => { + const { initDocument, setLineMetadata, getDocument } = useDocumentStore.getState(); + initDocument(WINDOW_LABEL); + + setLineMetadata(WINDOW_LABEL, { lineEnding: "crlf" }); + expect(getDocument(WINDOW_LABEL)?.lineEnding).toBe("crlf"); + expect(getDocument(WINDOW_LABEL)?.hardBreakStyle).toBe("unknown"); + }); + + it("updates hardBreakStyle", () => { + const { initDocument, setLineMetadata, getDocument } = useDocumentStore.getState(); + initDocument(WINDOW_LABEL); + + setLineMetadata(WINDOW_LABEL, { hardBreakStyle: "backslash" }); + expect(getDocument(WINDOW_LABEL)?.hardBreakStyle).toBe("backslash"); + expect(getDocument(WINDOW_LABEL)?.lineEnding).toBe("unknown"); + }); + + it("updates both at once", () => { + const { initDocument, setLineMetadata, getDocument } = useDocumentStore.getState(); + initDocument(WINDOW_LABEL); + + setLineMetadata(WINDOW_LABEL, { lineEnding: "lf", hardBreakStyle: "twoSpaces" }); + const doc = getDocument(WINDOW_LABEL); + expect(doc?.lineEnding).toBe("lf"); + expect(doc?.hardBreakStyle).toBe("twoSpaces"); + }); + }); + + describe("loadContent filePath handling", () => { + it("preserves existing filePath when filePath arg is undefined", () => { + const { initDocument, loadContent, getDocument } = useDocumentStore.getState(); + initDocument(WINDOW_LABEL, "Initial", "/original/path.md"); + + loadContent(WINDOW_LABEL, "New content"); + + const doc = getDocument(WINDOW_LABEL); + expect(doc?.filePath).toBe("/original/path.md"); + expect(doc?.content).toBe("New content"); + }); + + it("clears filePath when explicitly passed null", () => { + const { initDocument, loadContent, getDocument } = useDocumentStore.getState(); + initDocument(WINDOW_LABEL, "Initial", "/original/path.md"); + + loadContent(WINDOW_LABEL, "New content", null); + + expect(getDocument(WINDOW_LABEL)?.filePath).toBeNull(); + }); }); describe("setCursorInfo", () => { diff --git a/src/stores/documentStore.ts b/src/stores/documentStore.ts index 8fcb436b2..81afd0304 100644 --- a/src/stores/documentStore.ts +++ b/src/stores/documentStore.ts @@ -121,6 +121,21 @@ function updateDoc( }; } +/** + * Compute post-save state. Compares written disk content against current editor + * content to handle TOCTOU races (user edits during async save). + */ +function buildPostSaveState(doc: DocumentState, lastDiskContent: string | undefined) { + const diskContent = lastDiskContent ?? doc.content; + const dirty = doc.content !== diskContent; + return { + savedContent: dirty ? doc.savedContent : doc.content, + lastDiskContent: diskContent, + isDirty: dirty, + isDivergent: false, + }; +} + export const useDocumentStore = create((set, get) => ({ documents: {}, @@ -128,6 +143,7 @@ export const useDocumentStore = create((set, get) => ({ const doc = createInitialDocument(content, filePath); if (savedContent !== undefined) { doc.savedContent = savedContent; + doc.lastDiskContent = savedContent; doc.isDirty = savedContent !== content; } set((state) => ({ @@ -149,7 +165,7 @@ export const useDocumentStore = create((set, get) => ({ content, savedContent: content, lastDiskContent: content, - filePath: filePath ?? null, + filePath: filePath === undefined ? doc.filePath : filePath, isDirty: false, isDivergent: false, // Reload from disk clears divergent state documentId: doc.documentId + 1, @@ -172,21 +188,13 @@ export const useDocumentStore = create((set, get) => ({ markSaved: (tabId, lastDiskContent) => set((state) => - updateDoc(state, tabId, (doc) => ({ - savedContent: doc.content, - lastDiskContent: lastDiskContent ?? doc.content, - isDirty: false, - isDivergent: false, // Manual save syncs local with disk - })) + updateDoc(state, tabId, (doc) => buildPostSaveState(doc, lastDiskContent)) ), markAutoSaved: (tabId, lastDiskContent) => set((state) => updateDoc(state, tabId, (doc) => ({ - savedContent: doc.content, - lastDiskContent: lastDiskContent ?? doc.content, - isDirty: false, - isDivergent: false, // Auto-save syncs local with disk + ...buildPostSaveState(doc, lastDiskContent), lastAutoSave: Date.now(), })) ), diff --git a/website/guide/shortcuts.md b/website/guide/shortcuts.md index 557669c9f..427a94fbb 100644 --- a/website/guide/shortcuts.md +++ b/website/guide/shortcuts.md @@ -232,7 +232,7 @@ This is a native macOS system shortcut that searches all menu items. Type a keyw ## Smart Tab Navigation -Tab is context-aware — it escapes brackets, quotes, formatting marks, and navigates links. +Tab and Shift+Tab are context-aware — they escape brackets, quotes, formatting marks, and links. | Context | Tab Action | |---------|------------| @@ -241,6 +241,13 @@ Tab is context-aware — it escapes brackets, quotes, formatting marks, and navi | Inside **bold**, *italic*, `code` | Jump after formatting | | Inside a link | Jump after link | +| Context | Shift+Tab Action | +|---------|------------------| +| After `(`, `[`, `{`, quotes | Jump before opening character | +| After CJK brackets `「`, `『`, etc. | Jump before opening bracket | +| Inside **bold**, *italic*, `code` | Jump before formatting | +| Inside a link | Jump before link | + ::: tip See [Smart Tab Navigation](/guide/tab-navigation) for the complete guide including CJK brackets, curly quotes, and settings. ::: diff --git a/website/guide/tab-navigation.md b/website/guide/tab-navigation.md index 3a6d9423e..b7e8311eb 100644 --- a/website/guide/tab-navigation.md +++ b/website/guide/tab-navigation.md @@ -1,22 +1,22 @@ # Smart Tab Navigation -VMark's Tab key is context-aware — it helps you navigate efficiently through formatted text, brackets, and links without reaching for arrow keys. +VMark's Tab and Shift+Tab keys are context-aware — they help you navigate efficiently through formatted text, brackets, and links without reaching for arrow keys. ## Quick Overview -| Context | Tab Action | -|---------|------------| -| Inside brackets `()` `[]` `{}` | Jump past closing bracket | -| Inside quotes `""` `''` | Jump past closing quote | -| Inside CJK brackets `「」` `『』` | Jump past closing bracket | -| Inside **bold**, *italic*, `code`, ~~strike~~ | Jump after the formatting | -| Inside a link | Jump after the link | -| In a table cell | Move to next cell | -| In a list item | Indent the item | +| Context | Tab Action | Shift+Tab Action | +|---------|------------|------------------| +| Inside brackets `()` `[]` `{}` | Jump past closing bracket | Jump before opening bracket | +| Inside quotes `""` `''` | Jump past closing quote | Jump before opening quote | +| Inside CJK brackets `「」` `『』` | Jump past closing bracket | Jump before opening bracket | +| Inside **bold**, *italic*, `code`, ~~strike~~ | Jump after the formatting | Jump before the formatting | +| Inside a link | Jump after the link | Jump before the link | +| In a table cell | Move to next cell | Move to previous cell | +| In a list item | Indent the item | Outdent the item | ## Bracket & Quote Escape -When your cursor is right before a closing bracket or quote, pressing Tab jumps over it instead of inserting spaces. +When your cursor is right before a closing bracket or quote, pressing Tab jumps over it. When your cursor is right after an opening bracket or quote, pressing Shift+Tab jumps back before it. ### Supported Characters @@ -56,6 +56,20 @@ function hello(world)| This works with nested brackets too — Tab jumps over the immediately adjacent closing character. +Press **Shift+Tab** reverses the action — if cursor is right after an opening character: + +``` +function hello(|world) + ↑ cursor after ( +``` + +Press **Shift+Tab**: + +``` +function hello|(world) + ↑ cursor before ( +``` + ### CJK Example ``` @@ -72,7 +86,7 @@ Press **Tab**: ## Formatting Escape (WYSIWYG Mode) -In WYSIWYG mode, Tab can escape from inline formatting marks. +In WYSIWYG mode, Tab and Shift+Tab can escape from inline formatting marks. ### Supported Formats @@ -98,9 +112,23 @@ This is **bold text**| here ↑ cursor after bold ``` +Shift+Tab works in reverse — it jumps to the start of the formatting: + +``` +This is **bold te|xt** here + ↑ cursor inside bold +``` + +Press **Shift+Tab**: + +``` +This is |**bold text** here + ↑ cursor before bold +``` + ### Link Escape -Tab also escapes from links: +Tab and Shift+Tab also escape from links: ``` Check out [VMark|](https://vmark.app) @@ -114,6 +142,13 @@ Check out [VMark](https://vmark.app)| and... ↑ cursor after link ``` +Press **Shift+Tab** inside a link moves to the start: + +``` +Check out |[VMark](https://vmark.app) and... + ↑ cursor before link +``` + ## Link Navigation (Source Mode) In Source mode, Tab provides smart navigation within Markdown link syntax. @@ -228,17 +263,17 @@ If Tab escape conflicts with your workflow, you can disable auto-pair brackets e ## Comparison: WYSIWYG vs Source Mode -| Feature | WYSIWYG | Source | -|---------|---------|--------| -| Bracket escape | ✓ | ✓ | -| CJK bracket escape | ✓ | ✓ | -| Curly quote escape | ✓ | ✓ | -| Mark escape (bold, etc.) | ✓ | N/A | -| Link escape | ✓ | ✓ | -| Markdown char escape (`*`, `_`) | N/A | ✓ | -| Table navigation | ✓ | N/A | -| List indentation | ✓ | ✓ | -| Multi-cursor support | ✓ | ✓ | +| Feature | Tab (WYSIWYG) | Shift+Tab (WYSIWYG) | Source | +|---------|---------------|---------------------|--------| +| Bracket escape | ✓ (forward) | ✓ (backward) | ✓ | +| CJK bracket escape | ✓ (forward) | ✓ (backward) | ✓ | +| Curly quote escape | ✓ (forward) | ✓ (backward) | ✓ | +| Mark escape (bold, etc.) | ✓ (forward) | ✓ (backward) | N/A | +| Link escape | ✓ (forward) | ✓ (backward) | ✓ | +| Markdown char escape (`*`, `_`) | N/A | N/A | ✓ | +| Table navigation | Next cell | Previous cell | N/A | +| List indentation | Indent | Outdent | ✓ | +| Multi-cursor support | ✓ | ✓ | ✓ | ## Multi-Cursor Support @@ -246,10 +281,9 @@ Tab escape works with multiple cursors — each cursor is processed independentl ### How It Works -When you have multiple cursors and press Tab: -- Cursors inside formatting (bold, italic, etc.) escape to the end of that formatting -- Cursors inside links escape from the link -- Cursors before closing brackets jump over them +When you have multiple cursors and press Tab or Shift+Tab: +- **Tab**: Cursors inside formatting escape to the end; cursors before closing brackets jump over them +- **Shift+Tab**: Cursors inside formatting escape to the start; cursors after opening brackets jump before them - Cursors in plain text stay in place ### Example @@ -280,6 +314,6 @@ This is particularly powerful for bulk editing — select multiple occurrences w 3. **Nested structures** — Tab escapes one level at a time. For `((nested))`, you need two Tabs to fully exit. -4. **Shift + Tab** — In tables and lists, Shift + Tab moves backward or outdents. In other contexts, it removes leading spaces. +4. **Shift + Tab** — The mirror of Tab. Escapes backward from marks, links, and opening brackets. In tables, moves to the previous cell. In lists, outdents the item. 5. **Multi-cursor** — Tab escape works with all your cursors simultaneously, making bulk edits even faster.