annotepage-client 2.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.
@@ -0,0 +1,2573 @@
1
+ /* ============================================================================
2
+ annotepage -- the annotation layer, browser side.
3
+
4
+ Package version : 2.0.0
5
+ Format version : 2 (see FORMAT.md)
6
+ Licence : MIT
7
+
8
+ GENERATED FILE -- do not edit it by hand. The sources are in src/, and
9
+ "npm run build" remakes this file. A fix made here would be lost at the
10
+ next build, and the published SRI digest would no longer match anything.
11
+ ============================================================================ */
12
+
13
+ (function () {
14
+ 'use strict';
15
+
16
+ /* Injected by the build: they come from package.json and from
17
+ src/styles.css, so that no value is written in two places and can
18
+ therefore diverge. */
19
+ const TOOL_VERSION = "2.0.0";
20
+ const FORMAT = 2;
21
+ const STYLES = "/* ============================================================================\n styles.css -- THE STYLES OF THE TOOL, AND OF NO OTHER ELEMENT.\n\n This sheet is INLINED into the served file by the build, then put into the\n tool's shadow root -- as a constructed sheet when the browser can do it, in\n a <style> otherwise. It was loaded by a <link> in the original tool; the\n move to a CDN under SRI brought it inside the file, so that there is only\n one digest to keep up to date. The containment itself has not changed, and\n is still twofold:\n\n - from the tool towards the site: no rule from here can reach an element\n of the host site, the browser sees to that. That is what makes the\n claim \"the layer touches nothing\" checkable rather than promised;\n - from the site towards the tool: no rule of the site can reach an\n element here. A redesign of the site's stylesheet therefore cannot\n distort the tool, nor the other way round.\n\n The \"ap-\" prefix on every class is the third safeguard: the day somebody\n loads these styles WITHOUT a shadow root -- by mistake, or to debug --\n nothing would answer a selector of the site.\n\n NO RULE TARGETS html, body, * OR ANY SELECTOR OF THE SITE. That is the one\n absolute prohibition of this file.\n\n COLOURS: the tool has its OWN palette, defined on the shadow root. It\n reads neither the site's variables nor its theme attribute: it has no\n reason to know how the site names its colours, and it must stay readable\n on a light site as on a dark one. The switch follows the system\n preference, the only information the tool has without asking anyone.\n ============================================================================ */\n\n\n:host {\n --ap-bg: #ffffff;\n --ap-bg-soft: #f4f6f8;\n --ap-bg-raised: #e9edf2;\n --ap-text: #1a1d21;\n --ap-text-soft: #5b6570;\n --ap-border: #d5dbe2;\n --ap-accent: #2f6fed;\n --ap-accent-dark: #1d55c8;\n --ap-accent-text: #ffffff;\n --ap-accent-veil: rgba(47, 111, 237, 0.14);\n --ap-alert-bg: #fdeceb;\n --ap-alert-border: #e3a9a4;\n --ap-alert-text: #8a1f16;\n --ap-shadow: 0 6px 24px rgba(16, 24, 40, 0.18);\n --ap-radius: 10px;\n --ap-font: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\",\n Arial, sans-serif;\n}\n\n@media (prefers-color-scheme: dark) {\n :host {\n --ap-bg: #1d2126;\n --ap-bg-soft: #262b32;\n --ap-bg-raised: #323942;\n --ap-text: #e9ecf0;\n --ap-text-soft: #a4adb8;\n --ap-border: #3a424c;\n --ap-accent: #6d9bff;\n --ap-accent-dark: #8fb4ff;\n --ap-accent-text: #10151c;\n --ap-accent-veil: rgba(109, 155, 255, 0.18);\n --ap-alert-bg: #3a1f1c;\n --ap-alert-border: #7c3a33;\n --ap-alert-text: #ffb9b1;\n --ap-shadow: 0 6px 24px rgba(0, 0, 0, 0.55);\n }\n}\n\n/* ----------------------------------------------------------------------------\n The layer.\n\n It covers the viewport and receives NO click: that is what lets the page\n behave exactly as usual as long as the tool is not in annotation mode.\n Each widget re-enables clicks for itself alone.\n ---------------------------------------------------------------------------- */\n\n.ap-layer {\n position: absolute;\n inset: 0;\n pointer-events: none;\n font-family: var(--ap-font);\n font-size: 14px;\n line-height: 1.45;\n color: var(--ap-text);\n text-align: left;\n -webkit-font-smoothing: antialiased;\n}\n\n.ap-layer button,\n.ap-layer input,\n.ap-layer textarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: inherit;\n margin: 0;\n box-sizing: border-box;\n}\n\n/* ----------------------------------------------------------------------------\n The button: the only thing visible when the tool is at rest.\n ---------------------------------------------------------------------------- */\n\n.ap-button {\n position: fixed;\n right: 16px;\n bottom: 16px;\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 9px 14px;\n border: 1px solid var(--ap-border);\n border-radius: 999px;\n background: var(--ap-bg);\n color: var(--ap-text);\n box-shadow: var(--ap-shadow);\n cursor: pointer;\n pointer-events: auto;\n opacity: 0.92;\n transition: opacity 0.15s ease, transform 0.15s ease;\n}\n\n.ap-button:hover,\n.ap-button:focus-visible {\n opacity: 1;\n transform: translateY(-1px);\n}\n\n.ap-button:focus-visible {\n outline: 2px solid var(--ap-accent);\n outline-offset: 2px;\n}\n\n.ap-button[aria-pressed=\"true\"] {\n background: var(--ap-accent);\n border-color: var(--ap-accent);\n color: var(--ap-accent-text);\n opacity: 1;\n}\n\n.ap-button-dot {\n display: inline-block;\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--ap-accent);\n flex: none;\n}\n\n.ap-button[aria-pressed=\"true\"] .ap-button-dot {\n background: var(--ap-accent-text);\n}\n\n.ap-button-count {\n padding: 1px 7px;\n border-radius: 999px;\n background: var(--ap-bg-raised);\n color: var(--ap-text-soft);\n font-size: 12px;\n}\n\n.ap-button[aria-pressed=\"true\"] .ap-button-count {\n background: rgba(255, 255, 255, 0.22);\n color: var(--ap-accent-text);\n}\n\n/* ----------------------------------------------------------------------------\n The pointing highlight.\n\n It is DRAWN HERE, from the coordinates of the element being pointed at.\n Nothing is put on the element itself: no class, no attribute, no style. So\n the site cannot move by a single pixel because of the pointing.\n ---------------------------------------------------------------------------- */\n\n.ap-highlight {\n position: fixed;\n border: 2px solid var(--ap-accent);\n border-radius: 3px;\n background: var(--ap-accent-veil);\n pointer-events: none;\n display: none;\n}\n\n.ap-highlight-label {\n position: fixed;\n max-width: 320px;\n padding: 4px 8px;\n border-radius: 6px;\n background: var(--ap-accent);\n color: var(--ap-accent-text);\n font-size: 12px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n pointer-events: none;\n display: none;\n box-shadow: var(--ap-shadow);\n}\n\n/* ----------------------------------------------------------------------------\n The markers: \"there are already notes here\".\n ---------------------------------------------------------------------------- */\n\n.ap-marker {\n position: fixed;\n min-width: 22px;\n height: 22px;\n padding: 0 6px;\n border: 2px solid var(--ap-bg);\n border-radius: 999px;\n background: var(--ap-accent);\n color: var(--ap-accent-text);\n font-size: 12px;\n font-weight: 700;\n line-height: 18px;\n text-align: center;\n cursor: pointer;\n pointer-events: auto;\n box-shadow: var(--ap-shadow);\n}\n\n.ap-marker:focus-visible {\n outline: 2px solid var(--ap-accent-dark);\n outline-offset: 2px;\n}\n\n/* ----------------------------------------------------------------------------\n The panel.\n ---------------------------------------------------------------------------- */\n\n.ap-panel {\n position: fixed;\n top: 12px;\n right: 12px;\n bottom: 72px;\n width: 360px;\n max-width: calc(100vw - 24px);\n display: none;\n flex-direction: column;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg);\n box-shadow: var(--ap-shadow);\n pointer-events: auto;\n overflow: hidden;\n}\n\n.ap-panel.ap-open {\n display: flex;\n}\n\n.ap-panel-header {\n display: flex;\n align-items: baseline;\n gap: 8px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--ap-border);\n background: var(--ap-bg-soft);\n}\n\n.ap-panel-title {\n font-size: 15px;\n font-weight: 600;\n flex: 1 1 auto;\n}\n\n.ap-panel-instructions {\n padding: 10px 14px;\n border-bottom: 1px solid var(--ap-border);\n color: var(--ap-text-soft);\n font-size: 13px;\n}\n\n.ap-panel-body {\n flex: 1 1 auto;\n overflow-y: auto;\n overscroll-behavior: contain;\n padding: 4px 14px 14px;\n}\n\n.ap-panel-footer {\n padding: 8px 14px;\n border-top: 1px solid var(--ap-border);\n background: var(--ap-bg-soft);\n color: var(--ap-text-soft);\n font-size: 12px;\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.ap-section-title {\n margin: 14px 0 6px;\n color: var(--ap-text-soft);\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n}\n\n.ap-section-help {\n margin: 0 0 8px;\n color: var(--ap-text-soft);\n font-size: 12px;\n}\n\n.ap-empty {\n margin: 16px 0;\n color: var(--ap-text-soft);\n}\n\n/* ----------------------------------------------------------------------------\n A note, and its replies.\n ---------------------------------------------------------------------------- */\n\n.ap-note {\n margin: 8px 0;\n padding: 10px 12px;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg);\n}\n\n.ap-note.ap-orphan {\n background: var(--ap-bg-soft);\n}\n\n.ap-note.ap-focused {\n border-color: var(--ap-accent);\n box-shadow: 0 0 0 3px var(--ap-accent-veil);\n}\n\n.ap-note-header {\n display: flex;\n align-items: baseline;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.ap-note-author {\n font-weight: 600;\n}\n\n.ap-note-date {\n color: var(--ap-text-soft);\n font-size: 12px;\n}\n\n.ap-note-target {\n margin: 4px 0 0;\n color: var(--ap-text-soft);\n font-size: 12px;\n font-style: italic;\n overflow-wrap: anywhere;\n}\n\n.ap-note-text {\n margin: 6px 0 0;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n\n.ap-note-actions {\n margin-top: 8px;\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.ap-replies {\n margin: 8px 0 0;\n padding-left: 10px;\n border-left: 2px solid var(--ap-border);\n}\n\n.ap-reply {\n margin: 8px 0 0;\n}\n\n/* ----------------------------------------------------------------------------\n The form, anchored near the element pointed at.\n ---------------------------------------------------------------------------- */\n\n.ap-form {\n position: fixed;\n width: 340px;\n max-width: calc(100vw - 24px);\n display: none;\n flex-direction: column;\n gap: 8px;\n padding: 14px;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg);\n box-shadow: var(--ap-shadow);\n pointer-events: auto;\n}\n\n.ap-form.ap-open {\n display: flex;\n}\n\n.ap-form-title {\n font-size: 15px;\n font-weight: 600;\n}\n\n.ap-form-target {\n color: var(--ap-text-soft);\n font-size: 12px;\n font-style: italic;\n overflow-wrap: anywhere;\n}\n\n.ap-label {\n display: block;\n margin-bottom: 3px;\n font-size: 12px;\n font-weight: 600;\n color: var(--ap-text-soft);\n}\n\n.ap-help {\n margin: 3px 0 0;\n font-size: 12px;\n color: var(--ap-text-soft);\n}\n\n.ap-field,\n.ap-area {\n width: 100%;\n padding: 8px 10px;\n border: 1px solid var(--ap-border);\n border-radius: 8px;\n background: var(--ap-bg-soft);\n color: var(--ap-text);\n}\n\n.ap-field:focus,\n.ap-area:focus {\n outline: 2px solid var(--ap-accent);\n outline-offset: 1px;\n}\n\n.ap-area {\n min-height: 92px;\n resize: vertical;\n}\n\n.ap-actions {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.ap-counter {\n margin-left: auto;\n font-size: 12px;\n color: var(--ap-text-soft);\n}\n\n/* ----------------------------------------------------------------------------\n Buttons.\n ---------------------------------------------------------------------------- */\n\n.ap-primary,\n.ap-secondary,\n.ap-link {\n border-radius: 8px;\n cursor: pointer;\n pointer-events: auto;\n}\n\n.ap-primary {\n padding: 8px 14px;\n border: 1px solid var(--ap-accent);\n background: var(--ap-accent);\n color: var(--ap-accent-text);\n font-weight: 600;\n}\n\n.ap-primary:hover {\n background: var(--ap-accent-dark);\n border-color: var(--ap-accent-dark);\n}\n\n.ap-secondary {\n padding: 8px 14px;\n border: 1px solid var(--ap-border);\n background: var(--ap-bg);\n color: var(--ap-text);\n}\n\n.ap-secondary:hover {\n background: var(--ap-bg-raised);\n}\n\n.ap-link {\n padding: 2px 4px;\n border: 0;\n background: none;\n color: var(--ap-accent);\n text-decoration: underline;\n font-size: 13px;\n}\n\n.ap-primary:disabled,\n.ap-secondary:disabled,\n.ap-link:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.ap-primary:focus-visible,\n.ap-secondary:focus-visible,\n.ap-link:focus-visible {\n outline: 2px solid var(--ap-accent);\n outline-offset: 2px;\n}\n\n/* ----------------------------------------------------------------------------\n The failures.\n\n They are RED, at the top of the block concerned, and carry the message the\n server returned as it stands: that is how a non-technical team learns that\n its remark is not saved, instead of believing it is.\n ---------------------------------------------------------------------------- */\n\n.ap-error {\n margin: 8px 0;\n padding: 10px 12px;\n border: 1px solid var(--ap-alert-border);\n border-radius: var(--ap-radius);\n background: var(--ap-alert-bg);\n color: var(--ap-alert-text);\n}\n\n.ap-error-title {\n font-weight: 700;\n margin-bottom: 4px;\n}\n\n.ap-error-detail {\n margin: 6px 0 0;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n font-size: 13px;\n}\n\n.ap-error .ap-link {\n color: var(--ap-alert-text);\n}\n\n/* ----------------------------------------------------------------------------\n Narrow: the panel takes the full width, and so does the form.\n ---------------------------------------------------------------------------- */\n\n/* ----------------------------------------------------------------------------\n Narrow.\n\n DEFECT OBSERVED at 375 px wide: a panel taking the full height covers the\n whole page, and no element can be pointed at any more -- every click lands\n on the panel. So it becomes a bottom band, which leaves the top half of\n the viewport free; one scrolls the page there to bring the wanted element\n into view. The form, for its part, hides the panel while typing (see\n notes.js): on a screen that size, writing and reading the list at the same\n time does not hold.\n ---------------------------------------------------------------------------- */\n\n/* On a narrow screen the panel becomes a bottom band and the form takes the\n full width.\n\n THE WIDTH CEILING IS KEPT, and it comes from a measured defect: \"left: 8;\n right: 8\" sizes the element against its CONTAINING BLOCK, which the host\n site's horizontal overflow can make wider than the visible window.\n Measured, in mobile emulation at 390 px: the site overflows to 407 px\n (with the tool and without it), and the panel came out 391 px wide\n starting at 8, that is 9 px off screen. \"100vw\" is the window, not the\n containing block: the ceiling therefore does nothing when the site does\n not overflow, and pulls the width back when it does. */\n@media (max-width: 560px) {\n .ap-panel {\n top: auto;\n right: 8px;\n left: 8px;\n bottom: 66px;\n height: 52vh;\n width: auto;\n max-width: calc(100vw - 16px);\n }\n\n .ap-form {\n left: 8px;\n right: 8px;\n width: auto;\n max-width: calc(100vw - 16px);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .ap-button {\n transition: none;\n }\n}\n\n/* The failure shows without opening the panel: the button's dot changes\n colour. A team that does not click must be able to see that something is\n wrong. */\n.ap-button.ap-failed .ap-button-dot {\n background: var(--ap-alert-text);\n}\n\n.ap-button.ap-failed {\n border-color: var(--ap-alert-border);\n}\n\n/* Signature reminder, in the note form.\n The name was shown at the foot of the panel only: invisible at the moment\n one writes. A user reported not knowing which name they were writing\n under. */\n.ap-form-signature {\n display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;\n margin: 0 0 .6rem; font-size: .85rem; opacity: .8;\n}\n\n/* Resolution state, said on the card.\n Two cases NOT to be confused: resolved and online, resolved but not\n deployed yet. The second keeps the defect on the reviewer's screen; hiding\n it or announcing it as fixed would cost them their trust in the tool. */\n.ap-state-mark {\n display: inline-block; margin: 0 0 .5rem;\n padding: .15rem .55rem; border-radius: 4px;\n font-size: .75rem; font-weight: 600; letter-spacing: .02em;\n}\n.ap-note.ap-resolved { opacity: .72; }\n.ap-note.ap-resolved .ap-state-mark {\n color: #0f7a52; background: rgba(16, 185, 129, .14);\n}\n.ap-note.ap-resolved-pending .ap-state-mark {\n color: #8a5a00; background: rgba(245, 158, 11, .16);\n}\n/* The \"it is fixed\" / \"reopen\" block, opened under the card. Same shape as\n the reply block: it is the same gesture, one answers a remark. */\n.ap-resolve,\n.ap-reply-form {\n margin-top: .6rem;\n padding-top: .6rem;\n border-top: 1px solid var(--ap-border);\n}\n\n.ap-history-toggle {\n display: block; width: 100%; margin: 1rem 0 .25rem;\n padding: .5rem .75rem; border: 1px dashed currentColor; border-radius: 6px;\n background: none; color: inherit; font: inherit; opacity: .7; cursor: pointer;\n}\n.ap-history-toggle:hover { opacity: 1; }\n\n\n/* ----------------------------------------------------------------------------\n Setup and pasting the salt.\n\n These are the only screens where something is copied by hand. Everything\n there is SELECTABLE and monospaced: a 43-character salt copied wrong\n cannot be recovered, and nothing helps less than a font that confuses I, l\n and 1.\n ---------------------------------------------------------------------------- */\n\n.ap-panel-wide {\n width: 560px;\n}\n\n.ap-copy {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n margin: 0 0 12px;\n}\n\n.ap-code {\n flex: 1 1 auto;\n width: 100%;\n padding: 8px 10px;\n border: 1px solid var(--ap-border);\n border-radius: 8px;\n background: var(--ap-bg-soft);\n color: var(--ap-text);\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\",\n monospace;\n font-size: 12.5px;\n line-height: 1.5;\n resize: vertical;\n white-space: pre;\n overflow-x: auto;\n}\n\n.ap-code:focus-visible {\n outline: 2px solid var(--ap-accent);\n outline-offset: 1px;\n}\n\n@media (max-width: 560px) {\n .ap-panel-wide {\n width: auto;\n }\n\n .ap-copy {\n flex-direction: column;\n }\n}\n";
22
+
23
+ /* ==== 00-preamble.js ==== */
24
+
25
+ /* -- 0. Where am I, which project, and therefore where is the API --------
26
+ Nothing below is guessed. Everything is DECLARED on the tag, because a
27
+ client served by a CDN can no longer deduce anything from its own
28
+ address: that address says nothing about the site under review. */
29
+
30
+ const script = document.currentScript;
31
+ if (!script || !script.src) {
32
+ /* Loaded some other way than <script src>: we do not guess an API
33
+ address, we stay out. Careful, this also covers type="module" --
34
+ document.currentScript is null there. The tag must stay a classic
35
+ tag, and the README says so. */
36
+ return;
37
+ }
38
+
39
+ const data = script.dataset || {};
40
+ const read = (name) => String((data[name] === undefined ? '' : data[name])).trim();
41
+
42
+ /* The server address.
43
+
44
+ Self-hosted, the client is served by the site itself and the old
45
+ "../api.php" deduction is still enough: it worked for the whole life of
46
+ format 1, we are not removing it.
47
+
48
+ As soon as the client goes to a CDN it becomes wrong -- the API is not at
49
+ the CDN -- and it has to be declared. We do not try to recover: an API
50
+ address guessed wrong would send the remarks nowhere. */
51
+ const DECLARED_SERVER = read('server');
52
+ let API = '';
53
+ if (DECLARED_SERVER) {
54
+ API = new URL(DECLARED_SERVER, document.baseURI).href;
55
+ } else if (new URL(script.src).origin === location.origin) {
56
+ API = new URL('../api.php', script.src).href;
57
+ }
58
+
59
+ /* The project id, generated at setup (see 70-setup). 22 base64url
60
+ characters: the shape is checked here, because an id truncated by a
61
+ copy-paste would otherwise produce an empty project on the server side,
62
+ and a page that never shows a single note. */
63
+ const DECLARED_PROJECT = read('project');
64
+ const PROJECT_WELL_FORMED = /^[A-Za-z0-9_-]{22}$/.test(DECLARED_PROJECT);
65
+ const PROJECT = PROJECT_WELL_FORMED ? DECLARED_PROJECT : '';
66
+
67
+ /* The write mode for the notes TO COME. Encrypted by default: it is the only
68
+ default that does not ask the installer to understand the threat model
69
+ before writing a first remark.
70
+
71
+ The server stays the authority: on a relay it REFUSES "plain" with a 400,
72
+ and its message is what gets shown. We do not duplicate here a rule we
73
+ cannot check -- the client does not know whether it is talking to a relay. */
74
+ const MODE = read('mode').toLowerCase() === 'plain' ? 'plain' : 'encrypted';
75
+
76
+ /* The scope: which pages belong to the project.
77
+
78
+ The path prefix is checked HERE, before anything else, and this is the
79
+ only place where it can be: the server does not see paths (blind index,
80
+ FORMAT.md section 4). So it is TIDINESS -- the tag can stay at the foot of
81
+ every page of the site without the online documentation collecting the
82
+ staging notes -- and NOT a security boundary: whoever has the project id
83
+ and the salt writes wherever they like. */
84
+ const PATH_PREFIX = read('path');
85
+
86
+ /* The project origins. The real lock is the server's (FORMAT.md section
87
+ 6.2); this one only avoids talking to a server that is going to say no,
88
+ for instance when the tag was copied onto another site along with the
89
+ rest of a template. It protects nothing: a hand-made client does not read
90
+ it. */
91
+ const DOMAINS = read('domains').split(',').map((d) => d.trim()).filter(Boolean);
92
+
93
+ /* Setup screen. It opens ONLY when asked for by an attribute: without it, a
94
+ tag with no project does strictly nothing, like a directory copied there
95
+ by mistake. That is the rule of silence, applied to setup. */
96
+ const SETUP_REQUESTED = Object.prototype.hasOwnProperty.call(data, 'setup');
97
+
98
+ /* Note-taking context, DECLARED by the host site, never guessed. A
99
+ standalone tool cannot know how the site names its version; the site
100
+ does. Without these attributes the fields stay empty: an invented version
101
+ would send someone hunting for a defect in a build that never existed.
102
+
103
+ The viewport size is read AT SEND TIME and not here: the person may have
104
+ resized, or flipped their phone, between the page load and the remark.
105
+ What counts is the size they had in front of them. */
106
+ const SITE_VERSION = read('version');
107
+ const ENVIRONMENT = read('environment');
108
+ const currentViewport = () =>
109
+ String(window.innerWidth || 0) + 'x' + String(window.innerHeight || 0);
110
+
111
+ /* A label file belonging to the site: DECLARED, and resolved against the
112
+ DOCUMENT and not against this file. A translation file belongs to the site
113
+ under review, not to the CDN serving the client. */
114
+ const LOCAL_LABELS_URL = read('labels')
115
+ ? new URL(read('labels'), document.baseURI).href
116
+ : null;
117
+
118
+ /* -- Limits ------------------------------------------------------------
119
+ The SERVER is the authority: it applies its own and refuses by naming
120
+ them, and it is ITS message that gets shown then. These only warn before
121
+ sending, and keep an absurd string from going out.
122
+
123
+ To be said plainly: in encrypted mode the server no longer sees fields,
124
+ only an envelope (FORMAT.md section 3.6). Those limits then become a
125
+ CLIENT CONVENTION, which nothing enforces on a modified client. That is
126
+ the price of end-to-end encryption, and it is paid gladly: this tool is
127
+ for a review team, not for a hostile audience. */
128
+
129
+ const MAX_TEXT = 4000;
130
+ const MAX_AUTHOR = 80;
131
+ const MAX_SELECTOR = 500;
132
+ const MAX_FINGERPRINT = 255;
133
+ const MAX_EXCERPT = 160;
134
+
135
+ /* ==== 10-utils.js ==== */
136
+
137
+ /* -- 1. Labels ----------------------------------------------------------
138
+ No text meant for the screen is written anywhere but in 15-labels. See
139
+ the header of that file for the two ways of replacing them. */
140
+
141
+ const ns = (window.Annotepage = window.Annotepage || {});
142
+
143
+ /* The package version, put where a console can read it. It is the only fact
144
+ the tool publishes about itself: when a team says "it stopped working
145
+ this morning", the first question is which one is running. */
146
+ ns.version = TOOL_VERSION;
147
+ ns.format = FORMAT;
148
+
149
+ const T = (key, values) => {
150
+ const local = ns.labels || {};
151
+ const defaults = ns.defaultLabels || {};
152
+ // A missing label falls back on the default set; failing that, on the
153
+ // key -- which should never reach the screen, but beats a hole.
154
+ let text = local[key];
155
+ if (typeof text !== 'string') text = defaults[key];
156
+ if (typeof text !== 'string') text = key;
157
+ if (!values) return text;
158
+ return text.replace(/\{([a-z]+)\}/g, (raw, name) =>
159
+ Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : raw
160
+ );
161
+ };
162
+
163
+ /** "0 notes", "1 note", "n notes" -- the plural is a label. */
164
+ const readableCount = (n, zero, one, many) =>
165
+ n === 0 ? T(zero) : n === 1 ? T(one) : T(many, { n: n });
166
+
167
+ /* -- 2. Small utilities -------------------------------------------------- */
168
+
169
+ const create = (tag, cls, text) => {
170
+ const e = document.createElement(tag);
171
+ if (cls) e.className = cls;
172
+ // textContent everywhere, innerHTML nowhere: the text of a note is typed
173
+ // by a human and must never be interpreted as markup, whatever it
174
+ // contains. This rule has no exception in this package, not even for the
175
+ // setup screen.
176
+ if (text !== undefined && text !== null) e.textContent = text;
177
+ return e;
178
+ };
179
+
180
+ const empty = (e) => {
181
+ while (e.firstChild) e.removeChild(e.firstChild);
182
+ };
183
+
184
+ const normalize = (t) => String(t == null ? '' : t).replace(/\s+/g, ' ').trim();
185
+
186
+ const clip = (t, max) => (t.length > max ? t.slice(0, max) : t);
187
+
188
+ /* -- 3. Bytes, text, base64url ------------------------------------------
189
+ base64url WITHOUT padding: it is the only form in the format (FORMAT.md
190
+ sections 1.1 and 3.3). It goes through a query string, a urlencoded body
191
+ and an SQL column without escaping, and it can be copied by hand without a
192
+ trailing "=" getting lost in an email. */
193
+
194
+ const utf8Encoder = new TextEncoder();
195
+ const utf8Decoder = new TextDecoder();
196
+
197
+ const utf8 = (t) => utf8Encoder.encode(String(t));
198
+ const fromUtf8 = (bytes) => utf8Decoder.decode(bytes);
199
+
200
+ const b64url = (source) => {
201
+ const u = new Uint8Array(source);
202
+ let raw = '';
203
+ // In chunks: String.fromCharCode.apply on an array of 24000 bytes blows
204
+ // the call stack in some browsers.
205
+ for (let i = 0; i < u.length; i += 4096) {
206
+ raw += String.fromCharCode.apply(null, u.subarray(i, i + 4096));
207
+ }
208
+ return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
209
+ };
210
+
211
+ /**
212
+ * Returns a Uint8Array, or null if the string is not base64url.
213
+ *
214
+ * Returning null rather than throwing: the caller is always in the middle of
215
+ * reading a line that came off the network, and an unreadable line gets
216
+ * counted, it does not stop the others from being read.
217
+ */
218
+ const fromB64url = (text) => {
219
+ const t = String(text).replace(/-/g, '+').replace(/_/g, '/');
220
+ if (!/^[A-Za-z0-9+/]*$/.test(t)) return null;
221
+ let raw = '';
222
+ try {
223
+ raw = atob(t + '==='.slice((t.length + 3) % 4));
224
+ } catch (e) {
225
+ return null;
226
+ }
227
+ const u = new Uint8Array(raw.length);
228
+ for (let i = 0; i < raw.length; i += 1) u[i] = raw.charCodeAt(i);
229
+ return u;
230
+ };
231
+
232
+ /* -- 4. Versions ---------------------------------------------------------
233
+ Is the fix for a note ALREADY ONLINE?
234
+ We compare the three leading numbers of the version (1.0.69-rc.abc1234):
235
+ they grow with every build. A note resolved in a version more recent than
236
+ the one being served is fixed but not deployed yet, and that has to be
237
+ said -- otherwise we hide it while the defect is still there.
238
+ Unreadable or missing version: the fix is taken as NOT deployed, because
239
+ showing one note too many costs less than hiding one that still counts. */
240
+
241
+ const versionNumbers = (v) => {
242
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v || ''));
243
+ return m ? [+m[1], +m[2], +m[3]] : null;
244
+ };
245
+
246
+ const alreadyDeployed = (fixVersion) => {
247
+ const a = versionNumbers(fixVersion);
248
+ const b = versionNumbers(SITE_VERSION);
249
+ if (!a || !b) return false;
250
+ for (let i = 0; i < 3; i += 1) {
251
+ if (b[i] !== a[i]) return b[i] > a[i];
252
+ }
253
+ return true;
254
+ };
255
+
256
+ /**
257
+ * ISO date from the server -> THE READER'S LOCAL TIME.
258
+ *
259
+ * The server writes in UTC with an explicit offset; the conversion happens
260
+ * here, once, and nobody has to wonder which time zone they are looking at.
261
+ *
262
+ * The language is THE DOCUMENT'S (the lang attribute of <html>), falling
263
+ * back on the browser's: on a French page read from an English browser,
264
+ * "20 aout 2026" is more accurate than "Aug 20, 2026".
265
+ */
266
+ const readableDate = (iso) => {
267
+ const d = new Date(iso);
268
+ if (isNaN(d.getTime())) return T('date.unknown');
269
+ const language = (document.documentElement.getAttribute('lang') || '').trim();
270
+ try {
271
+ return d.toLocaleString(language || undefined,
272
+ { dateStyle: 'medium', timeStyle: 'short' });
273
+ } catch (e) {
274
+ try {
275
+ return d.toLocaleString();
276
+ } catch (e2) {
277
+ return iso;
278
+ }
279
+ }
280
+ };
281
+
282
+ /* ==== 15-labels.js ==== */
283
+
284
+ /* -- 5. EVERY TEXT THE TOOL PUTS ON SCREEN -------------------------------
285
+
286
+ English is the default language, and this is the only place it is
287
+ written: no other file in this package contains a sentence meant for the
288
+ screen. Translating the tool, or simply changing a word that does not suit
289
+ a team, therefore never means touching the code.
290
+
291
+ TWO WAYS TO REPLACE A LABEL, in order of priority:
292
+
293
+ 1. an object defined BEFORE the client is loaded:
294
+
295
+ <script>
296
+ window.Annotepage = { labels: {
297
+ 'button.open': 'Annoter la page'
298
+ } };
299
+ </script>
300
+ <script src="https://.../annotepage.js" ... defer></script>
301
+
302
+ 2. a neighbouring file, DECLARED on the tag:
303
+
304
+ <script src="https://.../annotepage.js"
305
+ data-labels="/local-labels.js" defer></script>
306
+
307
+ That file writes, like this one, into window.Annotepage: it sets
308
+ "labels" (its own texts) and not "defaultLabels". It is resolved
309
+ against the DOCUMENT, not against the CDN: a translation belongs to
310
+ the site under review.
311
+
312
+ A full French set ships in labels/fr.json, as a worked example.
313
+
314
+ Why the local file is DECLARED and not looked for: going to see "whether
315
+ it is there" means a request that usually answers 404 -- and the browser
316
+ logs that failure itself, in the console of EVERY page.
317
+
318
+ A MISSING LABEL FALLS BACK ON ENGLISH. A partial translation is therefore
319
+ usable as it is.
320
+
321
+ SHAPE: a FLAT object. The keys are dotted so they read, not so they nest --
322
+ "button.open" is a string, not a path.
323
+
324
+ { ... } in a value is a placeholder replaced at display time ({n}, {max},
325
+ {name}, {excerpt}, {code}). An unknown placeholder is left as it is. */
326
+
327
+ ns.defaultLabels = {
328
+
329
+ /* -- The button, the tool's only trace when it is at rest ---------- */
330
+ 'button.open': 'Annotate this page',
331
+ 'button.close': 'Done',
332
+ 'button.help': 'Write and read the remarks on this page',
333
+ 'button.notes_zero': '',
334
+ 'button.notes_one': '1 note',
335
+ 'button.notes_n': '{n} notes',
336
+
337
+ /* -- The panel ----------------------------------------------------- */
338
+ 'panel.title': 'Review notes',
339
+ 'panel.close': 'Close',
340
+ 'panel.instructions': 'Click an element of the page to write a remark about it.',
341
+ 'panel.escape': 'Press Escape to stop.',
342
+ 'panel.empty': 'Nobody has written a note on this page yet.',
343
+ 'panel.section_page': 'On this page',
344
+ 'panel.refresh': 'Refresh',
345
+
346
+ /* -- Notes whose element cannot be found any more ------------------- */
347
+ 'orphans.title': 'Notes whose element has changed',
348
+ 'orphans.help':
349
+ 'These remarks were about an element that no longer exists in the same '
350
+ + 'form. They are kept as they are.',
351
+
352
+ /* -- A note -------------------------------------------------------- */
353
+ 'note.about': 'About: {excerpt}',
354
+ 'note.no_element': 'About the whole page',
355
+ 'note.element_lost': 'Element not found on the current page',
356
+ 'note.show': 'Show on the page',
357
+ 'note.reply': 'Reply',
358
+ 'note.reply_placeholder': 'Your reply',
359
+ 'note.reply_send': 'Send the reply',
360
+ 'note.cancel': 'Cancel',
361
+ 'note.mark_resolved': 'Mark resolved',
362
+ 'note.reopen': 'Reopen this remark',
363
+
364
+ /* -- Marking a remark resolved, and taking that mark back ----------- */
365
+ 'resolution.help':
366
+ 'The remark moves to the history once the fix is online. It is never '
367
+ + 'deleted: it can be reopened.',
368
+ 'resolution.confirm': 'It is fixed',
369
+ 'reopening.help':
370
+ 'The remark comes back into the list, with its replies. Do this if the '
371
+ + 'fix turns out to be incomplete.',
372
+ 'reopening.confirm': 'Reopen',
373
+
374
+ /* -- The form ------------------------------------------------------ */
375
+ 'form.title': 'Your remark',
376
+ 'form.about': 'About: {excerpt}',
377
+ 'form.about_no_text': 'About the element you have just pointed at',
378
+ 'form.name': 'Your name',
379
+ 'form.name_help': 'It will appear next to your remarks, and be remembered for next time.',
380
+ 'form.name_placeholder': 'First name, or first and last name',
381
+ 'form.text_placeholder': 'What you noticed',
382
+ 'form.send': 'Send',
383
+ 'form.sending': 'Sending...',
384
+ 'form.cancel': 'Cancel',
385
+ 'form.name_missing': 'Give your name before sending.',
386
+ 'form.text_missing': 'Write your remark before sending.',
387
+ 'form.too_long': 'Your remark is {n} characters long; the limit is {max}.',
388
+ 'form.remaining': '{n} characters left',
389
+
390
+ /* -- The reviewer's name ------------------------------------------- */
391
+ 'author.known': 'You are writing as {name}.',
392
+ 'author.change': 'Change',
393
+ 'history.show': 'See the history ({n} resolved)',
394
+ 'history.hide': 'Hide the history',
395
+ 'history.help': 'Remarks that are resolved, and whose fix is online. '
396
+ + 'They stay here: a correction believed done can turn out to be incomplete.',
397
+ 'note.resolved': 'Resolved on {date} by {by}',
398
+ 'note.resolved_pending': 'Resolved, waiting to be deployed',
399
+ 'note.resolved_version': 'Fix shipped in version {version}',
400
+
401
+ /* -- Failures. They are shown, they are never kept quiet ----------- */
402
+ 'error.title': 'Your remark has NOT been saved',
403
+ 'error.title_read': 'The notes could not be read back',
404
+ 'error.title_resolution': 'The state of the remark has NOT been changed',
405
+ 'error.network':
406
+ 'The server did not answer. Your text is kept above: try again in a '
407
+ + 'moment.',
408
+ 'error.unexpected':
409
+ 'The server answered something unexpected. Your text is kept above; '
410
+ + 'tell whoever looks after the site.',
411
+
412
+ /* A FLAT refusal: a 4xx code with no readable message, almost always a
413
+ firewall's HTML page. It gets its own sentence because "something
414
+ unexpected" helped nobody: the refusal is plain, it has a code, and
415
+ there is a move that often gets around it -- rephrasing. */
416
+ 'error.refused':
417
+ 'The server REFUSED the request (code {code}) without saying why. '
418
+ + 'That is almost always a firewall in front of the site, which took '
419
+ + 'the text for an attack. Your text is kept above: rephrase it -- no '
420
+ + '< > tags, no quotes, no fragment of code or web address -- then try '
421
+ + 'again. If the refusal persists, tell whoever looks after the site: '
422
+ + 'it is a firewall rule to adjust, not a broken tool.',
423
+ 'error.refused_size':
424
+ 'The server refused the request because it is too long (code {code}). '
425
+ + 'Your text is kept above: shorten it, or split it into two remarks.',
426
+ 'error.refused_rate':
427
+ 'The server refused the request because it received too many in too '
428
+ + 'little time (code {code}). Your text is kept above: wait a minute '
429
+ + 'and try again.',
430
+ 'error.server_failure':
431
+ 'The server failed (code {code}). It is not your text: it is kept '
432
+ + 'above. Try again in a moment, then tell whoever looks after the '
433
+ + 'site.',
434
+ 'error.encryption':
435
+ 'Encryption failed in this browser: nothing was sent. Your text is '
436
+ + 'kept above. Reload the page and try again; if it happens again, '
437
+ + 'tell whoever looks after the site.',
438
+ 'error.partial_read': 'What is shown may be incomplete.',
439
+ 'error.hide': 'Hide',
440
+
441
+ /* -- The notes we cannot read, and do not hide --------------------- */
442
+ 'read.newer_one':
443
+ '1 note was written by a more recent version of annotepage and could '
444
+ + 'not be read.',
445
+ 'read.newer_n':
446
+ '{n} notes were written by a more recent version of annotepage and '
447
+ + 'could not be read.',
448
+ 'read.unreadable_one':
449
+ '1 note could not be decrypted. The salt in this browser may not be '
450
+ + 'the one it was written with.',
451
+ 'read.unreadable_n':
452
+ '{n} notes could not be decrypted. The salt in this browser may not be '
453
+ + 'the one they were written with.',
454
+ 'read.unknown_one':
455
+ '1 note is written in a mode this tool does not know, and was not '
456
+ + 'read.',
457
+ 'read.unknown_n':
458
+ '{n} notes are written in a mode this tool does not know, and were not '
459
+ + 'read.',
460
+ 'read.title_partial': 'Some notes could not be read',
461
+
462
+ /* -- The markers put on the elements already annotated ------------- */
463
+ 'marker.one': '1 note here',
464
+ 'marker.n': '{n} notes here',
465
+
466
+ /* -- The salt: the only secret, and it cannot be recovered ---------- */
467
+ 'salt.title': 'The salt of this project is needed',
468
+ 'salt.help':
469
+ 'The notes of this project are encrypted in your browser. Without the '
470
+ + 'project salt, this browser can neither read them nor write any. Ask '
471
+ + 'whoever installed the tool for it, and paste it below. It will be '
472
+ + 'remembered by this browser, for this site.',
473
+ 'salt.label': 'The project salt (43 characters)',
474
+ 'salt.confirm': 'Use this salt',
475
+ 'salt.empty': 'Paste the salt before confirming.',
476
+ 'salt.shape':
477
+ 'This is not a salt: 43 characters are expected, from A-Z a-z 0-9 - _, '
478
+ + 'with no space and no decorative dash. Copy it in one block.',
479
+ 'salt.wrong':
480
+ 'This salt is not the one for this project. Nothing was sent, nothing '
481
+ + 'was decrypted. Check that you are pasting the salt of the right '
482
+ + 'project.',
483
+ 'salt.origin_changed':
484
+ 'This salt is remembered per browser AND per domain. The day staging '
485
+ + 'becomes production, it has to be pasted once more on the new domain '
486
+ + '-- the notes themselves do not move.',
487
+ 'salt.not_kept':
488
+ 'This browser refuses to remember the salt (private browsing, or '
489
+ + 'storage blocked). The tool works for this page, but the salt will '
490
+ + 'have to be pasted again on the next load.',
491
+ 'salt.replace': 'Paste another salt',
492
+ 'salt.forget': 'Forget the salt on this browser',
493
+
494
+ /* -- Setup --------------------------------------------------------- */
495
+ 'setup.title': 'Install annotepage on this site',
496
+ 'setup.generate': 'Generate a salt and create the project',
497
+ 'setup.warning_title': 'Read this before continuing',
498
+ 'setup.warning':
499
+ 'The salt below is the ONLY secret of the project, and nobody else has '
500
+ + 'it: not the server, not the author of the tool, nobody you can ask. '
501
+ + 'SALT LOST = NOTES LOST, for good, with no recovery. Put it away now, '
502
+ + 'where your team keeps its passwords, before continuing.',
503
+ 'setup.salt': 'The project salt -- keep it',
504
+ 'setup.project': 'The project id -- public, it goes into the page',
505
+ 'setup.tag': 'The tag to paste at the end of <body>, on the pages to annotate',
506
+ 'setup.server': 'To declare in the server configuration',
507
+ 'setup.copy': 'Copy',
508
+ 'setup.copied': 'Copied',
509
+ 'setup.copy_failed': 'Select the text and copy it by hand.',
510
+ 'setup.continue': 'I have put the salt away, continue',
511
+ 'setup.done':
512
+ 'The salt is remembered by this browser. Paste the tag above into the '
513
+ + 'pages, declare the project on the server, then reload this page: the '
514
+ + 'tool takes over.',
515
+ 'setup.no_server':
516
+ 'No server address is declared on the tag (data-server), and the client '
517
+ + 'does not come from the site: it cannot guess where to write. Add '
518
+ + 'data-server to the tag.',
519
+ 'setup.plain_mode':
520
+ 'This project is declared in PLAIN mode: the server will read the '
521
+ + 'remarks, the names and the paths. That mode is only acceptable if '
522
+ + 'the server is the site itself, behind the same access restriction. A '
523
+ + 'relay will refuse it.',
524
+
525
+ /* -- The secure context, without which nothing is possible --------- */
526
+ 'context.title': 'annotepage cannot work on this page',
527
+ 'context.help':
528
+ 'Encrypting the notes and grouping them by page rest on WebCrypto, '
529
+ + 'which the browser only provides in a secure context: https, or '
530
+ + 'localhost. This page is not one. Nothing can be written or read '
531
+ + 'back here while it is served this way.',
532
+
533
+ /* -- Odds and ends ------------------------------------------------- */
534
+ 'date.unknown': 'unknown date'
535
+ };
536
+
537
+ /* ==== 20-crypto.js ==== */
538
+
539
+ /* -- 6. The salt, the three derivations, the envelope --------------------
540
+
541
+ This whole file implements FORMAT.md sections 1, 3 and 4, and nothing
542
+ else. When a line here contradicts FORMAT.md, this line is wrong.
543
+
544
+ THE SALT NEVER LEAVES THE BROWSER. It is not sent to the server in any
545
+ form, in any mode, derived forms included. The only path out of here is
546
+ the setup screen, which shows it to the person who has just generated it
547
+ so that they can put it away. */
548
+
549
+ const HKDF_SALT_STRING = 'annotepage/1';
550
+ const SALT_LENGTH = 43; // 32 bytes in base64url without padding
551
+ const NONCE_LENGTH = 16; // 12 bytes in base64url without padding
552
+
553
+ /* WebCrypto only exists in a SECURE context: https, or localhost. On a
554
+ staging site served over bare http, subtle is missing and the tool can do
555
+ NOTHING -- not even compute the page index, which is an HMAC in both modes.
556
+ We find that out here, once, so we can say it on screen instead of
557
+ throwing an unreadable error on the first click. */
558
+ const CRYPTO = window.crypto && window.crypto.subtle ? window.crypto : null;
559
+
560
+ /** 32 bytes from the browser's generator, and from nowhere else. */
561
+ const generateSalt = () => {
562
+ const bytes = new Uint8Array(32);
563
+ CRYPTO.getRandomValues(bytes);
564
+ return b64url(bytes);
565
+ };
566
+
567
+ /**
568
+ * The text of a salt -> its 32 bytes, or null.
569
+ *
570
+ * We refuse anything that has not exactly the right shape rather than
571
+ * "cleaning up" spaces or dashes: an almost-right salt derives a wrong
572
+ * project id, and the message "this salt is not the salt of this project"
573
+ * would then send someone looking in the wrong place.
574
+ */
575
+ const saltFromText = (text) => {
576
+ const t = String(text == null ? '' : text).trim();
577
+ if (!/^[A-Za-z0-9_-]{43}$/.test(t)) return null;
578
+ const bytes = fromB64url(t);
579
+ return bytes && bytes.length === 32 ? bytes : null;
580
+ };
581
+
582
+ /**
583
+ * The three derivations, in one go.
584
+ *
585
+ * TRAP, named because it costs dearly: HKDF's "salt" parameter is NOT our
586
+ * salt. Our salt is the input keying material (IKM); HKDF's salt is the
587
+ * fixed, public string "annotepage/1", which separates this tool from any
588
+ * other software one might one day trust with the same secret. Swapping them
589
+ * produces a system that works, that encrypts, and whose notes become
590
+ * unreadable on the first reimplementation.
591
+ */
592
+ const derive = (saltBytes) => {
593
+ const params = (label) => ({
594
+ name: 'HKDF',
595
+ hash: 'SHA-256',
596
+ salt: utf8(HKDF_SALT_STRING), // NOT the salt: see above
597
+ info: utf8(label)
598
+ });
599
+
600
+ return CRYPTO.subtle
601
+ .importKey('raw', saltBytes, 'HKDF', false, ['deriveBits', 'deriveKey'])
602
+ .then((master) => Promise.all([
603
+ CRYPTO.subtle.deriveBits(params('id'), master, 256),
604
+ // The encryption key is generated NON-EXTRACTABLE. That is
605
+ // hygiene, not a barrier: the salt sleeps in localStorage right
606
+ // next to it, and whoever reads one rebuilds the other in three
607
+ // lines. We write it down so that nobody takes this "false" for
608
+ // a protection it is not.
609
+ CRYPTO.subtle.deriveKey(params('encrypted'), master,
610
+ { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']),
611
+ CRYPTO.subtle.deriveBits(params('index'), master, 256)
612
+ ]))
613
+ .then((three) => CRYPTO.subtle
614
+ .importKey('raw', three[2], { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
615
+ .then((indexKey) => ({
616
+ // 16 bytes and not 32: this value travels in a query string,
617
+ // a tag attribute, a configuration file and an indexed
618
+ // column. 128 bits are unguessable, and 22 characters can be
619
+ // copied by hand -- 43 cannot.
620
+ id: b64url(new Uint8Array(three[0]).subarray(0, 16)),
621
+ encryptionKey: three[1],
622
+ indexKey: indexKey
623
+ })));
624
+ };
625
+
626
+ /**
627
+ * page_index = HMAC(index_key, path), first 16 bytes, base64url.
628
+ *
629
+ * NO normalisation other than format 1's (a single leading slash, no ".."
630
+ * segment): no lowercasing, no stripping of a trailing slash, no decoding of
631
+ * %xx. "/Contact" and "/contact" are two pages; "/a/" and "/a" are two
632
+ * pages. It is what the browser gives, it is what we index -- and it is the
633
+ * only way two implementations agree.
634
+ *
635
+ * The computation happens IN BOTH MODES: one code path, one way of grouping.
636
+ * Two would have diverged by the second fix.
637
+ */
638
+ const pagePath = () => {
639
+ let c = String(location.pathname || '/');
640
+ if (c.charAt(0) !== '/') c = '/' + c;
641
+ c = c.replace(/^\/+/, '/');
642
+ if (c.indexOf('/../') !== -1 || /\/\.\.$/.test(c)) {
643
+ c = c.split('/').filter((s) => s !== '..').join('/') || '/';
644
+ if (c.charAt(0) !== '/') c = '/' + c;
645
+ }
646
+ return c;
647
+ };
648
+
649
+ const indexOfPath = (indexKey, path) =>
650
+ CRYPTO.subtle.sign('HMAC', indexKey, utf8(path))
651
+ .then((signature) => b64url(new Uint8Array(signature).subarray(0, 16)));
652
+
653
+ /* -- The envelope --------------------------------------------------------
654
+ AES-256-GCM, no exception and no fallback. No choice of algorithm, no
655
+ negotiation, no "suite": a format that negotiates is a format that gets
656
+ pushed down onto its weakest option. */
657
+
658
+ /**
659
+ * The AAD binds the envelope to its place. Without it, a malicious server
660
+ * can move a note from one page to another, or from one project to another:
661
+ * decryption would succeed and the remark would appear under an element it
662
+ * was not aimed at.
663
+ */
664
+ const aad = (project, pageIndex, role) =>
665
+ utf8(FORMAT + '\n' + project + '\n' + pageIndex + '\n' + role);
666
+
667
+ const envelopeError = (reason) => {
668
+ const e = new Error('envelope ' + reason);
669
+ e.reason = reason;
670
+ return e;
671
+ };
672
+
673
+ /** An empty field is ABSENT from the object, it is not written as "". Same
674
+ rule as in the text export, and for the same reason: do not write a key
675
+ to say there is nothing. */
676
+ const compact = (object) => {
677
+ const clean = {};
678
+ Object.keys(object).forEach((key) => {
679
+ const v = object[key];
680
+ if (v !== undefined && v !== null && String(v) !== '') clean[key] = String(v);
681
+ });
682
+ return clean;
683
+ };
684
+
685
+ const seal = (encryptionKey, project, pageIndex, role, object) => {
686
+ // A 12-byte nonce drawn at EVERY encryption. Never a counter, never
687
+ // derived from the content, never reused: a nonce repeated with the same
688
+ // key under GCM does not leak a note, it leaks the authentication key.
689
+ const nonce = new Uint8Array(12);
690
+ CRYPTO.getRandomValues(nonce);
691
+ const plain = utf8(JSON.stringify(compact(object)));
692
+ return CRYPTO.subtle.encrypt(
693
+ { name: 'AES-GCM', iv: nonce, additionalData: aad(project, pageIndex, role), tagLength: 128 },
694
+ encryptionKey, plain
695
+ ).then((ciphertext) => 'ap' + FORMAT + '.' + b64url(nonce) + '.' + b64url(ciphertext));
696
+ };
697
+
698
+ /**
699
+ * Returns the JSON object of the envelope.
700
+ *
701
+ * Rejects with a reason:
702
+ * 'newer' the envelope carries a format number above ours. We do not
703
+ * guess at cryptography: flat refusal, the note is skipped
704
+ * and counted, and the tool SAYS that it exists.
705
+ * 'unreadable' invalid shape, or decryption failed -- wrong salt, note
706
+ * moved by the server, damaged bytes. All three are worth
707
+ * the same to the reader: there is nothing to read.
708
+ */
709
+ const open = (encryptionKey, project, pageIndex, role, envelope) => {
710
+ const parts = String(envelope == null ? '' : envelope).split('.');
711
+ if (parts.length !== 3) return Promise.reject(envelopeError('unreadable'));
712
+
713
+ const mark = /^ap(\d+)$/.exec(parts[0]);
714
+ if (!mark) return Promise.reject(envelopeError('unreadable'));
715
+ const number = parseInt(mark[1], 10);
716
+ if (number > FORMAT) return Promise.reject(envelopeError('newer'));
717
+ if (number !== FORMAT) return Promise.reject(envelopeError('unreadable'));
718
+
719
+ // A reader that counts a nonce of another length refuses the row instead
720
+ // of guessing.
721
+ if (parts[1].length !== NONCE_LENGTH) return Promise.reject(envelopeError('unreadable'));
722
+ const nonce = fromB64url(parts[1]);
723
+ const ciphertext = fromB64url(parts[2]);
724
+ if (!nonce || nonce.length !== 12 || !ciphertext) {
725
+ return Promise.reject(envelopeError('unreadable'));
726
+ }
727
+
728
+ return CRYPTO.subtle.decrypt(
729
+ { name: 'AES-GCM', iv: nonce, additionalData: aad(project, pageIndex, role), tagLength: 128 },
730
+ encryptionKey, ciphertext
731
+ ).then((plain) => {
732
+ let object = null;
733
+ try {
734
+ object = JSON.parse(fromUtf8(new Uint8Array(plain)));
735
+ } catch (e) {
736
+ throw envelopeError('unreadable');
737
+ }
738
+ if (!object || typeof object !== 'object' || Array.isArray(object)) {
739
+ throw envelopeError('unreadable');
740
+ }
741
+ return object;
742
+ }, () => {
743
+ // GCM does not say WHY it refuses, and that is intended: wrong key,
744
+ // different AAD, one changed byte, everything lands here.
745
+ throw envelopeError('unreadable');
746
+ });
747
+ };
748
+
749
+ /* ==== 30-state.js ==== */
750
+
751
+ /* -- 7. State, browser memory, scope ------------------------------------- */
752
+
753
+ let host = null; // the single element added to the site
754
+ let root = null; // its shadow root
755
+ let ui = null; // the interface elements, once built
756
+ let mode = false; // is annotation mode on?
757
+ let notes = []; // the page's notes, as the server states them
758
+ let anchored = []; // { element, notes[] } : the notes found again
759
+ let orphans = []; // notes whose element was not found
760
+ let historyOpen = false; // resolved AND deployed notes are folded away
761
+ let target = null; // element being annotated
762
+ let hovered = null; // element under the pointer
763
+ let currentFailure = null; // { title, detail } shown in the panel
764
+ let author = ''; // read at startup: see 90-boot
765
+ let timer = null;
766
+ let rafPending = false;
767
+
768
+ /* What we did NOT manage to read at the last load. We count it so we can say
769
+ it: a note skipped in silence is a remark that disappears. */
770
+ let skipped = { newer: 0, unreadable: 0, unknown: 0 };
771
+
772
+ /* The salt of this project, and everything derived from it. "keys" stays
773
+ null as long as the salt is unknown: no request, no decryption goes out
774
+ before then. */
775
+ let saltText = '';
776
+ let keys = null; // { id, encryptionKey, indexKey }
777
+ let PAGE_INDEX = ''; // blind index of the current page
778
+
779
+ const inTool = (n) => !!(host && n && (n === host || host.contains(n)));
780
+
781
+ /* -- The browser's memory ------------------------------------------------
782
+ The try/catch blocks wrap ONLY the storage access, because that is the
783
+ only thing here that is allowed to fail: private browsing, or storage
784
+ refused by a browser policy. Widening them would turn a programming
785
+ mistake into a silent failure, and therefore into one nobody can find. */
786
+
787
+ // A per-browser convenience, not an identity: nobody is authenticated, and
788
+ // the name is there to know who to talk to, not to prove who one is.
789
+ const AUTHOR_KEY = 'annotepage/author';
790
+
791
+ /* The salt is stored UNDER THE PROJECT ID. That naming is not cosmetic: two
792
+ projects reviewed from the same browser must not overwrite each other.
793
+
794
+ An unpleasant consequence, to be stated: localStorage is PER ORIGIN. The
795
+ day staging becomes production, every reviewer has to paste the salt once
796
+ more on the new domain. The notes themselves do not move -- and that is
797
+ exactly what the rule "the domain is not in the key" buys. */
798
+ const saltKey = (project) => 'annotepage/salt/' + project;
799
+
800
+ const readSalt = (project) => {
801
+ try {
802
+ return String(window.localStorage.getItem(saltKey(project)) || '').trim();
803
+ } catch (e) {
804
+ // Without storage the salt will be asked for on every visit: that is
805
+ // less comfortable, it is not a failure.
806
+ return '';
807
+ }
808
+ };
809
+
810
+ const writeSalt = (project, text) => {
811
+ try {
812
+ window.localStorage.setItem(saltKey(project), text);
813
+ return true;
814
+ } catch (e) {
815
+ // We return false so the screen can SAY it: a salt that is not kept
816
+ // will have to be pasted again on every page, and it is better to
817
+ // know that straight away than on the third time.
818
+ return false;
819
+ }
820
+ };
821
+
822
+ const forgetSalt = (project) => {
823
+ try {
824
+ window.localStorage.removeItem(saltKey(project));
825
+ } catch (e) {
826
+ // Nothing to do: there was no storage in the first place.
827
+ }
828
+ };
829
+
830
+ function readAuthor() {
831
+ let raw = '';
832
+ try {
833
+ raw = window.localStorage.getItem(AUTHOR_KEY) || '';
834
+ } catch (e) {
835
+ return '';
836
+ }
837
+ return normalize(raw);
838
+ }
839
+
840
+ function writeAuthor(value) {
841
+ author = value;
842
+ try {
843
+ window.localStorage.setItem(AUTHOR_KEY, value);
844
+ } catch (e) {
845
+ // No consequence: only the memory of the name is lost.
846
+ }
847
+ }
848
+
849
+ /* -- The scope -----------------------------------------------------------
850
+ Two checks, and neither is a security measure. They keep a tag left in a
851
+ shared template from collecting notes where the project does not go, and
852
+ keep a client from talking to a server that is going to say no. The real
853
+ boundary is the server's domain lock (FORMAT.md section 6.2), which is
854
+ itself only an anti-abuse measure. */
855
+
856
+ const inScope = () => {
857
+ if (DOMAINS.length && DOMAINS.indexOf(location.origin) === -1) return false;
858
+ if (PATH_PREFIX && pagePath().indexOf(PATH_PREFIX) !== 0) return false;
859
+ return true;
860
+ };
861
+
862
+ /* ==== 40-api.js ==== */
863
+
864
+ /* -- 8. The API ---------------------------------------------------------
865
+ The contract, as the server fixed it:
866
+
867
+ 200 + application/json normal response
868
+ 200 + JSON "active: false" tool dropped in, not configured -> stand down
869
+ 404 + text/plain nothing at this address -> stand down
870
+ 4xx/5xx + text/plain message written for a human -> SHOW IT
871
+ 4xx with no readable text FLAT REFUSAL, almost always a firewall ->
872
+ name it, with its code (see below)
873
+ anything else PHP not executed -> stand down
874
+
875
+ This function never rejects and never writes to the console: it returns a
876
+ cause, and the caller decides whether we keep quiet or speak. */
877
+
878
+ const call = (action, body) => {
879
+ if (!API) return Promise.resolve({ ok: false, cause: 'inactive' });
880
+
881
+ const options = {
882
+ method: body ? 'POST' : 'GET',
883
+ cache: 'no-store',
884
+ // On a relay this means "no cookie": that is what we want. The
885
+ // project is not a session, it is a bearer token (FORMAT.md section
886
+ // 6.3), and the urlencoded body makes a write a "simple request" in
887
+ // the CORS sense -- so no OPTIONS preflight.
888
+ credentials: 'same-origin'
889
+ };
890
+ if (body) options.body = body;
891
+
892
+ let address = API + (API.indexOf('?') === -1 ? '?' : '&')
893
+ + 'action=' + encodeURIComponent(action);
894
+ if (!body) {
895
+ // The real path is NEVER sent, in any mode: only the blind index
896
+ // goes out. Sending the path in plain mode and the index in
897
+ // encrypted mode would make two code paths, and the second would be
898
+ // the less tested one.
899
+ address += '&project=' + encodeURIComponent(PROJECT)
900
+ + '&index=' + encodeURIComponent(PAGE_INDEX);
901
+ }
902
+
903
+ return fetch(address, options)
904
+ .then((response) => response.text().then((text) => ({ response: response, text: text })))
905
+ .then((r) => {
906
+ const status = r.response.status;
907
+ const type = (r.response.headers.get('content-type') || '').toLowerCase();
908
+ const isJson = type.indexOf('application/json') !== -1;
909
+
910
+ if (r.response.ok && isJson) {
911
+ let data = null;
912
+ try {
913
+ data = JSON.parse(r.text);
914
+ } catch (e) {
915
+ return { ok: false, cause: 'nonjson' };
916
+ }
917
+ // The tool is dropped in here but not configured: it SAYS so
918
+ // with a 200, so as not to leave the browser an error to log.
919
+ // We stand down, as on a 404.
920
+ if (data && data.active === false) {
921
+ return { ok: false, cause: 'inactive' };
922
+ }
923
+ return { ok: true, data: data };
924
+ }
925
+ if (status === 404) {
926
+ // The tool is not configured here -- or there is nothing at
927
+ // this address. Either way: silence.
928
+ return { ok: false, cause: 'inactive' };
929
+ }
930
+ if (!r.response.ok && type.indexOf('text/plain') !== -1) {
931
+ return { ok: false, cause: 'server', message: clip(r.text.trim(), 2000) };
932
+ }
933
+
934
+ /* THE FLAT REFUSAL. Seen in production: a hosting firewall
935
+ answers 403 with an HTML page, and the client showed "the
936
+ server answered something unexpected". That was true and
937
+ useless -- nobody knew what to do with the sentence.
938
+
939
+ It is not our server speaking: it is an intermediary that
940
+ decided the request looked like an attack, often because of a
941
+ word in the text that was typed. So we name the refusal, we
942
+ give its code, and we suggest the one move that really gets
943
+ around it: rephrase. The text stays in the form -- that has
944
+ never changed and will not. */
945
+ if (status === 413) return { ok: false, cause: 'refused-size', code: status };
946
+ if (status === 429) return { ok: false, cause: 'refused-rate', code: status };
947
+ if (status >= 400 && status < 500) return { ok: false, cause: 'refused', code: status };
948
+ if (status >= 500) return { ok: false, cause: 'failure', code: status };
949
+
950
+ // A 200 that is not JSON: PHP is not executed, the source is
951
+ // served in the clear, or an intermediary answered.
952
+ return { ok: false, cause: 'nonjson' };
953
+ })
954
+ .catch(() => ({ ok: false, cause: 'network' }));
955
+ };
956
+
957
+ /** Turns a cause into a showable failure. Returns null if there is nothing
958
+ to say. */
959
+ const failureFrom = (result, title) => {
960
+ if (result.ok) return null;
961
+ const say = (key) => ({ title: T(title), detail: T(key, { code: result.code }) });
962
+ if (result.cause === 'server') return { title: T(title), detail: result.message };
963
+ if (result.cause === 'network') return say('error.network');
964
+ if (result.cause === 'refused') return say('error.refused');
965
+ if (result.cause === 'refused-size') return say('error.refused_size');
966
+ if (result.cause === 'refused-rate') return say('error.refused_rate');
967
+ if (result.cause === 'failure') return say('error.server_failure');
968
+ return say('error.unexpected');
969
+ };
970
+
971
+ /* -- 9. Writing: the mode decides where the fields go --------------------
972
+ One single place builds a request body. In plain mode the fields go out as
973
+ they are -- exactly format 1's columns. In encrypted mode, EVERYTHING typed
974
+ or observed goes into the envelope: encrypting the text alone would hand
975
+ over the site's tree, the wording of its elements and the names of its
976
+ reviewers (FORMAT.md section 2.3). */
977
+
978
+ const PAYLOAD_FIELDS = ['page', 'selector', 'fingerprint', 'excerpt',
979
+ 'author', 'text', 'version', 'environment', 'viewport'];
980
+
981
+ const noteBody = (fields, replyTo) => {
982
+ const body = new URLSearchParams();
983
+ body.set('project', PROJECT);
984
+ body.set('mode', MODE);
985
+ if (replyTo) {
986
+ // A reply INHERITS the page index of its parent, and in plain mode
987
+ // its page and its element. Asking the client for them again would
988
+ // open the door to a reply attached somewhere other than the note it
989
+ // comments on.
990
+ body.set('reply_to', String(replyTo));
991
+ } else {
992
+ body.set('index', PAGE_INDEX);
993
+ }
994
+
995
+ if (MODE === 'plain') {
996
+ PAYLOAD_FIELDS.forEach((key) => {
997
+ if (fields[key] !== undefined) body.set(key, String(fields[key]));
998
+ });
999
+ return Promise.resolve(body);
1000
+ }
1001
+ // The AAD uses the page index WE computed, never the one the server
1002
+ // announces: it is precisely against a server that moves a note from one
1003
+ // page to another that the AAD exists.
1004
+ return seal(keys.encryptionKey, PROJECT, PAGE_INDEX, 'note', fields)
1005
+ .then((envelope) => {
1006
+ body.set('payload', envelope);
1007
+ return body;
1008
+ });
1009
+ };
1010
+
1011
+ const resolutionBody = (note, mark, name) => {
1012
+ const body = new URLSearchParams();
1013
+ body.set('project', PROJECT);
1014
+ body.set('id', String(note.id));
1015
+ body.set('resolved', mark ? '1' : '0');
1016
+ if (!mark) {
1017
+ // Reopening writes nothing: the server clears the resolution. We do
1018
+ // not ask for the fixer's name in order to cancel the fix.
1019
+ return Promise.resolve(body);
1020
+ }
1021
+ if (MODE === 'plain') {
1022
+ body.set('by', name);
1023
+ body.set('version', SITE_VERSION);
1024
+ return Promise.resolve(body);
1025
+ }
1026
+ // A second envelope, its own nonce, its own role: it is written by
1027
+ // another person, at another moment, often from another machine. Melting
1028
+ // it into the note's envelope would mean re-encrypting a remark we have
1029
+ // no right to rewrite.
1030
+ return seal(keys.encryptionKey, PROJECT, PAGE_INDEX, 'resolution',
1031
+ { by: name, version: SITE_VERSION })
1032
+ .then((envelope) => {
1033
+ body.set('resolution_payload', envelope);
1034
+ return body;
1035
+ });
1036
+ };
1037
+
1038
+ /* -- 10. Reading: open what we can, count what we cannot ------------------ */
1039
+
1040
+ const fillFrom = (note, object) => {
1041
+ // UNKNOWN fields of the object are ignored in silence: that is what
1042
+ // makes it possible to add one some day without changing the format
1043
+ // number.
1044
+ PAYLOAD_FIELDS.forEach((key) => {
1045
+ note[key] = object[key] === undefined ? '' : String(object[key]);
1046
+ });
1047
+ return note;
1048
+ };
1049
+
1050
+ /**
1051
+ * One row -> one readable note, or null if we cannot read it.
1052
+ * What is skipped is COUNTED: a note that disappears in silence is worse
1053
+ * than a note we announce we cannot read.
1054
+ */
1055
+ const openNote = (note) => {
1056
+ if (!note || typeof note !== 'object') return Promise.resolve(null);
1057
+
1058
+ // "mode" missing or empty: the row comes from format 1, it is plain.
1059
+ const m = String(note.mode || 'plain');
1060
+
1061
+ if (m === 'plain') return Promise.resolve(note);
1062
+
1063
+ if (m !== 'encrypted') {
1064
+ // Neither guessed, nor blanked without saying so.
1065
+ skipped.unknown += 1;
1066
+ return Promise.resolve(null);
1067
+ }
1068
+
1069
+ return open(keys.encryptionKey, PROJECT, PAGE_INDEX, 'note', note.payload)
1070
+ .then(
1071
+ (object) => fillFrom(note, object),
1072
+ (e) => {
1073
+ if (e && e.reason === 'newer') skipped.newer += 1;
1074
+ else skipped.unreadable += 1;
1075
+ return null;
1076
+ }
1077
+ )
1078
+ .then((read) => {
1079
+ if (!read || !read.resolution_payload) return read;
1080
+ return open(keys.encryptionKey, PROJECT, PAGE_INDEX, 'resolution', read.resolution_payload)
1081
+ .then(
1082
+ (object) => {
1083
+ read.resolved_by = object.by === undefined ? '' : String(object.by);
1084
+ read.resolved_version = object.version === undefined ? '' : String(object.version);
1085
+ return read;
1086
+ },
1087
+ () => {
1088
+ /* The note reads, its resolution does not. We keep
1089
+ the note: "resolved by somebody" beats nothing, and
1090
+ the resolution date is in the clear anyway. */
1091
+ read.resolved_by = '';
1092
+ read.resolved_version = '';
1093
+ return read;
1094
+ }
1095
+ );
1096
+ });
1097
+ };
1098
+
1099
+ /** Opens a note and its replies. A reply is a note: same role. */
1100
+ const openThread = (note) =>
1101
+ openNote(note).then((parent) => {
1102
+ if (!parent) return null;
1103
+ const children = Array.isArray(parent.replies) ? parent.replies : [];
1104
+ if (!children.length) return parent;
1105
+ return Promise.all(children.map(openNote))
1106
+ .then((read) => {
1107
+ parent.replies = read.filter(Boolean);
1108
+ return parent;
1109
+ });
1110
+ });
1111
+
1112
+ const readList = (data) => {
1113
+ skipped = { newer: 0, unreadable: 0, unknown: 0 };
1114
+ const raw = data && Array.isArray(data.notes) ? data.notes : [];
1115
+ return Promise.all(raw.map(openThread)).then((read) => read.filter(Boolean));
1116
+ };
1117
+
1118
+ /** What we could not read, said on screen. Returns null if there is nothing
1119
+ to say. */
1120
+ const readFailure = () => {
1121
+ const lines = [];
1122
+ if (skipped.newer) {
1123
+ lines.push(readableCount(skipped.newer, '', 'read.newer_one', 'read.newer_n'));
1124
+ }
1125
+ if (skipped.unreadable) {
1126
+ lines.push(readableCount(skipped.unreadable, '', 'read.unreadable_one', 'read.unreadable_n'));
1127
+ }
1128
+ if (skipped.unknown) {
1129
+ lines.push(readableCount(skipped.unknown, '', 'read.unknown_one', 'read.unknown_n'));
1130
+ }
1131
+ if (!lines.length) return null;
1132
+ return { title: T('read.title_partial'), detail: lines.join('\n') };
1133
+ };
1134
+
1135
+ /* ==== 50-anchors.js ==== */
1136
+
1137
+ /* -- 11. The three anchors of an element ---------------------------------
1138
+ None is reliable on its own: a path breaks at the first inserted block, a
1139
+ fingerprint of classes breaks when the styling is redone, a text excerpt
1140
+ breaks at the editorial pass. Together they make it possible to DEGRADE --
1141
+ to flag the note as orphaned -- instead of losing it. */
1142
+
1143
+ const cssPath = (el) => {
1144
+ const parts = [];
1145
+ let n = el;
1146
+ while (n && n.nodeType === 1 && n !== document.body && n !== document.documentElement) {
1147
+ const tag = n.localName;
1148
+ let rank = 1;
1149
+ let s = n.previousElementSibling;
1150
+ while (s) {
1151
+ if (s.localName === tag) rank += 1;
1152
+ s = s.previousElementSibling;
1153
+ }
1154
+ parts.unshift(tag + ':nth-of-type(' + rank + ')');
1155
+ n = n.parentElement;
1156
+ }
1157
+ // Too long for the column: we drop the leading segments. The path becomes
1158
+ // relative and may designate several elements -- which is exactly why the
1159
+ // fingerprint and the excerpt exist.
1160
+ let path = parts.join(' > ');
1161
+ while (path.length > MAX_SELECTOR && parts.length > 1) {
1162
+ parts.shift();
1163
+ path = parts.join(' > ');
1164
+ }
1165
+ return clip(path, MAX_SELECTOR);
1166
+ };
1167
+
1168
+ const fingerprintOf = (el) => {
1169
+ if (!el || el.nodeType !== 1) return '';
1170
+ let e = el.localName;
1171
+ if (el.id) e += '#' + el.id;
1172
+ const classes = (el.getAttribute('class') || '').split(/\s+/).filter(Boolean);
1173
+ for (let i = 0; i < classes.length && i < 4; i += 1) e += '.' + classes[i];
1174
+ return clip(e, MAX_FINGERPRINT);
1175
+ };
1176
+
1177
+ /**
1178
+ * The text by which a human recognises the element. It is what shows in the
1179
+ * panel: "About: Contact us". Never the path, never the fingerprint -- those
1180
+ * are anchors for machines.
1181
+ */
1182
+ const excerptOf = (el) => {
1183
+ if (!el || el.nodeType !== 1) return '';
1184
+ let t = normalize(el.textContent);
1185
+ if (!t) {
1186
+ t = normalize(
1187
+ el.getAttribute('alt') ||
1188
+ el.getAttribute('aria-label') ||
1189
+ el.getAttribute('placeholder') ||
1190
+ el.getAttribute('title') ||
1191
+ (el.localName === 'input' ? el.value : '') ||
1192
+ ''
1193
+ );
1194
+ }
1195
+ return clip(t, MAX_EXCERPT);
1196
+ };
1197
+
1198
+ /* -- 12. Finding the element of a note ----------------------------------- */
1199
+
1200
+ const score = (el, note) => {
1201
+ let s = 0;
1202
+ if (note.fingerprint && fingerprintOf(el) === note.fingerprint) s += 2;
1203
+ if (note.excerpt) {
1204
+ const t = excerptOf(el);
1205
+ if (t === note.excerpt) s += 2;
1206
+ else if (t && note.excerpt.length >= 12 && t.indexOf(note.excerpt.slice(0, 24)) === 0) s += 1;
1207
+ }
1208
+ return s;
1209
+ };
1210
+
1211
+ /**
1212
+ * Three attempts, from the most precise to the widest. If none returns an
1213
+ * element that resembles it enough, the note becomes ORPHANED: it stays
1214
+ * readable in the panel, with its date and its author, instead of vanishing
1215
+ * without anyone knowing.
1216
+ */
1217
+ const findElement = (note) => {
1218
+ if (!note.selector && !note.fingerprint && !note.excerpt) return null;
1219
+
1220
+ // 1. The path, confirmed by at least one of the two other anchors.
1221
+ if (note.selector) {
1222
+ let el = null;
1223
+ try {
1224
+ el = document.body.querySelector(note.selector);
1225
+ } catch (e) {
1226
+ el = null; // path gone invalid: this is not a failure
1227
+ }
1228
+ if (el && !inTool(el)) {
1229
+ if (!note.fingerprint && !note.excerpt) return el;
1230
+ if (score(el, note) >= 1) return el;
1231
+ }
1232
+ }
1233
+
1234
+ // 2. The fingerprint: same tag, same classes, same id.
1235
+ if (note.fingerprint) {
1236
+ const tag = note.fingerprint.split(/[#.]/)[0];
1237
+ let candidates = [];
1238
+ try {
1239
+ candidates = Array.prototype.slice.call(document.body.querySelectorAll(tag));
1240
+ } catch (e) {
1241
+ candidates = [];
1242
+ }
1243
+ let best = null;
1244
+ let bestScore = 0;
1245
+ for (let i = 0; i < candidates.length; i += 1) {
1246
+ const c = candidates[i];
1247
+ if (inTool(c)) continue;
1248
+ const s = score(c, note);
1249
+ if (s > bestScore) {
1250
+ best = c;
1251
+ bestScore = s;
1252
+ }
1253
+ }
1254
+ if (best && bestScore >= 2) return best;
1255
+ }
1256
+
1257
+ // 3. The text alone, if it is long enough not to designate just
1258
+ // anything. It is the anchor that best survives a restyling.
1259
+ if (note.excerpt && note.excerpt.length >= 12) {
1260
+ const all = document.body.querySelectorAll('*');
1261
+ for (let i = 0; i < all.length; i += 1) {
1262
+ const c = all[i];
1263
+ if (inTool(c)) continue;
1264
+ if (excerptOf(c) === note.excerpt) return c;
1265
+ }
1266
+ }
1267
+
1268
+ return null;
1269
+ };
1270
+
1271
+ /** Splits the server's notes between elements found again and orphans. */
1272
+ const anchor = () => {
1273
+ anchored = [];
1274
+ orphans = [];
1275
+ for (let i = 0; i < notes.length; i += 1) {
1276
+ const note = notes[i];
1277
+ const el = findElement(note);
1278
+ if (!el) {
1279
+ orphans.push(note);
1280
+ continue;
1281
+ }
1282
+ let group = null;
1283
+ for (let j = 0; j < anchored.length; j += 1) {
1284
+ if (anchored[j].element === el) group = anchored[j];
1285
+ }
1286
+ if (!group) {
1287
+ group = { element: el, notes: [] };
1288
+ anchored.push(group);
1289
+ }
1290
+ group.notes.push(note);
1291
+ }
1292
+ };
1293
+
1294
+ /* ==== 60-ui.js ==== */
1295
+
1296
+ /* -- 13. Building the interface -------------------------------------------
1297
+ Everything below lives in the shadow root. The host site sees none of it,
1298
+ and is seen by none of it. */
1299
+
1300
+ /**
1301
+ * The host element and its shadow root, and NOTHING ELSE.
1302
+ *
1303
+ * It is created before the labels are loaded -- a shadow root is needed to
1304
+ * load them into -- but it shows nothing: the interface itself is only built
1305
+ * once the texts are available.
1306
+ */
1307
+ const buildHost = () => {
1308
+ // IDEMPOTENT, and this is not a stylistic precaution: the salt-pasting
1309
+ // screen built the host BEFORE the normal startup asked for it in turn.
1310
+ // Without this guard, the site received TWO elements, one of them empty
1311
+ // and orphaned -- the promise "one single element added" fell over at the
1312
+ // first pasted salt.
1313
+ if (host) return;
1314
+ host = document.createElement('annotepage-notes');
1315
+ // These properties are set INLINE and with !important, on our own
1316
+ // element: a site rule aiming at "body > div" must not be able to move
1317
+ // the layer. "all: initial" also cuts off any inheritance from the site
1318
+ // into the tool.
1319
+ host.style.cssText =
1320
+ 'all: initial !important;' +
1321
+ 'position: fixed !important;' +
1322
+ 'top: 0 !important; left: 0 !important;' +
1323
+ 'right: 0 !important; bottom: 0 !important;' +
1324
+ 'width: auto !important; height: auto !important;' +
1325
+ 'margin: 0 !important; padding: 0 !important; border: 0 !important;' +
1326
+ 'pointer-events: none !important;' +
1327
+ 'z-index: 2147483000 !important;';
1328
+ document.body.appendChild(host);
1329
+ root = host.attachShadow({ mode: 'open' });
1330
+
1331
+ /* The stylesheet is PUT HERE, in a <style>, and not loaded by a <link>
1332
+ as in the original tool.
1333
+
1334
+ Reason: the client goes to a CDN under SRI. A second request to a
1335
+ neighbouring file would mean a second digest to keep up to date, and
1336
+ nobody keeps two digests in agreement for long. One file, one digest,
1337
+ one thing to check.
1338
+
1339
+ Pleasant side effect: the sheet is there before the first pixel. The
1340
+ hiding and then showing of the host element, which existed so as not
1341
+ to show the tool unstyled for a fraction of a second, no longer has
1342
+ any reason to be and is gone.
1343
+
1344
+ The price, to be stated: the sheet weighs in the served file, and the
1345
+ styling can no longer be replaced by changing a neighbouring file -- it
1346
+ has to be rebuilt. */
1347
+ /* Two routes, and the first is not vanity: a strict content security
1348
+ policy (style-src without 'unsafe-inline') BLOCKS a <style> element,
1349
+ and the tool would show up unstyled -- which looks like a broken page.
1350
+ A CONSTRUCTED sheet, on the other hand, is not an inline sheet in the
1351
+ policy's sense, and goes through. We keep <style> for the browsers
1352
+ that do not construct sheets. */
1353
+ let placed = false;
1354
+ try {
1355
+ if (root.adoptedStyleSheets && typeof CSSStyleSheet === 'function') {
1356
+ const sheet = new CSSStyleSheet();
1357
+ sheet.replaceSync(STYLES);
1358
+ root.adoptedStyleSheets = [sheet];
1359
+ placed = true;
1360
+ }
1361
+ } catch (e) {
1362
+ placed = false;
1363
+ }
1364
+ if (!placed) {
1365
+ const style = document.createElement('style');
1366
+ style.textContent = STYLES;
1367
+ root.appendChild(style);
1368
+ }
1369
+ };
1370
+
1371
+ /** The interface. Built AFTER the labels: no fallback text to put in. */
1372
+ const buildUi = () => {
1373
+ const layer = create('div', 'ap-layer');
1374
+ root.appendChild(layer);
1375
+
1376
+ /* -- the button -- */
1377
+ const button = create('button', 'ap-button');
1378
+ button.type = 'button';
1379
+ button.setAttribute('aria-pressed', 'false');
1380
+ button.title = T('button.help');
1381
+ const dot = create('span', 'ap-button-dot');
1382
+ const buttonText = create('span', null, T('button.open'));
1383
+ const buttonCount = create('span', 'ap-button-count');
1384
+ button.appendChild(dot);
1385
+ button.appendChild(buttonText);
1386
+ button.appendChild(buttonCount);
1387
+ button.addEventListener('click', () => toggleMode());
1388
+ layer.appendChild(button);
1389
+
1390
+ /* -- pointing highlight -- */
1391
+ const highlight = create('div', 'ap-highlight');
1392
+ const label = create('div', 'ap-highlight-label');
1393
+ layer.appendChild(highlight);
1394
+ layer.appendChild(label);
1395
+
1396
+ /* -- markers -- */
1397
+ const markers = create('div', 'ap-markers');
1398
+ layer.appendChild(markers);
1399
+
1400
+ /* -- panel -- */
1401
+ const panel = create('aside', 'ap-panel');
1402
+ panel.setAttribute('role', 'complementary');
1403
+ const header = create('div', 'ap-panel-header');
1404
+ const title = create('span', 'ap-panel-title', T('panel.title'));
1405
+ const close = create('button', 'ap-link', T('panel.close'));
1406
+ close.type = 'button';
1407
+ close.addEventListener('click', () => leaveMode());
1408
+ header.appendChild(title);
1409
+ header.appendChild(close);
1410
+ const instructions = create('div', 'ap-panel-instructions');
1411
+ instructions.appendChild(create('div', null, T('panel.instructions')));
1412
+ instructions.appendChild(create('div', null, T('panel.escape')));
1413
+ const body = create('div', 'ap-panel-body');
1414
+ const footer = create('div', 'ap-panel-footer');
1415
+ panel.appendChild(header);
1416
+ panel.appendChild(instructions);
1417
+ panel.appendChild(body);
1418
+ panel.appendChild(footer);
1419
+ layer.appendChild(panel);
1420
+
1421
+ /* -- form -- */
1422
+ const form = create('div', 'ap-form');
1423
+ layer.appendChild(form);
1424
+
1425
+ ui = {
1426
+ layer: layer,
1427
+ button: button,
1428
+ buttonText: buttonText,
1429
+ buttonCount: buttonCount,
1430
+ highlight: highlight,
1431
+ label: label,
1432
+ markers: markers,
1433
+ panel: panel,
1434
+ body: body,
1435
+ footer: footer,
1436
+ form: form
1437
+ };
1438
+ };
1439
+
1440
+ /* -- 14. Highlight and markers ------------------------------------------- */
1441
+
1442
+ const place = (el, rect, margin) => {
1443
+ const m = margin || 0;
1444
+ el.style.left = Math.max(0, rect.left - m) + 'px';
1445
+ el.style.top = Math.max(0, rect.top - m) + 'px';
1446
+ el.style.width = Math.max(0, rect.width + m * 2) + 'px';
1447
+ el.style.height = Math.max(0, rect.height + m * 2) + 'px';
1448
+ };
1449
+
1450
+ const showHighlight = (el) => {
1451
+ if (!el) return hideHighlight();
1452
+ const r = el.getBoundingClientRect();
1453
+ if (r.width === 0 && r.height === 0) return hideHighlight();
1454
+ place(ui.highlight, r, 1);
1455
+ ui.highlight.style.display = 'block';
1456
+
1457
+ const text = excerptOf(el);
1458
+ ui.label.textContent = text || T('form.about_no_text');
1459
+ ui.label.style.display = 'block';
1460
+ const top = r.top > 26 ? r.top - 24 : Math.min(window.innerHeight - 24, r.bottom + 4);
1461
+ ui.label.style.left = Math.max(4, Math.min(r.left, window.innerWidth - 330)) + 'px';
1462
+ ui.label.style.top = top + 'px';
1463
+ };
1464
+
1465
+ const hideHighlight = () => {
1466
+ if (!ui) return;
1467
+ ui.highlight.style.display = 'none';
1468
+ ui.label.style.display = 'none';
1469
+ };
1470
+
1471
+ /** One badge per annotated element. It only appears in annotation mode:
1472
+ outside that mode, the page is exactly the site's. */
1473
+ const drawMarkers = () => {
1474
+ empty(ui.markers);
1475
+ if (!mode) return;
1476
+ for (let i = 0; i < anchored.length; i += 1) {
1477
+ const group = anchored[i];
1478
+ const r = group.element.getBoundingClientRect();
1479
+ if (r.width === 0 && r.height === 0) continue;
1480
+ if (r.bottom < 0 || r.top > window.innerHeight) continue;
1481
+ const n = group.notes.length;
1482
+ const badge = create('button', 'ap-marker', String(n));
1483
+ badge.type = 'button';
1484
+ badge.title = n === 1 ? T('marker.one') : T('marker.n', { n: n });
1485
+ badge.style.left = Math.max(2, Math.min(r.left - 8, window.innerWidth - 30)) + 'px';
1486
+ badge.style.top = Math.max(2, Math.min(r.top - 8, window.innerHeight - 30)) + 'px';
1487
+ badge.addEventListener('click', ((note) => () => focusNote(note))(group.notes[0]));
1488
+ ui.markers.appendChild(badge);
1489
+ }
1490
+ };
1491
+
1492
+ const refreshPositions = () => {
1493
+ if (rafPending) return;
1494
+ rafPending = true;
1495
+ window.requestAnimationFrame(() => {
1496
+ rafPending = false;
1497
+ if (!mode) return;
1498
+ drawMarkers();
1499
+ if (hovered && document.contains(hovered)) showHighlight(hovered);
1500
+ if (target && document.contains(target)) positionForm(target);
1501
+ });
1502
+ };
1503
+
1504
+ /* -- 15. The panel ------------------------------------------------------- */
1505
+
1506
+ const failureBlock = (failure, onClose) => {
1507
+ const block = create('div', 'ap-error');
1508
+ block.setAttribute('role', 'alert');
1509
+ block.appendChild(create('div', 'ap-error-title', failure.title));
1510
+ // The server's message is shown AS IT WAS WRITTEN: that is how "the
1511
+ // database is unreachable" reaches a reviewer's screen.
1512
+ block.appendChild(create('p', 'ap-error-detail', failure.detail));
1513
+ if (onClose) {
1514
+ const hide = create('button', 'ap-link', T('error.hide'));
1515
+ hide.type = 'button';
1516
+ hide.addEventListener('click', onClose);
1517
+ block.appendChild(hide);
1518
+ }
1519
+ return block;
1520
+ };
1521
+
1522
+ const noteCard = (note, orphan) => {
1523
+ /* Resolution state, said on the card itself. Two distinct cases:
1524
+ resolved and online, or resolved but not deployed yet -- the second has
1525
+ to show, otherwise one believes the defect gone while it is still on
1526
+ screen. */
1527
+ const live = note.resolved_at ? alreadyDeployed(note.resolved_version) : false;
1528
+ const card = create('article', 'ap-note'
1529
+ + (orphan ? ' ap-orphan' : '')
1530
+ + (note.resolved_at ? (live ? ' ap-resolved' : ' ap-resolved-pending') : ''));
1531
+ card.setAttribute('data-ap-note', String(note.id));
1532
+ if (note.resolved_at) {
1533
+ const mark = create('div', 'ap-state-mark',
1534
+ live
1535
+ ? T('note.resolved', {
1536
+ date: readableDate(note.resolved_at),
1537
+ by: note.resolved_by || '?',
1538
+ })
1539
+ : T('note.resolved_pending'));
1540
+ mark.title = note.resolved_version
1541
+ ? T('note.resolved_version', { version: note.resolved_version })
1542
+ : '';
1543
+ card.appendChild(mark);
1544
+ }
1545
+
1546
+ const header = create('div', 'ap-note-header');
1547
+ header.appendChild(create('span', 'ap-note-author', note.author));
1548
+ header.appendChild(create('span', 'ap-note-date', readableDate(note.created_at)));
1549
+ card.appendChild(header);
1550
+
1551
+ // What the reviewer sees of the element: its TEXT, never its path.
1552
+ const targetText = orphan
1553
+ ? (note.excerpt
1554
+ ? T('note.about', { excerpt: note.excerpt }) + ' -- ' + T('note.element_lost')
1555
+ : T('note.element_lost'))
1556
+ : (note.excerpt ? T('note.about', { excerpt: note.excerpt }) : T('note.no_element'));
1557
+ card.appendChild(create('p', 'ap-note-target', targetText));
1558
+
1559
+ card.appendChild(create('p', 'ap-note-text', note.text));
1560
+
1561
+ const actions = create('div', 'ap-note-actions');
1562
+ const reply = create('button', 'ap-secondary', T('note.reply'));
1563
+ reply.type = 'button';
1564
+ actions.appendChild(reply);
1565
+ if (!orphan) {
1566
+ const show = create('button', 'ap-link', T('note.show'));
1567
+ show.type = 'button';
1568
+ show.addEventListener('click', () => showElement(note));
1569
+ actions.appendChild(show);
1570
+ }
1571
+ /* Mark resolved, and take that mark back. Without this button, half the
1572
+ tool -- the server action, its columns, the history and its labels --
1573
+ stayed written and out of reach: nobody could set the state the panel
1574
+ already knew how to show. */
1575
+ const state = create('button', 'ap-link',
1576
+ T(note.resolved_at ? 'note.reopen' : 'note.mark_resolved'));
1577
+ state.type = 'button';
1578
+ state.addEventListener('click', () => {
1579
+ const alreadyOpen = card.querySelector('.ap-resolve');
1580
+ if (alreadyOpen) {
1581
+ alreadyOpen.remove();
1582
+ return;
1583
+ }
1584
+ card.appendChild(resolutionForm(note, !note.resolved_at));
1585
+ });
1586
+ actions.appendChild(state);
1587
+ card.appendChild(actions);
1588
+
1589
+ const replies = create('div', 'ap-replies');
1590
+ const list = note.replies || [];
1591
+ for (let i = 0; i < list.length; i += 1) {
1592
+ const r = list[i];
1593
+ const block = create('div', 'ap-reply');
1594
+ const e = create('div', 'ap-note-header');
1595
+ e.appendChild(create('span', 'ap-note-author', r.author));
1596
+ e.appendChild(create('span', 'ap-note-date', readableDate(r.created_at)));
1597
+ block.appendChild(e);
1598
+ block.appendChild(create('p', 'ap-note-text', r.text));
1599
+ replies.appendChild(block);
1600
+ }
1601
+ if (list.length) card.appendChild(replies);
1602
+
1603
+ reply.addEventListener('click', () => {
1604
+ if (card.querySelector('.ap-reply-form')) return;
1605
+ card.appendChild(replyForm(note));
1606
+ });
1607
+
1608
+ return card;
1609
+ };
1610
+
1611
+ /**
1612
+ * Mark a note resolved, or reopen a resolved note.
1613
+ *
1614
+ * The name is asked for ONLY to mark a fix: it is what signs the gesture. To
1615
+ * reopen, the server does not require it and would erase it anyway -- asking
1616
+ * for the fixer's name in order to cancel the fix would make no sense.
1617
+ *
1618
+ * The site version is sent with the mark: it is what then allows "resolved
1619
+ * and online" to be told apart from "resolved, not deployed yet". Without
1620
+ * it, a note would be filed into the history while the defect is still on
1621
+ * screen.
1622
+ */
1623
+ const resolutionForm = (note, mark) => {
1624
+ const block = create('div', 'ap-resolve');
1625
+ block.appendChild(create('p', 'ap-help',
1626
+ T(mark ? 'resolution.help' : 'reopening.help')));
1627
+
1628
+ const nameParts = mark ? nameField() : null;
1629
+ if (nameParts) block.appendChild(nameParts.block);
1630
+
1631
+ const actions = create('div', 'ap-actions');
1632
+ const confirm = create('button', 'ap-primary',
1633
+ T(mark ? 'resolution.confirm' : 'reopening.confirm'));
1634
+ confirm.type = 'button';
1635
+ const cancel = create('button', 'ap-secondary', T('note.cancel'));
1636
+ cancel.type = 'button';
1637
+ cancel.addEventListener('click', () => block.remove());
1638
+ actions.appendChild(confirm);
1639
+ actions.appendChild(cancel);
1640
+ block.appendChild(actions);
1641
+
1642
+ const say = (failure) => {
1643
+ const previous = block.querySelector('.ap-error');
1644
+ if (previous) previous.remove();
1645
+ if (failure) block.insertBefore(failureBlock(failure), block.firstChild);
1646
+ };
1647
+
1648
+ confirm.addEventListener('click', () => {
1649
+ const name = nameParts ? normalize(nameParts.field.value) : author;
1650
+ if (mark && !name) {
1651
+ return say({ title: T('error.title_resolution'),
1652
+ detail: T('form.name_missing') });
1653
+ }
1654
+ say(null);
1655
+ confirm.disabled = true;
1656
+ cancel.disabled = true;
1657
+
1658
+ // The body is built BEFORE the send and, in encrypted mode, it has
1659
+ // to be encrypted to be obtained: that is asynchronous, like the
1660
+ // rest.
1661
+ resolutionBody(note, mark, name)
1662
+ .then((body) => call('resolve', body))
1663
+ .then((r) => {
1664
+ confirm.disabled = false;
1665
+ cancel.disabled = false;
1666
+ if (!r.ok) {
1667
+ say(failureFrom(r, 'error.title_resolution'));
1668
+ return;
1669
+ }
1670
+ if (name) writeAuthor(name);
1671
+ block.remove();
1672
+ // As everywhere: we read the server back instead of assuming.
1673
+ reload();
1674
+ }, () => {
1675
+ confirm.disabled = false;
1676
+ cancel.disabled = false;
1677
+ say({ title: T('error.title_resolution'), detail: T('error.encryption') });
1678
+ });
1679
+ });
1680
+
1681
+ return block;
1682
+ };
1683
+
1684
+ const replyForm = (note) => {
1685
+ const block = create('div', 'ap-reply-form');
1686
+ const area = create('textarea', 'ap-area');
1687
+ area.setAttribute('placeholder', T('note.reply_placeholder'));
1688
+ area.setAttribute('maxlength', String(MAX_TEXT));
1689
+ block.appendChild(area);
1690
+
1691
+ const nameParts = nameField();
1692
+ if (nameParts) block.appendChild(nameParts.block);
1693
+
1694
+ const actions = create('div', 'ap-actions');
1695
+ const send = create('button', 'ap-primary', T('note.reply_send'));
1696
+ send.type = 'button';
1697
+ const cancel = create('button', 'ap-secondary', T('note.cancel'));
1698
+ cancel.type = 'button';
1699
+ cancel.addEventListener('click', () => block.remove());
1700
+ actions.appendChild(send);
1701
+ actions.appendChild(cancel);
1702
+ block.appendChild(actions);
1703
+
1704
+ const say = (failure) => {
1705
+ const previous = block.querySelector('.ap-error');
1706
+ if (previous) previous.remove();
1707
+ if (failure) block.insertBefore(failureBlock(failure), block.firstChild);
1708
+ };
1709
+
1710
+ send.addEventListener('click', () => {
1711
+ const text = area.value.trim();
1712
+ const name = nameParts ? normalize(nameParts.field.value) : author;
1713
+ if (!name) return say({ title: T('error.title'), detail: T('form.name_missing') });
1714
+ if (!text) return say({ title: T('error.title'), detail: T('form.text_missing') });
1715
+ if (text.length > MAX_TEXT) {
1716
+ return say({
1717
+ title: T('error.title'),
1718
+ detail: T('form.too_long', { n: text.length, max: MAX_TEXT })
1719
+ });
1720
+ }
1721
+ say(null);
1722
+ send.disabled = true;
1723
+ cancel.disabled = true;
1724
+ send.textContent = T('form.sending');
1725
+
1726
+ noteBody({
1727
+ author: name,
1728
+ text: text,
1729
+ version: SITE_VERSION,
1730
+ environment: ENVIRONMENT,
1731
+ viewport: currentViewport()
1732
+ }, note.id).then((body) => call('add', body)).then((r) => {
1733
+ send.disabled = false;
1734
+ cancel.disabled = false;
1735
+ send.textContent = T('note.reply_send');
1736
+ if (!r.ok) {
1737
+ // The text stays in the area: nothing is lost.
1738
+ say(failureFrom(r, 'error.title'));
1739
+ return;
1740
+ }
1741
+ writeAuthor(name);
1742
+ block.remove();
1743
+ // We ask the server again instead of adding the reply to the
1744
+ // screen: what is shown is what the server says, never what the
1745
+ // browser assumes.
1746
+ reload();
1747
+ }, () => {
1748
+ send.disabled = false;
1749
+ cancel.disabled = false;
1750
+ send.textContent = T('note.reply_send');
1751
+ // Encryption failed: the reply did NOT go out, and the text
1752
+ // stays in the area.
1753
+ say({ title: T('error.title'), detail: T('error.encryption') });
1754
+ });
1755
+ });
1756
+
1757
+ // Convenience: the reply can be written straight away.
1758
+ window.setTimeout(() => area.focus(), 0);
1759
+ return block;
1760
+ };
1761
+
1762
+ /** The "your name" field, only for as long as we do not know it. */
1763
+ const nameField = () => {
1764
+ if (author) return null;
1765
+ const block = create('div');
1766
+ const label = create('label', 'ap-label', T('form.name'));
1767
+ const field = create('input', 'ap-field');
1768
+ field.type = 'text';
1769
+ field.setAttribute('maxlength', String(MAX_AUTHOR));
1770
+ field.setAttribute('placeholder', T('form.name_placeholder'));
1771
+ field.setAttribute('autocomplete', 'off');
1772
+ const id = 'ap-name-' + Math.random().toString(36).slice(2, 8);
1773
+ field.id = id;
1774
+ label.setAttribute('for', id);
1775
+ block.appendChild(label);
1776
+ block.appendChild(field);
1777
+ block.appendChild(create('p', 'ap-help', T('form.name_help')));
1778
+ return { block: block, field: field };
1779
+ };
1780
+
1781
+ const drawPanel = () => {
1782
+ empty(ui.body);
1783
+ empty(ui.footer);
1784
+
1785
+ if (currentFailure) {
1786
+ ui.body.appendChild(failureBlock(currentFailure, () => {
1787
+ currentFailure = null;
1788
+ drawPanel();
1789
+ }));
1790
+ }
1791
+
1792
+ /* What we could not read is SAID, with its count. A note skipped in
1793
+ silence is a remark that disappears, and the person who wrote it will
1794
+ think nobody read it. */
1795
+ const partial = readFailure();
1796
+ if (partial) ui.body.appendChild(failureBlock(partial));
1797
+
1798
+ /* A note that is resolved AND whose fix is online leaves the main view:
1799
+ it has done its job. It is not deleted -- a correction believed done
1800
+ can turn out to be incomplete, and the remark must be able to come
1801
+ back with its thread of replies.
1802
+
1803
+ A resolved note whose fix is NOT deployed yet stays visible: the
1804
+ defect is still on screen, hiding it would suggest it is gone. */
1805
+ const current = [];
1806
+ const archived = [];
1807
+ for (let i = 0; i < notes.length; i += 1) {
1808
+ const n = notes[i];
1809
+ if (orphans.indexOf(n) !== -1) continue;
1810
+ if (n.resolved_at && alreadyDeployed(n.resolved_version)) archived.push(n);
1811
+ else current.push(n);
1812
+ }
1813
+
1814
+ if (!current.length && !orphans.length && !archived.length) {
1815
+ ui.body.appendChild(create('p', 'ap-empty', T('panel.empty')));
1816
+ }
1817
+
1818
+ if (current.length) {
1819
+ ui.body.appendChild(create('h2', 'ap-section-title', T('panel.section_page')));
1820
+ for (let i = 0; i < current.length; i += 1) {
1821
+ ui.body.appendChild(noteCard(current[i], false));
1822
+ }
1823
+ }
1824
+
1825
+ if (orphans.length) {
1826
+ ui.body.appendChild(create('h2', 'ap-section-title', T('orphans.title')));
1827
+ ui.body.appendChild(create('p', 'ap-section-help', T('orphans.help')));
1828
+ for (let i = 0; i < orphans.length; i += 1) {
1829
+ ui.body.appendChild(noteCard(orphans[i], true));
1830
+ }
1831
+ }
1832
+
1833
+ if (archived.length) {
1834
+ const toggle = create('button', 'ap-history-toggle',
1835
+ T(historyOpen ? 'history.hide' : 'history.show',
1836
+ { n: archived.length }));
1837
+ toggle.type = 'button';
1838
+ toggle.addEventListener('click', () => {
1839
+ historyOpen = !historyOpen;
1840
+ drawPanel();
1841
+ });
1842
+ ui.body.appendChild(toggle);
1843
+
1844
+ if (historyOpen) {
1845
+ ui.body.appendChild(create('p', 'ap-section-help', T('history.help')));
1846
+ for (let i = 0; i < archived.length; i += 1) {
1847
+ ui.body.appendChild(noteCard(archived[i], false));
1848
+ }
1849
+ }
1850
+ }
1851
+
1852
+ if (author) {
1853
+ ui.footer.appendChild(create('span', null, T('author.known', { name: author })));
1854
+ const change = create('button', 'ap-link', T('author.change'));
1855
+ change.type = 'button';
1856
+ change.addEventListener('click', () => {
1857
+ writeAuthor('');
1858
+ drawPanel();
1859
+ });
1860
+ ui.footer.appendChild(change);
1861
+ }
1862
+
1863
+ /* The salt gets pasted again from here. This is not a convenience
1864
+ setting: the day staging becomes production, localStorage changes
1865
+ origin and the salt has to be pasted once more, on every browser.
1866
+ Without this button, one would have to clear the storage by hand to
1867
+ get there. */
1868
+ if (PROJECT && saltText) {
1869
+ const changeSalt = create('button', 'ap-link', T('salt.replace'));
1870
+ changeSalt.type = 'button';
1871
+ changeSalt.title = T('salt.origin_changed');
1872
+ changeSalt.addEventListener('click', () => openSaltScreen());
1873
+ ui.footer.appendChild(changeSalt);
1874
+ }
1875
+
1876
+ const total = notes.length;
1877
+ ui.buttonCount.textContent = readableCount(
1878
+ total, 'button.notes_zero', 'button.notes_one', 'button.notes_n');
1879
+ // The button carries the failure: someone who does not open it must be
1880
+ // able to see, at a glance, that something is wrong.
1881
+ ui.button.classList.toggle('ap-failed', !!currentFailure);
1882
+ ui.button.title = currentFailure ? currentFailure.title : T('button.help');
1883
+ };
1884
+
1885
+ /** Brings a note forward in the panel, without changing anything on the
1886
+ page. */
1887
+ const focusNote = (note) => {
1888
+ const card = ui.body.querySelector('[data-ap-note="' + note.id + '"]');
1889
+ if (!card) return;
1890
+ const previous = ui.body.querySelectorAll('.ap-focused');
1891
+ for (let i = 0; i < previous.length; i += 1) previous[i].classList.remove('ap-focused');
1892
+ card.classList.add('ap-focused');
1893
+ card.scrollIntoView({ block: 'nearest' });
1894
+ };
1895
+
1896
+ /** Brings the commented element back into view, by showing it on our side. */
1897
+ const showElement = (note) => {
1898
+ let el = null;
1899
+ for (let i = 0; i < anchored.length; i += 1) {
1900
+ if (anchored[i].notes.indexOf(note) !== -1) el = anchored[i].element;
1901
+ }
1902
+ if (!el) return;
1903
+ // scrollIntoView moves the viewpoint, never the document: no node, no
1904
+ // style of the site is touched.
1905
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
1906
+ window.setTimeout(() => {
1907
+ showHighlight(el);
1908
+ window.setTimeout(hideHighlight, 1400);
1909
+ }, 350);
1910
+ };
1911
+
1912
+ /* -- 16. The form for a new note ------------------------------------------ */
1913
+
1914
+ const positionForm = (el) => {
1915
+ const form = ui.form;
1916
+ const r = el.getBoundingClientRect();
1917
+ if (narrowScreen()) {
1918
+ // The stylesheet takes over: the form takes the full width.
1919
+ form.style.left = '';
1920
+ form.style.top = Math.max(8, Math.min(r.bottom + 8, window.innerHeight - 260)) + 'px';
1921
+ return;
1922
+ }
1923
+ const width = form.offsetWidth || 340;
1924
+ const height = form.offsetHeight || 260;
1925
+ let left = r.left;
1926
+ if (left + width > window.innerWidth - 12) left = window.innerWidth - width - 12;
1927
+ let top = r.bottom + 8;
1928
+ if (top + height > window.innerHeight - 12) top = Math.max(8, r.top - height - 8);
1929
+ form.style.left = Math.max(8, left) + 'px';
1930
+ form.style.top = Math.max(8, top) + 'px';
1931
+ };
1932
+
1933
+ /** True on the screens where the panel and the form do not fit side by
1934
+ side. The threshold is the stylesheet's. */
1935
+ const narrowScreen = () => window.innerWidth <= 560;
1936
+
1937
+ const closeForm = () => {
1938
+ target = null;
1939
+ ui.form.classList.remove('ap-open');
1940
+ empty(ui.form);
1941
+ // On a narrow screen, the list had given way to the typing.
1942
+ if (mode) ui.panel.classList.add('ap-open');
1943
+ };
1944
+
1945
+ /**
1946
+ * @param existingText remark already typed, when the form is REBUILT without
1947
+ * having been closed (name change). Rebuilding a piece of typing without
1948
+ * carrying it over would make it disappear under the fingers of whoever is
1949
+ * writing: that is the same wrong as losing a note.
1950
+ */
1951
+ const openForm = (el, existingText) => {
1952
+ target = el;
1953
+ const form = ui.form;
1954
+ empty(form);
1955
+
1956
+ const excerpt = excerptOf(el);
1957
+ form.appendChild(create('div', 'ap-form-title', T('form.title')));
1958
+ form.appendChild(create('div', 'ap-form-target',
1959
+ excerpt ? T('form.about', { excerpt: excerpt }) : T('form.about_no_text')));
1960
+
1961
+ const name = nameField();
1962
+ if (name) {
1963
+ form.appendChild(name.block);
1964
+ } else {
1965
+ /* The name is already known: we RECALL it here, with a way to change
1966
+ it, instead of leaving it at the foot of the panel where nobody
1967
+ sees it while writing. Showing what one is signing at the moment
1968
+ one signs it keeps a remark from going out under the name of a
1969
+ colleague who used the same machine. */
1970
+ const reminder = create('div', 'ap-form-signature');
1971
+ reminder.appendChild(create('span', null, T('author.known', { name: author })));
1972
+ const change = create('button', 'ap-link', T('author.change'));
1973
+ change.type = 'button';
1974
+ change.addEventListener('click', () => {
1975
+ // The remark in progress is CARRIED OVER into the rebuilt form:
1976
+ // changing the name does not cost what has been written.
1977
+ const pending = area.value;
1978
+ writeAuthor('');
1979
+ openForm(el, pending);
1980
+ });
1981
+ reminder.appendChild(change);
1982
+ form.appendChild(reminder);
1983
+ }
1984
+
1985
+ const area = create('textarea', 'ap-area');
1986
+ area.setAttribute('placeholder', T('form.text_placeholder'));
1987
+ area.setAttribute('maxlength', String(MAX_TEXT));
1988
+ if (typeof existingText === 'string') area.value = existingText;
1989
+ form.appendChild(area);
1990
+
1991
+ const actions = create('div', 'ap-actions');
1992
+ const send = create('button', 'ap-primary', T('form.send'));
1993
+ send.type = 'button';
1994
+ const cancel = create('button', 'ap-secondary', T('form.cancel'));
1995
+ cancel.type = 'button';
1996
+ const counter = create('span', 'ap-counter',
1997
+ T('form.remaining', { n: Math.max(0, MAX_TEXT - area.value.length) }));
1998
+ actions.appendChild(send);
1999
+ actions.appendChild(cancel);
2000
+ actions.appendChild(counter);
2001
+ form.appendChild(actions);
2002
+
2003
+ area.addEventListener('input', () => {
2004
+ counter.textContent = T('form.remaining',
2005
+ { n: Math.max(0, MAX_TEXT - area.value.length) });
2006
+ });
2007
+ cancel.addEventListener('click', () => closeForm());
2008
+
2009
+ const say = (failure) => {
2010
+ const previous = form.querySelector('.ap-error');
2011
+ if (previous) previous.remove();
2012
+ if (failure) form.insertBefore(failureBlock(failure), form.firstChild);
2013
+ };
2014
+
2015
+ send.addEventListener('click', () => {
2016
+ const text = area.value.trim();
2017
+ const writer = name ? normalize(name.field.value) : author;
2018
+ if (!writer) return say({ title: T('error.title'), detail: T('form.name_missing') });
2019
+ if (!text) return say({ title: T('error.title'), detail: T('form.text_missing') });
2020
+ if (text.length > MAX_TEXT) {
2021
+ return say({
2022
+ title: T('error.title'),
2023
+ detail: T('form.too_long', { n: text.length, max: MAX_TEXT })
2024
+ });
2025
+ }
2026
+ say(null);
2027
+ send.disabled = true;
2028
+ cancel.disabled = true;
2029
+ send.textContent = T('form.sending');
2030
+
2031
+ /* The page path goes into the PAYLOAD, never into the query string:
2032
+ the server groups by blind index. In plain mode it still files it
2033
+ in its "page" column, as in format 1. */
2034
+ noteBody({
2035
+ page: pagePath(),
2036
+ selector: cssPath(el),
2037
+ fingerprint: fingerprintOf(el),
2038
+ excerpt: excerpt,
2039
+ author: writer,
2040
+ text: text,
2041
+ version: SITE_VERSION,
2042
+ environment: ENVIRONMENT,
2043
+ viewport: currentViewport()
2044
+ }, null).then((body) => call('add', body)).then((r) => {
2045
+ send.disabled = false;
2046
+ cancel.disabled = false;
2047
+ send.textContent = T('form.send');
2048
+ if (!r.ok) {
2049
+ // The remark stays on screen. Nothing is lost, and the
2050
+ // person knows nothing is saved.
2051
+ say(failureFrom(r, 'error.title'));
2052
+ return;
2053
+ }
2054
+ writeAuthor(writer);
2055
+ closeForm();
2056
+ reload();
2057
+ }, () => {
2058
+ send.disabled = false;
2059
+ cancel.disabled = false;
2060
+ send.textContent = T('form.send');
2061
+ say({ title: T('error.title'), detail: T('error.encryption') });
2062
+ });
2063
+ });
2064
+
2065
+ // On a narrow screen, writing and reading the list at the same time is
2066
+ // impossible: the typing takes the whole space, the list comes back when
2067
+ // the form is closed.
2068
+ if (narrowScreen()) ui.panel.classList.remove('ap-open');
2069
+ form.classList.add('ap-open');
2070
+ positionForm(el);
2071
+ window.setTimeout(() => (name ? name.field : area).focus(), 0);
2072
+ };
2073
+
2074
+ /* -- 17. Annotation mode ------------------------------------------------- */
2075
+
2076
+ const onHover = (event) => {
2077
+ const el = event.target;
2078
+ if (!el || el.nodeType !== 1 || inTool(el)) return;
2079
+ if (el === document.body || el === document.documentElement) return;
2080
+ hovered = el;
2081
+ showHighlight(el);
2082
+ };
2083
+
2084
+ const onClick = (event) => {
2085
+ const el = event.target;
2086
+ // A click on the tool itself: we let the event go down into the shadow
2087
+ // root, where our own buttons are waiting for it.
2088
+ if (inTool(el)) return;
2089
+ // Everything else is captured: in annotation mode one points, one does
2090
+ // not navigate. That is what keeps a click on a link from carrying the
2091
+ // person away at the moment they meant to comment on it.
2092
+ event.preventDefault();
2093
+ event.stopPropagation();
2094
+ if (event.type !== 'click') return;
2095
+ if (!el || el.nodeType !== 1) return;
2096
+ if (el === document.body || el === document.documentElement) return;
2097
+ openForm(el);
2098
+ };
2099
+
2100
+ const onKey = (event) => {
2101
+ if (event.key !== 'Escape') return;
2102
+ if (ui.form.classList.contains('ap-open')) {
2103
+ closeForm();
2104
+ return;
2105
+ }
2106
+ leaveMode();
2107
+ };
2108
+
2109
+ const enterMode = () => {
2110
+ mode = true;
2111
+ ui.button.setAttribute('aria-pressed', 'true');
2112
+ ui.buttonText.textContent = T('button.close');
2113
+ ui.panel.classList.add('ap-open');
2114
+
2115
+ document.addEventListener('pointerover', onHover, true);
2116
+ document.addEventListener('pointerdown', onClick, true);
2117
+ document.addEventListener('click', onClick, true);
2118
+ document.addEventListener('auxclick', onClick, true);
2119
+ document.addEventListener('keydown', onKey, true);
2120
+ window.addEventListener('scroll', refreshPositions, true);
2121
+ window.addEventListener('resize', refreshPositions);
2122
+ // A carousel, a dropdown menu, an image loaded late move the elements
2123
+ // without emitting either scroll or resize.
2124
+ timer = window.setInterval(refreshPositions, 500);
2125
+
2126
+ // The markers for what we ALREADY know, straight away; the server is
2127
+ // asked next and will correct if there is anything new. Waiting for the
2128
+ // network to show what is already on screen would suggest an empty page.
2129
+ drawMarkers();
2130
+ reload();
2131
+ };
2132
+
2133
+ const leaveMode = () => {
2134
+ mode = false;
2135
+ ui.button.setAttribute('aria-pressed', 'false');
2136
+ ui.buttonText.textContent = T('button.open');
2137
+ ui.panel.classList.remove('ap-open');
2138
+ closeForm();
2139
+ hideHighlight();
2140
+ hovered = null;
2141
+ empty(ui.markers);
2142
+
2143
+ document.removeEventListener('pointerover', onHover, true);
2144
+ document.removeEventListener('pointerdown', onClick, true);
2145
+ document.removeEventListener('click', onClick, true);
2146
+ document.removeEventListener('auxclick', onClick, true);
2147
+ document.removeEventListener('keydown', onKey, true);
2148
+ window.removeEventListener('scroll', refreshPositions, true);
2149
+ window.removeEventListener('resize', refreshPositions);
2150
+ if (timer) {
2151
+ window.clearInterval(timer);
2152
+ timer = null;
2153
+ }
2154
+ };
2155
+
2156
+ const toggleMode = () => (mode ? leaveMode() : enterMode());
2157
+
2158
+ /* ==== 70-setup.js ==== */
2159
+
2160
+ /* -- 18. Setup, and the salt one pastes ----------------------------------
2161
+
2162
+ These screens are the only places where the salt is shown or typed in.
2163
+ They are BLOCKING: as long as the salt is unknown, the tool shows neither
2164
+ an annotation button nor a panel of notes. There is nothing to annotate
2165
+ without a salt -- not even in plain mode, where the page index is already
2166
+ an HMAC.
2167
+
2168
+ None of these screens makes a network request. A consequence to be
2169
+ stated: a page carrying a tag with a project, on a site whose server is
2170
+ not configured yet, will still show the "paste the salt" screen. That is
2171
+ accepted: without a salt we cannot even ask for the list of notes, so we
2172
+ cannot check that the server answers. The tag, on the other hand, was put
2173
+ there by somebody. */
2174
+
2175
+ /** Removes the current interface without touching the stylesheet. */
2176
+ const clearLayer = () => {
2177
+ if (!root) return;
2178
+ const previous = root.querySelectorAll('.ap-layer');
2179
+ for (let i = 0; i < previous.length; i += 1) previous[i].remove();
2180
+ ui = null;
2181
+ };
2182
+
2183
+ /**
2184
+ * A panel on its own, open, with no annotation button behind it.
2185
+ * @return { body, panel }
2186
+ */
2187
+ const blockingScreen = (title, wide) => {
2188
+ if (!host) buildHost();
2189
+ clearLayer();
2190
+
2191
+ const layer = create('div', 'ap-layer');
2192
+ root.appendChild(layer);
2193
+
2194
+ const panel = create('aside', 'ap-panel ap-open' + (wide ? ' ap-panel-wide' : ''));
2195
+ panel.setAttribute('role', 'complementary');
2196
+ const header = create('div', 'ap-panel-header');
2197
+ header.appendChild(create('span', 'ap-panel-title', title));
2198
+ const close = create('button', 'ap-link', T('panel.close'));
2199
+ close.type = 'button';
2200
+ close.addEventListener('click', () => {
2201
+ // We stand down for this page load. Nothing is remembered: on the
2202
+ // next reload the screen comes back, because the problem itself has
2203
+ // not been dealt with.
2204
+ if (host) host.remove();
2205
+ host = null;
2206
+ root = null;
2207
+ ui = null;
2208
+ });
2209
+ header.appendChild(close);
2210
+ const body = create('div', 'ap-panel-body');
2211
+ panel.appendChild(header);
2212
+ panel.appendChild(body);
2213
+ layer.appendChild(panel);
2214
+ return { body: body, panel: panel };
2215
+ };
2216
+
2217
+ /** A value to copy out: it is SELECTABLE, and copiable from a button. */
2218
+ const copyBlock = (parent, label, value) => {
2219
+ parent.appendChild(create('div', 'ap-label', label));
2220
+ const block = create('div', 'ap-copy');
2221
+ const area = create('textarea', 'ap-code');
2222
+ area.value = value;
2223
+ area.readOnly = true;
2224
+ area.rows = value.length > 90 ? 4 : 2;
2225
+ area.setAttribute('spellcheck', 'false');
2226
+ area.addEventListener('focus', () => area.select());
2227
+ block.appendChild(area);
2228
+
2229
+ const copy = create('button', 'ap-secondary', T('setup.copy'));
2230
+ copy.type = 'button';
2231
+ copy.addEventListener('click', () => {
2232
+ const say = (key) => {
2233
+ copy.textContent = T(key);
2234
+ window.setTimeout(() => { copy.textContent = T('setup.copy'); }, 2000);
2235
+ };
2236
+ // The clipboard can be refused (insecure context, permission). We
2237
+ // say so and let the selection do the work, rather than letting
2238
+ // someone believe the copy happened.
2239
+ try {
2240
+ navigator.clipboard.writeText(value)
2241
+ .then(() => say('setup.copied'), () => {
2242
+ area.select();
2243
+ say('setup.copy_failed');
2244
+ });
2245
+ } catch (e) {
2246
+ area.select();
2247
+ say('setup.copy_failed');
2248
+ }
2249
+ });
2250
+ block.appendChild(copy);
2251
+ parent.appendChild(block);
2252
+ return area;
2253
+ };
2254
+
2255
+ /** The exact tag to paste, with the SRI digest ACTUALLY being served. */
2256
+ const tagToPaste = (id) => {
2257
+ let t = '<script src="' + script.src + '"';
2258
+ // We copy the integrity and the crossorigin of the current tag: they are
2259
+ // the ones that work, here, now. A digest copied from a documentation
2260
+ // page is a digest of another version.
2261
+ const attribute = (name) => (script.getAttribute(name) || '').trim();
2262
+ if (attribute('integrity')) t += '\n integrity="' + attribute('integrity') + '"';
2263
+ if (attribute('crossorigin')) t += '\n crossorigin="' + attribute('crossorigin') + '"';
2264
+ if (DECLARED_SERVER) t += '\n data-server="' + DECLARED_SERVER + '"';
2265
+ t += '\n data-project="' + id + '"';
2266
+ if (MODE === 'plain') t += '\n data-mode="plain"';
2267
+ if (PATH_PREFIX) t += '\n data-path="' + PATH_PREFIX + '"';
2268
+ t += '\n defer></' + 'script>';
2269
+ return t;
2270
+ };
2271
+
2272
+ const serverConfig = (id) =>
2273
+ 'project ' + id + '\n'
2274
+ + ' origins ' + location.origin + '\n'
2275
+ + ' mode ' + MODE;
2276
+
2277
+ /* -- The "paste the salt" screen ---------------------------------------- */
2278
+
2279
+ const openSaltScreen = () => {
2280
+ const screen = blockingScreen(T('salt.title'), false);
2281
+ screen.body.appendChild(create('p', 'ap-help', T('salt.help')));
2282
+ screen.body.appendChild(create('p', 'ap-help', T('salt.origin_changed')));
2283
+
2284
+ screen.body.appendChild(create('div', 'ap-label', T('salt.label')));
2285
+ const field = create('input', 'ap-field');
2286
+ field.type = 'text';
2287
+ field.setAttribute('autocomplete', 'off');
2288
+ field.setAttribute('spellcheck', 'false');
2289
+ field.setAttribute('maxlength', String(SALT_LENGTH + 8));
2290
+ screen.body.appendChild(field);
2291
+
2292
+ const actions = create('div', 'ap-actions');
2293
+ const confirm = create('button', 'ap-primary', T('salt.confirm'));
2294
+ confirm.type = 'button';
2295
+ actions.appendChild(confirm);
2296
+ screen.body.appendChild(actions);
2297
+
2298
+ const say = (detail) => {
2299
+ const previous = screen.body.querySelector('.ap-error');
2300
+ if (previous) previous.remove();
2301
+ if (detail) {
2302
+ screen.body.insertBefore(
2303
+ failureBlock({ title: T('salt.title'), detail: detail }), screen.body.firstChild);
2304
+ }
2305
+ };
2306
+
2307
+ confirm.addEventListener('click', () => {
2308
+ const raw = normalize(field.value).replace(/\s+/g, '');
2309
+ if (!raw) return say(T('salt.empty'));
2310
+ const bytes = saltFromText(raw);
2311
+ if (!bytes) return say(T('salt.shape'));
2312
+ say(null);
2313
+ confirm.disabled = true;
2314
+
2315
+ /* The check happens HERE: we re-derive the project id and compare it
2316
+ with the tag's. Equal, the salt is the right one. Nothing is sent
2317
+ to the network and nothing is decrypted before this test -- which
2318
+ is what saves us from carrying a checksum alongside the salt: the
2319
+ project id already plays that part, and it is public. */
2320
+ derive(bytes).then((derived) => {
2321
+ confirm.disabled = false;
2322
+ if (derived.id !== PROJECT) return say(T('salt.wrong'));
2323
+ if (!writeSalt(PROJECT, raw)) {
2324
+ // Storage refuses: we carry on for this page anyway, but we
2325
+ // do not let anyone believe it is remembered.
2326
+ say(T('salt.not_kept'));
2327
+ }
2328
+ startWithSalt(raw, derived);
2329
+ }, () => {
2330
+ confirm.disabled = false;
2331
+ say(T('error.encryption'));
2332
+ });
2333
+ });
2334
+
2335
+ window.setTimeout(() => field.focus(), 0);
2336
+ };
2337
+
2338
+ /* -- The setup screen --------------------------------------------------- */
2339
+
2340
+ const openSetupScreen = () => {
2341
+ const screen = blockingScreen(T('setup.title'), true);
2342
+
2343
+ if (!API) screen.body.appendChild(create('p', 'ap-help', T('setup.no_server')));
2344
+ if (MODE === 'plain') screen.body.appendChild(create('p', 'ap-help', T('setup.plain_mode')));
2345
+
2346
+ const generate = create('button', 'ap-primary', T('setup.generate'));
2347
+ generate.type = 'button';
2348
+ screen.body.appendChild(generate);
2349
+
2350
+ generate.addEventListener('click', () => {
2351
+ generate.disabled = true;
2352
+ const fresh = generateSalt();
2353
+ const bytes = saltFromText(fresh);
2354
+ derive(bytes).then((derived) => {
2355
+ empty(screen.body);
2356
+
2357
+ /* The warning comes BEFORE the salt, and before the button that
2358
+ continues. It is spelled out in full, not in a footnote: it is
2359
+ the only secret of the project, and there is no recovery. */
2360
+ const warning = create('div', 'ap-error');
2361
+ warning.setAttribute('role', 'alert');
2362
+ warning.appendChild(create('div', 'ap-error-title', T('setup.warning_title')));
2363
+ warning.appendChild(create('p', 'ap-error-detail', T('setup.warning')));
2364
+ screen.body.appendChild(warning);
2365
+
2366
+ copyBlock(screen.body, T('setup.salt'), fresh);
2367
+ copyBlock(screen.body, T('setup.project'), derived.id);
2368
+ copyBlock(screen.body, T('setup.tag'), tagToPaste(derived.id));
2369
+ copyBlock(screen.body, T('setup.server'), serverConfig(derived.id));
2370
+
2371
+ const actions = create('div', 'ap-actions');
2372
+ const proceed = create('button', 'ap-primary', T('setup.continue'));
2373
+ proceed.type = 'button';
2374
+ proceed.addEventListener('click', () => {
2375
+ const kept = writeSalt(derived.id, fresh);
2376
+ const done = create('p', 'ap-help',
2377
+ kept ? T('setup.done') : T('salt.not_kept'));
2378
+ actions.replaceWith(done);
2379
+ });
2380
+ actions.appendChild(proceed);
2381
+ screen.body.appendChild(actions);
2382
+ }, () => {
2383
+ generate.disabled = false;
2384
+ screen.body.appendChild(failureBlock({
2385
+ title: T('setup.title'), detail: T('error.encryption')
2386
+ }));
2387
+ });
2388
+ });
2389
+ };
2390
+
2391
+ /* -- The "this browser cannot" screen ------------------------------------ */
2392
+
2393
+ const openContextScreen = () => {
2394
+ const screen = blockingScreen(T('context.title'), false);
2395
+ screen.body.appendChild(create('p', 'ap-help', T('context.help')));
2396
+ };
2397
+
2398
+ /* ==== 90-boot.js ==== */
2399
+
2400
+ /* -- 19. Reading the notes ----------------------------------------------- */
2401
+
2402
+ const redraw = () => {
2403
+ if (!ui) return;
2404
+ anchor();
2405
+ drawPanel();
2406
+ drawMarkers();
2407
+ };
2408
+
2409
+ const reload = () =>
2410
+ call('list').then((r) => {
2411
+ if (!r.ok) {
2412
+ // The tool is already in place: we no longer keep quiet. The
2413
+ // notes already on screen stay, with the warning that they may
2414
+ // be incomplete.
2415
+ const failure = failureFrom(r, 'error.title_read');
2416
+ failure.detail = failure.detail + '\n' + T('error.partial_read');
2417
+ currentFailure = failure;
2418
+ redraw();
2419
+ return null;
2420
+ }
2421
+ return readList(r.data).then((read) => {
2422
+ notes = read;
2423
+ currentFailure = null;
2424
+ redraw();
2425
+ return null;
2426
+ });
2427
+ });
2428
+
2429
+ /* -- 20. Startup ----------------------------------------------------------
2430
+ The order matters: we ask the API BEFORE touching the DOM. If it does not
2431
+ answer what it should, the site never saw anything go by.
2432
+
2433
+ One exception, accepted: the setup and salt-pasting screens, which CANNOT
2434
+ ask the API -- without a salt there is no page index to give it. They are
2435
+ declared (data-setup) or asked for by a tag that already carries a
2436
+ project: either way, somebody put that tag here on purpose. */
2437
+
2438
+ let localLabelsLoaded = false;
2439
+
2440
+ const loadLocalLabels = () => {
2441
+ if (!LOCAL_LABELS_URL || localLabelsLoaded || !root) return Promise.resolve();
2442
+ localLabelsLoaded = true;
2443
+ return new Promise((resolve) => {
2444
+ const s = document.createElement('script');
2445
+ s.src = LOCAL_LABELS_URL;
2446
+ s.addEventListener('load', () => resolve(true));
2447
+ s.addEventListener('error', () => resolve(false));
2448
+ // INSIDE THE SHADOW ROOT, and not in <head> or <body>: a script
2449
+ // inserted into a shadow root runs like any other -- it is connected
2450
+ // to the document -- but it appears neither in
2451
+ // document.querySelectorAll('script') nor in the page's node count.
2452
+ // The only node the site receives stays the host element, and that
2453
+ // is checkable: +1 element, not +2.
2454
+ root.appendChild(s);
2455
+ });
2456
+ };
2457
+
2458
+ const withdraw = () => {
2459
+ if (host) host.remove();
2460
+ host = null;
2461
+ root = null;
2462
+ ui = null;
2463
+ };
2464
+
2465
+ /** A blocking screen: the host exists from now on, the labels come first. */
2466
+ const showScreen = (open) => {
2467
+ buildHost();
2468
+ loadLocalLabels().then(open);
2469
+ };
2470
+
2471
+ /**
2472
+ * Does the server have something to SAY at startup?
2473
+ *
2474
+ * "inactive", "nonjson" and "network" are silences: the tool is not
2475
+ * configured here, PHP is not running, or the browser is offline. Nobody has
2476
+ * written anything yet, there is nothing to announce.
2477
+ *
2478
+ * A REFUSAL, on the other hand, gets said -- and that is a deliberate change
2479
+ * from the original tool. A firewall answering 403 on the very first request
2480
+ * made the tool entirely invisible: one looked for the failure in the wrong
2481
+ * file for half a day. The tag carries a project, so somebody put it here on
2482
+ * purpose: we speak.
2483
+ */
2484
+ const speaksAtStartup = (r) =>
2485
+ r.cause === 'server' || r.cause === 'failure' || String(r.cause).indexOf('refused') === 0;
2486
+
2487
+ /**
2488
+ * The salt is known and checked: we derive the page index, we ask the
2489
+ * server, and the tool takes its normal shape.
2490
+ */
2491
+ function startWithSalt(text, derived) {
2492
+ saltText = text;
2493
+ keys = derived;
2494
+
2495
+ return indexOfPath(keys.indexKey, pagePath())
2496
+ .then((index) => {
2497
+ PAGE_INDEX = index;
2498
+ return call('list');
2499
+ })
2500
+ .then((first) => {
2501
+ if (!first.ok && !speaksAtStartup(first)) {
2502
+ // Complete silence: no node, no pixel, no message. If a salt
2503
+ // screen was open, it goes away with the rest.
2504
+ withdraw();
2505
+ return null;
2506
+ }
2507
+
2508
+ // From here on the tool EXISTS, and will no longer keep quiet
2509
+ // about its failures.
2510
+ buildHost();
2511
+ return loadLocalLabels().then(() => {
2512
+ clearLayer();
2513
+ buildUi();
2514
+ if (first.ok) {
2515
+ return readList(first.data).then((read) => {
2516
+ notes = read;
2517
+ redraw();
2518
+ return null;
2519
+ });
2520
+ }
2521
+ currentFailure = failureFrom(first, 'error.title_read');
2522
+ redraw();
2523
+ return null;
2524
+ });
2525
+ });
2526
+ }
2527
+
2528
+ const start = () => {
2529
+ author = readAuthor();
2530
+
2531
+ // Outside the project's scope: silence. So the tag can live in a
2532
+ // template shared by the whole site.
2533
+ if (!inScope()) return;
2534
+
2535
+ if (!CRYPTO) {
2536
+ // Without a secure context nothing is possible -- but if somebody
2537
+ // declared a project here, they have a right to know why.
2538
+ if (PROJECT || SETUP_REQUESTED) showScreen(openContextScreen);
2539
+ return;
2540
+ }
2541
+
2542
+ if (!PROJECT) {
2543
+ if (SETUP_REQUESTED) showScreen(openSetupScreen);
2544
+ return;
2545
+ }
2546
+
2547
+ const text = readSalt(PROJECT);
2548
+ const bytes = saltFromText(text);
2549
+ if (!bytes) {
2550
+ showScreen(openSaltScreen);
2551
+ return;
2552
+ }
2553
+
2554
+ derive(bytes).then((derived) => {
2555
+ if (derived.id !== PROJECT) {
2556
+ // The salt stored under this key does not derive this id: the
2557
+ // tag has changed project, or the storage was tampered with. We
2558
+ // ask again, we do not guess.
2559
+ showScreen(openSaltScreen);
2560
+ return null;
2561
+ }
2562
+ return startWithSalt(text, derived);
2563
+ }, () => {
2564
+ showScreen(openSaltScreen);
2565
+ });
2566
+ };
2567
+
2568
+ if (document.body) {
2569
+ start();
2570
+ } else {
2571
+ document.addEventListener('DOMContentLoaded', start);
2572
+ }
2573
+ }());