@samuelbradshaw/highlight-helper-js 0.10.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/demo.html CHANGED
@@ -6,418 +6,945 @@
6
6
  <meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1.0">
7
7
  <meta name="google" content="notranslate">
8
8
  <style>
9
+ /* GLOBAL STYLES for all demos */
9
10
  :root {
10
- -webkit-text-size-adjust: 100%;
11
- --page-bg: hsl(0, 0%, 100%);
12
- --text-color: hsl(0, 0%, 0%);
13
- --gray-90: hsl(0, 0%, 90%);
14
- --gray-50: hsl(0, 0%, 50%);
11
+ --page-bg: light-dark(white, #222);
12
+ --text-color: light-dark(black, white);
13
+ --light-gray: light-dark(#eee, #444);
14
+ --dark-gray: light-dark(#777, #ccc);
15
15
  }
16
- @media (prefers-color-scheme: dark) { :root {
17
- --page-bg: hsl(0, 0%, 17%);
18
- --text-color: hsl(0, 0%, 95%);
19
- --gray-90: hsl(0, 0%, 30%);
20
- --gray-50: hsl(0, 0%, 70%);
21
- }}
22
-
23
- body {
16
+ html {
17
+ color-scheme: light dark;
18
+ margin: 1em;
24
19
  background-color: var(--page-bg);
25
20
  color: var(--text-color);
21
+ font-family: serif;
26
22
  line-height: 1.4;
23
+ overflow-x: hidden;
27
24
  }
28
- main { margin-right: 2em; }
29
- a { color: var(--gray-50); }
30
- button {
31
- cursor: pointer;
32
- user-select: none;
25
+ html:has([name="theme"][value="light"]:checked) { color-scheme: light; }
26
+ html:has([name="theme"][value="dark"]:checked) { color-scheme: dark; }
27
+ section {
28
+ margin: 3em 0;
29
+ border-top: 1px solid var(--text-color);
33
30
  }
31
+ a { color: var(--dark-gray); }
32
+ #jump-links {
33
+ margin: 0;
34
+ padding-left: 1.5em;
35
+ }
36
+ label {
37
+ display: flex;
38
+ gap: 0.4em;
39
+ align-items: center;
40
+ }
41
+ details { margin: 1em 0; }
42
+ .code-block {
43
+ display: block;
44
+ background-color: var(--light-gray);
45
+ margin: 1em 0; padding: 0.5em;
46
+ border: 1px solid var(--dark-gray);
47
+ }
48
+ .code-block figcaption {
49
+ color: var(--page-bg);
50
+ background-color: var(--dark-gray);
51
+ font-weight: bold;
52
+ margin: -0.5em -0.5em 0.5em -0.5em; padding: 0.3em 0.5em;
53
+ }
54
+ .code-block code {
55
+ display: block;
56
+ text-indent: -2em;
57
+ margin-left: calc(var(--indent) + 2em);
58
+ overflow-wrap: break-word;
59
+ }
60
+ /* Add an invisible space after each code line, to prevent empty code lines from collapsing */
61
+ code::after { content: '\200B'; }
62
+ </style>
63
+ <script src="highlight-helper.js"></script>
64
+ </head>
65
+ <body>
66
+ <p>← <a href="https://github.com/samuelbradshaw/highlight-helper-js">Back to GitHub</a></p>
67
+ <h1>HighlightHelper.js Demo</h1>
34
68
 
35
- #annotation-menu {
36
- font-family: "Helvetica", sans-serif;
37
- font-size: 0.8em;
38
- .buttons-area {
39
- display: grid;
40
- grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
41
- gap: 0.6em;
42
- overflow: scroll;
43
- & > * {
44
- background-color: var(--page-bg);
45
- border: 0; border-radius: 0.4em;
46
- padding: 0.6em 0.4em;
47
- font-size: inherit;
48
- white-space: nowrap;
69
+ <p>
70
+ Theme:
71
+ <label><input type="radio" name="theme" value="system-default" checked> System Default</label>
72
+ <label><input type="radio" name="theme" value="light"> Light</label>
73
+ <label><input type="radio" name="theme" value="dark"> Dark</label>
74
+ </p>
75
+
76
+ Jump to:
77
+ <ul id="jump-links"></ul>
78
+
79
+ <section id="basic-highlighter">
80
+ <h2>Demo 1: Basic highlighter</h2>
81
+
82
+ <div class="container">
83
+ <p id="p1"><b>HighlightHelper.js</b> is a JavaScript library for drawing highlights in HTML content. Select text, or tap an existing highlight. Then choose a color or style. Highlights can be drawn across element boundaries, and they can overlap!</p>
84
+ <p id="p2">You can use default fill and underline styles, or <a target="_blank" href="https://github.com/samuelbradshaw/highlight-helper-js#options">define your own</a>. The library can adapt to a variety of <a target="_blank" href="https://github.com/samuelbradshaw/highlight-helper-js#features">use cases</a> and languages.</p>
85
+ <p id="p3">わたしニーファイは<ruby><rb>善</rb><rt>よ</rt></ruby>い<ruby><rb>両</rb><rt>りょう</rt></ruby><ruby><rb>親</rb><rt>しん</rt></ruby>から<ruby><rb>生</rb><rt>う</rt></ruby>まれたので,<ruby><rb>父</rb><rt>ちち</rt></ruby>が<ruby><rb>学</rb><rt>まな</rt></ruby>んだすべてのことの<ruby><rb>中</rb><rt>なか</rt></ruby>から<ruby><rb>幾</rb><rt>いく</rt></ruby>らかの<ruby><rb>教</rb><rt>おし</rt></ruby>えを<ruby><rb>受</rb><rt>う</rt></ruby>けた。</p>
86
+ <p id="p4" style="direction: rtl;">فِي الْبَدْءِ خَلَقَ اللهُ السَّمَاوَاتِ وَالأَرْضَ. وَكَانَتِ الأَرْضُ خَرِبَةً وَخَالِيَةً، وَعَلَى وَجْهِ الْغَمْرِ ظُلْمَةٌ، وَرُوحُ اللهِ يَرِفُّ عَلَى وَجْهِ الْمِيَاهِ. وَقَالَ اللهُ: «لِيَكُنْ نُورٌ»، فَكَانَ نُورٌ.</p>
87
+ <p id="p5">១ព្រះ​បន្ទូល​ទាំង​ឡាយ​នៃ​ព្រះ ដែល​ទ្រង់​មាន​ព្រះ​បន្ទូល​ទៅ​ម៉ូសេ ក្នុង​កាល​ម៉ូសេ​ត្រូវ​បាន​នាំ​ឡើង​ទៅ​លើ​ភ្នំ​ដ៏​ខ្ពស់​បំផុត</p>
88
+ </div>
89
+
90
+ <div class="controls">
91
+ <div class="toggle-buttons">
92
+ <label class="toggle-proxy" style="--hh-color: hsl(360, 100%, 70%)">
93
+ <input type="radio" name="color" value="red"> R</label>
94
+ <label class="toggle-proxy" style="--hh-color: hsl(30, 100%, 60%)">
95
+ <input type="radio" name="color" value="orange"> O</label>
96
+ <label class="toggle-proxy" style="--hh-color: hsl(50, 100%, 60%)">
97
+ <input type="radio" name="color" value="yellow"> Y</label>
98
+ <label class="toggle-proxy" style="--hh-color: hsl(80, 100%, 45%)">
99
+ <input type="radio" name="color" value="green"> G</label>
100
+ <label class="toggle-proxy" style="--hh-color: hsl(180, 100%, 45%)">
101
+ <input type="radio" name="color" value="blue"> B</label>
102
+ </div>
103
+ <div class="toggle-buttons">
104
+ <button id="remove">Remove</button>
105
+ </div>
106
+
107
+ <div class="other-controls">
108
+ <label>
109
+ Drawing mode:
110
+ <select name="drawingMode">
111
+ <option value="svg" selected>SVG</option>
112
+ <option value="highlight-api">Highlight API</option>
113
+ <option value="mark-elements">Mark elements</option>
114
+ </select>
115
+ </label>
116
+ <label>
117
+ Style:
118
+ <select name="style">
119
+ <option value="fill" selected>Fill</option>
120
+ <option value="underline">Underline</option>
121
+ </select>
122
+ </label>
123
+ <label>
124
+ Wrapper:
125
+ <select name="wrapper">
126
+ <option value="" selected>None</option>
127
+ <option value="🙂">🙂 Smile</option>
128
+ <option value="✅">✅ Checkmark</option>
129
+ </select>
130
+ </label>
131
+ <label><input type="checkbox" name="highContrast"> High contrast</label>
132
+ <label><input type="checkbox" name="liveHighlighting"> Live highlighting</label>
133
+ <label><input type="checkbox" name="showHandlesSelect"> Show handles for text selection</label>
134
+ <label><input type="checkbox" name="snapToWord"> Snap to word</label>
135
+ </div>
136
+ </div>
137
+
138
+ <dialog id="disambiguation-panel">
139
+ <h2>Select One</h2>
140
+ <div id="disambiguation-buttons"></div>
141
+ <button id="disambiguation-cancel">Cancel</button>
142
+ </dialog>
143
+
144
+ <script type="module">
145
+ // Prepare highlights
146
+ const highlights = [
147
+ {
148
+ highlightId: 'hh-01',
149
+ color: 'green', style: 'fill',
150
+ wrapper: 'margin-indicator', variables: { content: '🙂' },
151
+ startParagraphId: 'p1', startParagraphOffset: 24,
152
+ endParagraphId: 'p1', endParagraphOffset: 42,
153
+ },
154
+ {
155
+ highlightId: 'hh-02',
156
+ color: 'yellow', style: 'fill',
157
+ startParagraphId: 'p1', startParagraphOffset: 211,
158
+ endParagraphId: 'p1', endParagraphOffset: 219,
159
+ },
160
+ {
161
+ highlightId: 'hh-03',
162
+ color: 'blue', style: 'fill',
163
+ startParagraphId: 'p1', startParagraphOffset: 216,
164
+ endParagraphId: 'p1', endParagraphOffset: 228,
165
+ },
166
+ {
167
+ highlightId: 'hh-04',
168
+ color: 'red', style: 'fill',
169
+ startParagraphId: 'p2', startParagraphOffset: 12,
170
+ endParagraphId: 'p2', endParagraphOffset: 24,
171
+ },
172
+ {
173
+ highlightId: 'hh-05',
174
+ color: 'red', style: 'underline',
175
+ startParagraphId: 'p2', startParagraphOffset: 29,
176
+ endParagraphId: 'p2', endParagraphOffset: 38,
177
+ },
178
+ {
179
+ highlightId: 'hh-06',
180
+ color: 'orange', style: 'fill',
181
+ startParagraphId: 'p3', startParagraphOffset: 56,
182
+ endParagraphId: 'p3', endParagraphOffset: 65,
183
+ },
184
+ ];
185
+
186
+ // Variables
187
+ const container = document.querySelector('#basic-highlighter .container');
188
+ let activeHighlightId = null;
189
+ let previousColor = 'yellow';
190
+ let previousStyle = 'fill';
191
+ let previousWrapper = '';
192
+ let liveHighlighting = false;
193
+
194
+ // Create a Highlighter instance
195
+ const highlighter = new Highlighter('#basic-highlighter .container', '[id]');
196
+
197
+ // Set options and load highlights
198
+ const options = {
199
+ drawingMode: 'svg',
200
+ enableHover: true,
201
+ styleDefs: {
202
+ 'fill': {
203
+ css: 'background-color: hsl(from var(--hh-color) h s l / var(--highlight-opacity, 50%));',
204
+ cssActive: 'background-color: hsl(from var(--hh-color) h s l / var(--highlight-opacity-active, 80%));',
205
+ svg: '<rect x="{line.x}" y="{line.y}" style="fill: hsl(from var(--hh-color) h s l / var(--highlight-opacity, 50%)); width: {line.width}px; height: calc({line.height}px * 0.85); transform: translateY(calc({line.height}px * 0.15));" />',
206
+ svgActive: `
207
+ <rect x="{line.x}" y="{line.y}" style="fill: hsl(from var(--hh-color) h s l / var(--highlight-opacity-active, 80%)); width: {line.width}px; height: calc({line.height}px * 0.85); transform: translateY(calc({line.height}px * 0.15));" />
208
+ `,
209
+ },
210
+ },
211
+ wrapperDefs: {
212
+ 'margin-indicator': {
213
+ start: '<span class="margin-indicator"><span class="note" data-content="{content}" aria-label="{ariaLabel}"></span></span>&NoBreak;',
214
+ },
215
+ },
216
+ };
217
+ highlighter.setOptions(options);
218
+ highlighter.loadHighlights(highlights);
219
+
220
+ // Add event listener for hover
221
+ container.addEventListener('hh:hover', (event) => {
222
+ container.style.cursor = (event.detail.targetCount > 0 && !highlighter.getActiveHighlightId()) ? 'pointer' : '';
223
+ });
224
+
225
+ // Add event listener for tap
226
+ container.addEventListener('hh:tap', respondToTap);
227
+ function respondToTap(event) {
228
+ if (event.detail.targetCount === 0 || event.detail.isLongPress || event.detail.selectionType === 'Range') return;
229
+ if (event.detail.targetCount > 1) {
230
+ handleAmbiguousTap(event);
231
+ } else if (event.detail.targetCount === 1) {
232
+ if (event.detail.highlights.length === 1) {
233
+ highlighter.activateHighlight(event.detail.highlights[0].highlightId);
234
+ } else if (event.detail.wrappers.length === 1) {
235
+ highlighter.activateHighlight(event.detail.wrappers[0].highlight.highlightId);
236
+ } else if (event.detail.hyperlinks.length === 1) {
237
+ highlighter.activateHyperlink(event.detail.hyperlinks[0].index);
238
+ }
49
239
  }
50
- input[type="text"] { width: 2em; padding: 0.1em 0.3em; border: 1px solid var(--gray-50); }
51
- button[name="hh-color"] { color: var(--page-bg); }
52
- button[name="hh-color"][value="red"] { background-color: hsl(352, 99%, 65%); }
53
- button[name="hh-color"][value="orange"] { background-color: hsl(31, 99%, 58%); }
54
- button[name="hh-color"][value="yellow"] { background-color: hsl(50, 98%, 61%); }
55
- button[name="hh-color"][value="green"] { background-color: hsl(75, 70%, 49%); }
56
- button[name="hh-color"][value="blue"] { background-color: hsl(182, 86%, 47%); }
57
- button[name="hh-style"], button[name="hh-wrapper"] {
58
- position: relative;
59
- color: inherit; border: 2px solid var(--gray-50);
240
+ }
241
+
242
+ // Load disambiguation panel for ambiguous taps
243
+ const disambiguationPanel = document.getElementById('disambiguation-panel');
244
+ const disambiguationButtons = document.getElementById('disambiguation-buttons');
245
+ const disambiguationCancelButton = document.getElementById('disambiguation-cancel');
246
+ disambiguationCancelButton.addEventListener('click', (event) => disambiguationPanel.close());
247
+ const handleAmbiguousTap = (event) => {
248
+ // Build buttons
249
+ let buttonsHtml = '';
250
+ const highlightIds = new Set();
251
+ for (const highlight of event.detail.highlights) highlightIds.add(highlight.highlightId);
252
+ for (const wrapper of event.detail.wrappers) highlightIds.add(wrapper.highlight.highlightId);
253
+ for (const highlight of highlighter.getHighlightInfo(highlightIds)) {
254
+ let colorString = highlighter.getOptions().colorDefs[highlight.color];
255
+ let styleString = highlighter.getOptions().styleDefs[highlight.style]['css'];
256
+ buttonsHtml += `
257
+ <button data-highlight-id="${highlight.highlightId}" style="--hh-color: ${colorString};">
258
+ <span style="${styleString}">${highlight.rangeText}</span>
259
+ </button>
260
+ `;
261
+ }
262
+ for (const hyperlink of event.detail.hyperlinks) {
263
+ buttonsHtml += `
264
+ <button data-hyperlink-index="${hyperlink.index}">
265
+ <span style="color: var(--dark-gray); text-decoration: underline;">${hyperlink.text}</span>
266
+ </button>
267
+ `;
60
268
  }
61
- button[name="hh-remove"] { background-color: var(--gray-50); color: var(--page-bg); }
62
- .highlight-placeholder {
63
- background-color: var(--gray-90);
64
- padding: 0 0.3em;
269
+ disambiguationButtons.innerHTML = buttonsHtml;
270
+
271
+ // Add event listeners to buttons
272
+ for (const button of disambiguationButtons.children) {
273
+ button.addEventListener('click', () => {
274
+ disambiguationPanel.close();
275
+ if (button.dataset.highlightId) {
276
+ highlighter.activateHighlight(button.dataset.highlightId);
277
+ } else if (button.dataset.hyperlinkIndex) {
278
+ highlighter.activateHyperlink(button.dataset.hyperlinkIndex);
279
+ }
280
+ });
65
281
  }
282
+
283
+ // Waiting 10 milliseconds before showing the disambiguation panel is a workaround for an issue in Android Chrome where a click event fires right after the pointerup event that triggered hh:tap. If the click event happens to be at the same position as a button in the panel, the button will receive the click, dismissing the panel before the user sees it.
284
+ setTimeout(function() { disambiguationPanel.showModal(); }, 10);
66
285
  }
67
- }
68
- .footnote-marker {
69
- display: inline-block;
70
- vertical-align: super;
71
- font-size: smaller;
72
- line-height: 0.5em;
73
- &::before {
74
- content: attr(data-content) / attr(data-accessibility-label);
286
+
287
+ // Add event listeners for removing a highlight
288
+ function removeHighlight(event) {
289
+ const activeHighlightId = highlighter.getActiveHighlightId();
290
+ highlighter.removeHighlights([activeHighlightId]);
75
291
  }
76
- }
77
- .note-container {
78
- display: inline-block;
79
- visibility: visible;
80
- font-style: normal;
81
- height: 1em;
82
- }
83
- .note {
84
- position: absolute;
85
- right: 0.5rem;
86
- font-size: 1.2rem;
87
- margin-top: -0.2rem;
88
- &::before {
89
- content: attr(data-content) / attr(data-accessibility-label);
292
+ document.getElementById('remove').addEventListener('click', removeHighlight);
293
+ document.addEventListener('keydown', (event) => {
294
+ if (event.key === 'Backspace') removeHighlight(event);
295
+ });
296
+
297
+ // Add an event listener for selection change (used for live highlighting)
298
+ function respondToSelectionChange(event) {
299
+ if (liveHighlighting && !highlighter.getActiveHighlightId()) {
300
+ highlighter.createOrUpdateHighlight({
301
+ color: previousColor,
302
+ style: previousStyle,
303
+ wrapper: previousWrapper ? 'margin-indicator' : '',
304
+ variables: previousWrapper ? { content: previousWrapper } : {},
305
+ });
306
+ }
90
307
  }
91
- }
308
+ container.addEventListener('hh:selectionchange', respondToSelectionChange);
309
+
310
+ const colorButtons = document.querySelectorAll('[name="color"]');
311
+ for (const colorButton of colorButtons) {
312
+ colorButton.addEventListener('change', (event) => {
313
+ highlighter.createOrUpdateHighlight({ color: colorButton.value });
314
+ if (window.getSelection().type !== 'Range') colorButton.checked = false;
315
+ if (colorButton.value) previousColor = colorButton.value;
316
+ });
317
+ }
318
+
319
+ const drawingModeMenu = document.querySelector('[name="drawingMode"]');
320
+ drawingModeMenu.addEventListener('change', (event) => {
321
+ highlighter.setOptions({ drawingMode: drawingModeMenu.value });
322
+ });
323
+
324
+ const styleMenu = document.querySelector('[name="style"]');
325
+ styleMenu.addEventListener('change', (event) => {
326
+ highlighter.createOrUpdateHighlight({ style: styleMenu.value });
327
+ previousStyle = styleMenu.value;
328
+ });
329
+
330
+ const wrapperMenu = document.querySelector('[name="wrapper"]');
331
+ wrapperMenu.addEventListener('change', (event) => {
332
+ highlighter.createOrUpdateHighlight({
333
+ wrapper: wrapperMenu.value ? 'margin-indicator' : '',
334
+ variables: { content: wrapperMenu.value },
335
+ });
336
+ previousWrapper = wrapperMenu.value;
337
+ });
338
+
339
+ function setColorOpacity(highContrast = false) {
340
+ if (highContrast) {
341
+ container.style.setProperty('--highlight-opacity', '80%');
342
+ container.style.setProperty('--highlight-opacity-active', '100%');
343
+ } else {
344
+ container.style.setProperty('--highlight-opacity', '50%');
345
+ container.style.setProperty('--highlight-opacity-active', '80%');
346
+ }
347
+ }
348
+ const highContrastToggle = document.querySelector('[name="highContrast"]');
349
+ highContrastToggle.addEventListener('change', (event) => { setColorOpacity(highContrastToggle.checked) });
350
+ setColorOpacity();
351
+
352
+ const liveHighlightingToggle = document.querySelector('[name="liveHighlighting"]');
353
+ liveHighlightingToggle.addEventListener('change', (event) => {
354
+ liveHighlighting = liveHighlightingToggle.checked;
355
+ });
356
+
357
+ const showHandlesSelectToggle = document.querySelector('[name="showHandlesSelect"]');
358
+ showHandlesSelectToggle.addEventListener('change', (event) => {
359
+ const showDragHandles = ['highlights'];
360
+ if (showHandlesSelectToggle.checked) showDragHandles.push('selection');
361
+ highlighter.setOptions({ showDragHandles: showDragHandles });
362
+ });
92
363
 
93
- #disambiguation-panel {
94
- color: inherit;
95
- background-color: inherit;
96
- width: 400px; max-width: 100%;
97
- flex-direction: column;
98
- gap: 0.5em;
99
- box-sizing: border-box;
100
- &[open] { display: flex; }
364
+ const snapToWordToggle = document.querySelector('[name="snapToWord"]');
365
+ snapToWordToggle.addEventListener('change', (event) => {
366
+ highlighter.setOptions({ snapToWord: snapToWordToggle.checked });
367
+ });
368
+
369
+ function handleSelectionChange(event) {
370
+ if (activeHighlightId !== highlighter.getActiveHighlightId()) {
371
+ activeHighlightId = highlighter.getActiveHighlightId();
372
+ const activeHighlightInfo = highlighter.getHighlightInfo([activeHighlightId])?.[0] ?? null;
373
+ if (activeHighlightInfo) {
374
+ document.querySelector(`[name="color"][value="${activeHighlightInfo.color}"]`).checked = true;
375
+ } else {
376
+ const checkedColorButton = document.querySelector(`[name="color"]:checked`);
377
+ if (checkedColorButton) checkedColorButton.checked = false;
378
+ }
379
+ styleMenu.value = activeHighlightInfo?.style ?? 'fill';
380
+ wrapperMenu.value = activeHighlightInfo?.variables?.content ?? '';
381
+ }
382
+ moveMarginIndicator(event);
383
+ }
384
+ container.addEventListener('hh:selectionchange', handleSelectionChange);
385
+
386
+ // Update margin indicator position when a highlight with a start wrapper is active
387
+ function moveMarginIndicator(event) {
388
+ const activeHighlightId = highlighter.getActiveHighlightId();
389
+ if (!event.detail.activeHighlightId) return;
390
+ const marginIndicator = container.querySelector(`[data-hh-wrapper][data-hh-position="start"][data-hh-highlight-id="${activeHighlightId}"] .note`);
391
+ if (marginIndicator) {
392
+ marginIndicator.style.top = `${event.detail.rangeRect.top}px`;
393
+ }
394
+ }
395
+
396
+ </script>
397
+ <style>
398
+ #basic-highlighter .container {
399
+ padding-right: 1em;
400
+ }
401
+ #disambiguation-panel {
402
+ width: 400px; max-width: 100%;
403
+ box-sizing: border-box;
404
+ }
405
+ #disambiguation-panel h2 { margin: 0 }
101
406
  #disambiguation-buttons {
102
407
  display: flex;
103
408
  flex-direction: column;
104
- & button {
105
- display: inline-block;
106
- border: none;
107
- background-color: inherit;
108
- color: inherit;
109
- font-family: inherit;
110
- font-size: inherit;
111
- text-align: inherit;
112
- margin-bottom: 1em;
113
- & * {
114
- display: inline;
115
- }
116
- }
409
+ margin: 0.5em 0;
410
+ }
411
+ #disambiguation-buttons button {
412
+ all: unset;
413
+ display: block;
414
+ cursor: pointer;
415
+ padding: 0.5em 0;
117
416
  }
118
- &::backdrop {
417
+ #disambiguation-buttons button * { display: inline; }
418
+ #disambiguation-panel::backdrop {
119
419
  background-color: hsla(0, 0%, 0%, 0.5);
120
420
  }
121
- }
122
- </style>
123
- </head>
124
- <body>
125
- <main>
126
- <h1 id="title1">HighlightHelper.js Demo</h1>
127
- <p id="p1"><b>HighlightHelper.js</b> enables highlighting or underlining text in an HTML page. Highlights are drawn via <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/SVG">SVG shapes</a>, the <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API">Custom Highlight API</a>, or <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/mark">HTML mark elements</a>. Highlights can be drawn across multiple paragraphs or <b>inline <i>style</i></b> elements—they can even overlap!</p>
128
- <p id="p2">Select text, then choose a color and style. Tap away when you’re done. Tap to activate it again for editing. Secondary elements like images (right) and Japanese ruby/furigana (below) are highlighted around.</p>
129
- <p id="p3">わたしニーファイは<ruby><rb>善</rb><rt>よ</rt></ruby>い<ruby><rb>両</rb><rt>りょう</rt></ruby><ruby><rb>親</rb><rt>しん</rt></ruby>から<ruby><rb>生</rb><rt>う</rt></ruby>まれたので,<ruby><rb>父</rb><rt>ちち</rt></ruby>が<ruby><rb>学</rb><rt>まな</rt></ruby>んだすべてのことの<ruby><rb>中</rb><rt>なか</rt></ruby>から<ruby><rb>幾</rb><rt>いく</rt></ruby>らかの<ruby><rb>教</rb><rt>おし</rt></ruby>えを<ruby><rb>受</rb><rt>う</rt></ruby>けた。</p>
130
- <p id="p4" style="direction: rtl;">فِي الْبَدْءِ خَلَقَ اللهُ السَّمَاوَاتِ وَالأَرْضَ. وَكَانَتِ الأَرْضُ خَرِبَةً وَخَالِيَةً، وَعَلَى وَجْهِ الْغَمْرِ ظُلْمَةٌ، وَرُوحُ اللهِ يَرِفُّ عَلَى وَجْهِ الْمِيَاهِ. وَقَالَ اللهُ: «لِيَكُنْ نُورٌ»، فَكَانَ نُورٌ.</p>
131
- </main>
132
-
133
- <hr>
134
- <aside id="annotation-menu">
135
- <div class="buttons-area">
136
- <button name="hh-color" value="red">Red</button>
137
- <button name="hh-color" value="orange">Orange</button>
138
- <button name="hh-color" value="yellow">Yellow</button>
139
- <button name="hh-color" value="green">Green</button>
140
- <button name="hh-color" value="blue">Blue</button>
141
- <button name="hh-style" value="fill" checked> Fill</button>
142
- <button name="hh-style" value="single-underline"> Underline</button>
143
- <button name="hh-style" value="double-underline"> Double underline</button>
144
- <button name="hh-style" value="colored-text"> Colored text</button>
145
- <button name="hh-style" value="redacted"> Redacted</button>
146
- <button name="hh-remove">Remove</button>
147
- <button name="hh-wrapper" value="none" title="No wrapper"><span class="highlight-placeholder">…</span></button>
148
- <button name="hh-wrapper" value="footnote" title="Footnote"><span class="footnote-marker" data-content="a" data-accessibility-label="Footnote [a]"></span><span class="highlight-placeholder">…</span></button>
149
- <button name="hh-wrapper" value="margin-note" title="Margin note"><span class="note-container"><span class="note" data-content="🙂" data-accessibility-label="Smile emoji"></span></span><span class="highlight-placeholder">…</span></button>
150
- <button name="hh-wrapper" value="margin-note" title="Margin note"><span class="note-container"><span class="note" data-content="✅" data-accessibility-label="Checkmark emoji"></span></span><span class="highlight-placeholder">…</span></button>
151
- </div>
152
- <p>
153
- <label><input type="checkbox" id="hh-remember-style" checked> Remember most recent color and style</label><br>
154
- <label><input type="checkbox" id="hh-snap-to-word"> Snap highlights to the nearest word</label><br>
155
- </p>
156
- <p>
157
- Pointer mode:<br>
158
- <label><input type="radio" name="hh-pointer-mode" value="auto" checked> Auto (based on input device)</label><br>
159
- <label><input type="radio" name="hh-pointer-mode" value="simple"> Simple</label><br>
160
- <label><input type="radio" name="hh-pointer-mode" value="live"> Live</label><br>
161
- </p>
162
- <p>
163
- Drawing mode:<br>
164
- <label><input type="radio" name="hh-drawing-mode" value="svg" checked> SVG shapes</label><br>
165
- <label><input type="radio" name="hh-drawing-mode" value="highlight-api"> Custom Highlight API</label><br>
166
- <label><input type="radio" name="hh-drawing-mode" value="mark-elements"> HTML mark elements</label><br>
167
- </p>
168
- <p>
169
- Show custom selection handles:<br>
170
- <label><input type="checkbox" id="hh-selection-handles-active-highlights" checked> For active highlights</label><br>
171
- <label><input type="checkbox" id="hh-selection-handles-text-selection"> For text selection</label><br>
172
- <label><input type="checkbox" id="hh-selection-handles-touch"> On touch devices</label><br>
173
- </p>
174
- <b>Active highlight:</b>
175
- <div id="snippet-container">
176
- <div id="snippet">N/A</div>
421
+ #basic-highlighter .controls {
422
+ border: 1px solid var(--dark-gray);
423
+ background-color: var(--page-bg);
424
+ margin: 2em 0; padding: 1em;
425
+ -webkit-user-select: none;
426
+ user-select: none;
427
+ max-width: 600px;
428
+ font-family: sans-serif;
429
+ font-size: 0.8em;
430
+ }
431
+ #basic-highlighter .toggle-buttons {
432
+ display: flex;
433
+ justify-content: stretch;
434
+ gap: 0.8em;
435
+ margin-bottom: 0.8em;
436
+ }
437
+ #basic-highlighter .toggle-buttons label,
438
+ #basic-highlighter .toggle-buttons button {
439
+ all: unset;
440
+ flex-grow: 1;
441
+ border: 1px solid var(--dark-gray);
442
+ background-color: var(--hh-color, var(--light-gray));
443
+ padding: 0.3em 0.6em;
444
+ border-radius: 0.25em;
445
+ text-align: center;
446
+ cursor: pointer;
447
+ }
448
+ #basic-highlighter .toggle-proxy:has(input:checked),
449
+ #basic-highlighter button:focus-visible {
450
+ outline: 4px solid hsl(from var(--hh-color, var(--dark-gray)) h s l / 60%);
451
+ outline-offset: 0;
452
+ }
453
+ #basic-highlighter .toggle-proxy input {
454
+ position: absolute;
455
+ width: 1px; height: 1px;
456
+ margin: -1px; padding: 0;
457
+ overflow: hidden;
458
+ clip-path: inset(50%);
459
+ border: 0;
460
+ }
461
+ #basic-highlighter .other-controls {
462
+ display: flex;
463
+ flex-wrap: wrap;
464
+ gap: 0.6em;
465
+ margin-top: 1.2em;
466
+ }
467
+ [data-hh-wrapper="margin-indicator"] {
468
+ white-space: nowrap;
469
+ }
470
+ #basic-highlighter .margin-indicator {
471
+ display: inline-block;
472
+ font-style: normal;
473
+ height: 1em;
474
+ }
475
+ #basic-highlighter .margin-indicator .note {
476
+ visibility: visible;
477
+ position: absolute;
478
+ inset-inline-end: 0.8rem;
479
+ font-size: 1.2rem;
480
+ margin-top: -0.2rem;
481
+ }
482
+ #basic-highlighter .margin-indicator .note::before {
483
+ content: attr(data-content);
484
+ }
485
+ @media (width <= 500px) {
486
+ #basic-highlighter .controls {
487
+ position: sticky;
488
+ bottom: 0;
489
+ margin: 0 -1em;
490
+ border-width: 1px 0 0 0;
491
+ }
492
+ }
493
+ </style>
494
+
495
+ </section>
496
+
497
+ <p id="compatibility-warning">Warning: For simpler code snippets, the below demos use <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope">CSS @scope</a>, which is not supported on your browser.</p>
498
+
499
+ <section id="audio-sync">
500
+ <h2>Demo 2: Audio sync</h2>
501
+ <audio src="https://media2.ldscdn.org/assets/scriptures/the-new-testament/2015-11-1460-1-corinthians-13-male-voice-64k-eng.mp3" controls></audio>
502
+ <div class="container">
503
+ <p id="title-1">CHAPTER 13</p>
504
+ <p id="summary-1">Paul discusses the high status of charity—Charity, a pure love, excels and exceeds almost all else.</p>
505
+ <div class="columns">
506
+ <div class="verse-group">
507
+ <p class="verse" id="verse-1"> <span class="verse-number">1</span> <span class="drop-cap">T</span>hough I speak with the tongues of men and of angels, and have not charity, I am become as sounding brass, or a tinkling cymbal.</p>
508
+ <p class="verse" id="verse-2"><span class="verse-number">2</span> And though I have the gift of prophecy, and understand all mysteries, and all knowledge; and though I have all faith, so that I could remove mountains, and have not charity, I am nothing.</p>
509
+ <p class="verse" id="verse-3"><span class="verse-number">3</span> And though I bestow all my goods to feed the poor, and though I give my body to be burned, and have not charity, it profiteth me nothing.</p>
510
+ </div>
511
+ <div class="verse-group">
512
+ <p class="verse" id="verse-4"><span class="verse-number">4</span> Charity suffereth long, and is kind; charity envieth not; charity vaunteth not itself, is not puffed up,</p>
513
+ <p class="verse" id="verse-5"><span class="verse-number">5</span> Doth not behave itself unseemly, seeketh not her own, is not easily provoked, thinketh no evil;</p>
514
+ <p class="verse" id="verse-6"><span class="verse-number">6</span> Rejoiceth not in iniquity, but rejoiceth in the truth;</p>
515
+ <p class="verse" id="verse-7"><span class="verse-number">7</span> Beareth all things, believeth all things, hopeth all things, endureth all things.</p>
516
+ <p class="verse" id="verse-8"><span class="verse-number">8</span> Charity never faileth: but whether there be prophecies, they shall fail; whether there be tongues, they shall cease; whether there be knowledge, it shall vanish away.</p>
517
+ <p class="verse" id="verse-9"><span class="verse-number">9</span> For we know in part, and we prophesy in part.</p>
518
+ <p class="verse" id="verse-10"><span class="verse-number">10</span> But when that which is perfect is come, then that which is in part shall be done away.</p>
519
+ <p class="verse" id="verse-11"><span class="verse-number">11</span> When I was a child, I spake as a child, I understood as a child, I thought as a child: but when I became a man, I put away childish things.</p>
520
+ <p class="verse" id="verse-12"><span class="verse-number">12</span> For now we see through a glass, darkly; but then face to face: now I know in part; but then shall I know even as also I am known.</p>
521
+ </div>
522
+ <div class="verse-group">
523
+ <p class="verse" id="verse-13"><span class="verse-number">13</span> And now abideth faith, hope, charity, these three; but the greatest of these is charity.</p>
524
+ </div>
525
+ <hr>
526
+ <p class="explanation">This demo example has two highlights: a bold highlight for the current word, and an invisible highlight for the current verse. The highlights move as the audio advances. The callout bar is a wrapper on the verse highlight.</p>
527
+ </div>
177
528
  </div>
