Skip to content

Commit 688d767

Browse files
nperez0111claude
andcommitted
refactor: simplify link API — move operations to StyleManager, rewrite conversion logic
- Remove unused setLink/toggleLink/unsetLink TipTap commands from Link mark extension - Move editLink/deleteLink/getLinkMarkAtPos from LinkToolbar into StyleManager, exposing them as public API on BlockNoteEditor - LinkToolbar now delegates to editor API instead of doing raw mark operations - Rewrite contentNodeToInlineContent as a two-pass flatten-then-merge approach, replacing ~200 lines of nested state machine - Simplify linkToNodes in blockToNode.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 989fb85 commit 688d767

File tree

13 files changed

+380
-450
lines changed

13 files changed

+380
-450
lines changed

packages/core/src/api/nodeConversions/blockToNode.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -80,28 +80,20 @@ function styledTextToNodes<T extends StyleSchema>(
8080

8181
/**
8282
* Converts a Link inline content element to
83-
* prosemirror text nodes with the appropriate marks
83+
* prosemirror text nodes with the link mark applied.
8484
*/
8585
function linkToNodes(
8686
link: PartialLink<StyleSchema>,
8787
schema: Schema,
8888
styleSchema: StyleSchema,
8989
): Node[] {
90-
const linkMark = schema.marks.link.create({
91-
href: link.href,
92-
});
90+
const linkMark = schema.marks.link.create({ href: link.href });
9391

9492
return styledTextArrayToNodes(link.content, schema, styleSchema).map(
95-
(node) => {
96-
if (node.type.name === "text") {
97-
return node.mark([...node.marks, linkMark]);
98-
}
99-
100-
if (node.type.name === "hardBreak") {
101-
return node;
102-
}
103-
throw new Error("unexpected node type");
104-
},
93+
(node) =>
94+
node.type.name === "text"
95+
? node.mark([...node.marks, linkMark])
96+
: node,
10597
);
10698
}
10799

packages/core/src/api/nodeConversions/nodeToBlock.ts

Lines changed: 113 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Mark, Node, Schema, Slice } from "@tiptap/pm/model";
1+
import { Node, Schema, Slice } from "@tiptap/pm/model";
22
import type { Block } from "../../blocks/defaultBlocks.js";
33
import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js";
44
import type {
@@ -135,206 +135,147 @@ export function contentNodeToTableContent<
135135
return ret;
136136
}
137137

