@undrr/undrr-mangrove 2.0.1 → 2.1.1
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/README.md +1 -1
- package/components/BookCard.js +1 -1
- package/components/Breadcrumbs.js +1 -1
- package/components/Checkbox.js +1 -1
- package/components/Chips.js +1 -1
- package/components/CopyButton.js +1 -1
- package/components/CtaButton.js +1 -1
- package/components/Drawer.js +2 -2
- package/components/EmbedContainer.js +1 -1
- package/components/Footer.js +1 -1
- package/components/FormAction.js +1 -1
- package/components/FormErrorSummary.js +1 -1
- package/components/FormGroup.js +1 -1
- package/components/FullWidth.js +1 -1
- package/components/Gallery.js +2 -2
- package/components/Hero.js +2 -2
- package/components/HighlightBox.js +1 -1
- package/components/HorizontalBookCard.js +1 -1
- package/components/HorizontalCard.js +1 -1
- package/components/IconCard.js +1 -1
- package/components/Legend.js +1 -1
- package/components/Loader.js +2 -2
- package/components/MegaMenu.js +2 -2
- package/components/Notice.js +1 -1
- package/components/PageHeader.js +1 -1
- package/components/Pager.js +1 -1
- package/components/ProgressBar.js +2 -2
- package/components/QuoteHighlight.js +1 -1
- package/components/Radio.js +1 -1
- package/components/Range.js +1 -1
- package/components/ScrollContainer.js +1 -1
- package/components/SegmentedControl.js +1 -1
- package/components/Select.js +1 -1
- package/components/ServiceNotice.js +1 -1
- package/components/ShareButtons.js +1 -1
- package/components/ShowMore.js +2 -2
- package/components/SkipLink.js +1 -1
- package/components/StatsCard.js +1 -1
- package/components/SyndicationSearchWidget.js +2 -2
- package/components/Tab.js +1 -1
- package/components/TextCta.js +1 -1
- package/components/TextInput.js +1 -1
- package/components/Textarea.js +1 -1
- package/components/Tree.js +1 -1
- package/components/UserFeedback.js +1 -1
- package/components/VerticalCard.js +1 -1
- package/components/hydrate.js +2 -2
- package/css/style-all.css +41 -42
- package/css/style-all.css.map +1 -1
- package/css/style-delta.css +41 -42
- package/css/style-delta.css.map +1 -1
- package/css/style-gutenberg.css +4 -4
- package/css/style-gutenberg.css.map +1 -1
- package/css/style-irp.css +41 -42
- package/css/style-irp.css.map +1 -1
- package/css/style-mcr.css +41 -42
- package/css/style-mcr.css.map +1 -1
- package/css/style-preventionweb.css +41 -42
- package/css/style-preventionweb.css.map +1 -1
- package/css/style.css +41 -42
- package/css/style.css.map +1 -1
- package/docs/EDITORIAL-MANUAL.md +1 -1
- package/docs/llms-motion.txt +125 -0
- package/error-pages/401.html +1 -1
- package/error-pages/403.html +1 -1
- package/error-pages/404.html +1 -1
- package/error-pages/429.html +1 -1
- package/error-pages/500.html +1 -1
- package/error-pages/502.html +1 -1
- package/error-pages/503.html +1 -1
- package/error-pages/504.html +1 -1
- package/error-pages/5xx.html +1 -1
- package/error-pages/challenge.html +1 -1
- package/error-pages/managed-challenge.html +1 -1
- package/js/cite-this.js +685 -43
- package/js/on-this-page-nav.js +80 -1
- package/js/show-more.js +149 -21
- package/package.json +1 -1
- package/scss/Atom/Images/AuthorImage/author-image.scss +9 -2
- package/scss/Atom/ReachElement/Details/details.scss +4 -2
- package/scss/Components/Cards/Card/card.scss +8 -2
- package/scss/Components/Cards/StatsCard/stats-card.scss +8 -1
- package/scss/Components/CiteThis/cite-this.scss +162 -9
- package/scss/Components/Gallery/gallery.scss +40 -15
- package/scss/Components/Hero/hero.scss +165 -0
- package/scss/Components/MegaMenu/mega-menu.scss +42 -121
- package/scss/Components/Navigation/Drawer/drawer.scss +10 -0
- package/scss/Components/OnThisPageNav/on-this-page-nav.scss +108 -3
- package/scss/Components/SyndicationSearchWidget/syndication-search-widget.scss +258 -127
- package/scss/Components/Tab/tab.scss +2 -1
- package/scss/Patterns/RichContent/rich-content.scss +177 -0
- package/scss/Patterns/_shared/pattern-demo.scss +5 -0
- package/scss/Utilities/Loader/loader.scss +89 -11
- package/scss/Utilities/ShowMore/show-more.scss +6 -0
- package/scss/assets/scss/_components.scss +3 -0
- package/scss/assets/scss/_foundational.scss +23 -8
- package/scss/assets/scss/_mixins.scss +5 -0
- package/scss/assets/scss/_variables.scss +1 -1
package/js/cite-this.js
CHANGED
|
@@ -18,6 +18,152 @@ const WORK_TYPES = new Set([
|
|
|
18
18
|
const instances = new WeakMap();
|
|
19
19
|
let nextDialogId = 0;
|
|
20
20
|
|
|
21
|
+
// Built-in interface text for the UN official languages and Japanese. Data
|
|
22
|
+
// attributes still override any string, for other languages or house style.
|
|
23
|
+
// Title quotes and separators follow each language's citation conventions.
|
|
24
|
+
const STRINGS = {
|
|
25
|
+
en: {
|
|
26
|
+
label: 'Cite this',
|
|
27
|
+
copy: 'Copy citation',
|
|
28
|
+
copied: 'Citation copied',
|
|
29
|
+
failed: 'Copy failed. Select the citation and copy it manually.',
|
|
30
|
+
copyUrl: 'Copy citation URL',
|
|
31
|
+
urlCopied: 'URL copied',
|
|
32
|
+
urlFailed:
|
|
33
|
+
'Copy failed. Select the URL in the citation and copy it manually.',
|
|
34
|
+
close: 'Close',
|
|
35
|
+
more: 'All citation options',
|
|
36
|
+
ris: 'Download RIS',
|
|
37
|
+
bibtex: 'Download BibTeX',
|
|
38
|
+
accessed: 'accessed {date}',
|
|
39
|
+
quoteOpen: '“',
|
|
40
|
+
quoteClose: '”',
|
|
41
|
+
separator: ', ',
|
|
42
|
+
},
|
|
43
|
+
ar: {
|
|
44
|
+
label: 'الاستشهاد بهذا المحتوى',
|
|
45
|
+
copy: 'نسخ الاستشهاد',
|
|
46
|
+
copied: 'تم نسخ الاستشهاد',
|
|
47
|
+
failed: 'تعذّر النسخ. حدّد الاستشهاد وانسخه يدويًا.',
|
|
48
|
+
copyUrl: 'نسخ رابط الاستشهاد',
|
|
49
|
+
urlCopied: 'تم نسخ الرابط',
|
|
50
|
+
urlFailed: 'تعذّر النسخ. حدّد الرابط في الاستشهاد وانسخه يدويًا.',
|
|
51
|
+
close: 'إغلاق',
|
|
52
|
+
more: 'جميع خيارات الاستشهاد',
|
|
53
|
+
ris: 'تنزيل RIS',
|
|
54
|
+
bibtex: 'تنزيل BibTeX',
|
|
55
|
+
accessed: 'تاريخ الاطلاع: {date}',
|
|
56
|
+
quoteOpen: '“',
|
|
57
|
+
quoteClose: '”',
|
|
58
|
+
separator: '، ',
|
|
59
|
+
},
|
|
60
|
+
es: {
|
|
61
|
+
label: 'Citar',
|
|
62
|
+
copy: 'Copiar la cita',
|
|
63
|
+
copied: 'Cita copiada',
|
|
64
|
+
failed: 'No se pudo copiar. Seleccione la cita y cópiela manualmente.',
|
|
65
|
+
copyUrl: 'Copiar la URL de la cita',
|
|
66
|
+
urlCopied: 'URL copiada',
|
|
67
|
+
urlFailed:
|
|
68
|
+
'No se pudo copiar. Seleccione la URL en la cita y cópiela manualmente.',
|
|
69
|
+
close: 'Cerrar',
|
|
70
|
+
more: 'Todas las opciones de cita',
|
|
71
|
+
ris: 'Descargar RIS',
|
|
72
|
+
bibtex: 'Descargar BibTeX',
|
|
73
|
+
accessed: 'consultado el {date}',
|
|
74
|
+
quoteOpen: '“',
|
|
75
|
+
quoteClose: '”',
|
|
76
|
+
separator: ', ',
|
|
77
|
+
},
|
|
78
|
+
fr: {
|
|
79
|
+
label: 'Citer',
|
|
80
|
+
copy: 'Copier la référence',
|
|
81
|
+
copied: 'Référence copiée',
|
|
82
|
+
failed:
|
|
83
|
+
'La copie a échoué. Sélectionnez la référence et copiez-la manuellement.',
|
|
84
|
+
copyUrl: 'Copier l’URL de la citation',
|
|
85
|
+
urlCopied: 'URL copiée',
|
|
86
|
+
urlFailed:
|
|
87
|
+
'La copie a échoué. Sélectionnez l’URL dans la référence et copiez-la manuellement.',
|
|
88
|
+
close: 'Fermer',
|
|
89
|
+
more: 'Toutes les options de citation',
|
|
90
|
+
ris: 'Télécharger RIS',
|
|
91
|
+
bibtex: 'Télécharger BibTeX',
|
|
92
|
+
accessed: 'consulté le {date}',
|
|
93
|
+
quoteOpen: '«\u202F',
|
|
94
|
+
quoteClose: '\u202F»',
|
|
95
|
+
separator: ', ',
|
|
96
|
+
},
|
|
97
|
+
ja: {
|
|
98
|
+
label: '引用する',
|
|
99
|
+
copy: '引用をコピー',
|
|
100
|
+
copied: '引用をコピーしました',
|
|
101
|
+
failed: 'コピーできませんでした。引用を選択して手動でコピーしてください。',
|
|
102
|
+
copyUrl: '引用URLをコピー',
|
|
103
|
+
urlCopied: 'URLをコピーしました',
|
|
104
|
+
urlFailed:
|
|
105
|
+
'コピーできませんでした。引用内のURLを選択して手動でコピーしてください。',
|
|
106
|
+
close: '閉じる',
|
|
107
|
+
more: 'すべての引用オプション',
|
|
108
|
+
ris: 'RISをダウンロード',
|
|
109
|
+
bibtex: 'BibTeXをダウンロード',
|
|
110
|
+
accessed: '閲覧日:{date}',
|
|
111
|
+
quoteOpen: '“',
|
|
112
|
+
quoteClose: '”',
|
|
113
|
+
separator: ', ',
|
|
114
|
+
},
|
|
115
|
+
ru: {
|
|
116
|
+
label: 'Как цитировать',
|
|
117
|
+
copy: 'Копировать библиографическую ссылку',
|
|
118
|
+
copied: 'Библиографическая ссылка скопирована',
|
|
119
|
+
failed:
|
|
120
|
+
'Не удалось скопировать. Выделите библиографическую ссылку и скопируйте её вручную.',
|
|
121
|
+
copyUrl: 'Копировать URL-адрес',
|
|
122
|
+
urlCopied: 'URL-адрес скопирован',
|
|
123
|
+
urlFailed:
|
|
124
|
+
'Не удалось скопировать. Выделите URL-адрес в библиографической ссылке и скопируйте его вручную.',
|
|
125
|
+
close: 'Закрыть',
|
|
126
|
+
more: 'Все варианты цитирования',
|
|
127
|
+
ris: 'Скачать RIS',
|
|
128
|
+
bibtex: 'Скачать BibTeX',
|
|
129
|
+
accessed: 'дата обращения: {date}',
|
|
130
|
+
quoteOpen: '«',
|
|
131
|
+
quoteClose: '»',
|
|
132
|
+
separator: ', ',
|
|
133
|
+
},
|
|
134
|
+
zh: {
|
|
135
|
+
label: '引用',
|
|
136
|
+
copy: '复制引用',
|
|
137
|
+
copied: '引用已复制',
|
|
138
|
+
failed: '复制失败。请选中引用内容并手动复制。',
|
|
139
|
+
copyUrl: '复制引用网址',
|
|
140
|
+
urlCopied: '网址已复制',
|
|
141
|
+
urlFailed: '复制失败。请选中引用中的网址并手动复制。',
|
|
142
|
+
close: '关闭',
|
|
143
|
+
more: '所有引用选项',
|
|
144
|
+
ris: '下载 RIS',
|
|
145
|
+
bibtex: '下载 BibTeX',
|
|
146
|
+
accessed: '访问日期:{date}',
|
|
147
|
+
quoteOpen: '《',
|
|
148
|
+
quoteClose: '》',
|
|
149
|
+
separator: ', ',
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
function languageOf(element) {
|
|
154
|
+
return (
|
|
155
|
+
element.closest('[lang]')?.getAttribute('lang') ||
|
|
156
|
+
element.ownerDocument.documentElement.lang ||
|
|
157
|
+
'en'
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Built-in interface text for a language tag, falling back to English. */
|
|
162
|
+
export function citeThisStrings(lang = 'en') {
|
|
163
|
+
const primary = String(lang).toLowerCase().split(/[-_]/)[0];
|
|
164
|
+
return STRINGS[primary] || STRINGS.en;
|
|
165
|
+
}
|
|
166
|
+
|
|
21
167
|
function firstText(value) {
|
|
22
168
|
if (Array.isArray(value))
|
|
23
169
|
return value.map(firstText).filter(Boolean).join(', ');
|
|
@@ -103,18 +249,79 @@ function schemaWork(doc, canonical) {
|
|
|
103
249
|
);
|
|
104
250
|
}
|
|
105
251
|
|
|
252
|
+
function personName(value) {
|
|
253
|
+
if (value?.familyName && value?.givenName) {
|
|
254
|
+
return `${firstText(value.familyName)}, ${firstText(value.givenName)}`;
|
|
255
|
+
}
|
|
256
|
+
return firstText(value);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function workType(configured, work) {
|
|
260
|
+
const type = configured?.trim().toLowerCase();
|
|
261
|
+
if (['book', 'report', 'webpage'].includes(type)) return type;
|
|
262
|
+
const types = workTypes(work);
|
|
263
|
+
if (types.includes('Book')) return 'book';
|
|
264
|
+
if (types.includes('Report')) return 'report';
|
|
265
|
+
return 'webpage';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// A key that survives title and URL edits, so analytics can count one work
|
|
269
|
+
// across the pages that cite it. The URL is only the last resort.
|
|
270
|
+
function workIdentifier(data, work, url, canonical, base) {
|
|
271
|
+
const schemaId = [work?.identifier]
|
|
272
|
+
.flat()
|
|
273
|
+
.map(value => {
|
|
274
|
+
const id = value && typeof value === 'object' ? value.value : value;
|
|
275
|
+
return typeof id === 'number' ? String(id) : firstText(id);
|
|
276
|
+
})
|
|
277
|
+
.find(Boolean);
|
|
278
|
+
const provided = data.mgCiteThisId?.trim() || schemaId;
|
|
279
|
+
if (provided) return provided;
|
|
280
|
+
const candidate = nodeId(work?.['@id'], [url, canonical], base) || url;
|
|
281
|
+
// A query can identify a distinct work or contain private data. Do not
|
|
282
|
+
// forward it automatically, or strip it and merge unrelated works. The
|
|
283
|
+
// host can supply an approved stable identifier for per-work analytics.
|
|
284
|
+
return new URL(candidate, base).search ? null : candidate;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// An @id is only stable when it is not the page or work URL plus a fragment,
|
|
288
|
+
// which would change with the URL or, if relative, repeat on every page.
|
|
289
|
+
function nodeId(value, urls, base) {
|
|
290
|
+
if (typeof value !== 'string' || !value.trim()) return '';
|
|
291
|
+
let resolved;
|
|
292
|
+
try {
|
|
293
|
+
resolved = new URL(value.trim(), base);
|
|
294
|
+
} catch {
|
|
295
|
+
return '';
|
|
296
|
+
}
|
|
297
|
+
const withoutHash = cleanUrl(resolved.href, base);
|
|
298
|
+
if (!withoutHash || urls.includes(withoutHash)) return '';
|
|
299
|
+
if (cleanUrl(resolved.href, base, true) === cleanUrl(base, base, true))
|
|
300
|
+
return '';
|
|
301
|
+
return resolved.href;
|
|
302
|
+
}
|
|
303
|
+
|
|
106
304
|
function publicationYear(value) {
|
|
107
305
|
const match = String(value || '').match(/^(\d{4})(?:-|$)/);
|
|
108
306
|
return match ? match[1] : '';
|
|
109
307
|
}
|
|
110
308
|
|
|
111
|
-
function formatCitation(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
309
|
+
function formatCitation(
|
|
310
|
+
{ author, title, publisher, year, url, accessed },
|
|
311
|
+
strings = STRINGS.en
|
|
312
|
+
) {
|
|
313
|
+
const sep = strings.separator;
|
|
314
|
+
const lead = author ? `${author}${year ? ` (${year})` : ''}${sep}` : '';
|
|
315
|
+
const source = publisher && publisher !== author ? `${sep}${publisher}` : '';
|
|
115
316
|
const date = !author && year ? ` (${year})` : '';
|
|
116
|
-
const access = year
|
|
117
|
-
|
|
317
|
+
const access = year
|
|
318
|
+
? ''
|
|
319
|
+
: `${sep}${strings.accessed.replace('{date}', accessed)}`;
|
|
320
|
+
const start = lead.length + strings.quoteOpen.length;
|
|
321
|
+
return {
|
|
322
|
+
text: `${lead}${strings.quoteOpen}${title}${strings.quoteClose}${source}${date}${access}${sep}${url}.`,
|
|
323
|
+
titleRange: [start, start + title.length],
|
|
324
|
+
};
|
|
118
325
|
}
|
|
119
326
|
|
|
120
327
|
/** Resolve a citation, in editorial → configured → matching JSON-LD → page order. */
|
|
@@ -123,8 +330,6 @@ export function resolveCitation(element, now = new Date()) {
|
|
|
123
330
|
const canonical = canonicalUrl(doc);
|
|
124
331
|
const data = element.dataset;
|
|
125
332
|
const explicit = data.mgCiteThisCitation?.trim();
|
|
126
|
-
if (explicit) return { text: explicit, source: 'provided', url: canonical };
|
|
127
|
-
|
|
128
333
|
const configured = [
|
|
129
334
|
data.mgCiteThisAuthor,
|
|
130
335
|
data.mgCiteThisPublisher,
|
|
@@ -144,6 +349,9 @@ export function resolveCitation(element, now = new Date()) {
|
|
|
144
349
|
data.mgCiteThisSite?.trim() ||
|
|
145
350
|
doc.querySelector('meta[property="og:site_name"]')?.content?.trim() ||
|
|
146
351
|
new URL(canonical).hostname.replace(/^www\./, '');
|
|
352
|
+
const authors = data.mgCiteThisAuthor?.trim()
|
|
353
|
+
? [data.mgCiteThisAuthor.trim()]
|
|
354
|
+
: [work?.author].flat().map(personName).filter(Boolean);
|
|
147
355
|
const author = data.mgCiteThisAuthor?.trim() || firstText(work?.author);
|
|
148
356
|
const publisher =
|
|
149
357
|
data.mgCiteThisPublisher?.trim() || firstText(work?.publisher) || site;
|
|
@@ -151,20 +359,227 @@ export function resolveCitation(element, now = new Date()) {
|
|
|
151
359
|
const url =
|
|
152
360
|
cleanUrl(data.mgCiteThisUrl || work?.url || canonical, doc.location.href) ||
|
|
153
361
|
canonical;
|
|
154
|
-
const pageLang =
|
|
362
|
+
const pageLang = languageOf(element);
|
|
155
363
|
const locale = pageLang === 'en' ? 'en-GB' : pageLang;
|
|
156
364
|
const accessed = new Intl.DateTimeFormat(locale, {
|
|
157
365
|
day: 'numeric',
|
|
158
366
|
month: 'long',
|
|
159
367
|
year: 'numeric',
|
|
368
|
+
numberingSystem: 'latn',
|
|
160
369
|
}).format(now);
|
|
370
|
+
const fields = {
|
|
371
|
+
type: workType(data.mgCiteThisType, work),
|
|
372
|
+
authors,
|
|
373
|
+
title,
|
|
374
|
+
publisher,
|
|
375
|
+
year,
|
|
376
|
+
url,
|
|
377
|
+
accessed: now,
|
|
378
|
+
};
|
|
379
|
+
// An editorial citation describes the work in words Mangrove cannot parse,
|
|
380
|
+
// so exports need configured fields rather than page guesses.
|
|
381
|
+
const id = workIdentifier(data, work, url, canonical, doc.location.href);
|
|
382
|
+
if (explicit) {
|
|
383
|
+
return {
|
|
384
|
+
text: explicit,
|
|
385
|
+
source: 'provided',
|
|
386
|
+
url,
|
|
387
|
+
id,
|
|
388
|
+
fields: configured ? fields : null,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const { text, titleRange } = formatCitation(
|
|
392
|
+
{ author, title, publisher, year, url, accessed },
|
|
393
|
+
citeThisStrings(pageLang)
|
|
394
|
+
);
|
|
161
395
|
return {
|
|
162
|
-
text
|
|
396
|
+
text,
|
|
397
|
+
titleRange,
|
|
163
398
|
source: configured ? 'metadata' : work ? 'schema' : 'page',
|
|
164
399
|
url,
|
|
400
|
+
id,
|
|
401
|
+
fields,
|
|
165
402
|
};
|
|
166
403
|
}
|
|
167
404
|
|
|
405
|
+
const EXPORT_FORMATS = {
|
|
406
|
+
ris: {
|
|
407
|
+
extension: 'ris',
|
|
408
|
+
mime: 'application/x-research-info-systems',
|
|
409
|
+
build: toRis,
|
|
410
|
+
},
|
|
411
|
+
bibtex: {
|
|
412
|
+
extension: 'bib',
|
|
413
|
+
mime: 'application/x-bibtex',
|
|
414
|
+
build: toBibtex,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// A line break inside a field would end an RIS tag or BibTeX value early.
|
|
419
|
+
function oneLine(value) {
|
|
420
|
+
return String(value ?? '')
|
|
421
|
+
.replace(/\s*[\r\n]+\s*/g, ' ')
|
|
422
|
+
.trim();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isoDate(date) {
|
|
426
|
+
return date.toISOString().slice(0, 10);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** RIS record for EndNote, Zotero and Mendeley. */
|
|
430
|
+
export function toRis(fields) {
|
|
431
|
+
const type = { book: 'BOOK', report: 'RPRT', webpage: 'ELEC' }[fields.type];
|
|
432
|
+
const lines = [
|
|
433
|
+
['TY', type],
|
|
434
|
+
...fields.authors.map(name => ['AU', name]),
|
|
435
|
+
['TI', fields.title],
|
|
436
|
+
['PY', fields.year],
|
|
437
|
+
['PB', fields.publisher],
|
|
438
|
+
['UR', fields.url],
|
|
439
|
+
['Y2', isoDate(fields.accessed).replaceAll('-', '/')],
|
|
440
|
+
]
|
|
441
|
+
.map(([tag, value]) => [tag, oneLine(value)])
|
|
442
|
+
.filter(([, value]) => value);
|
|
443
|
+
return `${[...lines, ['ER', '']]
|
|
444
|
+
.map(([tag, value]) => `${tag} - ${value}`)
|
|
445
|
+
.join('\r\n')}\r\n`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const BIBTEX_ESCAPES = {
|
|
449
|
+
'\\': '\\textbackslash{}',
|
|
450
|
+
'~': '\\textasciitilde{}',
|
|
451
|
+
'^': '\\textasciicircum{}',
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
function bibtexText(value) {
|
|
455
|
+
return oneLine(value).replace(
|
|
456
|
+
/[\\~^{}&%$#_]/g,
|
|
457
|
+
char => BIBTEX_ESCAPES[char] || `\\${char}`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Organisations are braced so BibTeX does not split them into personal names;
|
|
462
|
+
// "Family, Given" names come only from structured Schema.org people.
|
|
463
|
+
function bibtexName(name) {
|
|
464
|
+
return /^[^,]+, [^,]+$/.test(name)
|
|
465
|
+
? bibtexText(name)
|
|
466
|
+
: `{${bibtexText(name)}}`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** BibTeX entry; `urldate` is read by biblatex and ignored by classic BibTeX. */
|
|
470
|
+
export function toBibtex(fields) {
|
|
471
|
+
const entry = { book: 'book', report: 'techreport', webpage: 'misc' }[
|
|
472
|
+
fields.type
|
|
473
|
+
];
|
|
474
|
+
const key =
|
|
475
|
+
[fields.authors[0], fields.year, fields.title]
|
|
476
|
+
.filter(Boolean)
|
|
477
|
+
.map(part =>
|
|
478
|
+
part
|
|
479
|
+
.normalize('NFKD')
|
|
480
|
+
.replace(/[^A-Za-z0-9\s]/g, '')
|
|
481
|
+
.trim()
|
|
482
|
+
.split(/\s+/)[0]
|
|
483
|
+
?.toLowerCase()
|
|
484
|
+
)
|
|
485
|
+
.filter(Boolean)
|
|
486
|
+
.join('') || 'citation';
|
|
487
|
+
const publisherField = fields.type === 'report' ? 'institution' : 'publisher';
|
|
488
|
+
const lines = [
|
|
489
|
+
['author', fields.authors.map(bibtexName).join(' and ')],
|
|
490
|
+
['title', `{${bibtexText(fields.title)}}`],
|
|
491
|
+
[publisherField, fields.publisher && bibtexText(fields.publisher)],
|
|
492
|
+
['year', oneLine(fields.year)],
|
|
493
|
+
// Braces would close the field early; percent-encoding keeps the URL valid.
|
|
494
|
+
[
|
|
495
|
+
'url',
|
|
496
|
+
oneLine(fields.url).replace(
|
|
497
|
+
/[{}]/g,
|
|
498
|
+
char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
|
499
|
+
),
|
|
500
|
+
],
|
|
501
|
+
['urldate', isoDate(fields.accessed)],
|
|
502
|
+
].filter(([, value]) => value);
|
|
503
|
+
return `@${entry}{${key},\n${lines
|
|
504
|
+
.map(([name, value]) => ` ${name} = {${value}}`)
|
|
505
|
+
.join(',\n')}\n}\n`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Both formats are offered unless the site lists its own or sets "none".
|
|
509
|
+
function exportFormats(element, citation) {
|
|
510
|
+
if (!citation.fields) return [];
|
|
511
|
+
return (element.dataset.mgCiteThisExport ?? 'ris bibtex')
|
|
512
|
+
.toLowerCase()
|
|
513
|
+
.split(/[\s,]+/)
|
|
514
|
+
.filter(
|
|
515
|
+
(name, index, all) => EXPORT_FORMATS[name] && all.indexOf(name) === index
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function fileName(title) {
|
|
520
|
+
return (
|
|
521
|
+
title
|
|
522
|
+
.normalize('NFKD')
|
|
523
|
+
.replace(/[^\w\s-]/g, '')
|
|
524
|
+
.trim()
|
|
525
|
+
.replace(/\s+/g, '-')
|
|
526
|
+
.toLowerCase()
|
|
527
|
+
.slice(0, 60) || 'citation'
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const URL_PATTERN = /https?:\/\/[^\s<>"]+/g;
|
|
532
|
+
|
|
533
|
+
// Sentence punctuation and closing quotes stay outside the link; a closing
|
|
534
|
+
// bracket stays inside when the URL opened it, as in Wikipedia titles.
|
|
535
|
+
function trimUrl(url) {
|
|
536
|
+
let href = url;
|
|
537
|
+
for (;;) {
|
|
538
|
+
const last = href.at(-1);
|
|
539
|
+
const unbalanced = (open, close) =>
|
|
540
|
+
href.split(close).length > href.split(open).length;
|
|
541
|
+
if (/[.,;:!?'"\u2019\u201d]/.test(last)) href = href.slice(0, -1);
|
|
542
|
+
else if (last === ')' && unbalanced('(', ')')) href = href.slice(0, -1);
|
|
543
|
+
else if (last === ']' && unbalanced('[', ']')) href = href.slice(0, -1);
|
|
544
|
+
else return href;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Links each URL in place while keeping the copied text identical to the
|
|
549
|
+
// rendered text. Trailing sentence punctuation stays outside the link.
|
|
550
|
+
// URLs and the title are isolated so that, in right-to-left text, a Latin
|
|
551
|
+
// title or URL does not pull the surrounding punctuation out of place.
|
|
552
|
+
function appendText(output, text, links) {
|
|
553
|
+
const doc = output.ownerDocument;
|
|
554
|
+
let index = 0;
|
|
555
|
+
for (const match of text.matchAll(URL_PATTERN)) {
|
|
556
|
+
const href = trimUrl(match[0]);
|
|
557
|
+
if (match.index > index) output.append(text.slice(index, match.index));
|
|
558
|
+
const url = doc.createElement(links ? 'a' : 'span');
|
|
559
|
+
if (links) url.href = href;
|
|
560
|
+
url.dir = 'ltr';
|
|
561
|
+
url.textContent = href;
|
|
562
|
+
output.append(url);
|
|
563
|
+
index = match.index + href.length;
|
|
564
|
+
}
|
|
565
|
+
if (index < text.length) output.append(text.slice(index));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function renderCitation(output, citation, links = true) {
|
|
569
|
+
const { text, titleRange } = citation;
|
|
570
|
+
output.replaceChildren();
|
|
571
|
+
if (!titleRange) {
|
|
572
|
+
appendText(output, text, links);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const [start, end] = titleRange;
|
|
576
|
+
const title = output.ownerDocument.createElement('bdi');
|
|
577
|
+
title.textContent = text.slice(start, end);
|
|
578
|
+
appendText(output, text.slice(0, start), links);
|
|
579
|
+
output.append(title);
|
|
580
|
+
appendText(output, text.slice(end), links);
|
|
581
|
+
}
|
|
582
|
+
|
|
168
583
|
function elementsIn(scope) {
|
|
169
584
|
if (!scope) return [];
|
|
170
585
|
if (!scope.querySelectorAll) return Array.from(scope).flatMap(elementsIn);
|
|
@@ -174,29 +589,41 @@ function elementsIn(scope) {
|
|
|
174
589
|
];
|
|
175
590
|
}
|
|
176
591
|
|
|
177
|
-
function emit(element, name, citation) {
|
|
592
|
+
function emit(element, name, citation, extra = {}) {
|
|
178
593
|
element.dispatchEvent(
|
|
179
594
|
new CustomEvent(`mg-cite-this:${name}`, {
|
|
180
595
|
bubbles: true,
|
|
181
596
|
detail: {
|
|
182
597
|
source: citation.source,
|
|
598
|
+
id: citation.id,
|
|
183
599
|
url: cleanUrl(citation.url, element.ownerDocument.location.href, true),
|
|
600
|
+
pageUrl: canonicalUrl(element.ownerDocument).replace(/\?.*$/, ''),
|
|
601
|
+
layout: element.dataset.mgCiteThisLayout,
|
|
602
|
+
...extra,
|
|
184
603
|
},
|
|
185
604
|
})
|
|
186
605
|
);
|
|
187
606
|
}
|
|
188
607
|
|
|
608
|
+
// Fixed English labels for the UNDRR click tracker, which otherwise reports
|
|
609
|
+
// visible text: the citation itself, or a translated label.
|
|
610
|
+
const REGION = 'data-vf-google-analytics-region';
|
|
611
|
+
function track(node, action) {
|
|
612
|
+
node.dataset.vfAnalyticsLabel = `Cite this: ${action}`;
|
|
613
|
+
}
|
|
614
|
+
|
|
189
615
|
function createFallback(element) {
|
|
190
616
|
const doc = element.ownerDocument;
|
|
617
|
+
const t = citeThisStrings(languageOf(element));
|
|
191
618
|
const fallback = doc.createElement('details');
|
|
192
619
|
fallback.className = 'mg-cite-this__fallback';
|
|
193
620
|
const summary = doc.createElement('summary');
|
|
194
621
|
summary.className = 'mg-cite-this__summary';
|
|
195
|
-
summary.textContent = element.dataset.mgCiteThisLabel ||
|
|
622
|
+
summary.textContent = element.dataset.mgCiteThisLabel || t.label;
|
|
196
623
|
const content = doc.createElement('div');
|
|
197
624
|
content.className = 'mg-cite-this__content';
|
|
198
625
|
const output = doc.createElement('p');
|
|
199
|
-
output.className = 'mg-cite-this__text
|
|
626
|
+
output.className = 'mg-cite-this__text';
|
|
200
627
|
output.dataset.mgCiteThisText = '';
|
|
201
628
|
const actions = doc.createElement('div');
|
|
202
629
|
actions.className = 'mg-cite-this__actions';
|
|
@@ -204,7 +631,7 @@ function createFallback(element) {
|
|
|
204
631
|
copy.type = 'button';
|
|
205
632
|
copy.className = 'mg-cite-this__copy';
|
|
206
633
|
copy.dataset.mgCiteThisCopy = '';
|
|
207
|
-
copy.textContent = element.dataset.mgCiteThisCopyLabel ||
|
|
634
|
+
copy.textContent = element.dataset.mgCiteThisCopyLabel || t.copy;
|
|
208
635
|
copy.hidden = true;
|
|
209
636
|
const status = doc.createElement('span');
|
|
210
637
|
status.className = 'mg-cite-this__status';
|
|
@@ -221,7 +648,10 @@ function createFallback(element) {
|
|
|
221
648
|
/** Initialise citation dialogs within a document or Drupal behaviour scope. */
|
|
222
649
|
export function mgCiteThis(scope = document) {
|
|
223
650
|
for (const element of elementsIn(scope)) {
|
|
224
|
-
|
|
651
|
+
// The ready flag also stops a second copy of this module, such as a
|
|
652
|
+
// versioned and a latest/ build on one page, from enhancing it twice.
|
|
653
|
+
if (instances.has(element) || 'mgCiteThisReady' in element.dataset)
|
|
654
|
+
continue;
|
|
225
655
|
const existing = element.querySelector('details.mg-cite-this__fallback');
|
|
226
656
|
const generated = !existing && element.childElementCount === 0;
|
|
227
657
|
const fallback = generated ? createFallback(element) : existing;
|
|
@@ -231,12 +661,57 @@ export function mgCiteThis(scope = document) {
|
|
|
231
661
|
if (!fallback || !summary || !content || !output) continue;
|
|
232
662
|
|
|
233
663
|
const doc = element.ownerDocument;
|
|
664
|
+
const t = citeThisStrings(languageOf(element));
|
|
234
665
|
const citation = resolveCitation(element);
|
|
235
|
-
output
|
|
666
|
+
renderCitation(output, citation);
|
|
236
667
|
element.dataset.mgCiteThisSource = citation.source;
|
|
237
668
|
const dialog = doc.createElement('dialog');
|
|
669
|
+
element.dataset.mgCiteThisReady = '';
|
|
670
|
+
const regionSet = !element.hasAttribute(REGION);
|
|
671
|
+
if (regionSet) element.setAttribute(REGION, 'cite-this');
|
|
238
672
|
if (typeof dialog.showModal !== 'function') {
|
|
239
|
-
|
|
673
|
+
// Without dialog support the disclosure stays, with copying enabled.
|
|
674
|
+
element.dataset.mgCiteThisLayout = 'disclosure';
|
|
675
|
+
const fallbackCopy = content.querySelector('[data-mg-cite-this-copy]');
|
|
676
|
+
const fallbackStatus = content.querySelector(
|
|
677
|
+
'[data-mg-cite-this-status]'
|
|
678
|
+
);
|
|
679
|
+
let fallbackTimer;
|
|
680
|
+
const onFallbackCopy = async () => {
|
|
681
|
+
let message;
|
|
682
|
+
try {
|
|
683
|
+
await navigator.clipboard.writeText(citation.text);
|
|
684
|
+
message = element.dataset.mgCiteThisCopiedLabel || t.copied;
|
|
685
|
+
emit(element, 'copy', citation, { placement: 'disclosure' });
|
|
686
|
+
} catch {
|
|
687
|
+
message = element.dataset.mgCiteThisFailedLabel || t.failed;
|
|
688
|
+
emit(element, 'copy-error', citation, {
|
|
689
|
+
target: 'citation',
|
|
690
|
+
placement: 'disclosure',
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
if (!fallbackStatus) return;
|
|
694
|
+
fallbackStatus.textContent = message;
|
|
695
|
+
clearTimeout(fallbackTimer);
|
|
696
|
+
fallbackTimer = setTimeout(() => {
|
|
697
|
+
fallbackStatus.textContent = '';
|
|
698
|
+
}, 3000);
|
|
699
|
+
};
|
|
700
|
+
if (fallbackCopy) {
|
|
701
|
+
track(fallbackCopy, 'Copy citation (disclosure)');
|
|
702
|
+
fallbackCopy.hidden = false;
|
|
703
|
+
fallbackCopy.addEventListener('click', onFallbackCopy);
|
|
704
|
+
}
|
|
705
|
+
instances.set(element, () => {
|
|
706
|
+
clearTimeout(fallbackTimer);
|
|
707
|
+
if (fallbackCopy) {
|
|
708
|
+
fallbackCopy.removeEventListener('click', onFallbackCopy);
|
|
709
|
+
fallbackCopy.hidden = true;
|
|
710
|
+
delete fallbackCopy.dataset.vfAnalyticsLabel;
|
|
711
|
+
}
|
|
712
|
+
if (regionSet) element.removeAttribute(REGION);
|
|
713
|
+
if (generated) fallback.remove();
|
|
714
|
+
});
|
|
240
715
|
continue;
|
|
241
716
|
}
|
|
242
717
|
|
|
@@ -258,7 +733,7 @@ export function mgCiteThis(scope = document) {
|
|
|
258
733
|
close.className = 'mg-icon-button mg-cite-this__close';
|
|
259
734
|
close.setAttribute(
|
|
260
735
|
'aria-label',
|
|
261
|
-
element.dataset.mgCiteThisCloseLabel ||
|
|
736
|
+
element.dataset.mgCiteThisCloseLabel || t.close
|
|
262
737
|
);
|
|
263
738
|
const closeIcon = doc.createElement('span');
|
|
264
739
|
closeIcon.className = 'mg-icon mg-icon-close';
|
|
@@ -272,59 +747,223 @@ export function mgCiteThis(scope = document) {
|
|
|
272
747
|
const dialogContent = content.cloneNode(true);
|
|
273
748
|
const copy = dialogContent.querySelector('[data-mg-cite-this-copy]');
|
|
274
749
|
const status = dialogContent.querySelector('[data-mg-cite-this-status]');
|
|
275
|
-
if (copy)
|
|
750
|
+
if (copy) {
|
|
751
|
+
copy.hidden = false;
|
|
752
|
+
track(copy, 'Copy citation (dialog)');
|
|
753
|
+
}
|
|
754
|
+
for (const link of dialogContent.querySelectorAll(
|
|
755
|
+
'[data-mg-cite-this-text] a'
|
|
756
|
+
)) {
|
|
757
|
+
track(link, 'Citation link');
|
|
758
|
+
}
|
|
759
|
+
track(close, 'Close');
|
|
760
|
+
let copyUrl;
|
|
761
|
+
if (copy && element.dataset.mgCiteThisCopyUrl !== 'false') {
|
|
762
|
+
copyUrl = doc.createElement('button');
|
|
763
|
+
copyUrl.type = 'button';
|
|
764
|
+
copyUrl.className = 'mg-cite-this__copy mg-cite-this__copy-url';
|
|
765
|
+
copyUrl.textContent = element.dataset.mgCiteThisCopyUrlLabel || t.copyUrl;
|
|
766
|
+
track(copyUrl, 'Copy citation URL');
|
|
767
|
+
copy.after(copyUrl);
|
|
768
|
+
}
|
|
769
|
+
const exports = exportFormats(element, citation).map(format => {
|
|
770
|
+
const { extension, mime, build } = EXPORT_FORMATS[format];
|
|
771
|
+
const link = doc.createElement('a');
|
|
772
|
+
link.className = 'mg-cite-this__export';
|
|
773
|
+
link.href = `data:${mime};charset=utf-8,${encodeURIComponent(
|
|
774
|
+
build(citation.fields)
|
|
775
|
+
)}`;
|
|
776
|
+
link.download = `${fileName(citation.fields.title)}.${extension}`;
|
|
777
|
+
link.textContent =
|
|
778
|
+
element.dataset[
|
|
779
|
+
`mgCiteThis${format === 'ris' ? 'Ris' : 'Bibtex'}Label`
|
|
780
|
+
] || t[format];
|
|
781
|
+
link.dataset.mgCiteThisFormat = format;
|
|
782
|
+
track(link, `Download ${format === 'ris' ? 'RIS' : 'BibTeX'}`);
|
|
783
|
+
return link;
|
|
784
|
+
});
|
|
785
|
+
if (exports.length) {
|
|
786
|
+
const list = doc.createElement('div');
|
|
787
|
+
list.className = 'mg-cite-this__exports';
|
|
788
|
+
list.append(...exports);
|
|
789
|
+
dialogContent.append(list);
|
|
790
|
+
}
|
|
276
791
|
dialog.className = 'mg-cite-this__dialog';
|
|
277
792
|
dialog.setAttribute('aria-labelledby', title.id);
|
|
278
793
|
dialog.append(header, dialogContent);
|
|
279
|
-
|
|
794
|
+
// The default layout mirrors ShareButtons: a label, the citation as a
|
|
795
|
+
// copyable field, and a quiet link to the full dialog. The compact
|
|
796
|
+
// variant is the dialog trigger alone.
|
|
797
|
+
const panel = !element.classList.contains('mg-cite-this--compact');
|
|
798
|
+
element.dataset.mgCiteThisLayout = panel ? 'default' : 'compact';
|
|
799
|
+
const authored = ['role', 'aria-labelledby'].map(name => [
|
|
800
|
+
name,
|
|
801
|
+
element.getAttribute(name),
|
|
802
|
+
]);
|
|
803
|
+
let wrapper;
|
|
804
|
+
let label;
|
|
805
|
+
let preview;
|
|
806
|
+
let previewText;
|
|
807
|
+
let previewStatus;
|
|
808
|
+
if (panel) {
|
|
809
|
+
wrapper = doc.createElement('div');
|
|
810
|
+
wrapper.className = 'mg-cite-this__panel';
|
|
811
|
+
label = doc.createElement('p');
|
|
812
|
+
label.id = `${dialogId}-label`;
|
|
813
|
+
label.className = 'mg-cite-this__header';
|
|
814
|
+
label.textContent = trigger.textContent;
|
|
815
|
+
element.setAttribute('role', 'group');
|
|
816
|
+
element.setAttribute('aria-labelledby', label.id);
|
|
817
|
+
|
|
818
|
+
preview = doc.createElement('button');
|
|
819
|
+
preview.type = 'button';
|
|
820
|
+
preview.className = 'mg-cite-this__preview';
|
|
821
|
+
// The visible citation stays in the accessible name (WCAG 2.5.3); the
|
|
822
|
+
// hidden prefix says what pressing it does.
|
|
823
|
+
const previewAction = doc.createElement('span');
|
|
824
|
+
previewAction.className = 'mg-u-sr-only';
|
|
825
|
+
previewAction.textContent = `${
|
|
826
|
+
element.dataset.mgCiteThisCopyLabel || t.copy
|
|
827
|
+
}: `;
|
|
828
|
+
const previewIcon = doc.createElement('span');
|
|
829
|
+
previewIcon.className = 'mg-icon mg-icon-file-alt';
|
|
830
|
+
previewIcon.setAttribute('aria-hidden', 'true');
|
|
831
|
+
previewText = doc.createElement('span');
|
|
832
|
+
previewText.className = 'mg-cite-this__preview-text';
|
|
833
|
+
renderCitation(previewText, citation, false);
|
|
834
|
+
const copyIcon = doc.createElement('span');
|
|
835
|
+
copyIcon.className = 'mg-icon mg-icon-clone';
|
|
836
|
+
copyIcon.setAttribute('aria-hidden', 'true');
|
|
837
|
+
preview.append(previewIcon, previewAction, previewText, copyIcon);
|
|
838
|
+
track(preview, 'Copy citation (field)');
|
|
839
|
+
|
|
840
|
+
previewStatus = doc.createElement('span');
|
|
841
|
+
previewStatus.className = 'mg-u-sr-only';
|
|
842
|
+
previewStatus.setAttribute('role', 'status');
|
|
843
|
+
previewStatus.setAttribute('aria-live', 'polite');
|
|
844
|
+
|
|
845
|
+
trigger.textContent = element.dataset.mgCiteThisMoreLabel || t.more;
|
|
846
|
+
trigger.classList.add('mg-cite-this__more');
|
|
847
|
+
track(trigger, 'All citation options');
|
|
848
|
+
wrapper.append(label, preview, previewStatus, trigger);
|
|
849
|
+
element.append(wrapper, dialog);
|
|
850
|
+
} else {
|
|
851
|
+
track(trigger, 'Open');
|
|
852
|
+
element.append(trigger, dialog);
|
|
853
|
+
}
|
|
280
854
|
fallback.hidden = true;
|
|
281
855
|
|
|
282
856
|
let statusTimer;
|
|
283
|
-
|
|
857
|
+
let previewTimer;
|
|
858
|
+
// `trigger` is a person opening the dialog; `copy-error` is the dialog
|
|
859
|
+
// opening itself after a failed copy from the field.
|
|
860
|
+
let returnFocus = trigger;
|
|
861
|
+
const openDialog = (placement, opener = trigger) => {
|
|
862
|
+
returnFocus = opener;
|
|
284
863
|
dialog.showModal();
|
|
285
864
|
title.focus();
|
|
286
|
-
emit(element, 'open', citation);
|
|
865
|
+
emit(element, 'open', citation, { placement });
|
|
287
866
|
};
|
|
867
|
+
const onOpen = () => openDialog('trigger');
|
|
288
868
|
const onClose = () => {
|
|
289
869
|
clearTimeout(statusTimer);
|
|
290
870
|
if (status) status.textContent = '';
|
|
291
|
-
|
|
871
|
+
returnFocus.focus();
|
|
292
872
|
};
|
|
293
873
|
const closeDialog = () => dialog.close();
|
|
294
|
-
const
|
|
874
|
+
const announce = (message, delay) => {
|
|
875
|
+
if (status) status.textContent = message;
|
|
876
|
+
clearTimeout(statusTimer);
|
|
877
|
+
statusTimer = setTimeout(() => {
|
|
878
|
+
if (status) status.textContent = '';
|
|
879
|
+
}, delay);
|
|
880
|
+
};
|
|
881
|
+
const copyText = async (text, copiedLabel, failedLabel, eventName) => {
|
|
882
|
+
try {
|
|
883
|
+
await navigator.clipboard.writeText(text);
|
|
884
|
+
announce(copiedLabel, 3000);
|
|
885
|
+
emit(element, eventName, citation, { placement: 'dialog' });
|
|
886
|
+
} catch {
|
|
887
|
+
announce(failedLabel, 5000);
|
|
888
|
+
emit(element, 'copy-error', citation, {
|
|
889
|
+
target: eventName === 'copy' ? 'citation' : 'url',
|
|
890
|
+
placement: 'dialog',
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
const onCopy = () =>
|
|
895
|
+
copyText(
|
|
896
|
+
citation.text,
|
|
897
|
+
element.dataset.mgCiteThisCopiedLabel || t.copied,
|
|
898
|
+
element.dataset.mgCiteThisFailedLabel || t.failed,
|
|
899
|
+
'copy'
|
|
900
|
+
);
|
|
901
|
+
// The citation's own locator, not a shortlink, so the copied link lasts.
|
|
902
|
+
const onCopyUrl = () =>
|
|
903
|
+
copyText(
|
|
904
|
+
citation.url,
|
|
905
|
+
element.dataset.mgCiteThisUrlCopiedLabel || t.urlCopied,
|
|
906
|
+
element.dataset.mgCiteThisUrlFailedLabel || t.urlFailed,
|
|
907
|
+
'copy-url'
|
|
908
|
+
);
|
|
909
|
+
const onPreviewCopy = async () => {
|
|
910
|
+
const message = element.dataset.mgCiteThisCopiedLabel || t.copied;
|
|
295
911
|
try {
|
|
296
912
|
await navigator.clipboard.writeText(citation.text);
|
|
297
|
-
|
|
298
|
-
status.textContent =
|
|
299
|
-
element.dataset.mgCiteThisCopiedLabel || 'Citation copied';
|
|
300
|
-
emit(element, 'copy', citation);
|
|
301
|
-
clearTimeout(statusTimer);
|
|
302
|
-
statusTimer = setTimeout(() => {
|
|
303
|
-
if (status) status.textContent = '';
|
|
304
|
-
}, 3000);
|
|
913
|
+
emit(element, 'copy', citation, { placement: 'field' });
|
|
305
914
|
} catch {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
915
|
+
// The field is a button, so open the dialog where the citation is
|
|
916
|
+
// selectable text and explain there.
|
|
917
|
+
emit(element, 'copy-error', citation, {
|
|
918
|
+
target: 'citation',
|
|
919
|
+
placement: 'field',
|
|
920
|
+
});
|
|
921
|
+
openDialog('copy-error', preview);
|
|
922
|
+
announce(element.dataset.mgCiteThisFailedLabel || t.failed, 5000);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
previewText.textContent = message;
|
|
926
|
+
previewStatus.textContent = message;
|
|
927
|
+
clearTimeout(previewTimer);
|
|
928
|
+
previewTimer = setTimeout(() => {
|
|
929
|
+
renderCitation(previewText, citation, false);
|
|
930
|
+
previewStatus.textContent = '';
|
|
931
|
+
}, 3000);
|
|
932
|
+
};
|
|
933
|
+
const onDialogClick = event => {
|
|
934
|
+
const format = event.target.closest('[data-mg-cite-this-format]')?.dataset
|
|
935
|
+
.mgCiteThisFormat;
|
|
936
|
+
if (format) {
|
|
937
|
+
emit(element, 'export', citation, { format, placement: 'dialog' });
|
|
938
|
+
} else if (event.target.closest('[data-mg-cite-this-text] a')) {
|
|
939
|
+
emit(element, 'visit', citation, { placement: 'dialog' });
|
|
315
940
|
}
|
|
316
941
|
};
|
|
317
942
|
trigger.addEventListener('click', onOpen);
|
|
943
|
+
dialogContent.addEventListener('click', onDialogClick);
|
|
318
944
|
close.addEventListener('click', closeDialog);
|
|
319
945
|
dialog.addEventListener('close', onClose);
|
|
320
946
|
copy?.addEventListener('click', onCopy);
|
|
947
|
+
copyUrl?.addEventListener('click', onCopyUrl);
|
|
948
|
+
preview?.addEventListener('click', onPreviewCopy);
|
|
321
949
|
instances.set(element, () => {
|
|
950
|
+
// Close first, while the close handler still returns focus.
|
|
951
|
+
if (dialog.open) dialog.close();
|
|
322
952
|
clearTimeout(statusTimer);
|
|
953
|
+
clearTimeout(previewTimer);
|
|
323
954
|
trigger.removeEventListener('click', onOpen);
|
|
324
955
|
close.removeEventListener('click', closeDialog);
|
|
325
956
|
dialog.removeEventListener('close', onClose);
|
|
326
957
|
copy?.removeEventListener('click', onCopy);
|
|
327
|
-
|
|
958
|
+
copyUrl?.removeEventListener('click', onCopyUrl);
|
|
959
|
+
dialogContent.removeEventListener('click', onDialogClick);
|
|
960
|
+
preview?.removeEventListener('click', onPreviewCopy);
|
|
961
|
+
wrapper?.remove();
|
|
962
|
+
if (regionSet) element.removeAttribute(REGION);
|
|
963
|
+
for (const [name, value] of authored) {
|
|
964
|
+
if (value === null) element.removeAttribute(name);
|
|
965
|
+
else element.setAttribute(name, value);
|
|
966
|
+
}
|
|
328
967
|
trigger.remove();
|
|
329
968
|
dialog.remove();
|
|
330
969
|
if (generated) fallback.remove();
|
|
@@ -336,8 +975,11 @@ export function mgCiteThis(scope = document) {
|
|
|
336
975
|
/** Remove listeners before replacing content in a client-side navigation. */
|
|
337
976
|
export function mgCiteThisDestroy(scope = document) {
|
|
338
977
|
for (const element of elementsIn(scope)) {
|
|
339
|
-
instances.
|
|
978
|
+
if (!instances.has(element)) continue;
|
|
979
|
+
instances.get(element)();
|
|
340
980
|
instances.delete(element);
|
|
981
|
+
delete element.dataset.mgCiteThisReady;
|
|
982
|
+
delete element.dataset.mgCiteThisLayout;
|
|
341
983
|
}
|
|
342
984
|
}
|
|
343
985
|
|