@samuelbradshaw/highlight-helper-js 0.9.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,375 +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
- img { width: 120px; float: right; }
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
  }
34
-
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;
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>
68
+
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
+ }
239
+ }
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
+ `;
49
268
  }
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"] { color: inherit; border: 2px solid var(--gray-50); }
58
- button[name="hh-remove"] { background-color: var(--gray-50); color: var(--page-bg); }
59
- .highlight-placeholder {
60
- background-color: var(--gray-90);
61
- 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
+ });
62
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);
63
285
  }
64
- }
65
- .footnote-marker {
66
- display: inline-block;
67
- vertical-align: super;
68
- font-size: smaller;
69
- line-height: 0.5em;
70
- &::before {
71
- content: attr(data-marker) / 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]);
72
291
  }
73
- }
74
-
75
- #disambiguation-panel {
76
- color: inherit;
77
- background-color: inherit;
78
- width: 400px; max-width: 100%;
79
- flex-direction: column;
80
- gap: 0.5em;
81
- box-sizing: border-box;
82
- &[open] { display: flex; }
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
+ }
307
+ }
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
+ });
363
+
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 }
83
406
  #disambiguation-buttons {
84
407
  display: flex;
85
408
  flex-direction: column;
86
- & button {
87
- display: inline-block;
88
- border: none;
89
- background-color: inherit;
90
- color: inherit;
91
- font-family: inherit;
92
- font-size: inherit;
93
- text-align: inherit;
94
- margin-bottom: 1em;
95
- & * {
96
- display: inline;
97
- }
98
- }
409
+ margin: 0.5em 0;
99
410
  }
100
- &::backdrop {
411
+ #disambiguation-buttons button {
412
+ all: unset;
413
+ display: block;
414
+ cursor: pointer;
415
+ padding: 0.5em 0;
416
+ }
417
+ #disambiguation-buttons button * { display: inline; }
418
+ #disambiguation-panel::backdrop {
101
419
  background-color: hsla(0, 0%, 0%, 0.5);
102
420
  }
103
- }
104
- </style>
105
- </head>
106
- <body>
107
- <main>
108
- <h1 id="title1">HighlightHelper.js Demo</h1>
109
- <img src="https://upload.wikimedia.org/wikipedia/commons/d/db/CMYK-color_model.png">
110
- <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>
111
- <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>
112
- <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>
113
- <p id="p4" style="direction: rtl;">فِي الْبَدْءِ خَلَقَ اللهُ السَّمَاوَاتِ وَالأَرْضَ. وَكَانَتِ الأَرْضُ خَرِبَةً وَخَالِيَةً، وَعَلَى وَجْهِ الْغَمْرِ ظُلْمَةٌ، وَرُوحُ اللهِ يَرِفُّ عَلَى وَجْهِ الْمِيَاهِ. وَقَالَ اللهُ: «لِيَكُنْ نُورٌ»، فَكَانَ نُورٌ.</p>
114
- </main>
115
-
116
- <hr>
117
- <aside id="annotation-menu">
118
- <div class="buttons-area">
119
- <button name="hh-color" value="red">Red</button>
120
- <button name="hh-color" value="orange">Orange</button>
121
- <button name="hh-color" value="yellow">Yellow</button>
122
- <button name="hh-color" value="green">Green</button>
123
- <button name="hh-color" value="blue">Blue</button>
124
- <button name="hh-style" value="fill" checked> Fill</button>
125
- <button name="hh-style" value="single-underline"> Underline</button>
126
- <button name="hh-style" value="double-underline"> Double underline</button>
127
- <button name="hh-style" value="colored-text"> Colored text</button>
128
- <button name="hh-style" value="redacted"> Redacted</button>
129
- <button name="hh-remove">Remove</button>
130
- <div></div>
131
- <!--button name="hh-wrapper" value="none" title="No wrapper"><span class="highlight-placeholder">…</span></button>
132
- <button name="hh-wrapper" value="footnote" title="Footnote"><span class="footnote-marker" data-marker="a" data-accessibility-label="Footnote"></span><span class="highlight-placeholder">…</span></button-->
133
- </div>
134
- <p>
135
- <label><input type="checkbox" id="hh-remember-style" checked> Remember most recent color and style</label><br>
136
- <label><input type="checkbox" id="hh-snap-to-word"> Snap highlights to the nearest word</label><br>
137
- </p>
138
- <p>
139
- Pointer mode:<br>
140
- <label><input type="radio" name="hh-pointer-mode" value="auto" checked> Auto (based on input device)</label><br>
141
- <label><input type="radio" name="hh-pointer-mode" value="simple"> Simple</label><br>
142
- <label><input type="radio" name="hh-pointer-mode" value="live"> Live</label><br>
143
- </p>
144
- <p>
145
- Drawing mode:<br>
146
- <label><input type="radio" name="hh-drawing-mode" value="svg" checked> SVG shapes</label><br>
147
- <label><input type="radio" name="hh-drawing-mode" value="highlight-api"> Custom Highlight API</label><br>
148
- <label><input type="radio" name="hh-drawing-mode" value="mark-elements"> HTML mark elements</label><br>
149
- </p>
150
- <p>
151
- Show custom selection handles:<br>
152
- <label><input type="checkbox" id="hh-selection-handles-active-highlights" checked> For active highlights</label><br>
153
- <label><input type="checkbox" id="hh-selection-handles-text-selection"> For text selection</label><br>
154
- <label><input type="checkbox" id="hh-selection-handles-touch"> On touch devices</label><br>
155
- </p>
156
- <b>Active highlight:</b>
157
- <div id="snippet-container">
158
- <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>
159
528
  </div>
160
- <p id="highlight-info"></p>
161
- </aside>
162
- <dialog id="disambiguation-panel">
163
- <h1>Select One</h1>
164
- <div id="disambiguation-buttons"></div>
165
- <button id="disambiguation-cancel">Cancel</button>
166
- </dialog>
167
- <p>
168
- ← <a href="https://github.com/samuelbradshaw/highlight-helper-js">Back to GitHub</a>
169
- </p>
170
-
171
- <!-- Link to the HighlightHelper.js JavaScript file -->
172
- <script src="highlight-helper.js"></script>
173
- <script>
174
- // Example highlights
175
- let highlights = [
176
- {
177
- highlightId: 'hh-1729991847207',
178
- color: 'green', style: 'fill',
179
- startParagraphId: 'p1', startParagraphOffset: 27,
180
- endParagraphId: 'p1', endParagraphOffset: 59,
181
- },
182
- {
183
- highlightId: 'hh-1729991847300',
184
- color: 'blue', style: 'fill',
185
- startParagraphId: 'p2', startParagraphOffset: 27,
186
- endParagraphId: 'p2', endParagraphOffset: 42,
187
- },
188
- {
189
- highlightId: 'hh-1729991847468',
190
- color: 'red', style: 'colored-text', wrapper: 'footnote',
191
- wrapperVariables: { marker: 'a', accessibilityLabel: 'Footnote 1a', },
192
- readOnly: true,
193
- startParagraphId: 'p2', startParagraphOffset: 119,
194
- endParagraphId: 'p2', endParagraphOffset: 127,
195
- },
196
- {
197
- highlightId: 'hh-1729991847301',
198
- color: 'orange', style: 'fill',
199
- startParagraphId: 'p3', startParagraphOffset: 56,
200
- endParagraphId: 'p3', endParagraphOffset: 65,
201
- },
202
- {
203
- highlightId: 'hh-1735379727958',
204
- color: 'yellow', style: 'fill',
205
- startParagraphId: 'p1', startParagraphOffset: 248,
206
- endParagraphId: 'p1', endParagraphOffset: 261,
207
- },
208
- {
209
- highlightId: 'hh-1735379721523',
210
- color: 'red', style: 'fill',
211
- startParagraphId: 'p1', startParagraphOffset: 239,
212
- endParagraphId: 'p1', endParagraphOffset: 252,
213
- },
214
- {
215
- highlightId: 'hh-1735479727958',
216
- color: 'yellow', style: 'fill',
217
- startParagraphId: 'p4', startParagraphOffset: 191,
218
- endParagraphId: 'p4', endParagraphOffset: 207,
219
- },
220
- ];
221
-
222
- // Initialize a Highlighter instance, then load highlights
223
- const highlighter = new Highlighter({
224
- containerSelector: 'main',
225
- paragraphSelector: 'main [id]',
226
- wrappers: {
227
- 'footnote': { start: '<span class="footnote-marker" data-marker="{marker}" data-accessibility-label="{accessibilityLabel}"></span>', end: '', },
228
- },
229
- });
230
- highlighter.loadHighlights(highlights);
231
-
232
- // Call HighlightHelper.js methods when color, style, wrapper, or remove buttons on this demo page are tapped
233
- const colorButtons = document.getElementsByName('hh-color');
234
- for (const colorButton of colorButtons) {
235
- colorButton.addEventListener('click', () => { highlighter.createOrUpdateHighlight({ color: colorButton.value }); });
236
- }
237
- const styleButtons = document.getElementsByName('hh-style');
238
- for (const styleButton of styleButtons) {
239
- styleButton.addEventListener('click', () => { highlighter.createOrUpdateHighlight({ style: styleButton.value }); });
240
- }
241
- const wrapperButtons = document.getElementsByName('hh-wrapper');
242
- for (const wrapperButton of wrapperButtons) {
243
- wrapperButton.addEventListener('click', () => { highlighter.createOrUpdateHighlight({ wrapper: wrapperButton.value }); });
244
- }
245
- const removeButton = document.getElementsByName('hh-remove')[0];
246
- removeButton.addEventListener('click', () => { highlighter.removeHighlights([highlighter.getActiveHighlightId()]); });
247
- document.addEventListener('keydown', respondToKeyPress);
248
- function respondToKeyPress(event) {
249
- if (event.key === 'Backspace') highlighter.removeHighlights([highlighter.getActiveHighlightId()]);
250
- }
251
-
252
- // Call HighlightHelper.js methods when options are changed
253
- const rememberStyleCheckbox = document.getElementById('hh-remember-style');
254
- const snapToWordCheckbox = document.getElementById('hh-snap-to-word');
255
- const pointerModeRadioButtons = document.getElementsByName('hh-pointer-mode');
256
- const drawingModeRadioButtons = document.getElementsByName('hh-drawing-mode');
257
- rememberStyleCheckbox.addEventListener('change', (event) => { highlighter.setOption('rememberStyle', event.currentTarget.checked); });
258
- snapToWordCheckbox.addEventListener('change', (event) => { highlighter.setOption('snapToWord', event.currentTarget.checked); });
259
- for (const pointerModeRadioButton of pointerModeRadioButtons) {
260
- pointerModeRadioButton.addEventListener('change', (event) => {
261
- let pointerMode = document.querySelector('[name="hh-pointer-mode"]:checked').value;
262
- highlighter.setOption('pointerMode', pointerMode);
263
- });
264
- }
265
- for (const drawingModeRadioButton of drawingModeRadioButtons) {
266
- drawingModeRadioButton.addEventListener('change', (event) => {
267
- let drawingMode = document.querySelector('[name="hh-drawing-mode"]:checked').value;
268
- if (drawingMode === 'highlight-api' && !CSS.highlights) {
269
- alert('Your browser doesn’t support the Custom Highlight API.');
270
- } else {
271
- highlighter.setOption('drawingMode', drawingMode);
529
+
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;
272
608
  }
609
+
610
+ // Update word highlight
611
+ highlighter.createOrUpdateHighlight({
612
+ highlightId: 'hh-word',
613
+ style: 'bold',
614
+ startParagraphId: currentParagraphId,
615
+ startParagraphOffset: cueData.startOffset,
616
+ endParagraphId: currentParagraphId,
617
+ endParagraphOffset: cueData.endOffset,
618
+ }, true, false);
273
619
  });
274
- }
275
- const selectionHandlesActiveHighlightsCheckbox = document.getElementById('hh-selection-handles-active-highlights');
276
- selectionHandlesActiveHighlightsCheckbox.addEventListener('change', (event) => { highlighter.setOption('showCustomSelectionHandlesForActiveHighlights', event.currentTarget.checked); });
277
- const selectionHandlesTextSelectionCheckbox = document.getElementById('hh-selection-handles-text-selection');
278
- selectionHandlesTextSelectionCheckbox.addEventListener('change', (event) => { highlighter.setOption('showCustomSelectionHandlesForTextSelection', event.currentTarget.checked); });
279
- const selectionHandlesOnTouchCheckbox = document.getElementById('hh-selection-handles-touch');
280
- selectionHandlesOnTouchCheckbox.addEventListener('change', (event) => { highlighter.setOption('showCustomSelectionHandlesOnTouch', event.currentTarget.checked); });
281
-
282
- const container = document.querySelector(highlighter.getOptions().containerSelector);
283
- const snippetDiv = document.getElementById('snippet');
284
- const infoDiv = document.getElementById('highlight-info');
285
-
286
- // Respond to highlight update and highlight activate event
287
- container.addEventListener('hh:highlightupdate', updateHighlightInfo);
288
- container.addEventListener('hh:highlightactivate', updateHighlightInfo);
289
- function updateHighlightInfo(event) {
290
- clearHighlightInfo();
291
- infoDiv.innerHTML = `
292
- <b>Highlight:</b> ${event.detail.highlight.highlightId}<br>
293
- <b>Color and style:</b> ${event.detail.highlight.color}, ${event.detail.highlight.style}<br>
294
- <b>Wrapper:</b> ${event.detail.highlight.wrapper}, ${JSON.stringify(event.detail.highlight.wrapperVariables)}<br>
295
- <b>Read-only:</b> ${event.detail.highlight.readOnly}<br>
296
- <b>Start:</b> #${event.detail.highlight.startParagraphId}, offset ${event.detail.highlight.startParagraphOffset}<br>
297
- <b>End:</b> #${event.detail.highlight.endParagraphId}, offset ${event.detail.highlight.endParagraphOffset}
298
- `;
299
- const annotatedParagraphs = container.querySelectorAll(`#${event.detail.highlight.rangeParagraphIds.join(', #')}`);
300
- for (const annotatedParagraph of annotatedParagraphs) {
301
- const snippetParagraph = annotatedParagraph.cloneNode(true);
302
- snippetParagraph.id = `${snippetParagraph.id}_${event.detail.highlight.highlightId}`;
303
- snippetDiv.append(snippetParagraph);
620
+
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;
630
+ }
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;
662
+ }
304
663
  }
