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.
- package/README.md +311 -0
- package/dist/HASHES.txt +1 -0
- package/dist/annotepage.js +2573 -0
- package/labels/fr.json +103 -0
- package/package.json +46 -0
|
@@ -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
|
+
}());
|