178
- <p id="highlight-info"></p>
179
- </aside>
180
- <dialog id="disambiguation-panel">
181
- <h1>Select One</h1>
182
- <div id="disambiguation-buttons"></div>
183
- <button id="disambiguation-cancel">Cancel</button>
184
- </dialog>
185
- <p>
186
- ← <a href="https://github.com/samuelbradshaw/highlight-helper-js">Back to GitHub</a>
187
- </p>
188
529
 
189
- <!-- Link to the HighlightHelper.js JavaScript file -->
190
- <script src="highlight-helper.js"></script>
191
- <script>
192
- // Example highlights
193
- let highlights = [
194
- {
195
- highlightId: 'hh-1729991847207',
196
- color: 'green', style: 'fill',
197
- startParagraphId: 'p1', startParagraphOffset: 27,
198
- endParagraphId: 'p1', endParagraphOffset: 59,
199
- },
200
- {
201
- highlightId: 'hh-1729991847300',
202
- color: 'blue', style: 'fill', wrapper: 'margin-note',
203
- wrapperVariables: { content: '🙂', accessibilityLabel: 'Smile emoji', },
204
- startParagraphId: 'p2', startParagraphOffset: 27,
205
- endParagraphId: 'p2', endParagraphOffset: 42,
206
- },
207
- {
208
- highlightId: 'hh-1729991847468',
209
- color: 'red', style: 'single-underline', wrapper: 'footnote',
210
- wrapperVariables: { content: 'a', accessibilityLabel: 'Footnote [a]', },
211
- startParagraphId: 'p2', startParagraphOffset: 119,
212
- endParagraphId: 'p2', endParagraphOffset: 127,
213
- },
214
- {
215
- highlightId: 'hh-1729991847301',
216
- color: 'orange', style: 'fill',
217
- startParagraphId: 'p3', startParagraphOffset: 56,
218
- endParagraphId: 'p3', endParagraphOffset: 65,
219
- },
220
- {
221
- highlightId: 'hh-1735379727958',
222
- color: 'yellow', style: 'fill',
223
- startParagraphId: 'p1', startParagraphOffset: 248,
224
- endParagraphId: 'p1', endParagraphOffset: 261,
225
- },
226
- {
227
- highlightId: 'hh-1735379721523',
228
- color: 'red', style: 'fill',
229
- startParagraphId: 'p1', startParagraphOffset: 239,
230
- endParagraphId: 'p1', endParagraphOffset: 252,
231
- },
232
- {
233
- highlightId: 'hh-1735479727958',
234
- color: 'yellow', style: 'fill',
235
- startParagraphId: 'p4', startParagraphOffset: 191,
236
- endParagraphId: 'p4', endParagraphOffset: 207,
237
- },
238
- ];
239
-
240
- // Initialize a Highlighter instance, then load highlights
241
- const highlighter = new Highlighter({
242
- containerSelector: 'main',
243
- paragraphSelector: 'main [id]',
244
- wrappers: {
245
- 'footnote': { start: '<span class="footnote-marker" data-content="{content}" data-accessibility-label="{accessibilityLabel}"></span>', end: '', },
246
- 'margin-note': { start: '<span class="note-container"><span class="note" data-content="{content}" data-accessibility-label="{accessibilityLabel}"></span></span>', end: '', },
247
- },
248
- });
249
- highlighter.loadHighlights(highlights);
250
-
251
- // Call HighlightHelper.js methods when color, style, wrapper, or remove buttons on this demo page are tapped
252
- const colorButtons = document.getElementsByName('hh-color');
253
- for (const colorButton of colorButtons) {
254
- colorButton.addEventListener('click', () => { highlighter.createOrUpdateHighlight({ color: colorButton.value }); });
255
- }
256
- const styleButtons = document.getElementsByName('hh-style');
257
- for (const styleButton of styleButtons) {
258
- styleButton.addEventListener('click', () => { highlighter.createOrUpdateHighlight({ style: styleButton.value }); });
259
- }
260
- const wrapperButtons = document.getElementsByName('hh-wrapper');
261
- for (const wrapperButton of wrapperButtons) {
262
- const contentSpan = wrapperButton.querySelector('[data-content]');
263
- wrapperButton.addEventListener('click', () => {
530
+ <script type="module">
531
+ const options = {
532
+ colorDefs: { 'red': '#d00' },
533
+ styleDefs: {
534
+ 'bold': { css: 'font-weight: bold;' },
535
+ 'unstyled': {},
536
+ },
537
+ wrapperDefs: {
538
+ 'callout-bar': {
539
+ start: '<div class="callout-bar" style="top: calc({range.top}px + 0.3em); left: calc({column.left}px - 0.8em); height: {range.height}px; background-color: var(--hh-color);" data-verse-number="{verseNumber}"></div>',
540
+ },
541
+ },
542
+ };
543
+
544
+ // Load Highlighter instance
545
+ const highlighter = new Highlighter('#audio-sync .container', '[id]');
546
+ highlighter.setOptions(options);
547
+
548
+ // Paragraph/verse and word timestamps
549
+ const paragraphTimestamps = [0.88, 2.64, 11.6, 21.56, 35.82, 45.86, 55.68, 64.54, 69.52, 76.36, 88.74, 93.14, 99.1, 111.38, 122.36];
550
+ const wordTimeStamps = [0.88, 1.1, 2.64, 2.8, 3.38, 3.52, 3.74, 4.22, 4.52, 5.84, 6.78, 6.88, 7.18, 7.98, 8.38, 8.8, 9.24, 9.78, 10.28, 11.6, 11.74, 11.94, 12.28, 12.66, 12.84, 13.18, 13.46, 13.78, 14.2, 14.34, 15.28, 15.4, 15.58, 15.8, 17.08, 17.22, 17.38, 17.76, 18.08, 18.52, 19.62, 19.74, 19.88, 20.26, 21.56, 21.66, 21.88, 22.12, 22.36, 22.6, 23.0, 23.12, 24.26, 24.4, 24.9, 25.32, 26.26, 26.36, 26.54, 27.62, 27.72, 27.92, 28.12, 28.4, 28.7, 29.84, 30.02, 30.18, 30.34, 30.54, 30.96, 32.06, 32.18, 32.36, 32.64, 33.96, 34.1, 34.26, 35.82, 35.82, 36.0, 36.22, 36.74, 37.04, 37.28, 37.64, 38.1, 38.26, 38.5, 39.44, 39.54, 39.76, 39.92, 40.08, 40.3, 40.72, 40.94, 41.12, 41.94, 42.06, 42.28, 42.52, 43.78, 44.02, 44.46, 44.7, 45.86, 46.26, 46.96, 47.78, 47.9, 48.06, 49.18, 49.5, 50.16, 51.3, 51.7, 52.36, 52.58, 53.68, 53.84, 54.04, 54.54, 55.68, 55.82, 56.04, 56.44, 56.94, 58.4, 58.86, 59.06, 59.38, 60.26, 60.4, 60.6, 61.1, 62.46, 62.94, 63.16, 64.54, 65.06, 65.36, 65.72, 66.76, 66.88, 67.62, 67.94, 68.08, 69.52, 69.74, 70.0, 70.96, 71.44, 71.68, 72.66, 73.0, 73.34, 74.32, 75.0, 75.16, 76.36, 76.78, 77.3, 78.66, 78.76, 79.06, 79.28, 79.46, 80.72, 80.88, 81.16, 82.18, 82.34, 82.56, 82.74, 83.74, 83.82, 84.06, 85.12, 85.3, 85.54, 85.74, 86.74, 86.84, 87.06, 87.44, 88.74, 88.84, 89.02, 89.34, 89.56, 90.42, 90.52, 90.68, 91.4, 91.82, 93.14, 93.14, 93.36, 93.58, 93.82, 94.2, 94.46, 94.84, 95.74, 95.88, 96.08, 96.34, 96.54, 96.72, 97.44, 97.54, 97.72, 97.94, 99.1, 99.18, 99.36, 99.54, 99.74, 100.46, 100.56, 100.98, 101.18, 101.3, 102.36, 102.46, 102.88, 103.6, 103.72, 104.68, 104.8, 105.1, 105.58, 105.68, 106.82, 106.92, 107.12, 107.3, 107.6, 107.9, 108.8, 108.9, 109.1, 109.4, 109.96, 111.38, 111.48, 111.74, 112.02, 112.34, 112.62, 112.82, 113.42, 114.36, 114.46, 114.76, 115.16, 115.4, 116.42, 116.58, 116.8, 117.1, 117.38, 118.32, 118.42, 118.64, 118.9, 119.12, 119.42, 119.9, 120.16, 120.56, 121.0, 121.16, 122.36, 122.36, 122.66, 123.3, 124.26, 125.3, 126.4, 126.62, 127.84, 127.94, 128.1, 128.48, 128.8, 129.16, 129.6];
551
+
552
+ const audio = document.querySelector('#audio-sync audio');
553
+ const container = document.querySelector('#audio-sync .container');
554
+ const paragraphs = container.querySelectorAll('[id]');
555
+ const dataTrack = audio.addTextTrack('metadata');
556
+ let currentParagraphId;
557
+
558
+ // Add event listeners for playback
559
+ audio.addEventListener('play', () => { container.dataset.playing = '' });
560
+ audio.addEventListener('pause', () => { delete container.dataset.playing });
561
+ audio.addEventListener('ended', () => { highlighter.removeHighlights(); });
562
+
563
+ // Add audio cues for paragraphs and words
564
+ let wordCounter = 0;
565
+ for (const paragraph of paragraphs) {
566
+ let charCounter = 0;
567
+ const words = paragraph.textContent.trim().split(/(?<=[^\s—]+[\s—]+)/g);
568
+ for (const word of words) {
569
+ const startOffset = charCounter;
570
+ charCounter += word.length;
571
+ if (/^\d+\s$/.test(word)) continue; // Skip verse numbers
572
+
573
+ const cue = new VTTCue(wordTimeStamps[wordCounter], wordTimeStamps[wordCounter + 1] ?? 1e9, '');
574
+ cue.data = {
575
+ paragraphId: paragraph.id,
576
+ startOffset: startOffset,
577
+ endOffset: charCounter,
578
+ }
579
+ dataTrack.addCue(cue);
580
+ wordCounter += 1;
581
+ }
582
+ }
583
+
584
+ // Add event listener for audio cue change
585
+ dataTrack.addEventListener('cuechange', (event) => {
586
+ const cueData = dataTrack.activeCues[0]?.data;
587
+ if (!cueData) return;
588
+ const highlights = highlighter.getHighlightInfo();
589
+ const paragraphHighlight = highlights.find(hl => hl.highlightId === 'hh-paragraph');
590
+ const wordHighlight = highlights.find(hl => hl.highlightId === 'hh-word');
591
+
592
+ // Update paragraph/verse highlight
593
+ if (cueData.paragraphId !== currentParagraphId) {
594
+ const newParagraph = document.getElementById(cueData.paragraphId);
595
+ highlighter.createOrUpdateHighlight({
596
+ highlightId: 'hh-paragraph',
597
+ color: 'red', style: 'unstyled',
598
+ wrapper: 'callout-bar',
599
+ startParagraphId: newParagraph.id,
600
+ startParagraphOffset: 0,
601
+ endParagraphId: newParagraph.id,
602
+ endParagraphOffset: newParagraph.textContent.length,
603
+ variables: {
604
+ verseNumber: newParagraph.querySelector('.verse-number')?.textContent ?? '',
605
+ },
606
+ }, true, false);
607
+ currentParagraphId = newParagraph.id;
608
+ }
609
+
610
+ // Update word highlight
264
611
  highlighter.createOrUpdateHighlight({
265
- wrapper: wrapperButton.value === 'none' ? null : wrapperButton.value,
266
- wrapperVariables: { content: contentSpan?.dataset?.content ?? '', accessibilityLabel: contentSpan?.dataset?.accessibilityLabel ?? '', },
267
- });
612
+ highlightId: 'hh-word',
613
+ style: 'bold',
614
+ startParagraphId: currentParagraphId,
615
+ startParagraphOffset: cueData.startOffset,
616
+ endParagraphId: currentParagraphId,
617
+ endParagraphOffset: cueData.endOffset,
618
+ }, true, false);
268
619
  });
269
- }
270
- const removeButton = document.getElementsByName('hh-remove')[0];
271
- removeButton.addEventListener('click', () => { highlighter.removeHighlights([highlighter.getActiveHighlightId()]); });
272
- document.addEventListener('keydown', respondToKeyPress);
273
- function respondToKeyPress(event) {
274
- if (event.key === 'Backspace') highlighter.removeHighlights([highlighter.getActiveHighlightId()]);
275
- }
276
620
 