305
- 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
+ },
306
693
  {
307
- highlightId: event.detail.highlight.highlightId + '_snippet',
308
- readOnly: true,
309
- color: event.detail.highlight.color, style: event.detail.highlight.style,
310
- startParagraphId: `${event.detail.highlight.startParagraphId}_${event.detail.highlight.highlightId}`, startParagraphOffset: event.detail.highlight.startParagraphOffset,
311
- endParagraphId: `${event.detail.highlight.endParagraphId}_${event.detail.highlight.highlightId}`, endParagraphOffset: event.detail.highlight.endParagraphOffset,
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
+ },
725
+ {
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,
312
754
  },
313
755
  ];
314
- const snippetHighlighter = new Highlighter({
315
- containerSelector: '#snippet-container',
316
- paragraphSelector: '#snippet [id]',
317
- });
318
- snippetHighlighter.loadHighlights(snippetHighlights);
319
- }
320
-
321
- // Respond to highlight deactivate event
322
- container.addEventListener('hh:highlightdeactivate', clearHighlightInfo);
323
- function clearHighlightInfo(event) {
324
- snippetDiv.innerHTML = '';
325
- infoDiv.innerHTML = 'N/A';
756
+
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
+ };
788
+
789
+ // Load highlights
790
+ const highlighter = new Highlighter('#document-draft .container', '[id]');
791
+ highlighter.setOptions(options);
792
+ highlighter.loadHighlights(highlights);
793
+
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
+ }
813
+ }
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
+ }
883
+ }
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>`)
326
921
  }
327
-
328
- // Respond to ambiguous action events (when a user taps on overlapping highlights, or an overlapping link and highlight)
329
- const disambiguationPanel = document.getElementById('disambiguation-panel');
330
- const disambiguationButtonsDiv = document.getElementById('disambiguation-buttons');
331
- const disambiguationCancelButton = document.getElementById('disambiguation-cancel');
332
- disambiguationCancelButton.addEventListener('click', (event) => disambiguationPanel.close());
333
- container.addEventListener('hh:ambiguousaction', handleAmbiguousAction);
334
- function handleAmbiguousAction(event) {
335
- disambiguationButtonsDiv.innerHTML = '';
336
- for (const highlight of event.detail.highlights) {
337
- let buttonStyle = highlighter.getOptions().styles[highlight.style]['css']
338
- let buttonHtml = `
339
- <button data-color="${highlight.color}" onclick="disambiguationPanel.close();highlighter.activateHighlight('${highlight.highlightId}');">
340
- <span style="${buttonStyle}">${highlight.rangeHtml}</span>
341
- </button>
342
- `;
343
- disambiguationButtonsDiv.insertAdjacentHTML('beforeend', buttonHtml);
922
+
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>';
344
935
  }
345
- for (const hyperlink of event.detail.hyperlinks) {
346
- let buttonStyle = 'color: var(--gray-50); text-decoration: underline;'
347
- let buttonHtml = `
348
- <button onclick="disambiguationPanel.close();highlighter.activateHyperlink(${hyperlink.position});">
349
- <span style="${buttonStyle}">${hyperlink.text}</span>
350
- </button>
351
- `;
352
- disambiguationButtonsDiv.insertAdjacentHTML('beforeend', buttonHtml);
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
+ `);
353
946
  }
