Skip to content

Commit 055a46f

Browse files
committed
SF-3629 Add link blot support
1 parent 81d50a8 commit 055a46f

File tree

5 files changed

+187
-4
lines changed

5 files changed

+187
-4
lines changed

src/SIL.XForge.Scripture/ClientApp/src/_usx.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,10 @@ usx-note {
456456
}
457457
}
458458

459+
usx-link {
460+
text-decoration: underline dotted;
461+
}
462+
459463
usx-ref {
460464
font-style: italic;
461465
}

src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-formats/quill-blot-value-types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ export interface Note extends UsxStyle {
4343
contents?: { ops: DeltaOperation[] };
4444
}
4545

46+
export interface Link extends UsxStyle {
47+
'link-href'?: string;
48+
contents?: { ops: DeltaOperation[] };
49+
}
50+
4651
export interface Figure extends UsxStyle {
4752
alt?: string;
4853
file: string;

src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-formats/quill-blots.spec.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
CharInline,
88
EmptyEmbed,
99
FigureEmbed,
10+
LinkEmbed,
1011
NoteEmbed,
1112
NoteThreadEmbed,
1213
NotNormalizedText,
@@ -248,6 +249,77 @@ describe('Quill blots', () => {
248249
});
249250
});
250251

252+
describe('LinkEmbed', () => {
253+
it('should create link with title', () => {
254+
const value = {
255+
style: 'xt',
256+
'link-href': 'GEN 1:1',
257+
contents: {
258+
ops: [{ insert: 'Genesis 1:1' }]
259+
}
260+
};
261+
const node = LinkEmbed.create(value) as HTMLElement;
262+
263+
expect(node.innerText).toBe('Genesis 1:1');
264+
expect(node.title).toBe('GEN 1:1');
265+
});
266+
267+
it('should handle partial figure data', () => {
268+
const value = {
269+
contents: {
270+
ops: [{ insert: 'Genesis 1:1' }]
271+
}
272+
};
273+
const node = LinkEmbed.create(value as any) as HTMLElement;
274+
expect(node.title).toBe('');
275+
});
276+
277+
it('should handle contents with multiple ops', () => {
278+
const value = {
279+
'link-href': 'GEN 1:1',
280+
contents: {
281+
ops: [{ insert: 'Genesis' }, { insert: ' 1:1' }]
282+
}
283+
};
284+
const node = LinkEmbed.create(value) as HTMLElement;
285+
286+
expect(node.innerText).toBe('Genesis 1:1');
287+
expect(node.title).toBe('GEN 1:1');
288+
});
289+
290+
it('should handle contents with unsupported ops', () => {
291+
const value = {
292+
'link-href': 'GEN 1:1',
293+
contents: {
294+
ops: [{ insert: 'Genesis 1:1' }, { insert: { ref: 'figure1' } }]
295+
}
296+
};
297+
const node = LinkEmbed.create(value) as HTMLElement;
298+
299+
expect(node.innerText).toBe('Genesis 1:1');
300+
expect(node.title).toBe('GEN 1:1');
301+
});
302+
303+
it('should retrieve stored value', () => {
304+
const value = {
305+
style: 'xt',
306+
'link-href': 'GEN 1:1',
307+
contents: {
308+
ops: [{ insert: 'Genesis 1:1' }]
309+
}
310+
};
311+
const node = LinkEmbed.create(value as any) as HTMLElement;
312+
expect(LinkEmbed.value(node)).toEqual(value as any);
313+
});
314+
315+
it('should maintain DOM structure with empty fields', () => {
316+
const value = {};
317+
const node = LinkEmbed.create(value as any) as HTMLElement;
318+
expect(node.innerText).toBe('');
319+
expect(node.title).toBe('');
320+
});
321+
});
322+
251323
describe('NoteEmbed', () => {
252324
it('should create note with caller and style', () => {
253325
const value = { caller: 'a', style: 'footnote' };
@@ -276,6 +348,66 @@ describe('Quill blots', () => {
276348
expect(node.title).toBe('note text');
277349
});
278350

351+
it('should set title from contents with a link', () => {
352+
const value = {
353+
caller: 'a',
354+
contents: {
355+
ops: [
356+
{ insert: 'note text ' },
357+
{ insert: { link: { style: 'xt', 'link-href': 'GEN: 1:1', contents: { ops: [{ insert: 'link text' }] } } } }
358+
]
359+
}
360+
};
361+
const node = NoteEmbed.create(value) as HTMLElement;
362+
363+
expect(node.title).toBe('note text link text');
364+
});
365+
366+
it('should set title from contents ignoring an link with no text', () => {
367+
const value = {
368+
caller: 'a',
369+
contents: {
370+
ops: [
371+
{ insert: 'note text' },
372+
{
373+
insert: {
374+
link: {
375+
style: 'xt',
376+
'link-href': 'GEN: 1:1'
377+
}
378+
}
379+
}
380+
]
381+
}
382+
};
383+
const node = NoteEmbed.create(value) as HTMLElement;
384+
385+
expect(node.title).toBe('note text');
386+
});
387+
388+
it('should set title from contents with a link containing an unsupported op', () => {
389+
const value = {
390+
caller: 'a',
391+
contents: {
392+
ops: [
393+
{ insert: 'note text ' },
394+
{
395+
insert: {
396+
link: {
397+
style: 'xt',
398+
'link-href': 'GEN: 1:1',
399+
contents: { ops: [{ insert: 'link text' }, { insert: { ref: 'unsupported op' } }] }
400+
}
401+
}
402+
}
403+
]
404+
}
405+
};
406+
const node = NoteEmbed.create(value) as HTMLElement;
407+
408+
expect(node.title).toBe('note text link text');
409+
});
410+
279411
it('should handle null style', () => {
280412
const value = { caller: 'a' };
281413
const node = NoteEmbed.create(value) as HTMLElement;

src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-formats/quill-blots.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Book,
1111
Chapter,
1212
Figure,
13+
Link,
1314
Note,
1415
NoteThread,
1516
Para,
@@ -272,10 +273,22 @@ export class NoteEmbed extends QuillEmbedBlot {
272273
}
273274
if (value.contents != null) {
274275
// ignore blank embeds (checked here as non-string insert)
275-
node.title = value.contents.ops.reduce(
276-
(text, op) => (typeof op.insert === 'string' ? text + op.insert : text),
277-
''
278-
);
276+
node.title = value.contents.ops.reduce((text, op) => {
277+
if (typeof op.insert === 'string') {
278+
return text + op.insert;
279+
}
280+
281+
// Handle link inserts with nested contents
282+
const link = op.insert?.link as Link | undefined;
283+
if (link?.contents?.ops) {
284+
const linkText = link.contents.ops
285+
.map(innerOp => (typeof innerOp.insert === 'string' ? innerOp.insert : ''))
286+
.join('');
287+
return text + linkText;
288+
}
289+
290+
return text;
291+
}, '');
279292
}
280293
setUsxValue(node, value);
281294
return node;
@@ -388,6 +401,33 @@ export class FigureEmbed extends QuillEmbedBlot {
388401
}
389402
}
390403

404+
export class LinkEmbed extends QuillEmbedBlot {
405+
static blotName = 'link';
406+
static tagName = 'usx-link';
407+
408+
static create(value: Link): Node {
409+
const node = super.create(value) as HTMLElement;
410+
if (value.style != null) {
411+
node.setAttribute(customAttributeName('style'), value.style);
412+
}
413+
if (value['link-href'] != null) {
414+
node.setAttribute(customAttributeName('link-href'), value['link-href']);
415+
node.title = value['link-href'];
416+
}
417+
const contentsSpan = document.createElement('span');
418+
contentsSpan.innerText =
419+
value.contents?.ops.map(innerOp => (typeof innerOp.insert === 'string' ? innerOp.insert : '')).join('') ?? '';
420+
421+
node.appendChild(contentsSpan);
422+
setUsxValue(node, value);
423+
return node;
424+
}
425+
426+
static value(node: HTMLElement): UsxStyle {
427+
return getUsxValue(node);
428+
}
429+
}
430+
391431
export class UnmatchedEmbed extends QuillEmbedBlot {
392432
static blotName = 'unmatched';
393433
static tagName = 'usx-unmatched';

src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-registrations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
CharInline,
2929
EmptyEmbed,
3030
FigureEmbed,
31+
LinkEmbed,
3132
NoteEmbed,
3233
NoteThreadEmbed,
3334
NotNormalizedText,
@@ -57,6 +58,7 @@ export function registerScriptureFormats(formatRegistry: QuillFormatRegistryServ
5758
UnmatchedEmbed,
5859
ChapterEmbed,
5960
UnknownBlot,
61+
LinkEmbed,
6062

6163
// Inline Blots
6264
CharInline,

0 commit comments

Comments
 (0)