Skip to content

Commit 03bf6db

Browse files
authored
SF-3629 Add support for links with titles (#3557)
1 parent 12c8136 commit 03bf6db

File tree

9 files changed

+226
-10
lines changed

9 files changed

+226
-10
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,

src/SIL.XForge.Scripture/usx-sf.rnc

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ Para =
134134
},
135135
attribute vid { xsd:string { pattern = "[A-Z1-4]{3} ?[a-z0-9\-,:]*" } }?,
136136
attribute status { text }?,
137-
(Reference | Note | Char | Milestone | Figure | Verse | Break | Unmatched | text)+
137+
(Reference | Note | Link | Char | Milestone | Figure | Verse | Break | Unmatched | text)+
138138
}
139139
Para.para.style.enum = (
140140
"restore" # Comment about when text was restored
@@ -430,7 +430,7 @@ ListChar.char.style.enum = (
430430

431431
char.link =
432432
attribute link-href { xsd:string
433-
{ pattern = "(.*///?(.*/?)+)|((prj:[A-Za-z\-0-9]{3,8} )?[A-Z1-4]{3} \d+:\d+(\-\d+)?)|(#[^\s]+)" } }?, # The resource being linked to as a URI
433+
{ pattern = "(.*///?(.*/?)+)|((prj:[A-Za-z\-0-9]{3,8} )?[A-Z1-4]{3} \d+:\d+(\-\d+)?[ ]*)|(#[^\s]+)" } }?, # The resource being linked to as a URI
434434
attribute link-title { xsd:string }?, # Plain text describing the remote resource such as might be shown in a tooltip
435435
attribute link-id { xsd:string { pattern = "[\w_\-\.:]+" } }? # Unique identifier for this location in the text
436436

@@ -483,15 +483,15 @@ Note =
483483
attribute style { "f" | "fe" | "ef" | "x" | "ex" },
484484
attribute caller { text },
485485
(attribute category { text })?,
486-
(NoteChar | Unmatched | text )+
486+
(NoteChar | Link | Unmatched | text )+
487487
}
488488

489489
NoteChar =
490490
element char {
491491
attribute style { FootnoteChar.char.style.enum | CrossReferenceChar.char.style.enum },
492492
char.link?,
493493
char.closed?,
494-
(Char | Reference | Unmatched | text)+
494+
(Char | Link | Reference | Unmatched | text)+
495495
}
496496
FootnoteChar.char.style.enum = (
497497
"fr" # The origin reference for the footnote
@@ -544,6 +544,13 @@ Reference =
544544
text?
545545
}
546546

547+
Link =
548+
element link {
549+
attribute style { CrossReferenceChar.char.style.enum },
550+
char.link?,
551+
text?
552+
}
553+
547554
Break =
548555
element optbreak { empty }
549556

src/SIL.XForge.Scripture/usx-sf.xsd

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@
143143
<xs:choice minOccurs="0" maxOccurs="unbounded">
144144
<xs:element ref="ref"/>
145145
<xs:element ref="note"/>
146+
<xs:element ref="link"/>
146147
<xs:group ref="Char"/>
147148
<xs:element ref="ms"/>
148149
<xs:element ref="figure"/>
@@ -563,7 +564,7 @@
563564
<xs:attribute name="link-href">
564565
<xs:simpleType>
565566
<xs:restriction base="xs:string">
566-
<xs:pattern value="(.*///?(.*/?)+)|((prj:[A-Za-z\-0-9]{3,8} )?[A-Z1-4]{3} \d+:\d+(\-\d+)?)|(#[^\s]+)"/>
567+
<xs:pattern value="(.*///?(.*/?)+)|((prj:[A-Za-z\-0-9]{3,8} )?[A-Z1-4]{3} \d+:\d+(\-\d+)?[ ]*)|(#[^\s]+)"/>
567568
</xs:restriction>
568569
</xs:simpleType>
569570
</xs:attribute>
@@ -726,6 +727,7 @@
726727
<xs:complexType mixed="true">
727728
<xs:choice minOccurs="0" maxOccurs="unbounded">
728729
<xs:group ref="NoteChar"/>
730+
<xs:element ref="link"/>
729731
<xs:element ref="unmatched"/>
730732
</xs:choice>
731733
<xs:attribute name="style" use="required">
@@ -749,6 +751,7 @@
749751
<xs:complexType mixed="true">
750752
<xs:choice minOccurs="0" maxOccurs="unbounded">
751753
<xs:group ref="Char"/>
754+
<xs:element ref="link"/>
752755
<xs:element ref="ref"/>
753756
<xs:element ref="unmatched"/>
754757
</xs:choice>
@@ -820,6 +823,12 @@
820823
</xs:attribute>
821824
</xs:complexType>
822825
</xs:element>
826+
<xs:element name="link">
827+
<xs:complexType mixed="true">
828+
<xs:attribute name="style" use="required" type="CrossReferenceChar.char.style.enum"/>
829+
<xs:attributeGroup ref="char.link"/>
830+
</xs:complexType>
831+
</xs:element>
823832
<xs:element name="optbreak">
824833
<xs:complexType/>
825834
</xs:element>

tools/Roundtrip/Program.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,14 @@ void Roundtrip(string usfm, string fileName, string path, RoundtripMethod roundt
258258
true
259259
);
260260
}
261+
262+
// Output the USX if requested
263+
if (outputAllFiles)
264+
{
265+
actualUsx.Save(
266+
Path.Join("output", $"{Path.GetFileName(path)}-{Path.GetFileNameWithoutExtension(fileName)}-usx.xml")
267+
);
268+
}
261269
}
262270
else
263271
{

0 commit comments

Comments
 (0)