354
- // 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.
355
- setTimeout(function() { disambiguationPanel.showModal(); }, 10);
356
947
  }
357
-
358
- // Log events for debugging
359
- container.addEventListener('hh:highlightcreate', console.info)
360
- container.addEventListener('hh:highlightupdate', console.info)
361
- container.addEventListener('hh:highlightremove', console.info)
362
- container.addEventListener('hh:highlightsload', console.info)
363
- container.addEventListener('hh:highlightactivate', console.info)
364
- container.addEventListener('hh:highlightdeactivate', console.info)
365
- container.addEventListener('hh:selectionupdate', console.info)
366
- container.addEventListener('hh:ambiguousaction', console.info)
367
- container.addEventListener('hh:tap', console.info)
368
-
369
- // snippetDiv.addEventListener('hh:highlightcreate', console.warn)
370
- // snippetDiv.addEventListener('hh:highlightupdate', console.warn)
371
- // snippetDiv.addEventListener('hh:highlightremove', console.warn)
372
- // snippetDiv.addEventListener('hh:highlightsload', console.warn)
373
- // snippetDiv.addEventListener('hh:highlightactivate', console.warn)
374
- // snippetDiv.addEventListener('hh:highlightdeactivate', console.warn)
375
- // snippetDiv.addEventListener('hh:selectionupdate', console.warn)
376
- // snippetDiv.addEventListener('hh:ambiguousaction', console.warn)
377
- // snippetDiv.addEventListener('hh:tap', console.warn)
378
948
  </script>
379
949
  </body>
380
950
  </html>