Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 22 additions & 11 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -47,13 +47,15 @@ import {
buildDocJson,
buildTiptapContent,
collapsedToFlat,
caretTakesMarksBefore,
ComposerCodeExtension,
ComposerTaskItemExtension,
flatToCollapsed,
flatToMarkdown,
flatToPm,
pmToFlat,
serializeEditorDoc,
stepCaretAcrossStyledEdge,
type SkillMeta,
} from "~/composer-rich-text-doc";
import {
Expand All @@ -62,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";
Expand Down Expand Up @@ -522,9 +523,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) {
Expand All @@ -533,10 +534,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) => {
Expand All @@ -554,7 +556,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;
Expand Down Expand Up @@ -833,7 +836,6 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
ComposerMarkersExtension,
...(richText
? [
ComposerTypingMarksExtension,
ComposerCodeExtension,
TaskList,
ComposerTaskItemExtension.extend({
Expand Down Expand Up @@ -921,6 +923,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();
Expand Down
170 changes: 170 additions & 0 deletions apps/web/src/composer-rich-text-doc.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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";

import {
buildDocJson,
caretTakesMarksBefore,
collapsedToFlat,
ComposerCodeExtension,
ComposerTaskItemExtension,
Expand All @@ -14,6 +17,7 @@ import {
flatToPm,
pmToFlat,
serializeEditorDoc,
stepCaretAcrossStyledEdge,
} from "./composer-rich-text-doc";

function stubAtom(name: string, attrs: Record<string, { default: unknown }>) {
Expand Down Expand Up @@ -361,3 +365,169 @@ 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;
}

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);
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);
});
});
56 changes: 54 additions & 2 deletions apps/web/src/composer-rich-text-doc.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -47,7 +48,12 @@ const TIPTAP_TO_MARK: Record<string, RichTextMark> = {
* 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
Expand Down Expand Up @@ -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);
}
Loading