Skip to content

Commit 28b31f7

Browse files
committed
fix: scope source projection to exact mark segments
1 parent 4343d23 commit 28b31f7

2 files changed

Lines changed: 123 additions & 43 deletions

File tree

src/features/editor/plugins/sourceProjection.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,46 @@ describe("source projection", () => {
290290
).toHaveTextContent("Link");
291291
});
292292

293+
it("projects only the exact mark combination around the caret", async () => {
294+
const mounted = await mountProjectionEditor("***Bold and italic***");
295+
296+
enterProjection(mounted, "strong");
297+
298+
const selectionFrom = getEditorTextPosition(mounted, "and");
299+
300+
setTextSelection(mounted.view, selectionFrom, selectionFrom + "and".length);
301+
302+
expect(runEditorCommand(mounted.editor, "format.strong")).toBe(true);
303+
expect(hasActiveSourceProjection(mounted.view.state)).toBe(false);
304+
305+
const expectedTargets = [
306+
{ documentText: "***Bold*** and italic", word: "Bold" },
307+
{ documentText: "Bold *and* italic", word: "and" },
308+
{ documentText: "Bold and ***italic***", word: "italic" },
309+
] as const;
310+
311+
for (const { documentText, word } of expectedTargets) {
312+
const caretPosition = getEditorTextPosition(mounted, word) + 1;
313+
314+
setTextSelection(mounted.view, caretPosition);
315+
316+
expect(hasActiveSourceProjection(mounted.view.state)).toBe(true);
317+
expect(getEditorTextContent(mounted)).toBe(documentText);
318+
319+
setSelectionAtDocumentEnd(mounted.view);
320+
}
321+
});
322+
323+
it("keeps mixed-format link labels under one unprojected link owner", async () => {
324+
const mounted = await mountProjectionEditor("[**Bold** and *soft*](https://example.com)");
325+
const link = getEditorDomElement(mounted, "a");
326+
327+
setSelectionAtElementTextEnd(mounted.view, link);
328+
329+
expect(hasActiveSourceProjection(mounted.view.state)).toBe(false);
330+
expect(getEditorTextContent(mounted)).toBe("Bold and soft");
331+
});
332+
293333
it("restores the exact original document after a clean projection", async () => {
294334
const mounted = await mountProjectionEditor(
295335
'**[Strong Link](https://example.com "Title")** plain',

src/features/editor/utils/sourceProjectionAdapters.ts

Lines changed: 83 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
1-
import { Fragment, Slice, type Node as ProseMirrorNode } from "@milkdown/kit/prose/model";
1+
import { Fragment, Mark, Slice, type Node as ProseMirrorNode } from "@milkdown/kit/prose/model";
22
import type { EditorState, Selection, Transaction } from "@milkdown/kit/prose/state";
33

44
import { isNonNullish } from "@/lib/predicates";
55

6-
import {
7-
getCandidateMarksAtSelection,
8-
getMarkRangeAtSelection,
9-
type ActiveMarkRange,
10-
} from "./marks";
6+
import { getCandidateMarksAtSelection, getMarkRangeAtSelection } from "./marks";
117
import { isTextCaretSelection } from "./selections";
128
import {
13-
areProjectionMarksEqual,
149
createProjectionMarkDescriptor,
1510
createProjectionSource,
1611
getProjectionDelimiterBounds,
@@ -110,10 +105,14 @@ export interface SourceProjectionInsertionMatch {
110105
candidate: SourceProjectionInsertionCandidate;
111106
}
112107

113-
interface ActiveProjectionRange extends ActiveMarkRange {
108+
interface ActiveProjectionRange extends TextRange {
114109
marks: ProjectionMarkDescriptor[];
115110
}
116111

112+
interface ProjectionMarkSegment extends ActiveProjectionRange {
113+
documentMarks: readonly Mark[];
114+
}
115+
117116
const createTextSlice = (
118117
state: EditorState,
119118
text: string,
@@ -190,8 +189,31 @@ const getActiveProjectionMarkRange = (state: EditorState): ActiveProjectionRange
190189
}
191190

192191
const candidateMarks = getCandidateMarksAtSelection(state);
192+
const linkType = state.schema.marks.link;
193+
const activeLink = linkType
194+
? (candidateMarks.find((mark) => mark.type === linkType) ?? null)
195+
: null;
196+
const segments = getProjectionMarkSegments(state);
197+
198+
if (activeLink) {
199+
const linkRange = getMarkRangeAtSelection(state, activeLink);
200+
201+
if (!linkRange) {
202+
return null;
203+
}
204+
205+
const uniformLinkSegment = segments.find(
206+
(segment) => segment.from === linkRange.from && segment.to === linkRange.to,
207+
);
208+
209+
return uniformLinkSegment ? getActiveProjectionRange(uniformLinkSegment) : null;
210+
}
193211

194212
for (const markName of SUPPORTED_PROJECTION_MARK_NAMES) {
213+
if (markName === "link") {
214+
continue;
215+
}
216+
195217
const markType = state.schema.marks[markName];
196218
if (!markType) {
197219
continue;
@@ -203,57 +225,75 @@ const getActiveProjectionMarkRange = (state: EditorState): ActiveProjectionRange
203225
continue;
204226
}
205227

206-
const range = getMarkRangeAtSelection(state, activeMark);
207-
if (!range) {
208-
continue;
209-
}
210-
211-
const marks = getProjectionMarksForRange(state, range);
228+
const segment = segments.find(
229+
({ documentMarks, from, to }) =>
230+
from <= selection.from && selection.from <= to && activeMark.isInSet(documentMarks),
231+
);
212232

213-
if (marks) {
214-
return {
215-
...range,
216-
marks,
217-
};
233+
if (segment) {
234+
return getActiveProjectionRange(segment);
218235
}
219236
}
220237

221238
return null;
222239
};
223240

224-
const getProjectionMarksForRange = (
225-
state: EditorState,
226-
range: ActiveMarkRange,
227-
): ProjectionMarkDescriptor[] | null => {
228-
let supportedMarks: ProjectionMarkDescriptor[] | null = null;
241+
const getActiveProjectionRange = ({ from, marks, to }: ProjectionMarkSegment) => ({
242+
from,
243+
marks,
244+
to,
245+
});
229246

230-
state.doc.nodesBetween(range.from, range.to, (node) => {
231-
if (node.isText) {
232-
const projectionMarks = getProjectionMarksFromTextNode(node);
233-
234-
if (
235-
!projectionMarks.length ||
236-
!projectionMarks.some((mark) => mark.markName === range.mark.type.name) ||
237-
(supportedMarks && !areProjectionMarksEqual(supportedMarks, projectionMarks))
238-
) {
239-
supportedMarks = null;
240-
return false;
241-
}
247+
const getProjectionMarkSegments = (state: EditorState): ProjectionMarkSegment[] => {
248+
const { $from } = state.selection;
249+
250+
if (!$from.parent.isTextblock) {
251+
return [];
252+
}
242253

243-
supportedMarks ??= projectionMarks;
254+
const parentStart = $from.start();
255+
const segments: ProjectionMarkSegment[] = [];
244256

245-
return true;
257+
$from.parent.forEach((node, offset) => {
258+
if (!node.isText) {
259+
return;
246260
}
247261

248-
if (node.isInline) {
249-
supportedMarks = null;
250-
return false;
262+
const marks = getProjectionMarksFromTextNode(node);
263+
264+
if (!marks.length) {
265+
return;
251266
}
252267

253-
return true;
268+
const from = parentStart + offset;
269+
const previousSegment = segments.at(-1);
270+
271+
if (previousSegment?.to === from && Mark.sameSet(previousSegment.documentMarks, node.marks)) {
272+
previousSegment.to = from + node.nodeSize;
273+
return;
274+
}
275+
276+
segments.push({
277+
documentMarks: node.marks,
278+
from,
279+
marks,
280+
to: from + node.nodeSize,
281+
});
254282
});
255283

256-
return supportedMarks;
284+
return segments.flatMap((segment) => {
285+
if (segment.marks.some((mark) => mark.markName === "link")) {
286+
return [segment];
287+
}
288+
289+
const text = getRangeText(state.doc, segment);
290+
const leadingWhitespaceLength = /^\s+/u.exec(text)?.[0].length ?? 0;
291+
const trailingWhitespaceLength = /\s+$/u.exec(text)?.[0].length ?? 0;
292+
const from = segment.from + leadingWhitespaceLength;
293+
const to = segment.to - trailingWhitespaceLength;
294+
295+
return from < to ? [{ ...segment, from, to }] : [];
296+
});
257297
};
258298

259299
const getProjectionMarksFromTextNode = (node: ProseMirrorNode): ProjectionMarkDescriptor[] => {

0 commit comments

Comments
 (0)