277
- // Call HighlightHelper.js methods when options are changed
278
- const rememberStyleCheckbox = document.getElementById('hh-remember-style');
279
- const snapToWordCheckbox = document.getElementById('hh-snap-to-word');
280
- const pointerModeRadioButtons = document.getElementsByName('hh-pointer-mode');
281
- const drawingModeRadioButtons = document.getElementsByName('hh-drawing-mode');
282
- rememberStyleCheckbox.addEventListener('change', (event) => { highlighter.setOption('rememberStyle', event.currentTarget.checked); });
283
- snapToWordCheckbox.addEventListener('change', (event) => { highlighter.setOption('snapToWord', event.currentTarget.checked); });
284
- for (const pointerModeRadioButton of pointerModeRadioButtons) {
285
- pointerModeRadioButton.addEventListener('change', (event) => {
286
- let pointerMode = document.querySelector('[name="hh-pointer-mode"]:checked').value;
287
- highlighter.setOption('pointerMode', pointerMode);
288
- });
289
- }
290
- for (const drawingModeRadioButton of drawingModeRadioButtons) {
291
- drawingModeRadioButton.addEventListener('change', (event) => {
292
- let drawingMode = document.querySelector('[name="hh-drawing-mode"]:checked').value;
293
- if (drawingMode === 'highlight-api' && !CSS.highlights) {
294
- alert('Your browser doesn’t support the Custom Highlight API.');
295
- } else {
296
- highlighter.setOption('drawingMode', drawingMode);
621
+ </script>
622
+ <style>
623
+ @scope {
624
+ .container { padding: 1em; }
625
+ #title-1 { font-weight: bold; }
626
+ #summary-1, .explanation { font-style: italic; }
627
+ .columns {
628
+ column-width: 300px;
629
+ gap: 3em;
297
630
  }
298
- });
299
- }
300
- const selectionHandlesActiveHighlightsCheckbox = document.getElementById('hh-selection-handles-active-highlights');
301
- selectionHandlesActiveHighlightsCheckbox.addEventListener('change', (event) => { highlighter.setOption('showCustomSelectionHandlesForActiveHighlights', event.currentTarget.checked); });
302
- const selectionHandlesTextSelectionCheckbox = document.getElementById('hh-selection-handles-text-selection');
303
- selectionHandlesTextSelectionCheckbox.addEventListener('change', (event) => { highlighter.setOption('showCustomSelectionHandlesForTextSelection', event.currentTarget.checked); });
304
- const selectionHandlesOnTouchCheckbox = document.getElementById('hh-selection-handles-touch');
305
- selectionHandlesOnTouchCheckbox.addEventListener('change', (event) => { highlighter.setOption('showCustomSelectionHandlesOnTouch', event.currentTarget.checked); });
306
-
307
- const container = document.querySelector(highlighter.getOptions().containerSelector);
308
- const snippetDiv = document.getElementById('snippet');
309
- const infoDiv = document.getElementById('highlight-info');
310
-
311
- // Respond to highlight update and highlight activate event
312
- container.addEventListener('hh:highlightupdate', updateHighlightInfo);
313
- container.addEventListener('hh:highlightactivate', updateHighlightInfo);
314
- function updateHighlightInfo(event) {
315
- clearHighlightInfo();
316
- infoDiv.innerHTML = `
317
- <b>Highlight:</b> ${event.detail.highlight.highlightId}<br>
318
- <b>Color and style:</b> ${event.detail.highlight.color}, ${event.detail.highlight.style}<br>
319
- <b>Wrapper:</b> ${event.detail.highlight.wrapper}, ${JSON.stringify(event.detail.highlight.wrapperVariables)}<br>
320
- <b>Read-only:</b> ${event.detail.highlight.readOnly}<br>
321
- <b>Start:</b> #${event.detail.highlight.startParagraphId}, offset ${event.detail.highlight.startParagraphOffset}<br>
322
- <b>End:</b> #${event.detail.highlight.endParagraphId}, offset ${event.detail.highlight.endParagraphOffset}
323
- `;
324
- const annotatedParagraphs = container.querySelectorAll(`#${event.detail.highlight.rangeParagraphIds.join(', #')}`);
325
- for (const annotatedParagraph of annotatedParagraphs) {
326
- const snippetParagraph = annotatedParagraph.cloneNode(true);
327
- snippetParagraph.id = `${snippetParagraph.id}_${event.detail.highlight.highlightId}`;
328
- for (const wrapperEl of snippetParagraph.querySelectorAll('[data-highlight-id]')) {
329
- wrapperEl.dataset.highlightId = wrapperEl.dataset.highlightId + '_snippet';
631
+ .verse-group { margin-bottom: 1em; }
632
+ .verse { display: inline; }
633
+ .verse-number {
634
+ vertical-align: text-top;
635
+ font-size: 0.8em;
636
+ }
637
+ #verse-1 .verse-number { display: none; }
638
+ #verse-1 .drop-cap mark { font-weight: normal; }
639
+ .drop-cap {
640
+ float: left;
641
+ font-size: 3.1em;
642
+ line-height: 0.9;
643
+ padding-right: 0.06em;
644
+ margin-bottom: -0.3em;
645
+ }
646
+ .callout-bar {
647
+ width: 0.2em;
648
+ position: absolute;
649
+ transition: top 0.25s ease, height 0.25s ease, left 0.25s ease;
650
+ }
651
+ .callout-bar::before {
652
+ content: attr(data-verse-number);
653
+ position: absolute;
654
+ inset-block-start: 0;
655
+ inset-inline-end: 100%;
656
+ padding-inline-end: 0.3em;
657
+ white-space: nowrap;
658
+ text-align: end;
659
+ }
660
+ .container[data-playing] p:not(:has(.callout-bar)) {
661
+ opacity: 0.5;
330
662
  }
331
- snippetDiv.append(snippetParagraph);
332
663
  }
333
- const snippetHighlights = [
664
+ </style>
665
+ </section>
666
+
667
+ <section id="document-draft">
668
+ <h2>Demo 3: Document draft</h2>
669
+ <article>
670
+ <div class="container">
671
+ <h3 id="cereal-title">About Cereal</h3>
672
+ <p id="cereal">A cereal is a grass (family Poaceae) cultivated for its edible grain. Cereals are the worlds largest crops, and are therefore staple foods. They include rice, wheat, rye, oats, barley, millet, and maize. Edible grains from other plant families, such as amaranth, buckwheat and quinoa, are pseudocereals (Eudicots). Most cereals are annuals, producing one crop from each planting, though rice is sometimes grown as a perennial. Winter varieties are hardy enough to be planted in the autumn, becoming dormant in the winter, and harvested in spring or early summer; spring varieties are planted in spring and harvested in late summer. The term cereal is derived from the name of the Roman goddess of grain crops and fertility, Ceres.</p>
673
+ <img src="https://upload.wikimedia.org/wikipedia/commons/7/76/Unload_wheat_by_the_combine_Claas_Lexion_584.jpg" loading="lazy">
674
+ <p id="more-cereal">Cereals were domesticated in the Neolithic around 8,000 years ago. Wheat and barley were domesticated in the Fertile Crescent. Rice and some millets were domesticated in East Asia, while sorghum and other millets were domesticated in Sudan. Maize was domesticated by Indigenous peoples of the Americas in southern Mexico about 9,000 years ago.</p>
675
+ <p id="even-more-cereal">In the 20th century, cereal productivity was greatly increased by the Green Revolution. This increase in production has acompanied a growing international trade, with some countrys producing large portions of the cereal supply for other countries.</p>
676
+ <p class="explanation">This demo uses highlights to show spelling errors, review comments, and the location of other people working in the document. Highlight wrappers are used for hover/tap popups and caret indicators.</p>
677
+
678
+ </div>
679
+ </article>
680
+
681
+ <script type="module">
682
+ const highlights = [
683
+ {
684
+ highlightId: 'spellcheck-1',
685
+ color: 'red', style: 'spelling-error',
686
+ wrapper: 'spelling-suggestion',
687
+ variables: {
688
+ 'suggestionText': 'world’s',
689
+ },
690
+ startParagraphId: 'cereal', startParagraphOffset: 86,
691
+ endParagraphId: 'cereal', endParagraphOffset: 92,
692
+ },
693
+ {
694
+ highlightId: 'spellcheck-2',
695
+ color: 'red', style: 'spelling-error',
696
+ wrapper: 'spelling-suggestion',
697
+ variables: {
698
+ 'suggestionText': 'countries',
699
+ },
700
+ startParagraphId: 'even-more-cereal', startParagraphOffset: 172,
701
+ endParagraphId: 'even-more-cereal', endParagraphOffset: 180,
702
+ },
703
+ {
704
+ highlightId: 'comment-1',
705
+ color: 'gray', style: 'comment',
706
+ wrapper: 'comment',
707
+ variables: {
708
+ 'name': 'Alice',
709
+ 'commentText': 'Start new paragraph?',
710
+ },
711
+ startParagraphId: 'cereal', startParagraphOffset: 315,
712
+ endParagraphId: 'cereal', endParagraphOffset: 327,
713
+ },
714
+ {
715
+ highlightId: 'comment-2',
716
+ color: 'gray', style: 'comment',
717
+ wrapper: 'comment',
718
+ variables: {
719
+ 'name': 'Alice',
720
+ 'commentText': 'Add citation',
721
+ },
722
+ startParagraphId: 'cereal', startParagraphOffset: 93,
723
+ endParagraphId: 'cereal', endParagraphOffset: 107,
724
+ },
334
725
  {
335
- highlightId: event.detail.highlight.highlightId + '_snippet',
336
- readOnly: true,
337
- color: event.detail.highlight.color, style: event.detail.highlight.style,
338
- startParagraphId: `${event.detail.highlight.startParagraphId}_${event.detail.highlight.highlightId}`, startParagraphOffset: event.detail.highlight.startParagraphOffset,
339
- endParagraphId: `${event.detail.highlight.endParagraphId}_${event.detail.highlight.highlightId}`, endParagraphOffset: event.detail.highlight.endParagraphOffset,
726
+ highlightId: 'alice',
727
+ color: 'green', style: 'text-selection',
728
+ wrapper: 'user-caret',
729
+ variables: {
730
+ 'name': 'Alice',
731
+ },
732
+ startParagraphId: 'more-cereal', startParagraphOffset: 67,
733
+ endParagraphId: 'more-cereal', endParagraphOffset: 88,
734
+ },
735
+ {
736
+ highlightId: 'henry',
737
+ color: 'orange', style: 'text-selection',
738
+ wrapper: 'user-caret',
739
+ variables: {
740
+ 'name': 'Henry',
741
+ },
742
+ startParagraphId: 'even-more-cereal', startParagraphOffset: 10,
743
+ endParagraphId: 'even-more-cereal', endParagraphOffset: 11,
744
+ },
745
+ {
746
+ highlightId: 'bob',
747
+ color: 'blue', style: 'text-selection',
748
+ wrapper: 'user-caret',
749
+ variables: {
750
+ 'name': 'Bob',
751
+ },
752
+ startParagraphId: 'cereal-title', startParagraphOffset: 0,
753
+ endParagraphId: 'cereal-title', endParagraphOffset: 5,
340
754
  },
341
755
  ];
342
- const snippetHighlighter = new Highlighter({
343
- containerSelector: '#snippet-container',
344
- paragraphSelector: '#snippet [id]',
345
- });
346
- snippetHighlighter.loadHighlights(snippetHighlights);
347
- }
348
756
 
349
- // Respond to highlight deactivate event
350
- container.addEventListener('hh:highlightdeactivate', clearHighlightInfo);
351
- function clearHighlightInfo(event) {
352
- snippetDiv.innerHTML = '';
353
- infoDiv.innerHTML = 'N/A';
354
- }
757
+ const options = {
758
+ drawingMode: 'svg',
759
+ enableHover: true,
760
+ colorDefs: {
761
+ 'gray': 'var(--text-color)',
762
+ },
763
+ styleDefs: {
764
+ 'spelling-error': {
765
+ css: 'text-decoration: var(--hh-color) underline wavy; text-decoration-skip-ink: none;',
766
+ },
767
+ 'comment': {
768
+ css: 'background-color: hsl(from var(--hh-color) h s l / 15%);',
769
+ svg: '<rect x="{line.x}" y="{line.y}" style="fill: hsl(from var(--hh-color) h s l / 15%); width: {line.width}px; height: calc({line.height}px * 0.85); transform: translateY(calc({line.height}px * 0.15));" />',
770
+ },
771
+ 'text-selection': {
772
+ css: 'background-color: hsl(from var(--hh-color) h s l / 30%);',
773
+ svg: '<rect x="{line.x}" y="{line.y}" style="fill: hsl(from var(--hh-color) h s l / 30%); width: {line.width}px; height: calc({line.height}px * 0.85); transform: translateY(calc({line.height}px * 0.15));" />',
774
+ },
775
+ },
776
+ wrapperDefs: {
777
+ 'spelling-suggestion': {
778
+ start: '<label class="spelling-error toggle-proxy"><input type="checkbox" aria-label="Spelling suggestion"></label><div class="popup">{suggestionText}</div>',
779
+ },
780
+ 'comment': {
781
+ start: '<label class="comment toggle-proxy"><input type="checkbox" aria-label="Show/hide comment"></label><div class="popup"><b>{name}:</b> {commentText}</div>',
782
+ },
783
+ 'user-caret': {
784
+ end: '<span class="user-caret" data-name="{name}" style="bottom: calc(-1 * {line.height}px * 0.3);"></span>',
785
+ },
786
+ },
787
+ };
355
788
 
356
- // Respond to tap
357
- container.addEventListener('hh:tap', respondToTap);
358
- function respondToTap(event) {
359
- if (event.detail.wrappers.length === 1) {
360
- highlighter.activateHighlight(event.detail.wrappers[0].highlightInfo.highlightId);
361
- }
362
- }
789
+ // Load highlights
790
+ const highlighter = new Highlighter('#document-draft .container', '[id]');
791
+ highlighter.setOptions(options);
792
+ highlighter.loadHighlights(highlights);
363
793
 
364
- // Respond to ambiguous action events (when a user taps on overlapping highlights, wrappers, and/or links)
365
- const disambiguationPanel = document.getElementById('disambiguation-panel');
366
- const disambiguationButtonsDiv = document.getElementById('disambiguation-buttons');
367
- const disambiguationCancelButton = document.getElementById('disambiguation-cancel');
368
- disambiguationCancelButton.addEventListener('click', (event) => disambiguationPanel.close());
369
- container.addEventListener('hh:ambiguousaction', handleAmbiguousAction);
370
- function handleAmbiguousAction(event) {
371
- disambiguationButtonsDiv.innerHTML = '';
372
- const highlightIds = new Set();
373
- for (const highlight of event.detail.highlights) highlightIds.add(highlight.highlightId);
374
- for (const wrapper of event.detail.wrappers) highlightIds.add(wrapper.highlightInfo.highlightId);
375
- const highlights = highlighter.getHighlightInfo(highlightIds);
376
- for (const highlight of highlights) {
377
- let buttonStyle = highlighter.getOptions().styles[highlight.style]['css']
378
- let buttonHtml = `
379
- <button data-color="${highlight.color}" onclick="disambiguationPanel.close();highlighter.activateHighlight('${highlight.highlightId}');">
380
- <span style="${buttonStyle}">${highlight.rangeHtml}</span>
381
- </button>
382
- `;
383
- disambiguationButtonsDiv.insertAdjacentHTML('beforeend', buttonHtml);
794
+ // Add event listener for tap and hover
795
+ let hoverHighlightId;
796
+ const container = document.querySelector('#document-draft .container');
797
+ const checkbox = (id) => container.querySelector(`[data-hh-wrapper][data-hh-highlight-id="${id}"] [type="checkbox"]`);
798
+ function showPopup(event) {
799
+ const highlight = event.detail.highlights[0];
800
+ if (hoverHighlightId && hoverHighlightId !== highlight?.highlightId) {
801
+ checkbox(hoverHighlightId).checked = false;
802
+ hoverHighlightId = null;
803
+ }
804
+ if (highlight?.style === 'spelling-error' || highlight?.style === 'comment') {
805
+ if (event.type === 'hh:hover') {
806
+ container.style.cursor = 'pointer';
807
+ hoverHighlightId = highlight?.highlightId;
808
+ }
809
+ checkbox(hoverHighlightId).checked = !checkbox(hoverHighlightId)?.checked;
810
+ } else {
811
+ container.style.cursor = '';
812
+ }
384
813
  }
385
- for (const hyperlink of event.detail.hyperlinks) {
386
- let buttonStyle = 'color: var(--gray-50); text-decoration: underline;'
387
- let buttonHtml = `
388
- <button onclick="disambiguationPanel.close();highlighter.activateHyperlink(${hyperlink.position});">
389
- <span style="${buttonStyle}">${hyperlink.text}</span>
390
- </button>
391
- `;
392
- disambiguationButtonsDiv.insertAdjacentHTML('beforeend', buttonHtml);
814
+ container.addEventListener('hh:tap', showPopup);
815
+ container.addEventListener('hh:hover', showPopup);
816
+ </script>
817
+ <style>
818
+ @scope {
819
+ article {
820
+ position: relative;
821
+ padding-right: 1em;
822
+ }
823
+ img { width: 100%; }
824
+ .explanation { font-style: italic; }
825
+
826
+ .user-caret {
827
+ position: relative;
828
+ display: inline-block;
829
+ height: 1em;
830
+ margin: 0 1px 0 -1px;
831
+ }
832
+ .user-caret::after {
833
+ display: inline-block;
834
+ content: "";
835
+ position: absolute; bottom: 0;
836
+ width: 3px;
837
+ height: 1.6em;
838
+ background-color: var(--hh-color);
839
+ }
840
+ .user-caret:hover::before {
841
+ position: absolute; top: -2em;
842
+ content: attr(data-name);
843
+ color: white;
844
+ font-size: 0.7rem;
845
+ padding: 0 0.4em;
846
+ font-family: sans-serif;
847
+ font-weight: normal;
848
+ background-color: var(--hh-color);
849
+ }
850
+
851
+ .toggle-proxy { display: inline; }
852
+ .toggle-proxy:has(input:focus-visible) {
853
+ outline: 2px solid -webkit-focus-ring-color;
854
+ }
855
+ .toggle-proxy input {
856
+ position: absolute;
857
+ width: 1px; height: 1px;
858
+ margin: -1px; padding: 0;
859
+ overflow: hidden;
860
+ clip-path: inset(50%);
861
+ border: 0;
862
+ }
863
+ .popup {
864
+ position: absolute;
865
+ padding: 0.2em 0.4em;
866
+ background-color: var(--page-bg);
867
+ border: 1px solid var(--dark-gray);
868
+ z-index: 1;
869
+ }
870
+ .toggle-proxy:has(input:not(:checked)) + .popup {
871
+ display: none;
872
+ }
873
+ [data-hh-wrapper="spelling-suggestion"] { position: relative; }
874
+ .toggle-proxy.comment::after {
875
+ content: "💬";
876
+ cursor: pointer;
877
+ position: absolute; right: -0.8rem;
878
+ }
879
+ [data-hh-wrapper="comment"] .popup { right: 0; }
880
+ [data-hh-wrapper="spelling-suggestion"] .popup {
881
+ bottom: 1.2em; left: -0.4em;
882
+ }
393
883
  }
394
- // TODO: Waiting 10 milliseconds before showing the disambiguation panel is a workaround for an issue in Android Chrome where a click event fires right after the pointerup event that triggered hh:ambiguousaction. If the click event happens to be at the same position as a button in the panel, the button will receive the click, dismissing the panel before the user sees it.
395
- setTimeout(function() { disambiguationPanel.showModal(); }, 10);
884
+ </style>
885
+ </section>
886
+
887
+ <!-- Demo template -->
888
+ <!--
889
+ <section id="demo-name">
890
+ <h2>Demo name</h2>
891
+ <div class="container">
892
+ </div>
893
+
894
+ <script type="module">
895
+ </script>
896
+ <style>
897
+ </style>
898
+ </section>
899
+ -->
900
+
901
+
902
+ <section>
903
+ <h3>Demo Credits</h3>
904
+ <ul>
905
+ <li>1 Corinthians 13 text and audio from <a href="https://www.churchofjesuschrist.org/study/scriptures/nt/1-cor/13?lang=eng">ChurchofJesusChrist.org</a>.</li>
906
+ <li>“Cereal” text adapted from <a href="https://en.wikipedia.org/wiki/Cereal">Wikipedia</a>.</li>
907
+ </ul>
908
+ </section>
909
+
910
+ <!-- GLOBAL JS for all demos -->
911
+ <script>
912
+ const jumpLinksList = document.getElementById('jump-links');
913
+ const sections = document.querySelectorAll('section');
914
+
915
+ if (typeof CSSScopeRule != 'undefined') document.getElementById('compatibility-warning').style.display = 'none';
916
+
917
+ for (const section of sections) {
918
+ showCode(section);
919
+ const title = section.querySelector('h2')?.textContent;
920
+ if (title) jumpLinksList.insertAdjacentHTML('beforeend', `<li><a href="#${section.id}">${title}</a></li>`)
396
921
  }
397
922
 
398
- // Update margin note position when a highlight with a start wrapper is active
399
- function updateMarginNotePosition(event) {
400
- const activeHighlightId = highlighter.getActiveHighlightId();
401
- if (!activeHighlightId) return;
402
- const note = container.querySelector(`.hh-wrapper-start[data-highlight-id="${activeHighlightId}"] .note`);
403
- if (!note) return;
404
- const selection = getSelection();
405
- const startRect = selection.type === 'Range' ? selection.getRangeAt(0).getBoundingClientRect() : null;
406
- if (startRect) note.style.top = (startRect.top + window.scrollY - 1) + 'px';
923
+ function showCode(section) {
924
+ const buildCodeBlock = (code, caption) => {
925
+ if (!code) return;
926
+ const codeLines = code.replaceAll('&', '&amp;').replaceAll('<', '&lt;').split('\n').slice(1, -1);
927
+ const minIndent = codeLines.reduce((min, line) => {
928
+ const spaces = line.length - line.trimStart().length;
929
+ return spaces === line.length ? min : Math.min(min, spaces);
930
+ }, Infinity);
931
+ return `<figure class="code-block"><figcaption>${caption}</figcaption>` + codeLines.map(line => {
932
+ const indent = line.length - line.trimStart().length - minIndent;
933
+ return `<code style="--indent: ${indent}em;">${line}</code>`;
934
+ }).join('') + '</figure>';
935
+ }
936
+ const jsCodeBlock = buildCodeBlock(section.querySelector('script')?.textContent ?? '', 'JavaScript');
937
+ const cssCodeBlock = buildCodeBlock(section.querySelector('style')?.textContent ?? '', 'CSS');
938
+ if (jsCodeBlock || cssCodeBlock) {
939
+ section.insertAdjacentHTML('beforeend', `
940
+ <details data-hh-ignore="">
941
+ <summary>See code</summary>
942
+ ${jsCodeBlock}
943
+ ${cssCodeBlock}
944
+ </details>
945
+ `);
946
+ }
407
947
  }
408
- container.addEventListener('hh:highlightactivate', updateMarginNotePosition);
409
- document.addEventListener('selectionchange', updateMarginNotePosition);
410
-
411
- // Log events for debugging
412
- container.addEventListener('hh:highlightcreate', console.info)
413
- container.addEventListener('hh:highlightupdate', console.info)
414
- container.addEventListener('hh:highlightremove', console.info)
415
- container.addEventListener('hh:highlightsload', console.info)
416
- container.addEventListener('hh:highlightactivate', console.info)
417
- container.addEventListener('hh:highlightdeactivate', console.info)
418
- container.addEventListener('hh:selectionupdate', console.info)
419
- container.addEventListener('hh:ambiguousaction', console.info)
420
- container.addEventListener('hh:tap', console.info)
421
948
  </script>
422
949
  </body>
423
950
  </html>