From a2c8a7bb87454e8bc52bb753b6b8f74757267e88 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 6 Oct 2026 20:19:28 +1100 Subject: [PATCH 1/2] fix(web): type in front of bold that starts a composer line (#13217) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (cherry picked from commit d0dc45885c1441da5bd46dcf13124d94ed728b0a) Carry-Group: upstream-bugfixes Carry-Upstream: https://github.com/pingdotgg/t3code/pull/13217 head 42ea6ff617128871b9fcd975811caae91f10cae2 Carry-Observation: Import the merged upstream squash d0dc45885c1441da5bd46dcf13124d94ed728b0a ahead of nightly adoption. --- .../components/ComposerPromptEditorTiptap.tsx | 31 ++++-- apps/web/src/composer-rich-text-doc.test.ts | 94 +++++++++++++++++++ apps/web/src/composer-rich-text-doc.ts | 56 ++++++++++- 3 files changed, 170 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 4fd3022e6d8d..79c30685082e 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -4,7 +4,7 @@ import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tip import StarterKit from "@tiptap/starter-kit"; import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; import { splitBlockKeepMarks } from "@tiptap/pm/commands"; -import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; +import { type EditorState, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; import type { AssistantCitation, @@ -47,6 +47,7 @@ import { buildDocJson, buildTiptapContent, collapsedToFlat, + caretTakesMarksBefore, ComposerCodeExtension, ComposerTaskItemExtension, flatToCollapsed, @@ -54,6 +55,7 @@ import { flatToPm, pmToFlat, serializeEditorDoc, + stepCaretAcrossStyledEdge, type SkillMeta, } from "~/composer-rich-text-doc"; import { @@ -522,9 +524,9 @@ const MarkerPluginKey = new PluginKey("composer-rich-markers"); const ComposerMarkerPlugin = new Plugin({ key: MarkerPluginKey, state: { - init: (_, state) => decorationsForSelection(state.doc, state.selection), - apply: (tr, old) => - tr.docChanged || tr.selectionSet ? decorationsForSelection(tr.doc, tr.selection) : old, + init: (_, state) => decorationsForSelection(state), + apply: (tr, old, _, state) => + tr.docChanged || tr.selectionSet || tr.storedMarksSet ? decorationsForSelection(state) : old, }, props: { decorations(state) { @@ -533,10 +535,11 @@ const ComposerMarkerPlugin = new Plugin({ }, }); -function decorationsForSelection( - doc: ProseMirrorNode, - selection: { from: number; to: number; empty: boolean }, -): DecorationSet { +function decorationsForSelection(state: EditorState): DecorationSet { + const { doc, selection } = state; + // Markers at the caret render after it while it types with the marks before + // the edge. Shifting keeps closers ahead of openers at a shared position. + const caretSide = caretTakesMarksBefore(state) ? 3 : 0; const decorations: Decoration[] = []; if (!selection.empty) { doc.nodesBetween(selection.from, selection.to, (node, pos) => { @@ -554,7 +557,8 @@ function decorationsForSelection( ? selection.from >= range.from && selection.from <= range.to : selection.from < range.to && selection.to > range.from; if (!active) continue; - for (const { at, side, text } of range.markers) { + for (const { at, side: baseSide, text } of range.markers) { + const side = selection.empty && at === selection.from ? baseSide + caretSide : baseSide; const marker = document.createElement("span"); marker.className = "composer-rich-marker"; marker.textContent = text; @@ -921,6 +925,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ) { const { $from } = view.state.selection; const direction = event.key === "ArrowLeft" ? -1 : 1; + // Take the other stop of a styled edge before skipping a chip, so + // the plain stop between styled text and a chip stays reachable. + const step = stepCaretAcrossStyledEdge(view.state, direction); + if (step) { + event.preventDefault(); + event.stopPropagation(); + view.dispatch(step); + return true; + } const adjacent = direction === -1 ? $from.nodeBefore : $from.nodeAfter; if (adjacent?.type.name.startsWith("composer-")) { event.preventDefault(); diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 8fa311b1999c..44f8e6d1f6d1 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -2,10 +2,12 @@ import { getSchemaByResolvedExtensions, Node, resolveExtensions } from "@tiptap/ import StarterKit from "@tiptap/starter-kit"; import { TaskList } from "@tiptap/extension-task-list"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { EditorState, TextSelection } from "@tiptap/pm/state"; import { describe, expect, it } from "vite-plus/test"; import { buildDocJson, + caretTakesMarksBefore, collapsedToFlat, ComposerCodeExtension, ComposerTaskItemExtension, @@ -14,6 +16,7 @@ import { flatToPm, pmToFlat, serializeEditorDoc, + stepCaretAcrossStyledEdge, } from "./composer-rich-text-doc"; function stubAtom(name: string, attrs: Record) { @@ -361,3 +364,94 @@ describe("composer rich text document model", () => { expect(collapsedToFlat(map, 9)).toBe(6); }); }); + +describe("caret stops at styled edges", () => { + function stateAt(value: string, pmPos: number) { + const doc = ProseMirrorNode.fromJSON( + schema, + buildDocJson(value, (name) => ({ label: name, description: null })), + ); + return EditorState.create({ doc, selection: TextSelection.create(doc, pmPos) }); + } + + function typed(state: EditorState, text: string) { + return serializeEditorDoc(state.apply(state.tr.insertText(text)).doc).value; + } + + it("lets the caret step out in front of bold that starts the line", () => { + const inside = stateAt("**bold** tail", 1); + expect(caretTakesMarksBefore(inside)).toBe(false); + expect(typed(inside, "x")).toBe("**xbold** tail"); + + const step = stepCaretAcrossStyledEdge(inside, -1); + expect(step).not.toBeNull(); + const outside = inside.apply(step!); + expect(caretTakesMarksBefore(outside)).toBe(true); + expect(typed(outside, "x")).toBe("x**bold** tail"); + // Already outside: the next ArrowLeft moves the caret as usual. + expect(stepCaretAcrossStyledEdge(outside, -1)).toBeNull(); + + const back = outside.apply(stepCaretAcrossStyledEdge(outside, 1)!); + expect(typed(back, "x")).toBe("**xbold** tail"); + }); + + it("lets the caret step out after bold that ends the line", () => { + const inside = stateAt("head **bold**", 10); + expect(caretTakesMarksBefore(inside)).toBe(true); + expect(typed(inside, "x")).toBe("head **boldx**"); + expect(stepCaretAcrossStyledEdge(inside, -1)).toBeNull(); + + const outside = inside.apply(stepCaretAcrossStyledEdge(inside, 1)!); + expect(caretTakesMarksBefore(outside)).toBe(false); + expect(typed(outside, "x")).toBe("head **bold**x"); + }); + + it("offers both stops where styled text meets plain text mid-line", () => { + // Caret after "a ": types plain until ArrowRight steps into the bold. + const plain = stateAt("a **b** c", 3); + expect(caretTakesMarksBefore(plain)).toBe(true); + expect(typed(plain, "x")).toBe("a x**b** c"); + const bold = plain.apply(stepCaretAcrossStyledEdge(plain, 1)!); + expect(typed(bold, "x")).toBe("a **xb** c"); + }); + + it("offers the plain stop between bold text and a chip right after it", () => { + // Markdown needs a space before a mention, but deleting it leaves them adjacent. + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, [ + schema.text("bold", [schema.marks.bold!.create()]), + schema.nodes["composer-mention"]!.create({ path: "README.md", source: "@README.md" }), + ]), + ]); + const inside = EditorState.create({ doc, selection: TextSelection.create(doc, 5) }); + expect(caretTakesMarksBefore(inside)).toBe(true); + const outside = inside.apply(stepCaretAcrossStyledEdge(inside, 1)!); + expect(caretTakesMarksBefore(outside)).toBe(false); + const withText = outside.apply(outside.tr.insertText("x")).doc; + expect(withText.child(0).child(1).text).toBe("x"); + expect(withText.child(0).child(1).marks).toEqual([]); + }); + + it("steps out of inline code at the end of a line without inserting a space", () => { + expect(ComposerCodeExtension.config.exitable).toBe(false); + const inside = stateAt("`code`", 5); + const outside = inside.apply(stepCaretAcrossStyledEdge(inside, 1)!); + expect(typed(outside, "x")).toBe("`code`x"); + expect(stepCaretAcrossStyledEdge(outside, 1)).toBeNull(); + }); + + it("keeps marks the user toggled at an edge and lets the arrow move", () => { + const inside = stateAt("a **b** c", 3); + const toggled = inside.apply( + inside.tr.setStoredMarks([schema.marks.bold!.create(), schema.marks.italic!.create()]), + ); + expect(stepCaretAcrossStyledEdge(toggled, 1)).toBeNull(); + expect(stepCaretAcrossStyledEdge(toggled, -1)).toBeNull(); + }); + + it("leaves arrow keys alone away from styled edges", () => { + expect(stepCaretAcrossStyledEdge(stateAt("**bold** tail", 3), -1)).toBeNull(); + expect(stepCaretAcrossStyledEdge(stateAt("plain text", 1), -1)).toBeNull(); + expect(caretTakesMarksBefore(stateAt("plain text", 1))).toBe(false); + }); +}); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index ed83c35b5f5b..49a489c82ad4 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1,4 +1,5 @@ -import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Mark, type Node as ProseMirrorNode } from "@tiptap/pm/model"; +import type { EditorState, Transaction } from "@tiptap/pm/state"; import { Code } from "@tiptap/extension-code"; import { TaskItem } from "@tiptap/extension-task-item"; @@ -47,7 +48,12 @@ const TIPTAP_TO_MARK: Record = { * spans markdown like `**\`x\`**` parses into and drops the whole insert. * Code nests inside emphasis here, so it only excludes itself like the rest. */ -export const ComposerCodeExtension = Code.extend({ excludes: "code" }); +export const ComposerCodeExtension = Code.extend({ + excludes: "code", + // ArrowRight leaves code through the caret stops at styled edges, so the + // stock exit (inserting a space at the end of a line) is not needed. + exitable: false, +}); /** * Task list items keep their exact source indent in an attribute so nesting @@ -650,3 +656,49 @@ export function pmToFlat(map: RichDocMap, pmPos: number): number { } return Math.max(0, Math.min(best, map.docLength)); } + +// ── Caret stops at styled edges ──────────────────────────────────────────── +// +// Markers are decorations, not text, so the position where styled text meets +// unstyled text (or a paragraph edge) is a single document position. The +// caret gets two stops there: one that types with the marks before the edge +// and one that types with the marks after it. Stored marks pick the stop, and +// the revealed markers render on the matching side of the caret, so a pasted +// `**bold**` at the start of a line can still be typed in front of. + +function styledEdge(state: EditorState) { + const { selection } = state; + if (!selection.empty) return null; + const { $from } = selection; + if (!$from.parent.inlineContent) return null; + const before = $from.nodeBefore?.marks ?? Mark.none; + const after = $from.nodeAfter?.marks ?? Mark.none; + if (Mark.sameSet(before, after)) return null; + return { before, after, current: state.storedMarks ?? $from.marks() }; +} + +/** True when the caret sits on a styled edge and types with the marks before it. */ +export function caretTakesMarksBefore(state: EditorState): boolean { + const edge = styledEdge(state); + return edge !== null && Mark.sameSet(edge.current, edge.before); +} + +/** + * Moves the caret to the other stop of the styled edge it sits on, toward + * `direction`. Returns null when there is no stop to take, so the arrow key + * moves the caret as usual. + */ +export function stepCaretAcrossStyledEdge( + state: EditorState, + direction: -1 | 1, +): Transaction | null { + const edge = styledEdge(state); + if (!edge) return null; + // Marks the user toggled by hand (neither stop) are theirs: move as usual. + if (!Mark.sameSet(edge.current, edge.before) && !Mark.sameSet(edge.current, edge.after)) { + return null; + } + const target = direction === -1 ? edge.before : edge.after; + if (Mark.sameSet(edge.current, target)) return null; + return state.tr.setStoredMarks(target); +} From a83340cef658bef81663385a9d20467dfa3e5a3f Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Tue, 6 Oct 2026 16:06:14 -0700 Subject: [PATCH 2/2] fix(web): replace superseded composer boundary plugin Use upstream's two caret stops as the single formatting boundary policy. Remove the competing plugin and retain paste, cut, history, and sustained formatting coverage under the upstream navigation semantics. Carry-Group: upstream-bugfixes Carry-Upstream: https://github.com/pingdotgg/t3code/pull/13217 head 42ea6ff617128871b9fcd975811caae91f10cae2 Carry-Supersedes: https://github.com/pingdotgg/t3code/pull/13518 head cc1d991af38464457ab1c104dc9d56356bd5aa52 Carry-Supersedes: https://github.com/lastobelus/lastCode/pull/295 Carry-Observation: Upstream closed 13518 in favor of 13217. Remove the old marks plugin so it cannot fight the two-stop policy; preserve this removal when the nightly absorbs 13217. --- .../components/ComposerPromptEditorTiptap.tsx | 2 - apps/web/src/composer-rich-text-doc.test.ts | 76 ++++++++ apps/web/src/composer-typing-marks.test.ts | 163 ------------------ apps/web/src/composer-typing-marks.ts | 38 ---- 4 files changed, 76 insertions(+), 203 deletions(-) delete mode 100644 apps/web/src/composer-typing-marks.test.ts delete mode 100644 apps/web/src/composer-typing-marks.ts diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 79c30685082e..6b7eb7df7066 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -64,7 +64,6 @@ import { groupUndoByChangeKind, markAsClipboardEdit, } from "~/composer-undo-grouping"; -import { ComposerTypingMarksExtension } from "~/composer-typing-marks"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; @@ -837,7 +836,6 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ComposerMarkersExtension, ...(richText ? [ - ComposerTypingMarksExtension, ComposerCodeExtension, TaskList, ComposerTaskItemExtension.extend({ diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 44f8e6d1f6d1..41b58277ceaa 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -1,6 +1,7 @@ import { getSchemaByResolvedExtensions, Node, resolveExtensions } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; import { TaskList } from "@tiptap/extension-task-list"; +import { history, redo, undo, undoDepth } from "@tiptap/pm/history"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { EditorState, TextSelection } from "@tiptap/pm/state"; import { describe, expect, it } from "vite-plus/test"; @@ -378,6 +379,81 @@ describe("caret stops at styled edges", () => { return serializeEditorDoc(state.apply(state.tr.insertText(text)).doc).value; } + function typeCharacters(state: EditorState, text: string) { + for (const character of text) state = state.apply(state.tr.insertText(character)); + return state; + } + + it("keeps typing plain after stepping outside pasted bold", () => { + let state = stateAt("", 1); + state = state.apply( + state.tr + .replaceSelectionWith(schema.text("bold", [schema.marks.bold!.create()]), false) + .setMeta("uiEvent", "paste"), + ); + state = state.apply(stepCaretAcrossStyledEdge(state, 1)!); + state = typeCharacters(state, " plain"); + expect(serializeEditorDoc(state.doc).value).toBe("**bold** plain"); + }); + + it("can step outside bold after cutting its trailing plain text", () => { + let state = stateAt("**b** plain", 8); + state = state.apply( + state.tr + .setSelection(TextSelection.create(state.doc, 2, 8)) + .deleteRange(2, 8) + .setMeta("uiEvent", "cut"), + ); + state = state.apply(stepCaretAcrossStyledEdge(state, 1)!); + state = typeCharacters(state, " again"); + expect(serializeEditorDoc(state.doc).value).toBe("**b** again"); + }); + + it("keeps caret steps out of history and can recover either stop after undo", () => { + let state = stateAt("**b**", 2).reconfigure({ plugins: [history()] }); + state = state.apply(stepCaretAcrossStyledEdge(state, 1)!); + expect(undoDepth(state)).toBe(0); + state = typeCharacters(state, " plain"); + expect(undoDepth(state)).toBe(1); + + expect( + undo(state, (transaction) => { + state = state.apply(transaction); + }), + ).toBe(true); + expect(serializeEditorDoc(state.doc).value).toBe("**b**"); + expect(state.selection.from).toBe(2); + expect(undoDepth(state)).toBe(0); + + expect( + redo(state, (transaction) => { + state = state.apply(transaction); + }), + ).toBe(true); + expect(serializeEditorDoc(state.doc).value).toBe("**b** plain"); + expect(state.selection.from).toBe(8); + + expect( + undo(state, (transaction) => { + state = state.apply(transaction); + }), + ).toBe(true); + expect(typed(state, "x")).toBe("**bx**"); + state = state.apply(stepCaretAcrossStyledEdge(state, 1)!); + expect(typed(state, "x")).toBe("**b**x"); + }); + + it("sustains explicit formatting toggles across several characters", () => { + let state = stateAt("**b**", 2); + state = state.apply(stepCaretAcrossStyledEdge(state, 1)!); + state = state.apply(state.tr.addStoredMark(schema.marks.bold!.create())); + state = typeCharacters(state, "xy"); + expect(serializeEditorDoc(state.doc).value).toBe("**bxy**"); + state = state.apply(state.tr.removeStoredMark(schema.marks.bold!)); + state = typeCharacters(state, " plain"); + expect(serializeEditorDoc(state.doc).value).toBe("**bxy** plain"); + }); + it("lets the caret step out in front of bold that starts the line", () => { const inside = stateAt("**bold** tail", 1); expect(caretTakesMarksBefore(inside)).toBe(false); diff --git a/apps/web/src/composer-typing-marks.test.ts b/apps/web/src/composer-typing-marks.test.ts deleted file mode 100644 index e6026874a2d0..000000000000 --- a/apps/web/src/composer-typing-marks.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { getSchema } from "@tiptap/core"; -import StarterKit from "@tiptap/starter-kit"; -import { history, redo, undo } from "@tiptap/pm/history"; -import { EditorState, TextSelection } from "@tiptap/pm/state"; -import { describe, expect, it } from "vite-plus/test"; - -import { buildDocJson, ComposerCodeExtension, serializeEditorDoc } from "./composer-rich-text-doc"; -import { createComposerTypingMarksPlugin } from "./composer-typing-marks"; - -const schema = getSchema([StarterKit.configure({ code: false }), ComposerCodeExtension]); - -function createState(value: string, cursor: number) { - const doc = schema.nodeFromJSON(buildDocJson(value, () => ({ label: "", description: null }))); - return EditorState.create({ - doc, - selection: TextSelection.create(doc, cursor), - plugins: [createComposerTypingMarksPlugin(), history()], - }); -} - -function move(state: EditorState, cursor: number) { - return state.applyTransaction(state.tr.setSelection(TextSelection.create(state.doc, cursor))) - .state; -} - -function type(state: EditorState, text: string) { - for (const character of text) - state = state.applyTransaction(state.tr.insertText(character)).state; - return state; -} - -function value(state: EditorState) { - return serializeEditorDoc(state.doc).value; -} - -describe("composer typing marks after moving the caret", () => { - it.each(["**b**", "*b*", "~~b~~", "`b`", "***b***", "**`b`**"])( - "types plain text after navigating away from and back to %s", - (markdown) => { - let state = createState(markdown, 2); - state = state.applyTransaction(state.tr.setStoredMarks([])).state; - state = move(move(state, 1), 2); - expect(value(type(state, " plain"))).toBe(`${markdown} plain`); - }, - ); - - it("covers caret placement after a controlled rewrite, including an unchanged position", () => { - let state = createState("x", 2); - const content = schema.nodeFromJSON( - buildDocJson("**b**", () => ({ label: "", description: null })), - ); - state = state.applyTransaction( - state.tr.replaceWith(0, state.doc.content.size, content.content), - ).state; - state = move(state, 2); - expect(value(type(state, " plain"))).toBe("**b** plain"); - }); - - it("resets typing marks after a paste and subsequent navigation", () => { - let state = createState("", 1); - const content = schema.nodeFromJSON( - buildDocJson("**b**", () => ({ label: "", description: null })), - ); - state = state.applyTransaction( - state.tr.replaceSelectionWith(content.firstChild!, false).setMeta("uiEvent", "paste"), - ).state; - expect(value(type(state, " plain"))).toBe("**b** plain"); - state = move(move(state, 1), 2); - expect(value(type(state, " plain"))).toBe("**b** plain"); - }); - - it("continues the following span after pasting in the middle of a line", () => { - let state = createState("tail", 1); - state = state.applyTransaction( - state.tr - .insertText("b") - .addMark(1, 2, schema.marks.bold!.create()) - .setMeta("uiEvent", "paste"), - ).state; - expect(value(type(state, "xy"))).toBe("**b**xytail"); - }); - - it("types plain after cutting back to a closing boundary", () => { - let state = createState("**b** plain", 8); - state = state.applyTransaction( - state.tr - .setSelection(TextSelection.create(state.doc, 2, 8)) - // Tiptap's deleteSelection command uses deleteRange without ensureMarks. - .deleteRange(2, 8) - .setMeta("uiEvent", "cut"), - ).state; - expect(value(state)).toBe("**b**"); - expect(value(type(state, " plain"))).toBe("**b** plain"); - }); - - it("preserves formatting while typing inside a span", () => { - const state = move(createState("**bold**", 5), 3); - expect(value(type(state, "xy"))).toBe("**boxyld**"); - }); - - it("adopts following text marks at a boundary", () => { - const state = move(createState("**b***tail*", 6), 2); - expect(value(type(state, "xy"))).toBe("**b***xytail*"); - }); - - it("matches the opening marker when placing the caret before formatted text", () => { - const state = move(createState("x **bold**", 7), 3); - expect(value(type(state, "xy"))).toBe("x **xybold**"); - }); - - it("preserves explicit formatting on and off over several keystrokes", () => { - let state = move(createState("**b**", 2), 2); - state = state.applyTransaction(state.tr.addStoredMark(schema.marks.bold!.create())).state; - state = type(state, "xy"); - expect(value(state)).toBe("**bxy**"); - state = state.applyTransaction(state.tr.removeStoredMark(schema.marks.bold!)).state; - expect(value(type(state, " plain"))).toBe("**bxy** plain"); - }); - - it("preserves an explicit mark decision in a selection transaction", () => { - let state = createState("**b**", 1); - state = state.applyTransaction( - state.tr - .setSelection(TextSelection.create(state.doc, 2)) - .addStoredMark(schema.marks.italic!.create()), - ).state; - expect(value(type(state, "xy"))).toBe("**b*xy***"); - }); - - it("does not alter ranged selection replacement", () => { - let state = createState("**bold**", 5); - state = state.applyTransaction( - state.tr.setSelection(TextSelection.create(state.doc, 2, 4)), - ).state; - expect(value(type(state, "x"))).toBe("**bxd**"); - }); - - it("leaves normal typing transactions alone", () => { - const state = createState("**b**", 2); - expect(value(type(state, "xy"))).toBe("**bxy**"); - expect(state.applyTransaction(state.tr.scrollIntoView()).transactions).toHaveLength(1); - }); - - it("adds no undo step and preserves undo/redo content and selection", () => { - let state = move(move(createState("**b**", 2), 1), 2); - state = type(state, " plain"); - expect( - undo(state, (transaction) => { - state = state.applyTransaction(transaction).state; - }), - ).toBe(true); - expect(value(state)).toBe("**b**"); - expect(state.selection.from).toBe(2); - expect(value(type(state, " again"))).toBe("**b** again"); - expect( - redo(state, (transaction) => { - state = state.applyTransaction(transaction).state; - }), - ).toBe(true); - expect(value(state)).toBe("**b** plain"); - expect(state.selection.from).toBe(8); - }); -}); diff --git a/apps/web/src/composer-typing-marks.ts b/apps/web/src/composer-typing-marks.ts deleted file mode 100644 index 7607f1b961ce..000000000000 --- a/apps/web/src/composer-typing-marks.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Extension } from "@tiptap/core"; -import { isHistoryTransaction } from "@tiptap/pm/history"; -import { Mark } from "@tiptap/pm/model"; -import { Plugin } from "@tiptap/pm/state"; - -/** - * Follow the text on the right when moving the caret, or type plain at a line end. - * Keep inclusive marks during edits so explicit formatting shortcuts continue typing. - */ -export function createComposerTypingMarksPlugin() { - return new Plugin({ - appendTransaction(transactions, _oldState, state) { - if ( - !state.selection.empty || - state.storedMarks !== null || - !transactions.some( - (transaction) => - (transaction.selectionSet && !transaction.docChanged) || - isHistoryTransaction(transaction) || - transaction.getMeta("uiEvent") === "paste" || - transaction.getMeta("uiEvent") === "cut", - ) - ) { - return null; - } - const marks = state.selection.$from.nodeAfter?.marks ?? []; - if (Mark.sameSet(marks, state.selection.$from.marks())) return null; - return state.tr.setStoredMarks(marks); - }, - }); -} - -export const ComposerTypingMarksExtension = Extension.create({ - name: "composer-typing-marks", - addProseMirrorPlugins() { - return [createComposerTypingMarksPlugin()]; - }, -});