138+
/**
139+
* Extract styles from a PM node's marks, separating link href from style marks.
140+
*/
141+
function extractMarks<S extends StyleSchema>(
142+
node: Node,
143+
styleSchema: S,
144+
): { styles: Styles<S>; href: string | undefined } {
145+
const styles: Styles<S> = {};
146+
let href: string | undefined;
147+
148+
for (const mark of node.marks) {
149+
if (mark.type.name === "link") {
150+
href = mark.attrs.href;
151+
} else {
152+
const config = styleSchema[mark.type.name];
153+
if (!config) {
154+
if (mark.type.spec.blocknoteIgnore) {
155+
continue;
156+
}
157+
throw new Error(`style ${mark.type.name} not found in styleSchema`);
158+
}
159+
if (config.propSchema === "boolean") {
160+
(styles as any)[config.type] = true;
161+
} else if (config.propSchema === "string") {
162+
(styles as any)[config.type] = mark.attrs.stringValue;
163+
} else {
164+
throw new UnreachableCaseError(config.propSchema);
165+
}
166+
}
167+
}
168+
169+
return { styles, href };
170+
}
171+
172+
// A flattened record representing one PM text node's contribution.
173+
type FlatTextRecord<S extends StyleSchema> = {
174+
kind: "text";
175+
text: string;
176+
styles: Styles<S>;
177+
href: string | undefined;
178+
};
179+
180+
type FlatRecord<S extends StyleSchema> =
181+
| FlatTextRecord<S>
182+
| { kind: "custom"; node: Node };
183+
138184
/**
139185
* Converts an internal (prosemirror) content node to a BlockNote InlineContent array.
186+
*
187+
* Two-pass approach:
188+
* 1. Flatten each PM child node into a simple record (text + styles + optional href, or custom node)
189+
* 2. Merge consecutive records with the same href/styles into StyledText or Link objects
140190
*/
141191
export function contentNodeToInlineContent<
142192
I extends InlineContentSchema,
143193
S extends StyleSchema,
144194
>(contentNode: Node, inlineContentSchema: I, styleSchema: S) {
145-
const content: InlineContent<any, S>[] = [];
146-
let currentContent: InlineContent<any, S> | undefined = undefined;
195+
// Pass 1: Flatten PM nodes into records
196+
const records: FlatRecord<S>[] = [];
147197

148-
// Most of the logic below is for handling links because in ProseMirror links are marks
149-
// while in BlockNote links are a type of inline content
150198
contentNode.content.forEach((node) => {
151-
// hardBreak nodes do not have an InlineContent equivalent, instead we
152-
// add a newline to the previous node.
153199
if (node.type.name === "hardBreak") {
154-
if (currentContent) {
155-
// Current content exists.
156-
if (isStyledTextInlineContent(currentContent)) {
157-
// Current content is text.
158-
currentContent.text += "\n";
159-
} else if (isLinkInlineContent(currentContent)) {
160-
// Current content is a link.
161-
currentContent.content[currentContent.content.length - 1].text +=
162-
"\n";
163-
} else {
164-
throw new Error("unexpected");
165-
}
200+
// Append newline to the previous text record, or create one
201+
const last = records[records.length - 1];
202+
if (last && last.kind === "text") {
203+
last.text += "\n";
166204
} else {
167-
// Current content does not exist.
168-
currentContent = {
169-
type: "text",
205+
records.push({
206+
kind: "text",
170207
text: "\n",
171-
styles: {},
172-
};
208+
styles: {} as Styles<S>,
209+
href: undefined,
210+
});
173211
}
174-
175212
return;
176213
}
177214

178-
if (node.type.name !== "link" && node.type.name !== "text") {
179-
if (!inlineContentSchema[node.type.name]) {
180-
// eslint-disable-next-line no-console
181-
console.warn("unrecognized inline content type", node.type.name);
182-
return;
183-
}
184-
if (currentContent) {
185-
content.push(currentContent);
186-
currentContent = undefined;
187-
}
188-
189-
content.push(
190-
nodeToCustomInlineContent(node, inlineContentSchema, styleSchema),
191-
);
215+
if (node.type.name === "text") {
216+
const { styles, href } = extractMarks(node, styleSchema);
217+
records.push({ kind: "text", text: node.textContent, styles, href });
218+
return;
219+
}
192220

221+
// Custom inline content node
222+
if (!inlineContentSchema[node.type.name]) {
223+
// eslint-disable-next-line no-console
224+
console.warn("unrecognized inline content type", node.type.name);
193225
return;
194226
}
227+
records.push({ kind: "custom", node });
228+
});
195229

196-
const styles: Styles<S> = {};
197-
let linkMark: Mark | undefined;
230+
// Pass 2: Merge consecutive text records into StyledText / Link
231+
const content: InlineContent<any, S>[] = [];
198232

199-
for (const mark of node.marks) {
200-
if (mark.type.name === "link") {
201-
linkMark = mark;
202-
} else {
203-
const config = styleSchema[mark.type.name];
204-
if (!config) {
205-
if (mark.type.spec.blocknoteIgnore) {
206-
// at this point, we don't want to show certain marks (such as comments)
207-
// in the BlockNote JSON output. These marks should be tagged with "blocknoteIgnore" in the spec
208-
continue;
209-
}
210-
throw new Error(`style ${mark.type.name} not found in styleSchema`);
211-
}
212-
if (config.propSchema === "boolean") {
213-
(styles as any)[config.type] = true;
214-
} else if (config.propSchema === "string") {
215-
(styles as any)[config.type] = mark.attrs.stringValue;
216-
} else {
217-
throw new UnreachableCaseError(config.propSchema);
218-
}
219-
}
233+
for (const record of records) {
234+
if (record.kind === "custom") {
235+
content.push(
236+
nodeToCustomInlineContent(record.node, inlineContentSchema, styleSchema),
237+
);
238+
continue;
220239
}
221240

222-
// Parsing links and text.
223-
// Current content exists.
224-
if (currentContent) {
225-
// Current content is text.
226-
if (isStyledTextInlineContent(currentContent)) {
227-
if (!linkMark) {
228-
// Node is text (same type as current content).
229-
if (
230-
JSON.stringify(currentContent.styles) === JSON.stringify(styles)
231-
) {
232-
// Styles are the same.
233-
currentContent.text += node.textContent;
234-
} else {
235-
// Styles are different.
236-
content.push(currentContent);
237-
currentContent = {
238-
type: "text",
239-
text: node.textContent,
240-
styles,
241-
};
242-
}
241+
const { text, styles, href } = record;
242+
const stylesKey = JSON.stringify(styles);
243+
const last = content[content.length - 1];
244+
245+
if (href !== undefined) {
246+
// This text belongs to a link
247+
if (
248+
last &&
249+
isLinkInlineContent(last) &&
250+
last.href === href
251+
) {
252+
// Same link — try to merge with the last StyledText inside it
253+
const lastChild = last.content[last.content.length - 1];
254+
if (JSON.stringify(lastChild.styles) === stylesKey) {
255+
lastChild.text += text;
243256
} else {
244-
// Node is a link (different type to current content).
245-
content.push(currentContent);
246-
currentContent = {
247-
type: "link",
248-
href: linkMark.attrs.href,
249-
content: [
250-
{
251-
type: "text",
252-
text: node.textContent,
253-
styles,
254-
},
255-
],
256-
};
257-
}
258-
} else if (isLinkInlineContent(currentContent)) {
259-
// Current content is a link.
260-
if (linkMark) {
261-
// Node is a link (same type as current content).
262-
// Link URLs are the same.
263-
if (currentContent.href === linkMark.attrs.href) {
264-
// Styles are the same.
265-
if (
266-
JSON.stringify(
267-
currentContent.content[currentContent.content.length - 1]
268-
.styles,
269-
) === JSON.stringify(styles)
270-
) {
271-
currentContent.content[currentContent.content.length - 1].text +=
272-
node.textContent;
273-
} else {
274-
// Styles are different.
275-
currentContent.content.push({
276-
type: "text",
277-
text: node.textContent,
278-
styles,
279-
});
280-
}
281-
} else {
282-
// Link URLs are different.
283-
content.push(currentContent);
284-
currentContent = {
285-
type: "link",
286-
href: linkMark.attrs.href,
287-
content: [
288-
{
289-
type: "text",
290-
text: node.textContent,
291-
styles,
292-
},
293-
],
294-
};
295-
}
296-
} else {
297-
// Node is text (different type to current content).
298-
content.push(currentContent);
299-
currentContent = {
300-
type: "text",
301-
text: node.textContent,
302-
styles,
303-
};
257+
last.content.push({ type: "text", text, styles });
304258
}
305259
} else {
306-
// TODO
307-
}
308-
}
309-
// Current content does not exist.
310-
else {
311-
// Node is text.
312-
if (!linkMark) {
313-
currentContent = {
314-
type: "text",
315-
text: node.textContent,
316-
styles,
317-
};
318-
}
319-
// Node is a link.
320-
else {
321-
currentContent = {
260+
// New link
261+
content.push({
322262
type: "link",
323-
href: linkMark.attrs.href,
324-
content: [
325-
{
326-
type: "text",
327-
text: node.textContent,
328-
styles,
329-
},
330-
],
331-
};
263+
href,
264+
content: [{ type: "text", text, styles }],
265+
});
266+
}
267+
} else {
268+
// Plain text
269+
if (
270+
last &&
271+
isStyledTextInlineContent(last) &&
272+
JSON.stringify(last.styles) === stylesKey
273+
) {
274+
last.text += text;
275+
} else {
276+
content.push({ type: "text", text, styles });
332277
}
333278
}
334-
});
335-
336-
if (currentContent) {
337-
content.push(currentContent);
338279
}
339280

340281
return content as InlineContent<I, S>[];

packages/core/src/editor/BlockNoteEditor.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,6 +1093,32 @@ export class BlockNoteEditor<
10931093
this._styleManager.createLink(url, text);
10941094
}
10951095

1096+
/**
1097+
* Find the link mark and its range at the given position.
1098+
* Returns undefined if there is no link at that position.
1099+
*/
1100+
public getLinkMarkAtPos(pos: number) {
1101+
return this._styleManager.getLinkMarkAtPos(pos);
1102+
}
1103+
1104+
/**
1105+
* Updates the link at the given position with a new URL and text.
1106+
* @param url The new link URL.
1107+
* @param text The new text to display.
1108+
* @param position The position inside the link to edit. Defaults to the current selection anchor.
1109+
*/
1110+
public editLink(url: string, text: string, position?: number) {
1111+
this._styleManager.editLink(url, text, position);
1112+
}
1113+
1114+
/**
1115+
* Removes the link at the given position, keeping the text.
1116+
* @param position The position inside the link to remove. Defaults to the current selection anchor.
1117+
*/
1118+
public deleteLink(position?: number) {
1119+
this._styleManager.deleteLink(position);
1120+
}
1121+
10961122
/**
10971123
* Checks if the block containing the text cursor can be nested.
10981124
*/

0 commit comments

Comments
 (0)