Skip to content

Commit ba40ae5

Browse files
committed
fix: onLinkDetect event recalls
1 parent 8e1b476 commit ba40ae5

3 files changed

Lines changed: 116 additions & 63 deletions

File tree

.playwright/tests/links.spec.ts

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ test('link mark round-trips in serialized HTML', async ({ page }) => {
7979

8080
await expect
8181
.poll(async () => getSerializedHtml(page))
82-
.toContain('<a href="https://example.com">Example</a>');
82+
.toContain('<a href="https://example.com" data-auto="false">Example</a>');
8383
});
8484

8585
test.describe('test-links setLink table', () => {
@@ -99,7 +99,8 @@ test.describe('test-links setLink table', () => {
9999
end: '11',
100100
text: 'world',
101101
url: 'https://example.com',
102-
expectContains: '<p>Hello <a href="https://example.com">world</a></p>',
102+
expectContains:
103+
'<p>Hello <a href="https://example.com" data-auto="false">world</a></p>',
103104
},
104105
{
105106
name: 'wraps multiword phrase with spaces',
@@ -109,7 +110,7 @@ test.describe('test-links setLink table', () => {
109110
text: 'two three',
110111
url: 'https://multi.example',
111112
expectContains:
112-
'<p>one <a href="https://multi.example">two three</a></p>',
113+
'<p>one <a href="https://multi.example" data-auto="false">two three</a></p>',
113114
},
114115
{
115116
name: 'inserts linked text at cursor when start and end are the same',
@@ -118,7 +119,8 @@ test.describe('test-links setLink table', () => {
118119
end: '1',
119120
text: 'm',
120121
url: 'https://same-range.example',
121-
expectContains: '<p>x<a href="https://same-range.example">m</a>x</p>',
122+
expectContains:
123+
'<p>x<a href="https://same-range.example" data-auto="false">m</a>x</p>',
122124
},
123125
{
124126
name: 'setLink blocked when selection entirely in codeblock',
@@ -164,7 +166,8 @@ test.describe('test-links setLink table', () => {
164166
end: '99999',
165167
text: 'Z',
166168
url: 'https://clamp.setlink',
167-
expectContains: '<p><a href="https://clamp.setlink">Z</a></p>',
169+
expectContains:
170+
'<p><a href="https://clamp.setlink" data-auto="false">Z</a></p>',
168171
},
169172
{
170173
name: 'setLink wraps link text before inline image - keeps the image',
@@ -174,7 +177,7 @@ test.describe('test-links setLink table', () => {
174177
text: 'abc',
175178
url: 'https://example.com',
176179
expectContains:
177-
'<p><a href="https://example.com">abc</a><img src="" width="80" height="80"/></p>',
180+
'<p><a href="https://example.com" data-auto="false">abc</a><img src="" width="80" height="80"/></p>',
178181
},
179182
{
180183
name: 'wraps bold italic list item text with link',
@@ -184,7 +187,7 @@ test.describe('test-links setLink table', () => {
184187
text: 'styled',
185188
url: 'https://list-styled.example',
186189
expectContains:
187-
'<ul><li><a href="https://list-styled.example"><b><i>styled</i></b></a></li><li>hello</li></ul>',
190+
'<ul><li><a href="https://list-styled.example" data-auto="false"><b><i>styled</i></b></a></li><li>hello</li></ul>',
188191
},
189192
{
190193
name: 'inserts linked text at cursor at very start of first list item',
@@ -194,7 +197,7 @@ test.describe('test-links setLink table', () => {
194197
text: 'Link',
195198
url: 'https://cursor-list.example',
196199
expectContains:
197-
'<ul><li><a href="https://cursor-list.example">Link</a>first</li><li>second</li></ul>',
200+
'<ul><li><a href="https://cursor-list.example" data-auto="false">Link</a>first</li><li>second</li></ul>',
198201
},
199202
{
200203
name: 'inserts linked text at cursor inside empty list item',
@@ -204,7 +207,7 @@ test.describe('test-links setLink table', () => {
204207
text: 'Link',
205208
url: 'https://empty-list.example',
206209
expectContains:
207-
'<ul><li><a href="https://empty-list.example">Link</a></li></ul>',
210+
'<ul><li><a href="https://empty-list.example" data-auto="false">Link</a></li></ul>',
208211
},
209212
{
210213
name: 'wraps mixed bold and bold-italic text in list item with link as outermost mark',
@@ -214,7 +217,7 @@ test.describe('test-links setLink table', () => {
214217
text: 'abcd',
215218
url: 'https://mixed-marks.example',
216219
expectContains:
217-
'<ul><li><a href="https://mixed-marks.example"><b>ab<i>cd</i></b></a></li></ul>',
220+
'<ul><li><a href="https://mixed-marks.example" data-auto="false"><b>ab<i>cd</i></b></a></li></ul>',
218221
},
219222
{
220223
name: 'setLink replacement longer than selection applies marks from range start only',
@@ -224,7 +227,7 @@ test.describe('test-links setLink table', () => {
224227
text: 'abcdef',
225228
url: 'https://mixed-marks-longer.example',
226229
expectContains:
227-
'<ul><li><a href="https://mixed-marks-longer.example"><b>abcdef</b></a></li></ul>',
230+
'<ul><li><a href="https://mixed-marks-longer.example" data-auto="false"><b>abcdef</b></a></li></ul>',
228231
},
229232
];
230233

@@ -267,7 +270,8 @@ test.describe('test-links removeLink table', () => {
267270
html: '<html><p><a href="https://partial.test">abcde</a></p></html>',
268271
start: '0',
269272
end: '2',
270-
expectContains: '<p>ab<a href="https://partial.test">cde</a></p>',
273+
expectContains:
274+
'<p>ab<a href="https://partial.test" data-auto="false">cde</a></p>',
271275
},
272276
{
273277
name: 'no link: plain paragraph unchanged',
@@ -288,7 +292,7 @@ test.describe('test-links removeLink table', () => {
288292
html: '<html><p>prefix <a href="https://noop.rm">ab</a> tail</p></html>',
289293
start: '1000',
290294
end: '1000',
291-
expectContains: '<a href="https://noop.rm">ab</a>',
295+
expectContains: '<a href="https://noop.rm" data-auto="false">ab</a>',
292296
},
293297
];
294298

@@ -376,7 +380,9 @@ test.describe('test-links autolink', () => {
376380

377381
await expect
378382
.poll(async () => getTestLinksSerializedHtml(page))
379-
.toContain('<a href="https://example.com">https://example.com</a>');
383+
.toContain(
384+
'<a href="https://example.com" data-auto="true">https://example.com</a>'
385+
);
380386
});
381387

382388
test('creates link while typing with custom regex', async ({ page }) => {
@@ -392,7 +398,7 @@ test.describe('test-links autolink', () => {
392398

393399
await expect
394400
.poll(async () => getTestLinksSerializedHtml(page))
395-
.toContain('<a href="issue-123">issue-123</a>');
401+
.toContain('<a href="issue-123" data-auto="true">issue-123</a>');
396402
});
397403

398404
test('creates link when pasting plain URL with default regex', async ({
@@ -407,7 +413,9 @@ test.describe('test-links autolink', () => {
407413

408414
await expect
409415
.poll(async () => getTestLinksSerializedHtml(page))
410-
.toContain('<a href="https://example.com">https://example.com</a>');
416+
.toContain(
417+
'<a href="https://example.com" data-auto="true">https://example.com</a>'
418+
);
411419
});
412420

413421
test('does not autolink when link regex is disabled', async ({ page }) => {

.playwright/tests/strictMarks.spec.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,12 @@ test.describe('strict marks', () => {
307307
.poll(async () => {
308308
const html = await getSerializedHtml(page);
309309
return (
310-
html.includes('<a href="https://example.com">Hello</a>') &&
311-
!html.includes('<a href="https://example.com">Hello more</a>')
310+
html.includes(
311+
'<a href="https://example.com" data-auto="false">Hello</a>'
312+
) &&
313+
!html.includes(
314+
'<a href="https://example.com" data-auto="false">Hello more</a>'
315+
)
312316
);
313317
})
314318
.toBe(true);

src/web/pmPlugins/AutolinkPlugin/index.ts

Lines changed: 86 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,31 @@ function rangeHasManualLink(
7272
return found;
7373
}
7474

75+
function rangeHasExactAutoLink(
76+
doc: Node,
77+
linkType: MarkType,
78+
from: number,
79+
to: number,
80+
href: string
81+
): boolean {
82+
let hasExact = true;
83+
let hasText = false;
84+
85+
doc.nodesBetween(from, to, (node) => {
86+
if (!hasExact) return false;
87+
if (!node.isText) return true;
88+
89+
hasText = true;
90+
const link = linkType.isInSet(node.marks);
91+
if (!link || link.attrs.auto !== true || link.attrs.href !== href) {
92+
hasExact = false;
93+
}
94+
return false;
95+
});
96+
97+
return hasText && hasExact;
98+
}
99+
75100
function extractRuns(
76101
block: Node,
77102
blockStartPos: number,
@@ -105,38 +130,6 @@ function extractRuns(
105130
return runs;
106131
}
107132

108-
function scanRunForAutolinks(
109-
run: Run,
110-
doc: Node,
111-
linkType: MarkType,
112-
linkRegex: RegExp | undefined,
113-
tr: Transaction,
114-
detected: OnLinkDetected[]
115-
): void {
116-
for (const match of run.text.matchAll(WHITESPACE_RE)) {
117-
const word = match[0];
118-
const wordStart = run.startPos + match.index!;
119-
const wordEnd = wordStart + word.length;
120-
121-
const ranges = findAutolinkRangesInWord(word, linkRegex);
122-
const fullMatch = ranges.some(
123-
(r) => r.start === 0 && r.endExclusive === word.length
124-
);
125-
126-
if (!fullMatch) continue;
127-
if (rangeHasManualLink(doc, linkType, wordStart, wordEnd)) continue;
128-
129-
const href = word;
130-
tr.addMark(wordStart, wordEnd, linkType.create({ href, auto: true }));
131-
detected.push({
132-
text: word,
133-
url: href,
134-
start: tiptapPosToNativePos(doc, wordStart),
135-
end: tiptapPosToNativePos(doc, wordEnd),
136-
});
137-
}
138-
}
139-
140133
function getDirtyBlocks(
141134
doc: Node,
142135
transactions: readonly Transaction[]
@@ -189,37 +182,85 @@ export const AutolinkPlugin = Extension.create<AutolinkPluginOptions>({
189182
const state = this.options.getLinkEmitter();
190183
if (!state || state.linkRegex === null) return null;
191184

192-
console.log('autoLink transaction');
193-
194185
const { schema, doc, tr } = newState;
195186
const linkType = schema.marks.link;
196187
if (!linkType) return null;
197188

198189
const dirtyBlocks = getDirtyBlocks(doc, transactions);
199190
if (dirtyBlocks.length === 0) return null;
200-
// console.log('autoLink transaction');
201191

202192
const detected: OnLinkDetected[] = [];
203193

204194
for (const { node, pos } of dirtyBlocks) {
205-
const from = pos + 1;
206-
const to = pos + node.nodeSize - 1;
195+
const blockFrom = pos + 1;
196+
const blockTo = pos + node.nodeSize - 1;
207197

208-
removeAutoLinksInRange(doc, tr, linkType, from, to);
198+
// find all valid links that should exist in this block
199+
const desiredLinks: Array<{
200+
start: number;
201+
end: number;
202+
href: string;
203+
}> = [];
209204

210205
for (const run of extractRuns(node, pos, schema)) {
211-
scanRunForAutolinks(
212-
run,
206+
for (const match of run.text.matchAll(WHITESPACE_RE)) {
207+
const word = match[0];
208+
const wordStart = run.startPos + match.index!;
209+
const wordEnd = wordStart + word.length;
210+
211+
const ranges = findAutolinkRangesInWord(word, state.linkRegex);
212+
const fullMatch = ranges.some(
213+
(r) => r.start === 0 && r.endExclusive === word.length
214+
);
215+
216+
if (!fullMatch) continue;
217+
if (rangeHasManualLink(doc, linkType, wordStart, wordEnd))
218+
continue;
219+
220+
desiredLinks.push({
221+
start: wordStart,
222+
end: wordEnd,
223+
href: word,
224+
});
225+
}
226+
}
227+
228+
let lastPos = blockFrom;
229+
230+
for (const link of desiredLinks) {
231+
// strip auto links in the "gap" before this desired link
232+
removeAutoLinksInRange(doc, tr, linkType, lastPos, link.start);
233+
234+
tr.addMark(
235+
link.start,
236+
link.end,
237+
linkType.create({ href: link.href, auto: true })
238+
);
239+
240+
const alreadyExisted = rangeHasExactAutoLink(
213241
doc,
214242
linkType,
215-
state.linkRegex,
216-
tr,
217-
detected
243+
link.start,
244+
link.end,
245+
link.href
218246
);
247+
248+
// don't emit if the link was not changed
249+
if (!alreadyExisted) {
250+
detected.push({
251+
text: link.href,
252+
url: link.href,
253+
start: tiptapPosToNativePos(doc, link.start),
254+
end: tiptapPosToNativePos(doc, link.end),
255+
});
256+
}
257+
258+
lastPos = link.end;
219259
}
220-
}
221260

222-
console.log('transaction steps length', tr.steps.length);
261+
// strip rest of the auto links that are now not desired
262+
removeAutoLinksInRange(doc, tr, linkType, lastPos, blockTo);
263+
}
223264

224265
if (tr.steps.length === 0) return null;
225266

0 commit comments

Comments
 (0)