Skip to content

Commit ff7ca3b

Browse files
authored
Support undeletable anchor Step 3: Support anchor (#2972)
* Support undeletable anchor Step 1: Support hidden properties * Support undeletable anchor step 2 * improve comments * Support undeletable anchor Step 3
1 parent 8bbbf87 commit ff7ca3b

File tree

11 files changed

+381
-11
lines changed

11 files changed

+381
-11
lines changed
Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,45 @@
1+
import { addSegment } from '../../modelApi/common/addSegment';
2+
import { createText } from '../../modelApi/creators/createText';
13
import { knownElementProcessor } from './knownElementProcessor';
24
import { parseFormat } from '../utils/parseFormat';
35
import { stackFormat } from '../utils/stackFormat';
6+
import type { StackFormatOptions } from '../utils/stackFormat';
47
import type { ElementProcessor } from 'roosterjs-content-model-types';
58

69
/**
710
* @internal
811
*/
912
export const linkProcessor: ElementProcessor<HTMLElement> = (group, element, context) => {
10-
if (element.hasAttribute('href')) {
11-
stackFormat(context, { link: 'linkDefault' }, () => {
13+
const name = element.getAttribute('name');
14+
const href = element.getAttribute('href');
15+
16+
if (name || href) {
17+
const isAnchor = !!name && !href;
18+
const option: StackFormatOptions = {
19+
// For anchor (name without ref), no need to add other styles
20+
// For link (href exists), add default link styles
21+
link: isAnchor ? 'empty' : 'linkDefault',
22+
};
23+
24+
stackFormat(context, option, () => {
1225
parseFormat(element, context.formatParsers.link, context.link.format, context);
1326
parseFormat(element, context.formatParsers.dataset, context.link.dataset, context);
1427

15-
knownElementProcessor(group, element, context);
28+
if (isAnchor && !element.firstChild) {
29+
// Empty anchor, need to make sure it has some child in model
30+
addSegment(
31+
group,
32+
createText('', context.segmentFormat, {
33+
dataset: context.link.dataset,
34+
format: context.link.format,
35+
})
36+
);
37+
} else {
38+
knownElementProcessor(group, element, context);
39+
}
1640
});
1741
} else {
18-
// A tag without href, can be treated as normal SPAN tag
42+
// A tag without name or href, can be treated as normal SPAN tag
1943
knownElementProcessor(group, element, context);
2044
}
2145
};

packages/roosterjs-content-model-dom/lib/formatHandlers/segment/linkFormatHandler.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ export const linkFormatHandler: FormatHandler<LinkFormat> = {
4646
}
4747
},
4848
apply: (format, element) => {
49-
if (isElementOfType(element, 'a') && format.href) {
50-
element.href = format.href;
49+
if (isElementOfType(element, 'a') && (format.href || format.name)) {
50+
if (format.href) {
51+
element.href = format.href;
52+
}
5153

5254
if (format.name) {
5355
element.name = format.name;

packages/roosterjs-content-model-dom/lib/modelApi/common/addDecorators.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export function addLink(
1212
segment: ShallowMutableContentModelSegment,
1313
link: ReadonlyContentModelLink
1414
) {
15-
if (link.format.href) {
15+
if (link.format.href || link.format.name) {
1616
segment.link = {
1717
format: { ...link.format },
1818
dataset: { ...link.dataset },

packages/roosterjs-content-model-dom/lib/modelApi/common/isEmpty.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,13 @@ export function isBlockGroupEmpty(group: ReadonlyContentModelBlockGroup): boolea
5151
/**
5252
* @internal
5353
*/
54-
export function isSegmentEmpty(segment: ReadonlyContentModelSegment): boolean {
54+
export function isSegmentEmpty(
55+
segment: ReadonlyContentModelSegment,
56+
treatAnchorAsNotEmpty?: boolean
57+
): boolean {
5558
switch (segment.segmentType) {
5659
case 'Text':
57-
return !segment.text;
60+
return !segment.text && (!treatAnchorAsNotEmpty || !segment.link?.format.name);
5861

5962
default:
6063
return false;

packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ function normalizeParagraphStyle(paragraph: ReadonlyContentModelParagraph) {
6565

6666
function removeEmptySegments(block: ReadonlyContentModelParagraph) {
6767
for (let j = block.segments.length - 1; j >= 0; j--) {
68-
if (isSegmentEmpty(block.segments[j])) {
68+
if (isSegmentEmpty(block.segments[j], true /*treatAnchorAsNotEmpty*/)) {
6969
mutateBlock(block).segments.splice(j, 1);
7070
}
7171
}

packages/roosterjs-content-model-dom/test/domToModel/processors/linkProcessorTest.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,158 @@ describe('linkProcessor', () => {
205205
],
206206
});
207207
});
208+
209+
it('undeletable link', () => {
210+
const group = createContentModelDocument();
211+
const a = document.createElement('a');
212+
213+
(a as any).__roosterjsHiddenProperty = {
214+
undeletable: true,
215+
};
216+
217+
a.href = '/test';
218+
a.textContent = 'test';
219+
220+
linkProcessor(group, a, context);
221+
222+
expect(group).toEqual({
223+
blockGroupType: 'Document',
224+
225+
blocks: [
226+
{
227+
blockType: 'Paragraph',
228+
format: {},
229+
isImplicit: true,
230+
segments: [
231+
{
232+
segmentType: 'Text',
233+
format: {},
234+
link: {
235+
format: { href: '/test', underline: true, undeletable: true },
236+
dataset: {},
237+
},
238+
text: 'test',
239+
},
240+
],
241+
},
242+
],
243+
});
244+
expect(context.link).toEqual({
245+
format: {},
246+
dataset: {},
247+
});
248+
});
249+
250+
it('anchor with child', () => {
251+
const group = createContentModelDocument();
252+
const a = document.createElement('a');
253+
254+
a.name = 'name';
255+
a.textContent = 'test';
256+
257+
linkProcessor(group, a, context);
258+
259+
expect(group).toEqual({
260+
blockGroupType: 'Document',
261+
262+
blocks: [
263+
{
264+
blockType: 'Paragraph',
265+
format: {},
266+
isImplicit: true,
267+
segments: [
268+
{
269+
segmentType: 'Text',
270+
format: {},
271+
link: {
272+
format: { name: 'name' },
273+
dataset: {},
274+
},
275+
text: 'test',
276+
},
277+
],
278+
},
279+
],
280+
});
281+
expect(context.link).toEqual({
282+
format: {},
283+
dataset: {},
284+
});
285+
});
286+
287+
it('anchor without child', () => {
288+
const group = createContentModelDocument();
289+
const a = document.createElement('a');
290+
291+
a.name = 'name';
292+
293+
linkProcessor(group, a, context);
294+
295+
expect(group).toEqual({
296+
blockGroupType: 'Document',
297+
298+
blocks: [
299+
{
300+
blockType: 'Paragraph',
301+
format: {},
302+
isImplicit: true,
303+
segments: [
304+
{
305+
segmentType: 'Text',
306+
format: {},
307+
link: {
308+
format: { name: 'name' },
309+
dataset: {},
310+
},
311+
text: '',
312+
},
313+
],
314+
},
315+
],
316+
});
317+
expect(context.link).toEqual({
318+
format: {},
319+
dataset: {},
320+
});
321+
});
322+
323+
it('undeletable anchor', () => {
324+
const group = createContentModelDocument();
325+
const a = document.createElement('a');
326+
327+
(a as any).__roosterjsHiddenProperty = {
328+
undeletable: true,
329+
};
330+
331+
a.name = 'name';
332+
333+
linkProcessor(group, a, context);
334+
335+
expect(group).toEqual({
336+
blockGroupType: 'Document',
337+
338+
blocks: [
339+
{
340+
blockType: 'Paragraph',
341+
format: {},
342+
isImplicit: true,
343+
segments: [
344+
{
345+
segmentType: 'Text',
346+
format: {},
347+
link: {
348+
format: { name: 'name', undeletable: true },
349+
dataset: {},
350+
},
351+
text: '',
352+
},
353+
],
354+
},
355+
],
356+
});
357+
expect(context.link).toEqual({
358+
format: {},
359+
dataset: {},
360+
});
361+
});
208362
});

packages/roosterjs-content-model-dom/test/formatHandlers/segment/linkFormatHandlerTest.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ describe('linkFormatHandler.parse', () => {
5858
name: 'name',
5959
});
6060
});
61+
62+
it('Anchor', () => {
63+
let a = document.createElement('a');
64+
65+
a.name = 'name';
66+
67+
linkFormatHandler.parse(format, a, context, defaultHTMLStyleMap.a!);
68+
69+
expect(format).toEqual({
70+
name: 'name',
71+
});
72+
});
6173
});
6274

6375
describe('linkFormatHandler.apply', () => {
@@ -107,4 +119,15 @@ describe('linkFormatHandler.apply', () => {
107119
'<a href="/test" name="name" target="target" id="id" class="class" title="title" rel="rel">test</a>'
108120
);
109121
});
122+
123+
it('Anchor', () => {
124+
format.name = 'name';
125+
126+
const a = document.createElement('a');
127+
a.innerHTML = 'test';
128+
129+
linkFormatHandler.apply(format, a, context);
130+
131+
expect(a.outerHTML).toEqual('<a name="name">test</a>');
132+
});
110133
});

packages/roosterjs-content-model-dom/test/modelApi/common/addDecoratorsTest.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,43 @@ describe('addLink', () => {
100100
},
101101
});
102102
});
103+
104+
it('has anchor', () => {
105+
const segment = createSelectionMarker();
106+
const link: ContentModelLink = {
107+
format: {
108+
name: 'name',
109+
},
110+
dataset: {},
111+
};
112+
addLink(segment, link);
113+
114+
expect(segment).toEqual({
115+
segmentType: 'SelectionMarker',
116+
format: {},
117+
isSelected: true,
118+
link: {
119+
format: {
120+
name: 'name',
121+
},
122+
dataset: {},
123+
},
124+
});
125+
126+
link.format.name = 'name2';
127+
128+
expect(segment).toEqual({
129+
segmentType: 'SelectionMarker',
130+
format: {},
131+
isSelected: true,
132+
link: {
133+
format: {
134+
name: 'name',
135+
},
136+
dataset: {},
137+
},
138+
});
139+
});
103140
});
104141

105142
describe('addCode', () => {

0 commit comments

Comments
 (0)