@altnovatrail/canvas 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/README.md +59 -2
- package/lib/core/boxes.d.ts +54 -0
- package/lib/core/boxes.js +116 -0
- package/lib/core/clock.d.ts +25 -0
- package/lib/core/clock.js +39 -0
- package/lib/core/crop.d.ts +87 -0
- package/lib/core/crop.js +227 -0
- package/lib/core/eraser.d.ts +42 -0
- package/lib/core/eraser.js +127 -0
- package/lib/core/example.d.ts +66 -0
- package/lib/core/example.js +116 -0
- package/lib/core/fields.d.ts +29 -0
- package/lib/core/fields.js +78 -0
- package/lib/core/geometry.d.ts +38 -0
- package/lib/core/geometry.js +107 -0
- package/lib/core/grid.d.ts +30 -0
- package/lib/core/grid.js +47 -0
- package/lib/core/groups.d.ts +55 -0
- package/lib/core/groups.js +172 -0
- package/lib/core/hands.d.ts +120 -0
- package/lib/core/hands.js +273 -0
- package/lib/core/history.d.ts +42 -0
- package/lib/core/history.js +228 -0
- package/lib/core/index.d.ts +12 -0
- package/lib/core/index.js +15 -0
- package/lib/core/ink.d.ts +1 -0
- package/lib/core/ink.js +5 -0
- package/lib/core/manifest.d.ts +22 -0
- package/lib/core/manifest.js +50 -0
- package/lib/core/marks.d.ts +145 -0
- package/lib/core/marks.js +454 -0
- package/lib/core/outline.d.ts +22 -0
- package/lib/core/outline.js +50 -0
- package/lib/core/page.d.ts +62 -0
- package/lib/core/page.js +309 -0
- package/lib/core/png.d.ts +13 -0
- package/lib/core/png.js +231 -0
- package/lib/core/raster.d.ts +14 -0
- package/lib/core/raster.js +72 -0
- package/lib/core/recorder.d.ts +73 -0
- package/lib/core/recorder.js +127 -0
- package/lib/core/screen.d.ts +35 -0
- package/lib/core/screen.js +36 -0
- package/lib/core/sets.d.ts +10 -0
- package/lib/core/sets.js +31 -0
- package/lib/core/sheet.d.ts +9 -0
- package/lib/core/sheet.js +6 -0
- package/lib/core/submission.d.ts +41 -0
- package/lib/core/submission.js +63 -0
- package/lib/core/task.d.ts +214 -0
- package/lib/core/task.js +343 -0
- package/lib/core/widths.d.ts +57 -0
- package/lib/core/widths.js +218 -0
- package/lib/core/work.d.ts +77 -0
- package/lib/core/work.js +293 -0
- package/lib/element/attributes.d.ts +11 -0
- package/lib/element/attributes.js +22 -0
- package/lib/element/index.d.ts +219 -0
- package/lib/element/index.js +1898 -0
- package/lib/element/labels.d.ts +41 -0
- package/lib/element/labels.js +74 -0
- package/lib/element/parts.d.ts +63 -0
- package/lib/element/parts.js +191 -0
- package/lib/element/pictures.d.ts +45 -0
- package/lib/element/pictures.js +167 -0
- package/lib/element/probe.d.ts +33 -0
- package/lib/element/probe.js +18 -0
- package/lib/element/session.d.ts +3 -0
- package/lib/element/session.js +7 -0
- package/lib/element/statement.d.ts +35 -0
- package/lib/element/statement.js +116 -0
- package/lib/element/tools.d.ts +64 -0
- package/lib/element/tools.js +254 -0
- package/package.json +41 -4
|
@@ -0,0 +1,1898 @@
|
|
|
1
|
+
// The package's entry point @altnovatrail/canvas: the canvas as a custom element, one for every
|
|
2
|
+
// canvas kind, with its markup in an open shadow root (R07). Importing it registers the element,
|
|
3
|
+
// once: a page has one element of this name. So far it is the handwriting sheet with its input,
|
|
4
|
+
// one pointer at a time (RUN.md, T09; R18), its undo and clearing (T33; R19). Its eraser takes
|
|
5
|
+
// strokes off whole, by the path of a press, as the tool or a pen's eraser, and the sheet keeps the
|
|
6
|
+
// history of writing (T10; R22). The sheet is a stack of layers, the task's grid on its template
|
|
7
|
+
// and the task's statement above it, its look the consumer's to theme (T11; R23). The tutor's
|
|
8
|
+
// marks of a submission hold on to the learner's strokes, and a frame shows where each is, over
|
|
9
|
+
// the work (T14; R26). The history of writing comes in and goes out, an event tells of every
|
|
10
|
+
// action, and the work for checking is the crop with the context of its submission (T15; R27).
|
|
11
|
+
// Above it all stands the element's own row of buttons — the tools the task allows, undo and
|
|
12
|
+
// clearing — labelled in the element's `lang` (T41; R28). On a tablet a pen's touch puts the page
|
|
13
|
+
// in pen mode, where a finger writes nothing and a button lets it write again, a palm writes
|
|
14
|
+
// nothing, and two fingers scroll (T35; R29). The task comes in the names of the module format
|
|
15
|
+
// 0.2, checked as its schema checks it, and the module's manifest gives the height of a digit and
|
|
16
|
+
// the statement's language (T44; R32). Up and Down in the row scroll what two fingers would, with
|
|
17
|
+
// a single pointer (T45; R33). The workbook's lines, laid out on its page, stand on the template in
|
|
18
|
+
// place of the grid — numbers, text and boxes, which a screen reader reads in the task's order (T51;
|
|
19
|
+
// R35, R36). Each stroke is in one box or in none, by a rule of whole numbers, and `filled` tells
|
|
20
|
+
// which boxes hold one: a filled box's frame is thicker, and its name says so (T48; R37). A
|
|
21
|
+
// workbook's work for checking is the crop of each filled box, with the table of the boxes' strokes
|
|
22
|
+
// for its context (T49; R38). A lesson's worked example it shows on a sheet the learner does not
|
|
23
|
+
// write on: its statement above the sheet, its grid, and a row without the tools of writing, while
|
|
24
|
+
// a finger or a pen scrolls the sheet as the browser scrolls the statement (T59; R42). The parts of
|
|
25
|
+
// its demo the learner opens one at a time with the row's Next, into a list above the sheet that a
|
|
26
|
+
// polite region tells a screen reader of, and hides and shows with Demo; app keeps how far the
|
|
27
|
+
// learner has gone by `demoState` and `demochange` (T60; R42). The pictures of the statement and of
|
|
28
|
+
// the demo come from the consumer's media, one file for each reference, all of them as the task or
|
|
29
|
+
// the example comes: a part opened shows its picture at once (T64; R42). A picture on an area of
|
|
30
|
+
// the sheet stands on the layer of the author's demo, over its area, and the sheet scrolls to the
|
|
31
|
+
// area as the part opens (T61; R42). The types of its contract with app are spec's, of
|
|
32
|
+
// @altnovatrail/spec/canvas: the element implements its CanvasElement, and the package puts the
|
|
33
|
+
// element and its events in the DOM's maps (T69; R07).
|
|
34
|
+
import { cellOf, cellsOf, filledOf, sheetFilledOf } from "../core/boxes.js";
|
|
35
|
+
import { Clock } from "../core/clock.js";
|
|
36
|
+
import { ERASER_BUTTON, Eraser } from "../core/eraser.js";
|
|
37
|
+
import { DEMO_START, demoStateOf, exampleOf, exampleTemplate, sameExample, } from "../core/example.js";
|
|
38
|
+
import { sameItems } from "../core/fields.js";
|
|
39
|
+
import { boxOf } from "../core/geometry.js";
|
|
40
|
+
import { gridPath } from "../core/grid.js";
|
|
41
|
+
import { Hands } from "../core/hands.js";
|
|
42
|
+
import { begins, sameHistory } from "../core/history.js";
|
|
43
|
+
import { NO_MANIFEST, settingsOf } from "../core/manifest.js";
|
|
44
|
+
import { marksOf, placesOf, shownOf } from "../core/marks.js";
|
|
45
|
+
import { INK_REACH, outline } from "../core/outline.js";
|
|
46
|
+
import { isBox, LAYOUT, } from "../core/page.js";
|
|
47
|
+
import { Recorder } from "../core/recorder.js";
|
|
48
|
+
import { fitScale, K_MAX, scaleFor, toSheet } from "../core/screen.js";
|
|
49
|
+
import { SHEET } from "../core/sheet.js";
|
|
50
|
+
import { cellsForCheck, workForCheck } from "../core/submission.js";
|
|
51
|
+
import { isTool, isWorkbook, NO_TASK, sameLayer, sameStatement, taskOf, templateOf, wholeSheet, } from "../core/task.js";
|
|
52
|
+
import { nextIdOf, resumeOf, Work } from "../core/work.js";
|
|
53
|
+
import { placeOver, SVG, write } from "./attributes.js";
|
|
54
|
+
import { labelsFor } from "./labels.js";
|
|
55
|
+
import { Parts } from "./parts.js";
|
|
56
|
+
import { Pictures } from "./pictures.js";
|
|
57
|
+
import { attachProbe } from "./probe.js";
|
|
58
|
+
import { PEN_MODE } from "./session.js";
|
|
59
|
+
import { Statement } from "./statement.js";
|
|
60
|
+
import { Tools } from "./tools.js";
|
|
61
|
+
/** The name the element is registered under. */
|
|
62
|
+
export const TAG = "novatrail-canvas";
|
|
63
|
+
/** The colour of the ink unless the consumer's theme gives one. */
|
|
64
|
+
const INK = "#111";
|
|
65
|
+
/** The colour of the sheet unless the consumer's theme gives one. */
|
|
66
|
+
const PAPER = "#fff";
|
|
67
|
+
/** The event the element sends after every action on the sheet, with the history's new events. */
|
|
68
|
+
const HISTORY_CHANGE = "historychange";
|
|
69
|
+
/** The event the element sends when the learner chooses another tool with its buttons. */
|
|
70
|
+
const TOOL_CHANGE = "toolchange";
|
|
71
|
+
/** The event the element sends when the learner opens a part of the demo, or hides or shows it. */
|
|
72
|
+
const DEMO_CHANGE = "demochange";
|
|
73
|
+
/** What Next and Demo control, by their IDs in the shadow root: the region, and the list (R42). */
|
|
74
|
+
const DEMO_IDS = { status: "demo-status", parts: "demo-parts" };
|
|
75
|
+
/**
|
|
76
|
+
* The colour of the tutor's frames unless the consumer's theme gives one: a blue, for now, and not
|
|
77
|
+
* a red, which would read as «wrong» (E4 in RUN.md, «Не входит»).
|
|
78
|
+
*/
|
|
79
|
+
const MARK = "#2563eb";
|
|
80
|
+
/** How far a frame stands off the points of its strokes, in sheet units, as canvas-api's (R26). */
|
|
81
|
+
const MARK_PADDING = 10;
|
|
82
|
+
/** The rounding of a frame's corners, in sheet units. */
|
|
83
|
+
const MARK_CORNER = 6;
|
|
84
|
+
/**
|
|
85
|
+
* How near a stroke's ink the eraser takes it, in CSS pixels: the same on every screen, whatever
|
|
86
|
+
* the sheet's scale (R22). T22 tries it on devices.
|
|
87
|
+
*/
|
|
88
|
+
const ERASER_RADIUS_PX = 10;
|
|
89
|
+
/**
|
|
90
|
+
* How much of what it shows a press of Up or Down scrolls (R33): four fifths, as WebKit pages on
|
|
91
|
+
* an iPad, so that the last lines stay in sight. T22 tries it on devices.
|
|
92
|
+
*/
|
|
93
|
+
const UP_DOWN = 4 / 5;
|
|
94
|
+
// The statement and the sheet stand in a column of their own, as wide as the element up to
|
|
95
|
+
// 1000 CSS pixels and in its middle beyond, and scroll in a container of their own, by the
|
|
96
|
+
// browser's own scrolling: the wheel, the scroll bar and the keyboard work as anywhere on a page.
|
|
97
|
+
// The layers of the sheet stand in its visible part, the stage, and each shows what lies there,
|
|
98
|
+
// shifted in the frame the ink is drawn in (R18, R23; architecture 3.2 in docs/architecture.md).
|
|
99
|
+
// Above the column, as wide, stands the row of the element's own buttons, outside the container's
|
|
100
|
+
// scrolling: it sticks to the top of whatever scrolls the element, as far down as the consumer's
|
|
101
|
+
// --novatrail-canvas-top says, while the element is in sight, over the sheet as it scrolls by
|
|
102
|
+
// (R28). The element is as tall as the row, the statement and the sheet unless it is given a
|
|
103
|
+
// height: then the statement and the sheet scroll in it, under the row. A worked example's open
|
|
104
|
+
// parts stand between the statement and the sheet, and the region that tells of them is out of
|
|
105
|
+
// sight, but not out of the tree a screen reader reads, where a hidden region would tell nothing
|
|
106
|
+
// (R42). A picture's frame there stands in its item's line, from its top, so that the item's
|
|
107
|
+
// number stands at the top too, as a paragraph's does, and not at the picture's bottom (T64). A
|
|
108
|
+
// picture on an area of the sheet stands on the layer of the author's demo, and the sheet scrolls
|
|
109
|
+
// to a hidden box of the area in its own flow, which neither paints nor takes a pointer (T61). On
|
|
110
|
+
// the canvas the browser neither scrolls nor zooms: the pointer writes, and two fingers scroll by
|
|
111
|
+
// the element's own code (R29), as Up and Down of the row do with a single pointer, a pair the
|
|
112
|
+
// row wraps together (R33).
|
|
113
|
+
// The canvas and the buttons take pointer events only as the element does: a consumer's
|
|
114
|
+
// pointer-events: none on the element stops the writing and the buttons' pointer, though not
|
|
115
|
+
// their keys (R20, R28). A worked example's canvas takes no pointer at all: a finger or a pen on
|
|
116
|
+
// its sheet scrolls, and zooms, as the browser does on the statement, and a long press or a right
|
|
117
|
+
// click opens no menu over the sheet all the same (R42). The theme is the consumer's, by custom
|
|
118
|
+
// properties that the shadow tree inherits; with forced colours the system's colours stand in for
|
|
119
|
+
// them. The element is horizontal whatever writing mode the page is in: a sheet has no other (R36).
|
|
120
|
+
//
|
|
121
|
+
// A workbook's lines are printed in the ink's colour and the consumer's font, and nothing else the
|
|
122
|
+
// element inherits — spacing, weight, transforms, the direction — moves them: `all: initial` takes
|
|
123
|
+
// it off, and what the print needs comes back (R36). A box's frame is one CSS pixel at any scale,
|
|
124
|
+
// on whole device pixels: an antialiased line of one pixel would lose its contrast. A filled box's
|
|
125
|
+
// is three, in the same ink, and the system's highlight with forced colours (R37).
|
|
126
|
+
const STYLE = `
|
|
127
|
+
:host { display: flex; flex-direction: column; isolation: isolate; writing-mode: horizontal-tb; }
|
|
128
|
+
:host([hidden]) { display: none; }
|
|
129
|
+
.tools {
|
|
130
|
+
position: sticky; top: var(--novatrail-canvas-top, 0px); z-index: 1; flex: none;
|
|
131
|
+
box-sizing: border-box; width: min(100%, ${K_MAX * SHEET.width}px); margin: 0 auto;
|
|
132
|
+
padding: 8px 0; display: flex; flex-wrap: wrap; gap: 8px;
|
|
133
|
+
background: var(--novatrail-canvas-paper, ${PAPER}); color: var(--novatrail-canvas-ink, ${INK});
|
|
134
|
+
}
|
|
135
|
+
.tools .demo, .tools .choice, .tools .updown {
|
|
136
|
+
display: flex; gap: 8px; margin-inline-end: 16px;
|
|
137
|
+
}
|
|
138
|
+
.tools button {
|
|
139
|
+
box-sizing: border-box; min-width: 44px; min-height: 44px; padding: 0 16px; margin: 0;
|
|
140
|
+
font: inherit; color: inherit; background: transparent; cursor: pointer;
|
|
141
|
+
border: 1px solid currentColor; border-radius: 6px;
|
|
142
|
+
}
|
|
143
|
+
.tools [aria-checked="true"] {
|
|
144
|
+
background: var(--novatrail-canvas-ink, ${INK}); color: var(--novatrail-canvas-paper, ${PAPER});
|
|
145
|
+
border-color: var(--novatrail-canvas-ink, ${INK});
|
|
146
|
+
}
|
|
147
|
+
.tools [aria-disabled="true"] { opacity: 0.4; cursor: default; }
|
|
148
|
+
.tools [hidden] { display: none; }
|
|
149
|
+
.tools button svg {
|
|
150
|
+
display: inline-block; width: 0.75em; height: 0.75em; margin-inline-end: 6px;
|
|
151
|
+
vertical-align: -0.05em; fill: currentColor;
|
|
152
|
+
}
|
|
153
|
+
.tools [aria-expanded="false"] svg { transform: rotate(-90deg); }
|
|
154
|
+
.tools:dir(rtl) [aria-expanded="false"] svg { transform: rotate(90deg); }
|
|
155
|
+
.viewport {
|
|
156
|
+
flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto; overflow-anchor: none;
|
|
157
|
+
}
|
|
158
|
+
.column { position: relative; width: min(100%, ${K_MAX * SHEET.width}px); margin: 0 auto; }
|
|
159
|
+
.statement { padding: 0 0 12px; overflow-wrap: break-word; }
|
|
160
|
+
.statement:empty { display: none; }
|
|
161
|
+
.statement p { margin: 0 0 8px; }
|
|
162
|
+
.picture {
|
|
163
|
+
box-sizing: border-box; max-width: 100%; margin: 0 0 8px; padding: 8px;
|
|
164
|
+
display: flex; align-items: center; justify-content: center; text-align: center;
|
|
165
|
+
border: 1px dashed currentColor; font-size: 0.875em;
|
|
166
|
+
}
|
|
167
|
+
.picture.decorative, .picture.shown { border: none; padding: 0; }
|
|
168
|
+
.picture img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
|
169
|
+
.parts { margin: 0 0 12px; padding-block: 0; padding-inline: 2em 0; overflow-wrap: break-word; }
|
|
170
|
+
.parts:empty, .parts[hidden] { display: none; }
|
|
171
|
+
.parts li { margin: 0 0 8px; }
|
|
172
|
+
.parts .picture { display: inline-flex; vertical-align: top; margin: 0; }
|
|
173
|
+
.status {
|
|
174
|
+
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0;
|
|
175
|
+
overflow: hidden; clip-path: inset(50%); white-space: nowrap;
|
|
176
|
+
}
|
|
177
|
+
.sheet {
|
|
178
|
+
position: relative; aspect-ratio: ${SHEET.width} / ${SHEET.height};
|
|
179
|
+
background: var(--novatrail-canvas-paper, ${PAPER});
|
|
180
|
+
}
|
|
181
|
+
.stage {
|
|
182
|
+
position: sticky; top: 0; max-height: 100%; overflow: clip;
|
|
183
|
+
user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
|
|
184
|
+
}
|
|
185
|
+
.spot { position: absolute; visibility: hidden; }
|
|
186
|
+
.stage > * { position: absolute; inset: 0; width: 100%; height: 100%; }
|
|
187
|
+
.stage > :not(canvas) { pointer-events: none; }
|
|
188
|
+
.grid {
|
|
189
|
+
fill: none; stroke: var(--novatrail-canvas-grid, #d3dbe5); stroke-width: 1px;
|
|
190
|
+
vector-effect: non-scaling-stroke;
|
|
191
|
+
}
|
|
192
|
+
.grid.dots { stroke-width: 3px; stroke-linecap: round; }
|
|
193
|
+
.print {
|
|
194
|
+
all: initial; font-family: inherit; visibility: inherit; pointer-events: none; direction: ltr;
|
|
195
|
+
font-variant-numeric: tabular-nums; font-size: ${LAYOUT.size}px;
|
|
196
|
+
color: var(--novatrail-canvas-ink, ${INK});
|
|
197
|
+
}
|
|
198
|
+
.print text { fill: currentColor; }
|
|
199
|
+
.print rect {
|
|
200
|
+
fill: none; stroke: currentColor; stroke-width: 1px; vector-effect: non-scaling-stroke;
|
|
201
|
+
shape-rendering: crispEdges;
|
|
202
|
+
}
|
|
203
|
+
.print rect.filled { stroke-width: 3px; }
|
|
204
|
+
canvas {
|
|
205
|
+
display: block; color: var(--novatrail-canvas-ink, ${INK}); transition: color 1ms step-end;
|
|
206
|
+
touch-action: none;
|
|
207
|
+
}
|
|
208
|
+
.sheet.example canvas { pointer-events: none; touch-action: auto; }
|
|
209
|
+
.marks rect {
|
|
210
|
+
fill: none; stroke: var(--novatrail-canvas-mark, ${MARK}); stroke-width: 2px;
|
|
211
|
+
vector-effect: non-scaling-stroke;
|
|
212
|
+
}
|
|
213
|
+
.marks rect.under { opacity: 0.25; }
|
|
214
|
+
@media (forced-colors: active) {
|
|
215
|
+
.sheet { outline: 1px solid CanvasText; }
|
|
216
|
+
.grid { stroke: GrayText; }
|
|
217
|
+
.print text { fill: CanvasText; }
|
|
218
|
+
.print rect { stroke: CanvasText; }
|
|
219
|
+
.print rect.filled { stroke: Highlight; }
|
|
220
|
+
canvas { color: CanvasText; }
|
|
221
|
+
.marks rect { stroke: Highlight; }
|
|
222
|
+
.tools { background: Canvas; color: CanvasText; }
|
|
223
|
+
.tools button { background: ButtonFace; color: ButtonText; border-color: ButtonBorder; }
|
|
224
|
+
.tools [aria-checked="true"] {
|
|
225
|
+
background: Highlight; color: HighlightText; border-color: Highlight;
|
|
226
|
+
}
|
|
227
|
+
.tools [aria-disabled="true"] { color: GrayText; border-color: GrayText; opacity: 1; }
|
|
228
|
+
}
|
|
229
|
+
`;
|
|
230
|
+
/** An `<svg>` layer of the sheet, which counts in sheet units and hides from screen readers. */
|
|
231
|
+
function svgLayer(name) {
|
|
232
|
+
const svg = document.createElementNS(SVG, "svg");
|
|
233
|
+
svg.setAttribute("class", name);
|
|
234
|
+
svg.setAttribute("preserveAspectRatio", "none");
|
|
235
|
+
svg.setAttribute("aria-hidden", "true");
|
|
236
|
+
return svg;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The layer of the tutor's marks, over the work: a screen reader reads each mark by its name, and
|
|
240
|
+
* the layer itself is no picture of its own (R26).
|
|
241
|
+
*/
|
|
242
|
+
function marksLayer() {
|
|
243
|
+
const svg = svgLayer("marks");
|
|
244
|
+
svg.removeAttribute("aria-hidden");
|
|
245
|
+
svg.setAttribute("role", "none");
|
|
246
|
+
return svg;
|
|
247
|
+
}
|
|
248
|
+
/** An HTML layer of the sheet, empty in the first code: the module's widget, from stage 4. */
|
|
249
|
+
function htmlLayer(name) {
|
|
250
|
+
const div = document.createElement("div");
|
|
251
|
+
div.className = name;
|
|
252
|
+
div.setAttribute("aria-hidden", "true");
|
|
253
|
+
return div;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The element `<novatrail-canvas>`: the contract's `CanvasElement` (R74 spec), and beyond it the
|
|
257
|
+
* tutor's marks, the media and the commands undo and clear, which the contract does not name.
|
|
258
|
+
*/
|
|
259
|
+
export class NovatrailCanvas extends HTMLElement {
|
|
260
|
+
#viewport = document.createElement("div");
|
|
261
|
+
#column = document.createElement("div");
|
|
262
|
+
#sheet = document.createElement("div");
|
|
263
|
+
/** The sheet's visible part, where its layers stand, bottom to top (concepts 8 spec; R23). */
|
|
264
|
+
#stage = document.createElement("div");
|
|
265
|
+
/** The author's template: a sheet's grid, or a workbook's lines, a list of its items (R36). */
|
|
266
|
+
#template = svgLayer("template");
|
|
267
|
+
#grid = document.createElementNS(SVG, "path");
|
|
268
|
+
#print = document.createElementNS(SVG, "g");
|
|
269
|
+
#lines = document.createElementNS(SVG, "g");
|
|
270
|
+
/** The module's widget, from stage 4 (R16 spec): empty for now. */
|
|
271
|
+
#widget = htmlLayer("widget");
|
|
272
|
+
/** The author's demo: a worked example's pictures on areas of the sheet (T61; R42). */
|
|
273
|
+
#show = svgLayer("show");
|
|
274
|
+
/** The tutor's layer, in two: the sketch under the learner's work, the marks over it (T14). */
|
|
275
|
+
#sketch = svgLayer("sketch");
|
|
276
|
+
/** The learner's work: the ink. */
|
|
277
|
+
#canvas = document.createElement("canvas");
|
|
278
|
+
#marksLayer = marksLayer();
|
|
279
|
+
#context = this.#canvas.getContext("2d");
|
|
280
|
+
/** The canvas's style as it is computed, read live: its colour is the ink's on the screen. */
|
|
281
|
+
#canvasStyle = getComputedStyle(this.#canvas);
|
|
282
|
+
/** The files of the pictures of the statement and of a worked example's demo (R23, R42). */
|
|
283
|
+
#pictures = new Pictures();
|
|
284
|
+
/** The task's statement, above the sheet (R23). */
|
|
285
|
+
#statement = new Statement(this.#pictures);
|
|
286
|
+
/** A worked example's open parts, under its statement, and the region that tells of them (R42). */
|
|
287
|
+
#parts = new Parts(DEMO_IDS, this.#pictures);
|
|
288
|
+
/** The element's own buttons, above the statement and the sheet (R28). */
|
|
289
|
+
#tools = new Tools({
|
|
290
|
+
choose: (tool) => this.#choose(tool),
|
|
291
|
+
undo: () => this.undo(),
|
|
292
|
+
clear: () => this.clear(),
|
|
293
|
+
upOrDown: (onward) => this.#upOrDown(onward),
|
|
294
|
+
// The finger writes again on every sheet of the page, with no event: the next pen's touch
|
|
295
|
+
// puts the page back in pen mode (R29).
|
|
296
|
+
fingers: () => {
|
|
297
|
+
PEN_MODE.on = false;
|
|
298
|
+
},
|
|
299
|
+
next: () => this.#openNext(),
|
|
300
|
+
toggle: () => this.#toggleDemo(),
|
|
301
|
+
}, DEMO_IDS);
|
|
302
|
+
/** The sheet's clock, for its strokes and its actions alike (R22). */
|
|
303
|
+
#clock = new Clock();
|
|
304
|
+
/**
|
|
305
|
+
* The strokes on the sheet, the actions undo takes back and the history of writing (R19, R22): a
|
|
306
|
+
* new work when the sheet is given another history (R27).
|
|
307
|
+
*/
|
|
308
|
+
#work = new Work(this.#clock);
|
|
309
|
+
/** How many events of the history `historychange` has told of, and whether it is telling now. */
|
|
310
|
+
#told = 0;
|
|
311
|
+
#telling = false;
|
|
312
|
+
/** The input: a finished stroke goes onto the work, and the work has changed. */
|
|
313
|
+
#recorder = new Recorder((stroke) => {
|
|
314
|
+
this.#work.write(stroke);
|
|
315
|
+
this.#changed();
|
|
316
|
+
}, this.#clock);
|
|
317
|
+
/** The eraser: what a press came near leaves the work when it lifts, if still on the sheet. */
|
|
318
|
+
#eraser = new Eraser((from, to, reach) => this.#work.near(from, to, reach), (ids, time) => {
|
|
319
|
+
if (this.#work.erase(ids, time))
|
|
320
|
+
this.#changed();
|
|
321
|
+
});
|
|
322
|
+
/**
|
|
323
|
+
* The hands on the sheet (R29): which pointer may write, which is a palm, and which two fingers
|
|
324
|
+
* scroll, in the page's pen mode. The recorder and the eraser say which pointer writes.
|
|
325
|
+
*/
|
|
326
|
+
#hands = new Hands({
|
|
327
|
+
writer: () => this.#writer(),
|
|
328
|
+
drop: (pointerId) => this.#drop(pointerId),
|
|
329
|
+
scroll: (dy, fresh) => this.#scroll(dy, fresh),
|
|
330
|
+
}, PEN_MODE);
|
|
331
|
+
/** Whether the row shows the finger's button: it comes and goes between strokes alone. */
|
|
332
|
+
#finger = false;
|
|
333
|
+
/** What two fingers scroll in the gesture under way, chosen at its first step. */
|
|
334
|
+
#latch;
|
|
335
|
+
/**
|
|
336
|
+
* A scroll of the page, or of an ancestor of the element in the page's own tree, heard on its way
|
|
337
|
+
* down: Up or Down may have come to an end, or left one (R33). Other scrollers are none of theirs.
|
|
338
|
+
*/
|
|
339
|
+
#heard = (event) => {
|
|
340
|
+
const target = event.target;
|
|
341
|
+
if (target === document || (target instanceof Element && holds(target, this)))
|
|
342
|
+
this.#edges();
|
|
343
|
+
};
|
|
344
|
+
/** The page grown or shrunk, or the window: what scrolls the element may have room now, or none. */
|
|
345
|
+
#page = new ResizeObserver(() => this.#edges());
|
|
346
|
+
#resized = () => this.#edges();
|
|
347
|
+
#unwatchPen;
|
|
348
|
+
#tool = "pen";
|
|
349
|
+
#task;
|
|
350
|
+
/** A lesson's worked example as the consumer gave it, and as the element checked it (R42). */
|
|
351
|
+
#workedExample;
|
|
352
|
+
#example;
|
|
353
|
+
/** How far the learner has gone through the example's demo, frozen, or undefined without one. */
|
|
354
|
+
#demoState;
|
|
355
|
+
#shownTemplate = NO_TASK;
|
|
356
|
+
/**
|
|
357
|
+
* A workbook's page, its boxes in the task's order and their frames, and the box each stroke is
|
|
358
|
+
* in, worked out once: a stroke never changes (R22), and a new page starts the map anew.
|
|
359
|
+
*/
|
|
360
|
+
#laid;
|
|
361
|
+
#cells = [];
|
|
362
|
+
#rects = [];
|
|
363
|
+
#taken = new WeakMap();
|
|
364
|
+
/** Which answer slots are filled, the same array until one changes, or null (R37, R42). */
|
|
365
|
+
#filled = null;
|
|
366
|
+
/** Whether the task's one answer is anywhere on the sheet or the whole sheet (R42). */
|
|
367
|
+
#whole = false;
|
|
368
|
+
/** The module's manifest as the consumer gave it, and what the element makes of it (R32). */
|
|
369
|
+
#manifest;
|
|
370
|
+
#settings = NO_MANIFEST;
|
|
371
|
+
/** The tutor's marks as the consumer gave them, and those of them that have a place (R26). */
|
|
372
|
+
#marks;
|
|
373
|
+
/** The marks as they were checked when given: a new history places these, whatever became of those. */
|
|
374
|
+
#given;
|
|
375
|
+
/**
|
|
376
|
+
* The last history given that was the sheet's, how long it was then and the sheet's history it
|
|
377
|
+
* was: given again, as long, while the sheet's is still that one, it is not walked through again.
|
|
378
|
+
*/
|
|
379
|
+
#equal;
|
|
380
|
+
#shown = [];
|
|
381
|
+
/** The strokes on the sheet by ID, kept while the sheet's strokes are the same array. */
|
|
382
|
+
#onSheet = {
|
|
383
|
+
strokes: [],
|
|
384
|
+
byId: new Map(),
|
|
385
|
+
};
|
|
386
|
+
/** The box of each stroke's points, worked out once: a stroke never changes (R22). */
|
|
387
|
+
#pointBoxes = new WeakMap();
|
|
388
|
+
/** The stroke under way, and how many of its points the marks have been checked against. */
|
|
389
|
+
#writing;
|
|
390
|
+
/** The path of each finished stroke, outlined once (R18). */
|
|
391
|
+
#inks = new WeakMap();
|
|
392
|
+
#resize = new ResizeObserver((entries) => {
|
|
393
|
+
for (const entry of entries) {
|
|
394
|
+
if (entry.target !== this.#viewport)
|
|
395
|
+
continue;
|
|
396
|
+
const box = entry.contentBoxSize[0];
|
|
397
|
+
if (box !== undefined)
|
|
398
|
+
this.#area = { width: box.inlineSize, height: box.blockSize };
|
|
399
|
+
}
|
|
400
|
+
// The container's new size, the statement's or that of the list of a demo's parts: each moves
|
|
401
|
+
// the sheet.
|
|
402
|
+
this.#layout();
|
|
403
|
+
});
|
|
404
|
+
/** The size of the container's visible part, in CSS pixels as laid out, before any transform. */
|
|
405
|
+
#area;
|
|
406
|
+
/**
|
|
407
|
+
* `k` of the last layout, and where the sheet's top was then in the container's content, in CSS
|
|
408
|
+
* pixels as laid out: under the statement.
|
|
409
|
+
*/
|
|
410
|
+
#k = 0;
|
|
411
|
+
#sheetAt = 0;
|
|
412
|
+
/** The top of the visible part, in sheet units, kept from the last scroll. */
|
|
413
|
+
#vy = 0;
|
|
414
|
+
/** The view box the layers were last given, so that a frame without a scroll writes none. */
|
|
415
|
+
#viewBox = "";
|
|
416
|
+
#frame = 0;
|
|
417
|
+
#outlines = 0;
|
|
418
|
+
#unwatchDensity;
|
|
419
|
+
#unwatchColours;
|
|
420
|
+
constructor() {
|
|
421
|
+
super();
|
|
422
|
+
const root = this.attachShadow({ mode: "open" });
|
|
423
|
+
const style = document.createElement("style");
|
|
424
|
+
style.textContent = STYLE;
|
|
425
|
+
this.#viewport.className = "viewport";
|
|
426
|
+
this.#column.className = "column";
|
|
427
|
+
this.#sheet.className = "sheet";
|
|
428
|
+
this.#stage.className = "stage";
|
|
429
|
+
this.#grid.setAttribute("class", "grid");
|
|
430
|
+
this.#print.setAttribute("class", "print");
|
|
431
|
+
this.#lines.setAttribute("role", "list");
|
|
432
|
+
this.#print.append(this.#lines);
|
|
433
|
+
this.#template.append(this.#grid, this.#print);
|
|
434
|
+
this.#show.append(this.#parts.layer);
|
|
435
|
+
this.#stage.append(this.#template, this.#widget, this.#show, this.#sketch, this.#canvas, this.#marksLayer);
|
|
436
|
+
this.#sheet.append(this.#stage, this.#parts.spot);
|
|
437
|
+
this.#column.append(this.#statement.element, this.#parts.list, this.#parts.status, this.#sheet);
|
|
438
|
+
this.#viewport.append(this.#column);
|
|
439
|
+
root.append(style, this.#tools.element, this.#viewport);
|
|
440
|
+
this.#lay(NO_TASK);
|
|
441
|
+
this.#refresh();
|
|
442
|
+
this.#canvas.addEventListener("pointerdown", (event) => this.#down(event));
|
|
443
|
+
this.#canvas.addEventListener("pointermove", (event) => this.#move(event));
|
|
444
|
+
this.#canvas.addEventListener("pointerup", (event) => this.#up(event));
|
|
445
|
+
this.#canvas.addEventListener("pointercancel", (event) => {
|
|
446
|
+
this.#drop(event.pointerId);
|
|
447
|
+
this.#lifted(event.pointerId);
|
|
448
|
+
});
|
|
449
|
+
// A stroke or an erasing that ends here, or on its pointerup, lands through its handler, which
|
|
450
|
+
// draws the sheet anew. A captured pointer comes here after its pointerup too, and finds the
|
|
451
|
+
// hands done with it; one that a script made, which no capture holds, has its pointerup alone.
|
|
452
|
+
this.#canvas.addEventListener("lostpointercapture", (event) => {
|
|
453
|
+
this.#recorder.end(event.pointerId);
|
|
454
|
+
this.#eraser.end(event.pointerId);
|
|
455
|
+
this.#lifted(event.pointerId);
|
|
456
|
+
});
|
|
457
|
+
// A long press of a pen or a finger, or a right click, opens no menu over the sheet: the
|
|
458
|
+
// canvas's event comes up to the stage, and so does the stage's own, where a worked example's
|
|
459
|
+
// sheet takes the pointer instead (R42).
|
|
460
|
+
this.#stage.addEventListener("contextmenu", (event) => event.preventDefault());
|
|
461
|
+
// A new colour of the ink, from the consumer's theme or the page's colour scheme, ends a
|
|
462
|
+
// transition of the canvas's colour a millisecond long: the ink is drawn anew in it.
|
|
463
|
+
this.#canvas.addEventListener("transitionend", () => this.#request());
|
|
464
|
+
this.#canvas.addEventListener("transitioncancel", () => this.#request());
|
|
465
|
+
this.#viewport.addEventListener("scroll", () => this.#scrolled(), { passive: true });
|
|
466
|
+
attachProbe(this, () => ({
|
|
467
|
+
strokes: this.#work.strokes,
|
|
468
|
+
live: this.#recorder.live,
|
|
469
|
+
pending: [...this.#eraser.pending],
|
|
470
|
+
outlines: this.#outlines,
|
|
471
|
+
view: this.#view(),
|
|
472
|
+
marks: this.#shown.map(({ name, placed, frame }) => ({ name, ids: placed.ids, frame })),
|
|
473
|
+
roles: this.#hands.roles,
|
|
474
|
+
}));
|
|
475
|
+
this.#adopt("tool");
|
|
476
|
+
this.#adopt("task");
|
|
477
|
+
this.#adopt("workedExample");
|
|
478
|
+
// The demo's state after its example: it is a TypeError without one.
|
|
479
|
+
this.#adopt("demoState");
|
|
480
|
+
this.#adopt("manifest");
|
|
481
|
+
this.#adopt("media");
|
|
482
|
+
// The history before the marks: they hold on to its strokes.
|
|
483
|
+
this.#adopt("history");
|
|
484
|
+
this.#adopt("marks");
|
|
485
|
+
}
|
|
486
|
+
/** The element follows its own `lang`: its buttons are labelled in it (R07, R28). */
|
|
487
|
+
static observedAttributes = ["lang"];
|
|
488
|
+
/**
|
|
489
|
+
* What a press of the primary button does: "pen" writes, as the element starts, and "eraser"
|
|
490
|
+
* erases (R22). The learner switches it with the element's own buttons, which say a choice of
|
|
491
|
+
* theirs with a `toolchange`, and the consumer may set it too, which says nothing (R28). A press
|
|
492
|
+
* under way goes on as it began. A pen's eraser erases whatever the tool. A task without the
|
|
493
|
+
* eraser has none of it: "eraser" is a TypeError then, and a new task without it puts the pen
|
|
494
|
+
* back. A worked example has the pen alone, which writes nothing on its sheet (R42).
|
|
495
|
+
*/
|
|
496
|
+
get tool() {
|
|
497
|
+
return this.#tool;
|
|
498
|
+
}
|
|
499
|
+
set tool(tool) {
|
|
500
|
+
if (!isTool(tool)) {
|
|
501
|
+
throw new TypeError(`the tool of <${TAG}> is "pen" or "eraser", not ${JSON.stringify(tool)}`);
|
|
502
|
+
}
|
|
503
|
+
if (!this.#shownTemplate.tools.includes(tool)) {
|
|
504
|
+
const shown = this.#example === undefined ? "task" : "worked example";
|
|
505
|
+
throw new TypeError(`the ${shown} of <${TAG}> allows no ${tool}`);
|
|
506
|
+
}
|
|
507
|
+
this.#tool = tool;
|
|
508
|
+
this.#refresh();
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* The open view of the task, of the module format 0.2 (R32): its ID, its statement, which the
|
|
512
|
+
* element shows above the page (R23), what the statement gives and asks, which app shows (R57
|
|
513
|
+
* spec), its canvas — the kind, the sheet's grid and the tools the task allows (R28) — and its
|
|
514
|
+
* items. A workbook's items are its lines, which the template shows, laid out on the page (R35,
|
|
515
|
+
* R36). The type is spec's, with the element's own tools (R73 spec; R41). A task the schema
|
|
516
|
+
* refuses is a TypeError that names the field, a field the open view has not among it, and so are
|
|
517
|
+
* a workbook whose lines do not fit, at the first item that does not (R35), and an answer slot in
|
|
518
|
+
* a zone; the sheet passes over a choice and an item's text, which app shows. Another task of the
|
|
519
|
+
* same layer, tools and statement changes nothing, and any other changes the template alone, of
|
|
520
|
+
* either kind: the work stays, for the next task is a new element (R20). A task without the
|
|
521
|
+
* eraser puts the pen back, with no event. While a worked example is shown a task is a TypeError,
|
|
522
|
+
* and `undefined` changes nothing: the element shows one or the other (R42).
|
|
523
|
+
*/
|
|
524
|
+
get task() {
|
|
525
|
+
return this.#task;
|
|
526
|
+
}
|
|
527
|
+
set task(task) {
|
|
528
|
+
if (task === this.#task)
|
|
529
|
+
return;
|
|
530
|
+
if (this.#example !== undefined) {
|
|
531
|
+
throw new TypeError(`<${TAG}> shows a worked example: a task is shown by a new element, or once the example is undefined`);
|
|
532
|
+
}
|
|
533
|
+
const checked = task === undefined ? undefined : taskOf(task);
|
|
534
|
+
const template = templateOf(checked);
|
|
535
|
+
this.#task = task;
|
|
536
|
+
this.#whole = wholeSheet(checked);
|
|
537
|
+
this.#present(template);
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* A lesson's worked example (R42; R58 spec): the open view of a segment `worked_example` of the
|
|
541
|
+
* module format 0.2 — its statement, which the element shows above the sheet as a task's (R23),
|
|
542
|
+
* the grid of its sheet, and the parts of the author's demo. The type is spec's (R73 spec; R41).
|
|
543
|
+
* The element shows a task or a worked example, not both: an example while a task is given, while
|
|
544
|
+
* the sheet has a history of writing, or while a stroke or an erasing is under way, is a
|
|
545
|
+
* TypeError, and so is one the schema refuses, at its field. `undefined` takes the example off,
|
|
546
|
+
* and the sheet is one with no task again. An example equal to the one shown changes nothing, and
|
|
547
|
+
* another replaces it, its grid and its statement apart, as a task does.
|
|
548
|
+
*
|
|
549
|
+
* On a worked example's sheet the learner does not write: the canvas takes no input, and a finger
|
|
550
|
+
* or a pen there scrolls the sheet as the browser scrolls the statement. Its row has no tools of
|
|
551
|
+
* writing and is named a worked example's. `task`, `history` other than the empty one and
|
|
552
|
+
* `marks` are TypeErrors, and so is an eraser: the tool is the pen alone, and `exportWork()` and
|
|
553
|
+
* `filled` give null. The next segment of a lesson is a new element (R20).
|
|
554
|
+
*
|
|
555
|
+
* The parts of the demo the learner opens one at a time with the row's Next, into a list between
|
|
556
|
+
* the statement and the sheet, and hides and shows whole with Demo; `demoState` tells how far
|
|
557
|
+
* they have gone. Another example starts its demo at the beginning.
|
|
558
|
+
*/
|
|
559
|
+
get workedExample() {
|
|
560
|
+
return this.#workedExample;
|
|
561
|
+
}
|
|
562
|
+
set workedExample(example) {
|
|
563
|
+
if (example === this.#workedExample)
|
|
564
|
+
return;
|
|
565
|
+
if (example !== undefined)
|
|
566
|
+
this.#mayShowExample();
|
|
567
|
+
const checked = example === undefined ? undefined : exampleOf(example);
|
|
568
|
+
const shown = this.#example;
|
|
569
|
+
this.#workedExample = example;
|
|
570
|
+
this.#example = checked;
|
|
571
|
+
// The demo's state before the template, whose row shows it: another example starts at the
|
|
572
|
+
// beginning, with no event, and no example has none.
|
|
573
|
+
if (checked === undefined) {
|
|
574
|
+
this.#sheet.classList.remove("example");
|
|
575
|
+
this.#demoState = undefined;
|
|
576
|
+
this.#parts.clear();
|
|
577
|
+
this.#present(NO_TASK);
|
|
578
|
+
}
|
|
579
|
+
else if (shown === undefined || !sameExample(checked, shown)) {
|
|
580
|
+
// No mark stands on its sheet: its history is empty, and every submission has strokes. What
|
|
581
|
+
// the hands hold — a palm, two fingers scrolling — goes: the sheet takes no input now.
|
|
582
|
+
this.#hands.reset();
|
|
583
|
+
this.#latch = undefined;
|
|
584
|
+
this.#sheet.classList.add("example");
|
|
585
|
+
this.#demoState = DEMO_START;
|
|
586
|
+
this.#parts.clear();
|
|
587
|
+
this.#present(exampleTemplate(checked));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* How far the learner has gone through the worked example's demo (R42): `opened`, how many of
|
|
592
|
+
* its parts are open, from the first, and `hidden`, whether the whole demo is hidden — frozen,
|
|
593
|
+
* and the same object until it changes. A new example starts at `{ opened: 0, hidden: false }`,
|
|
594
|
+
* and without one it is `undefined`. The learner changes it with the row's Next and Demo, which
|
|
595
|
+
* say so with a `demochange`; the consumer may give it back, which says nothing, tells a screen
|
|
596
|
+
* reader nothing, and scrolls nothing. It is given after the example, for it is checked against
|
|
597
|
+
* the example's parts: before one, a state is a TypeError, and `undefined` changes nothing. A
|
|
598
|
+
* state is an object of `opened`, a whole number from 0 to the number of parts, and `hidden`,
|
|
599
|
+
* true or false, or it is a TypeError that names the field. One that says the same changes
|
|
600
|
+
* nothing. Parts do not close one by one for the learner, but a consumer may give fewer.
|
|
601
|
+
*/
|
|
602
|
+
get demoState() {
|
|
603
|
+
return this.#demoState;
|
|
604
|
+
}
|
|
605
|
+
set demoState(state) {
|
|
606
|
+
if (state === undefined)
|
|
607
|
+
return;
|
|
608
|
+
const example = this.#example;
|
|
609
|
+
if (example === undefined) {
|
|
610
|
+
throw new TypeError(`<${TAG}> shows no worked example: the state of its demo is given after the example`);
|
|
611
|
+
}
|
|
612
|
+
const checked = demoStateOf(state, example.demo.length);
|
|
613
|
+
const shown = this.#demoState;
|
|
614
|
+
if (shown?.opened === checked.opened && shown.hidden === checked.hidden)
|
|
615
|
+
return;
|
|
616
|
+
this.#parts.quiet();
|
|
617
|
+
this.#showDemo(example, checked);
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* The open view of the module's manifest (R32), of which the element reads the version of the
|
|
621
|
+
* module format — "0.2", or the manifest is a TypeError (R44 spec) — the height of a digit on the
|
|
622
|
+
* crop for checking, `canvas.digit_height`, 24 px unless given, and the explanation language, the
|
|
623
|
+
* statement's and a workbook's lines'. Without a manifest the digits are 24 px, and the statement
|
|
624
|
+
* and the lines are in the element's own `lang`. The rest of it the element passes over, but for
|
|
625
|
+
* the canvas settings, which are its own: one it does not know is a TypeError. A manifest it
|
|
626
|
+
* cannot read is a TypeError that names the field, and the element stays as it was; the same
|
|
627
|
+
* object given again is read again, for the consumer may have changed it. The task and the
|
|
628
|
+
* manifest are each the consumer's to give, in either order: `exportWork()` takes the height
|
|
629
|
+
* when it is called (R27).
|
|
630
|
+
*/
|
|
631
|
+
get manifest() {
|
|
632
|
+
return this.#manifest;
|
|
633
|
+
}
|
|
634
|
+
set manifest(manifest) {
|
|
635
|
+
// The same object is read again: a consumer may have changed it in place.
|
|
636
|
+
const settings = settingsOf(manifest);
|
|
637
|
+
this.#manifest = manifest;
|
|
638
|
+
if (settings.lang !== this.#settings.lang) {
|
|
639
|
+
this.#statement.lang = settings.lang;
|
|
640
|
+
this.#parts.lang = settings.lang;
|
|
641
|
+
write(this.#lines, "lang", settings.lang ?? null);
|
|
642
|
+
}
|
|
643
|
+
this.#settings = settings;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* What gives the files of the pictures, by their references (R07): a task's statement's, and a
|
|
647
|
+
* worked example's — its statement's and every part's of its demo, which it asks for as the
|
|
648
|
+
* example comes, before the learner opens them (R42). A reference is asked for once, whichever
|
|
649
|
+
* pictures show it, and its file stays while the task or the example names it. Until a picture's
|
|
650
|
+
* file comes, or if it never does, its frame shows what it shows in words. A new function serves
|
|
651
|
+
* the references that have no file yet, and stops no load the last one began.
|
|
652
|
+
*/
|
|
653
|
+
get media() {
|
|
654
|
+
return this.#pictures.media;
|
|
655
|
+
}
|
|
656
|
+
set media(media) {
|
|
657
|
+
if (media !== undefined && typeof media !== "function") {
|
|
658
|
+
throw new TypeError(`the media of <${TAG}> is a function that gives a Blob, not ${typeof media}`);
|
|
659
|
+
}
|
|
660
|
+
this.#pictures.media = media;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* The history of writing (R22, R27): every stroke, erasing, clearing and undo on the sheet, in the
|
|
664
|
+
* order they landed, at the sheet's time — frozen, and the same array until the next action. The
|
|
665
|
+
* consumer keeps it, adding what `historychange` tells, and gives it back to open the sheet as it
|
|
666
|
+
* was: the strokes, and the actions undo takes back.
|
|
667
|
+
*
|
|
668
|
+
* The history the sheet has changes nothing: the very array, or one whose every event says the
|
|
669
|
+
* same — fields the sheet does not keep are passed over, and `undefined` is the empty one. A
|
|
670
|
+
* stroke under way goes on, and the marks stay. Any other history replaces the sheet, with no
|
|
671
|
+
* event: it must be one the sheet could have written, or it is a TypeError that names the field,
|
|
672
|
+
* and the sheet stays as it was. A stroke or an erasing under way goes: its IDs and its times are
|
|
673
|
+
* the last sheet's. The clock goes on more than the pause of the groups after the history's last
|
|
674
|
+
* event, the next stroke takes the next ID, and the tutor's marks go back on the strokes, or off,
|
|
675
|
+
* if the history has not their strokes. A history that is where the sheet's began loses what came
|
|
676
|
+
* after, and says so in the console. The task and the history are each the consumer's to give, in
|
|
677
|
+
* either order. A worked example's sheet has the empty history alone: any other is a TypeError
|
|
678
|
+
* (R42).
|
|
679
|
+
*/
|
|
680
|
+
get history() {
|
|
681
|
+
return this.#work.history;
|
|
682
|
+
}
|
|
683
|
+
set history(history) {
|
|
684
|
+
const before = this.#work.history;
|
|
685
|
+
const equal = this.#equal;
|
|
686
|
+
// A consumer that gives back the same array of its own on every render, as long as it was, is
|
|
687
|
+
// not walked through point by point each time. One it has added to is compared again.
|
|
688
|
+
if (equal !== undefined &&
|
|
689
|
+
equal.given === history &&
|
|
690
|
+
equal.history === before &&
|
|
691
|
+
Array.isArray(history) &&
|
|
692
|
+
history.length === equal.length) {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (sameHistory(history, before)) {
|
|
696
|
+
this.#equal = {
|
|
697
|
+
given: history,
|
|
698
|
+
length: Array.isArray(history) ? history.length : 0,
|
|
699
|
+
history: before,
|
|
700
|
+
};
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
// A worked example's sheet is no one's to write on (R42).
|
|
704
|
+
if (this.#example !== undefined) {
|
|
705
|
+
throw new TypeError(`<${TAG}> shows a worked example, whose sheet has no history of writing`);
|
|
706
|
+
}
|
|
707
|
+
// undefined, and undefined alone, is the empty history: null is a value, and a wrong one.
|
|
708
|
+
const work = Work.restore(history === undefined ? [] : history, this.#clock);
|
|
709
|
+
const after = work.history;
|
|
710
|
+
// The empty history is a new sheet, which a consumer asks for on purpose.
|
|
711
|
+
if (after.length > 0 && begins(after, before)) {
|
|
712
|
+
console.warn(`<${TAG}> takes a history that is where its own began: the ${before.length - after.length} actions after it are gone`);
|
|
713
|
+
}
|
|
714
|
+
this.#recorder.restart(nextIdOf(after));
|
|
715
|
+
const erasing = this.#eraser.pointer;
|
|
716
|
+
if (erasing !== undefined)
|
|
717
|
+
this.#eraser.cancel(erasing);
|
|
718
|
+
this.#clock.resume(resumeOf(after));
|
|
719
|
+
this.#work = work;
|
|
720
|
+
this.#fill();
|
|
721
|
+
this.#told = after.length;
|
|
722
|
+
this.#writing = undefined;
|
|
723
|
+
this.#equal = undefined;
|
|
724
|
+
this.#replace();
|
|
725
|
+
this.#refresh();
|
|
726
|
+
this.#request();
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Which answer slots the learner's strokes fill, per item, per slot, in the task's order, by the
|
|
730
|
+
* strokes `exportWork()` would take — not the stroke under way, but those under an eraser not
|
|
731
|
+
* yet lifted. A workbook's boxes, each whether it holds one (R34, R37): a stroke is in one box
|
|
732
|
+
* or in none, by the rule of R37, and what is in none is the draft. A sheet's one answer anywhere
|
|
733
|
+
* on it or the whole sheet, whether the sheet holds one (R42). `null` for a sheet of choices,
|
|
734
|
+
* whose options app marks, for a sheet with no task, and for a worked example. Frozen, and the
|
|
735
|
+
* same array until a slot changes. An action on the sheet changes it before its
|
|
736
|
+
* `historychange`, and a task or a history given changes it with no event.
|
|
737
|
+
*/
|
|
738
|
+
get filled() {
|
|
739
|
+
return this.#filled;
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* The tutor's marks of one submission, with the context the element gave with its work (R07,
|
|
743
|
+
* R26): each a frame where a group of strokes is, or a box on the crop the model saw, and on a
|
|
744
|
+
* workbook where an answer cell is, by its item and its slot (R39). A mark holds on to the
|
|
745
|
+
* strokes the model saw there and is drawn around those of them the sheet shows, as they are now
|
|
746
|
+
* — a cell's around its box too: new strokes do not move it, and when all of them are erased it
|
|
747
|
+
* is gone, till undo brings them back. A box that comes near no stroke, a cell on a sheet, which
|
|
748
|
+
* takes no zones, and a box of a workbook that was empty, have no frame: the question is asked
|
|
749
|
+
* without one. The type is the element's own until spec's contract gives one. A submission goes
|
|
750
|
+
* back to a sheet whose history holds its strokes, as its digest tells (R27), and a workbook's
|
|
751
|
+
* to a page of its boxes: one of other strokes or other boxes is a TypeError, and so are marks
|
|
752
|
+
* given before the task and the history they hold on to, and marks that are not marks. A new
|
|
753
|
+
* history, and a new page, put the marks back on their strokes, or take them off. `undefined`
|
|
754
|
+
* takes the marks off. Every value is checked as it comes, and marks equal to those shown write
|
|
755
|
+
* nothing. A worked example's sheet has nothing to mark: marks are a TypeError there (R42).
|
|
756
|
+
*/
|
|
757
|
+
get marks() {
|
|
758
|
+
return this.#marks;
|
|
759
|
+
}
|
|
760
|
+
set marks(marks) {
|
|
761
|
+
if (marks !== undefined && this.#example !== undefined) {
|
|
762
|
+
throw new TypeError(`<${TAG}> shows a worked example, whose sheet has nothing to mark`);
|
|
763
|
+
}
|
|
764
|
+
const given = marks === undefined ? undefined : marksOf(marks);
|
|
765
|
+
const shown = given === undefined ? [] : this.#places(given);
|
|
766
|
+
this.#marks = marks;
|
|
767
|
+
this.#given = given;
|
|
768
|
+
this.#showMarks(shown);
|
|
769
|
+
}
|
|
770
|
+
connectedCallback() {
|
|
771
|
+
this.#resize.observe(this.#viewport);
|
|
772
|
+
this.#resize.observe(this.#statement.element);
|
|
773
|
+
this.#resize.observe(this.#parts.list);
|
|
774
|
+
this.#watchDensity();
|
|
775
|
+
this.#watchColours();
|
|
776
|
+
this.#pictures.connect();
|
|
777
|
+
// The page's pen mode, on every sheet of it (R29). The row shows a change once the event that
|
|
778
|
+
// made it is over: by then a pen that came down writes, and every row of the page waits for its
|
|
779
|
+
// stroke. The sheet tells the page whether it is written on, and hears when a stroke ends.
|
|
780
|
+
const unwatch = PEN_MODE.watch(() => queueMicrotask(() => this.#refresh()));
|
|
781
|
+
const unenlist = PEN_MODE.enlist(() => this.#writer() !== undefined);
|
|
782
|
+
this.#unwatchPen = () => {
|
|
783
|
+
unwatch();
|
|
784
|
+
unenlist();
|
|
785
|
+
};
|
|
786
|
+
// A scroll does not bubble, but the document hears every one of its tree on the way down. A
|
|
787
|
+
// page that grows or shrinks scrolls nothing, and neither does a window of another size.
|
|
788
|
+
document.addEventListener("scroll", this.#heard, { capture: true, passive: true });
|
|
789
|
+
this.#page.observe(document.documentElement);
|
|
790
|
+
window.addEventListener("resize", this.#resized);
|
|
791
|
+
this.#refresh();
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* The element's `lang` came, changed or went: its buttons and a workbook's boxes are labelled in
|
|
795
|
+
* it, or in English for a language their labels are not in, and for none (R28, R36). An attribute
|
|
796
|
+
* the element had before it was defined comes here just after the constructor, which reads no
|
|
797
|
+
* attribute.
|
|
798
|
+
*/
|
|
799
|
+
attributeChangedCallback() {
|
|
800
|
+
this.#tools.label(this.getAttribute("lang"));
|
|
801
|
+
this.#markBoxes();
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Takes back the last action on the sheet — the last stroke written, the last erasing, or the
|
|
805
|
+
* clearing — and says whether there was one (R19, R22), with a `historychange` if there was
|
|
806
|
+
* (R27). The element's Undo button calls it, and so may the consumer (R28). A stroke under way
|
|
807
|
+
* stays: it lands when it ends. So does an erasing: what it came near leaves the sheet when it
|
|
808
|
+
* lifts, if it is on the sheet then.
|
|
809
|
+
*/
|
|
810
|
+
undo() {
|
|
811
|
+
const undone = this.#work.undo(performance.now());
|
|
812
|
+
if (undone)
|
|
813
|
+
this.#changed();
|
|
814
|
+
return undone;
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Takes every stroke off the sheet, an action that undo takes back, and says whether there were
|
|
818
|
+
* any (R19), with a `historychange` if there were (R27). The element's Clear button calls it, and
|
|
819
|
+
* so may the consumer (R28). A stroke under way stays: it lands when it ends.
|
|
820
|
+
*/
|
|
821
|
+
clear() {
|
|
822
|
+
const cleared = this.#work.clear(performance.now());
|
|
823
|
+
if (cleared)
|
|
824
|
+
this.#changed();
|
|
825
|
+
return cleared;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* The work for checking (R07, R27), when the consumer asks for it, at Check, at the height of a
|
|
829
|
+
* digit the manifest asks for (R25, R32). On a sheet it is the crop of the strokes the sheet
|
|
830
|
+
* shows, its PNG, and the context of the submission — the crop's numbers, the groups and their
|
|
831
|
+
* digest (R26, R27). On a workbook it is the crop of each filled box, by its strokes alone, with
|
|
832
|
+
* its item and its slot, and the context — the table «box → stroke IDs» and its digest (R34,
|
|
833
|
+
* R38); the draft goes nowhere. The consumer keeps the context, sends only the PNGs and their
|
|
834
|
+
* places to be checked (R09 spec), and gives the context back with the tutor's marks. It is the
|
|
835
|
+
* sheet as it is when this is called: a stroke under way is not on it yet, while the strokes
|
|
836
|
+
* under an eraser not yet lifted still are, as `filled` has them. A sheet without strokes, and a
|
|
837
|
+
* workbook with no box filled, have no work to check, and the promise gives null (H3); so has a
|
|
838
|
+
* worked example, whose sheet the learner does not write on (R42).
|
|
839
|
+
*/
|
|
840
|
+
async exportWork() {
|
|
841
|
+
if (this.#example !== undefined)
|
|
842
|
+
return null;
|
|
843
|
+
const { digit } = this.#settings;
|
|
844
|
+
if (isWorkbook(this.#shownTemplate)) {
|
|
845
|
+
const work = cellsForCheck(this.#work.strokes, this.#cells, digit);
|
|
846
|
+
if (work === null)
|
|
847
|
+
return null;
|
|
848
|
+
const cells = work.cells.map(({ item, slot, png }) => Object.freeze({ item, slot, png: blobOf(png) }));
|
|
849
|
+
return Object.freeze({ cells: Object.freeze(cells), submission: work.submission });
|
|
850
|
+
}
|
|
851
|
+
const work = workForCheck(this.#work.strokes, digit);
|
|
852
|
+
if (work === null)
|
|
853
|
+
return null;
|
|
854
|
+
return Object.freeze({ png: blobOf(work.png), submission: work.submission });
|
|
855
|
+
}
|
|
856
|
+
disconnectedCallback() {
|
|
857
|
+
// A stroke or an erasing under way ends where it is: its pointerup will not reach a sheet off
|
|
858
|
+
// the page. Its historychange reaches only the element's own listeners, for the element is in
|
|
859
|
+
// no tree now.
|
|
860
|
+
this.#recorder.end();
|
|
861
|
+
this.#eraser.end();
|
|
862
|
+
this.#hands.reset();
|
|
863
|
+
this.#latch = undefined;
|
|
864
|
+
this.#unwatchPen?.();
|
|
865
|
+
this.#unwatchPen = undefined;
|
|
866
|
+
document.removeEventListener("scroll", this.#heard, { capture: true });
|
|
867
|
+
this.#page.disconnect();
|
|
868
|
+
window.removeEventListener("resize", this.#resized);
|
|
869
|
+
// A stroke that ended here may be the one the other rows of the page waited for.
|
|
870
|
+
PEN_MODE.settle();
|
|
871
|
+
this.#pictures.disconnect();
|
|
872
|
+
this.#resize.disconnect();
|
|
873
|
+
this.#unwatchDensity?.();
|
|
874
|
+
this.#unwatchDensity = undefined;
|
|
875
|
+
this.#unwatchColours?.();
|
|
876
|
+
this.#unwatchColours = undefined;
|
|
877
|
+
cancelAnimationFrame(this.#frame);
|
|
878
|
+
this.#frame = 0;
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* A property given before the element was defined is the instance's own, which hides the class's
|
|
882
|
+
* accessor: it goes to the element instead, through the accessor. A value the element does not
|
|
883
|
+
* take leaves the property as the element starts it, with a warning, rather than fail the
|
|
884
|
+
* element's upgrade, which would leave it dead.
|
|
885
|
+
*/
|
|
886
|
+
#adopt(name) {
|
|
887
|
+
if (!Object.hasOwn(this, name))
|
|
888
|
+
return;
|
|
889
|
+
const value = Reflect.get(this, name);
|
|
890
|
+
Reflect.deleteProperty(this, name);
|
|
891
|
+
try {
|
|
892
|
+
Reflect.set(this, name, value);
|
|
893
|
+
}
|
|
894
|
+
catch (error) {
|
|
895
|
+
if (!(error instanceof TypeError))
|
|
896
|
+
throw error;
|
|
897
|
+
console.warn(`${error.message}: <${TAG}> goes on as it starts`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* A task's template, or a worked example's, in place of the last (R23, R42). The layer and the
|
|
902
|
+
* statement apart: a new grid or new lines leave the statement and its pictures as they are, and
|
|
903
|
+
* the other way round. A tool not allowed puts the pen back, with no event, and the row shows
|
|
904
|
+
* what it is of, by its name, and the tools allowed: only what has changed is written. Then the
|
|
905
|
+
* element names the pictures of the task or the example, its statement's and its demo's: a file
|
|
906
|
+
* both the last and the new name stays, and shows at once in the new statement, while those no
|
|
907
|
+
* longer named go, their frames already gone with the last statement.
|
|
908
|
+
*/
|
|
909
|
+
#present(template) {
|
|
910
|
+
const shown = this.#shownTemplate;
|
|
911
|
+
this.#shownTemplate = template;
|
|
912
|
+
if (!sameLayer(template, shown)) {
|
|
913
|
+
this.#lay(template);
|
|
914
|
+
// A page of other boxes, or of the other kind, puts the marks back or takes them off (R39).
|
|
915
|
+
this.#replace();
|
|
916
|
+
}
|
|
917
|
+
else if (this.#laid === undefined) {
|
|
918
|
+
// The same grid may ask another answer: the sheet's strokes, or choices that app marks (R42).
|
|
919
|
+
this.#fill();
|
|
920
|
+
}
|
|
921
|
+
if (!sameStatement(template, shown))
|
|
922
|
+
this.#statement.show(template.statement);
|
|
923
|
+
const parts = [...template.statement, ...(this.#example?.demo ?? [])];
|
|
924
|
+
this.#pictures.name(parts.flatMap((part) => ("picture" in part ? [part.picture] : [])));
|
|
925
|
+
if (!template.tools.includes(this.#tool))
|
|
926
|
+
this.#tool = "pen";
|
|
927
|
+
this.#refresh();
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Whether the element may show a worked example now (R42): not while it shows a task, not on a
|
|
931
|
+
* sheet the learner has written on, and not while a stroke or an erasing is under way, which
|
|
932
|
+
* would land on a sheet that takes none. A TypeError says which.
|
|
933
|
+
*/
|
|
934
|
+
#mayShowExample() {
|
|
935
|
+
if (this.#task !== undefined) {
|
|
936
|
+
throw new TypeError(`<${TAG}> shows a task: a worked example is shown by a new element, or once the task is undefined`);
|
|
937
|
+
}
|
|
938
|
+
if (this.#work.history.length > 0) {
|
|
939
|
+
throw new TypeError(`<${TAG}> has a history of writing: a worked example is shown by a new element`);
|
|
940
|
+
}
|
|
941
|
+
if (this.#writer() !== undefined) {
|
|
942
|
+
throw new TypeError(`<${TAG}> is being written on: a worked example comes once the stroke or the erasing ends`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* The template's layer (R23, R36): a sheet's grid, which says nothing to a screen reader, or a
|
|
947
|
+
* workbook's lines, which it reads — a list of the items in the task's order, each its number,
|
|
948
|
+
* its text and its boxes, where the layout puts them.
|
|
949
|
+
*/
|
|
950
|
+
#lay(template) {
|
|
951
|
+
const workbook = isWorkbook(template);
|
|
952
|
+
this.#grid.setAttribute("d", workbook ? "" : gridPath(template.grid));
|
|
953
|
+
this.#grid.classList.toggle("dots", !workbook && template.grid.kind === "dots");
|
|
954
|
+
write(this.#template, "aria-hidden", workbook ? null : "true");
|
|
955
|
+
write(this.#template, "role", workbook ? "none" : null);
|
|
956
|
+
this.#lines.replaceChildren(...(workbook ? template.page.items.map(itemOf) : []));
|
|
957
|
+
// A canvas of the other kind fills its answer slots anew: a workbook's are its own arrays, and a
|
|
958
|
+
// sheet's those all sheets share (R42).
|
|
959
|
+
if (workbook !== (this.#laid !== undefined))
|
|
960
|
+
this.#filled = null;
|
|
961
|
+
this.#laid = workbook ? template.page : undefined;
|
|
962
|
+
this.#cells = workbook ? cellsOf(template.page) : [];
|
|
963
|
+
// The frames in the order of the lines, the task's: item by item, slot by slot.
|
|
964
|
+
this.#rects = [...this.#lines.querySelectorAll("rect")];
|
|
965
|
+
this.#taken = new WeakMap();
|
|
966
|
+
// New frames: marked by the filling if it changed, or else here.
|
|
967
|
+
if (!this.#fill())
|
|
968
|
+
this.#markBoxes();
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Which answer slots the strokes fill, and whether that changed. A workbook's boxes by the rule
|
|
972
|
+
* of R37, each stroke's box worked out once: a new array, and the frames marked anew, only if a
|
|
973
|
+
* box changed. A sheet's one answer anywhere on it or the whole sheet by any stroke on it, or
|
|
974
|
+
* nothing for a sheet of choices or with no task (R42).
|
|
975
|
+
*/
|
|
976
|
+
#fill() {
|
|
977
|
+
const page = this.#laid;
|
|
978
|
+
if (page === undefined) {
|
|
979
|
+
// A sheet: its one answer anywhere on it or the whole sheet is filled by any stroke the work
|
|
980
|
+
// for checking would take; a sheet of choices, whose options app marks, has nothing to say.
|
|
981
|
+
const filled = this.#whole ? sheetFilledOf(this.#work.strokes.length > 0) : null;
|
|
982
|
+
const changed = filled !== this.#filled;
|
|
983
|
+
this.#filled = filled;
|
|
984
|
+
return changed;
|
|
985
|
+
}
|
|
986
|
+
const taken = this.#work.strokes.map((stroke) => {
|
|
987
|
+
let found = this.#taken.get(stroke);
|
|
988
|
+
if (found === undefined) {
|
|
989
|
+
found = cellOf(stroke, this.#cells);
|
|
990
|
+
this.#taken.set(stroke, found);
|
|
991
|
+
}
|
|
992
|
+
return found;
|
|
993
|
+
});
|
|
994
|
+
const filled = filledOf(page, this.#cells, taken);
|
|
995
|
+
const shown = this.#filled;
|
|
996
|
+
if (shown !== null && sameItems(filled, shown, (a, b) => sameItems(a, b)))
|
|
997
|
+
return false;
|
|
998
|
+
this.#filled = filled;
|
|
999
|
+
this.#markBoxes();
|
|
1000
|
+
return true;
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Each box of the lines as it now is (R36, R37): its frame, thicker when it is filled, and its
|
|
1004
|
+
* name in the labels' language, by the element's `lang` — "Box" alone in its line, numbered by
|
|
1005
|
+
* its slot among several, and "filled" when it is. Only what has changed is written.
|
|
1006
|
+
*/
|
|
1007
|
+
#markBoxes() {
|
|
1008
|
+
const { lang, labels } = labelsFor(this.getAttribute("lang"));
|
|
1009
|
+
for (const [c, rect] of this.#rects.entries()) {
|
|
1010
|
+
const cell = this.#cells[c];
|
|
1011
|
+
if (cell === undefined)
|
|
1012
|
+
continue;
|
|
1013
|
+
const several = (this.#laid?.items[cell.item - 1]?.boxes.length ?? 0) > 1;
|
|
1014
|
+
const filled = this.#filled?.[cell.item - 1]?.[cell.slot - 1] === true;
|
|
1015
|
+
const name = several ? `${labels.box} ${cell.slot}` : labels.box;
|
|
1016
|
+
rect.classList.toggle("filled", filled);
|
|
1017
|
+
write(rect, "aria-label", filled ? `${name}, ${labels.filled}` : name);
|
|
1018
|
+
write(rect, "lang", lang);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* The marks as they were checked when given, back on their strokes after a new history or a new
|
|
1023
|
+
* page, if those have their strokes and boxes, or else off, with no event (R27, R39).
|
|
1024
|
+
*/
|
|
1025
|
+
#replace() {
|
|
1026
|
+
if (this.#given === undefined)
|
|
1027
|
+
return;
|
|
1028
|
+
try {
|
|
1029
|
+
this.#showMarks(this.#places(this.#given));
|
|
1030
|
+
}
|
|
1031
|
+
catch (error) {
|
|
1032
|
+
if (!(error instanceof TypeError))
|
|
1033
|
+
throw error;
|
|
1034
|
+
this.#marks = undefined;
|
|
1035
|
+
this.#given = undefined;
|
|
1036
|
+
this.#showMarks([]);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
/** Every stroke the sheet has known, erased or not, as its history wrote them. */
|
|
1040
|
+
#known() {
|
|
1041
|
+
return this.#work.history.flatMap((event) => (event.kind === "write" ? [event.stroke] : []));
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* The marks of a submission, as `marksOf` checked them, that have a place on this sheet, each
|
|
1045
|
+
* with its name: a TypeError for a submission that is not this sheet's (R26, R27).
|
|
1046
|
+
*/
|
|
1047
|
+
#places(given) {
|
|
1048
|
+
const cells = isWorkbook(this.#shownTemplate) ? this.#cells : null;
|
|
1049
|
+
const places = placesOf(given, this.#known(), cells);
|
|
1050
|
+
return given.marks.flatMap(({ name }, n) => {
|
|
1051
|
+
const placed = places[n];
|
|
1052
|
+
return placed ? [{ name, placed }] : [];
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* The marks to show, each a frame of its own on the layer of the marks, hidden till it is drawn.
|
|
1057
|
+
* Marks of the names and the strokes of those shown keep their frames, and nothing is written.
|
|
1058
|
+
*/
|
|
1059
|
+
#showMarks(marks) {
|
|
1060
|
+
const same = marks.length === this.#shown.length &&
|
|
1061
|
+
marks.every(({ name, placed }, n) => {
|
|
1062
|
+
const shown = this.#shown[n];
|
|
1063
|
+
return (shown !== undefined &&
|
|
1064
|
+
shown.name === name &&
|
|
1065
|
+
sameItems(shown.placed.ids, placed.ids) &&
|
|
1066
|
+
sameBox(shown.placed.cell ?? null, placed.cell ?? null));
|
|
1067
|
+
});
|
|
1068
|
+
if (same)
|
|
1069
|
+
return;
|
|
1070
|
+
for (const { rect } of this.#shown)
|
|
1071
|
+
rect.remove();
|
|
1072
|
+
this.#shown = marks.map(({ name, placed }) => {
|
|
1073
|
+
const rect = document.createElementNS(SVG, "rect");
|
|
1074
|
+
rect.setAttribute("role", "img");
|
|
1075
|
+
rect.setAttribute("aria-label", name);
|
|
1076
|
+
rect.setAttribute("rx", String(MARK_CORNER));
|
|
1077
|
+
rect.setAttribute("display", "none");
|
|
1078
|
+
this.#marksLayer.append(rect);
|
|
1079
|
+
return { name, placed, rect, frame: null, under: false, drawn: "none" };
|
|
1080
|
+
});
|
|
1081
|
+
this.#request();
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* One pointer at a time writes or erases (R18, R22), as the hands let it: a pen, a finger out of
|
|
1085
|
+
* pen mode, or a mouse (R29). The primary button does what the tool says, and a pen's eraser
|
|
1086
|
+
* erases — if the task allows the eraser: without it, the pen's eraser does nothing, for the
|
|
1087
|
+
* recorder takes the primary button alone (R28).
|
|
1088
|
+
*/
|
|
1089
|
+
#down(event) {
|
|
1090
|
+
// A worked example's sheet takes no input (R42): the canvas takes no pointer then, and an event
|
|
1091
|
+
// that a script sends it does nothing either, nor puts the page in pen mode.
|
|
1092
|
+
if (this.#example !== undefined)
|
|
1093
|
+
return;
|
|
1094
|
+
// A pointer that comes down again lost its way up: what it was writing or erasing ends where it
|
|
1095
|
+
// was, as on a lost capture.
|
|
1096
|
+
this.#recorder.end(event.pointerId);
|
|
1097
|
+
this.#eraser.end(event.pointerId);
|
|
1098
|
+
const writes = this.#hands.down(contactOf(event));
|
|
1099
|
+
// The canvas holds on to every pointer that comes down on it until it lifts, writing or not: its
|
|
1100
|
+
// moves and its lift come here, so that a stroke goes on beyond the canvas, two fingers scroll,
|
|
1101
|
+
// a palm stays one, and the hands forget no pointer that lifted elsewhere.
|
|
1102
|
+
this.#capture(event.pointerId);
|
|
1103
|
+
if (!writes)
|
|
1104
|
+
return;
|
|
1105
|
+
const screen = this.#view();
|
|
1106
|
+
if (screen === undefined)
|
|
1107
|
+
return;
|
|
1108
|
+
const sample = this.#sample(event, screen);
|
|
1109
|
+
const erases = this.#shownTemplate.tools.includes("eraser") &&
|
|
1110
|
+
(this.#tool === "eraser" || sample.button === ERASER_BUTTON);
|
|
1111
|
+
const began = erases
|
|
1112
|
+
? this.#eraser.down(sample, ERASER_RADIUS_PX / screen.k + INK_REACH)
|
|
1113
|
+
: this.#recorder.down(sample);
|
|
1114
|
+
if (began)
|
|
1115
|
+
this.#request();
|
|
1116
|
+
}
|
|
1117
|
+
/** The canvas holds on to a pointer until it lifts. */
|
|
1118
|
+
#capture(pointerId) {
|
|
1119
|
+
try {
|
|
1120
|
+
this.#canvas.setPointerCapture(pointerId);
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
// The pointer is no longer active, or never was: an event that a script made.
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
#move(event) {
|
|
1127
|
+
// A pointer the hands did not see come down, such as a mouse or a pen hovering, does nothing,
|
|
1128
|
+
// and reads no layout.
|
|
1129
|
+
if (!this.#hands.knows(event.pointerId))
|
|
1130
|
+
return;
|
|
1131
|
+
// The layout is read once an event, and only when a move writes or erases.
|
|
1132
|
+
let screen;
|
|
1133
|
+
let looked = false;
|
|
1134
|
+
let changed = false;
|
|
1135
|
+
for (const each of coalesced(event)) {
|
|
1136
|
+
// The hands see every move: a finger may grow into a palm, and two of them scroll.
|
|
1137
|
+
if (!this.#hands.move(contactOf(each)))
|
|
1138
|
+
continue;
|
|
1139
|
+
const input = this.#inputOf(each.pointerId);
|
|
1140
|
+
if (input === undefined)
|
|
1141
|
+
continue;
|
|
1142
|
+
if (!looked) {
|
|
1143
|
+
screen = this.#view();
|
|
1144
|
+
looked = true;
|
|
1145
|
+
}
|
|
1146
|
+
if (screen === undefined)
|
|
1147
|
+
continue;
|
|
1148
|
+
const sample = this.#sample(each, screen);
|
|
1149
|
+
const moved = input === this.#eraser ? input.move(sample, inSight(each, screen)) : input.move(sample);
|
|
1150
|
+
changed = moved || changed;
|
|
1151
|
+
}
|
|
1152
|
+
this.#flush();
|
|
1153
|
+
if (changed)
|
|
1154
|
+
this.#request();
|
|
1155
|
+
}
|
|
1156
|
+
#up(event) {
|
|
1157
|
+
const input = this.#inputOf(event.pointerId);
|
|
1158
|
+
if (input !== undefined) {
|
|
1159
|
+
const screen = this.#view();
|
|
1160
|
+
if (screen === undefined)
|
|
1161
|
+
input.end(event.pointerId);
|
|
1162
|
+
else if (input === this.#eraser)
|
|
1163
|
+
input.up(this.#sample(event, screen), inSight(event, screen));
|
|
1164
|
+
else
|
|
1165
|
+
input.up(this.#sample(event, screen));
|
|
1166
|
+
}
|
|
1167
|
+
this.#lifted(event.pointerId);
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* A pointer lifted, or the device or the page took it from the sheet: the hands forget it, a
|
|
1171
|
+
* gesture that ended lets go of what it scrolled, and the finger's button may come or go now, on
|
|
1172
|
+
* this row and on the others of the page (R29).
|
|
1173
|
+
*/
|
|
1174
|
+
#lifted(pointerId) {
|
|
1175
|
+
this.#hands.lift(pointerId);
|
|
1176
|
+
if (!this.#hands.scrolling)
|
|
1177
|
+
this.#latch = undefined;
|
|
1178
|
+
this.#refresh();
|
|
1179
|
+
PEN_MODE.settle();
|
|
1180
|
+
}
|
|
1181
|
+
/** The pointer that writes or erases now, if one does: the recorder's or the eraser's. */
|
|
1182
|
+
#writer() {
|
|
1183
|
+
return this.#recorder.pointer ?? this.#eraser.pointer;
|
|
1184
|
+
}
|
|
1185
|
+
/** A pointer's stroke or erasing goes, as on `pointercancel`, and the sheet is drawn anew. */
|
|
1186
|
+
#drop(pointerId) {
|
|
1187
|
+
if (this.#recorder.cancel(pointerId) || this.#eraser.cancel(pointerId))
|
|
1188
|
+
this.#request();
|
|
1189
|
+
}
|
|
1190
|
+
/** The input a pointer drives: the recorder while it writes, the eraser while it erases. */
|
|
1191
|
+
#inputOf(pointerId) {
|
|
1192
|
+
if (this.#recorder.pointer === pointerId)
|
|
1193
|
+
return this.#recorder;
|
|
1194
|
+
if (this.#eraser.pointer === pointerId)
|
|
1195
|
+
return this.#eraser;
|
|
1196
|
+
return undefined;
|
|
1197
|
+
}
|
|
1198
|
+
#sample(event, screen) {
|
|
1199
|
+
const { x, y } = toSheet(screen, event.clientX - screen.left, event.clientY - screen.top);
|
|
1200
|
+
return {
|
|
1201
|
+
pointerId: event.pointerId,
|
|
1202
|
+
kind: event.pointerType,
|
|
1203
|
+
button: event.button,
|
|
1204
|
+
buttons: event.buttons,
|
|
1205
|
+
x,
|
|
1206
|
+
y,
|
|
1207
|
+
time: event.timeStamp,
|
|
1208
|
+
pressure: event.pressure,
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Where the sheet is on the screen now. The rectangles of the canvas and of the sheet give it:
|
|
1213
|
+
* the canvas is as wide as the sheet, and its top is the top of the visible part. `k` is the one
|
|
1214
|
+
* on the screen, after whatever transform the page puts on the element. The container's
|
|
1215
|
+
* rectangle gives the part of the canvas it shows: with the statement in sight, the canvas
|
|
1216
|
+
* goes on below the container's bottom. The row of buttons above the container hides what it
|
|
1217
|
+
* covers, once it sticks over the sheet as the page scrolls the element by (R28).
|
|
1218
|
+
*/
|
|
1219
|
+
#view() {
|
|
1220
|
+
const canvas = this.#canvas.getBoundingClientRect();
|
|
1221
|
+
if (canvas.width <= 0)
|
|
1222
|
+
return undefined;
|
|
1223
|
+
const k = scaleFor(canvas.width);
|
|
1224
|
+
const sheet = this.#sheet.getBoundingClientRect();
|
|
1225
|
+
const vy = (canvas.top - sheet.top) / k;
|
|
1226
|
+
const port = clientBoxOf(this.#viewport);
|
|
1227
|
+
// The row ends where the container begins, unless it sticks over it.
|
|
1228
|
+
const row = this.#tools.element.getBoundingClientRect();
|
|
1229
|
+
const shown = {
|
|
1230
|
+
left: Math.max(canvas.left, port.left),
|
|
1231
|
+
top: Math.max(canvas.top, port.top, row.bottom),
|
|
1232
|
+
right: Math.min(canvas.right, port.right),
|
|
1233
|
+
bottom: Math.min(canvas.bottom, port.bottom),
|
|
1234
|
+
};
|
|
1235
|
+
return { k, vy, left: canvas.left, top: canvas.top, height: canvas.height, shown };
|
|
1236
|
+
}
|
|
1237
|
+
/** The sheet's top in the container's content, in CSS pixels as laid out: under the statement. */
|
|
1238
|
+
#sheetTop() {
|
|
1239
|
+
const port = clientBoxOf(this.#viewport);
|
|
1240
|
+
const sheet = this.#sheet.getBoundingClientRect();
|
|
1241
|
+
return (sheet.top - port.top) / port.down + this.#viewport.scrollTop;
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* The top of the visible part from the scroll and the last layout, without reading the layout
|
|
1245
|
+
* again: the stage sticks to the container's top once the sheet's top has gone past it. Up or
|
|
1246
|
+
* Down may have come to an end of the container, or left one.
|
|
1247
|
+
*/
|
|
1248
|
+
#scrolled() {
|
|
1249
|
+
if (this.#k > 0)
|
|
1250
|
+
this.#vy = Math.max(0, this.#viewport.scrollTop - this.#sheetAt) / this.#k;
|
|
1251
|
+
this.#request();
|
|
1252
|
+
this.#edges();
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Up or Down was pressed (R33): what two fingers would scroll that way goes by four fifths of what
|
|
1256
|
+
* it shows in sight. It is chosen anew at every press, so that a container at its end hands the
|
|
1257
|
+
* scroll on at the next. While a pointer writes or erases on a sheet of the page, nothing moves
|
|
1258
|
+
* under it.
|
|
1259
|
+
*/
|
|
1260
|
+
#upOrDown(onward) {
|
|
1261
|
+
const latch = PEN_MODE.writing ? undefined : this.#scrollerFor(onward);
|
|
1262
|
+
if (latch !== undefined) {
|
|
1263
|
+
const step = Math.max(1, Math.round((UP_DOWN * this.#seen(latch.target)) / latch.scale));
|
|
1264
|
+
latch.target.scrollBy({ top: onward ? step : -step, behavior: "instant" });
|
|
1265
|
+
}
|
|
1266
|
+
this.#edges();
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* How much of what a scroller shows is in sight, down the screen: within the ancestors that clip
|
|
1270
|
+
* it and the visual viewport, and below the row of buttons while the row sticks over its top. A
|
|
1271
|
+
* scroller out of sight counts whole.
|
|
1272
|
+
*/
|
|
1273
|
+
#seen(target) {
|
|
1274
|
+
const sight = window.visualViewport;
|
|
1275
|
+
let top = sight?.offsetTop ?? 0;
|
|
1276
|
+
let bottom = top + (sight?.height ?? window.innerHeight);
|
|
1277
|
+
let whole = bottom - top;
|
|
1278
|
+
if (target instanceof Element) {
|
|
1279
|
+
const own = clientBoxOf(target);
|
|
1280
|
+
whole = own.bottom - own.top;
|
|
1281
|
+
// The scroller, and every ancestor that clips it, up to the page, whose part is the sight.
|
|
1282
|
+
for (let node = target; node !== null; node = parentOf(node)) {
|
|
1283
|
+
if (node === document.documentElement)
|
|
1284
|
+
break;
|
|
1285
|
+
if (node !== target && getComputedStyle(node).overflowY === "visible")
|
|
1286
|
+
continue;
|
|
1287
|
+
const clip = node === target ? own : clientBoxOf(node);
|
|
1288
|
+
top = Math.max(top, clip.top);
|
|
1289
|
+
bottom = Math.min(bottom, clip.bottom);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
// The row hides the top while it sticks there, as far down as the consumer's offset puts it.
|
|
1293
|
+
const row = this.#tools.element;
|
|
1294
|
+
const box = row.getBoundingClientRect();
|
|
1295
|
+
if (box.top <= top + (Number.parseFloat(getComputedStyle(row).top) || 0) + 0.5) {
|
|
1296
|
+
top = Math.max(top, box.bottom);
|
|
1297
|
+
}
|
|
1298
|
+
return bottom > top ? bottom - top : whole;
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Up and Down as the scrolling now is (R33): each stands unavailable while what two fingers would
|
|
1302
|
+
* scroll its way can go no further. The element hears its container, the page and the ancestors
|
|
1303
|
+
* in the page's own tree scroll, and the page and the window change size, but not the scrolling
|
|
1304
|
+
* in another shadow tree: a press asks anew.
|
|
1305
|
+
*/
|
|
1306
|
+
#edges() {
|
|
1307
|
+
if (!this.isConnected)
|
|
1308
|
+
return;
|
|
1309
|
+
const above = this.#scrollerFor(false) !== undefined;
|
|
1310
|
+
this.#tools.edges(above, this.#scrollerFor(true) !== undefined);
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* Two fingers moved down the screen by `dy` CSS pixels (R29), and what they scroll goes with
|
|
1314
|
+
* them. A gesture's first step chooses it, for the whole gesture: the sheet's own container if it
|
|
1315
|
+
* can go that way, or else the nearest ancestor of the element that scrolls and can, or else the
|
|
1316
|
+
* page. A container at its end hands the scroll on at the next gesture, not within this one. The
|
|
1317
|
+
* steps of an event add up, and its end scrolls them at once.
|
|
1318
|
+
*/
|
|
1319
|
+
#scroll(dy, fresh) {
|
|
1320
|
+
if (fresh)
|
|
1321
|
+
this.#latch = this.#scrollerFor(dy < 0);
|
|
1322
|
+
// The content goes with the fingers: down the screen, the scroll goes back.
|
|
1323
|
+
if (this.#latch !== undefined)
|
|
1324
|
+
this.#latch.rest -= dy / this.#latch.scale;
|
|
1325
|
+
}
|
|
1326
|
+
/** The scroll the fingers' moves of an event owe, in whole pixels: the rest waits for the next. */
|
|
1327
|
+
#flush() {
|
|
1328
|
+
const latch = this.#latch;
|
|
1329
|
+
if (latch === undefined)
|
|
1330
|
+
return;
|
|
1331
|
+
const step = Math.round(latch.rest);
|
|
1332
|
+
if (step === 0)
|
|
1333
|
+
return;
|
|
1334
|
+
latch.rest -= step;
|
|
1335
|
+
latch.target.scrollBy({ top: step, behavior: "instant" });
|
|
1336
|
+
}
|
|
1337
|
+
/** What scrolls on down the content, for `onward`, or back up it. */
|
|
1338
|
+
#scrollerFor(onward) {
|
|
1339
|
+
if (scrollsOn(this.#viewport, onward)) {
|
|
1340
|
+
return { target: this.#viewport, scale: scaleOf(this.#viewport), rest: 0 };
|
|
1341
|
+
}
|
|
1342
|
+
// The viewport takes the root's overflow, or the body's when the root's is visible (CSS
|
|
1343
|
+
// Overflow 3): then the body scrolls nothing of its own, and the page scrolls only as it allows.
|
|
1344
|
+
const root = getComputedStyle(document.documentElement).overflowY;
|
|
1345
|
+
const body = document.body === null ? "visible" : getComputedStyle(document.body).overflowY;
|
|
1346
|
+
const fromBody = root === "visible" && body !== "visible";
|
|
1347
|
+
for (let node = parentOf(this); node !== null; node = parentOf(node)) {
|
|
1348
|
+
if (node === document.documentElement || (fromBody && node === document.body))
|
|
1349
|
+
break;
|
|
1350
|
+
const style = getComputedStyle(node);
|
|
1351
|
+
if (!SCROLLING.test(style.overflowY))
|
|
1352
|
+
continue;
|
|
1353
|
+
if (scrollsOn(node, onward))
|
|
1354
|
+
return { target: node, scale: scaleOf(node), rest: 0 };
|
|
1355
|
+
// A container that keeps its scrolling to itself hands none on.
|
|
1356
|
+
if (style.overscrollBehaviorY === "contain" || style.overscrollBehaviorY === "none") {
|
|
1357
|
+
return undefined;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
// A page that hides its overflow, as one under a modal does, a finger would not scroll: nor do two.
|
|
1361
|
+
const overflow = fromBody ? body : root;
|
|
1362
|
+
if (overflow === "hidden" || overflow === "clip")
|
|
1363
|
+
return undefined;
|
|
1364
|
+
const page = document.scrollingElement;
|
|
1365
|
+
if (page === null || !scrollsOn(page, onward))
|
|
1366
|
+
return undefined;
|
|
1367
|
+
return { target: window, scale: 1, rest: 0 };
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* The stage takes the visible part of the sheet, and the canvas a pixel of its buffer for each
|
|
1371
|
+
* device pixel: its size on the screen times the density (R18). The layers follow in the same
|
|
1372
|
+
* frame.
|
|
1373
|
+
*/
|
|
1374
|
+
#layout() {
|
|
1375
|
+
const area = this.#area;
|
|
1376
|
+
if (area === undefined || area.width === 0)
|
|
1377
|
+
return;
|
|
1378
|
+
// The column is as wide as the container up to the cap, and the stage as tall as the visible
|
|
1379
|
+
// part of the sheet.
|
|
1380
|
+
const k = fitScale(area.width);
|
|
1381
|
+
const sheetAt = this.#sheetTop();
|
|
1382
|
+
// A new width scales the sheet, and a statement of a new height moves it: the top of the
|
|
1383
|
+
// visible part stays where it was on the sheet, unless the sheet's top is in sight. A new
|
|
1384
|
+
// height of the container alone moves neither, and a scroll under way goes on.
|
|
1385
|
+
if (this.#vy > 0 && (k !== this.#k || Math.abs(sheetAt - this.#sheetAt) > 0.5)) {
|
|
1386
|
+
this.#viewport.scrollTop = sheetAt + this.#vy * k;
|
|
1387
|
+
}
|
|
1388
|
+
this.#k = k;
|
|
1389
|
+
this.#sheetAt = sheetAt;
|
|
1390
|
+
this.#stage.style.height = `${Math.min(area.height, SHEET.height * k)}px`;
|
|
1391
|
+
const onScreen = this.#canvas.getBoundingClientRect();
|
|
1392
|
+
const width = Math.round(onScreen.width * devicePixelRatio);
|
|
1393
|
+
const height = Math.round(onScreen.height * devicePixelRatio);
|
|
1394
|
+
// A new size clears the canvas. The ink goes back at once, and the layers move with it, in
|
|
1395
|
+
// this frame: a ResizeObserver reports after the frame's animation callbacks, but before the
|
|
1396
|
+
// frame is painted.
|
|
1397
|
+
if (this.#canvas.width !== width || this.#canvas.height !== height) {
|
|
1398
|
+
this.#canvas.width = width;
|
|
1399
|
+
this.#canvas.height = height;
|
|
1400
|
+
}
|
|
1401
|
+
this.#draw();
|
|
1402
|
+
// A container of another height, or a statement, may give Up or Down somewhere to go, or none.
|
|
1403
|
+
this.#edges();
|
|
1404
|
+
}
|
|
1405
|
+
/** A new density — the window on another screen, the page zoomed — lays the canvas out anew. */
|
|
1406
|
+
#watchDensity() {
|
|
1407
|
+
const density = matchMedia(`(resolution: ${devicePixelRatio}dppx)`);
|
|
1408
|
+
const changed = () => {
|
|
1409
|
+
this.#layout();
|
|
1410
|
+
this.#watchDensity();
|
|
1411
|
+
};
|
|
1412
|
+
density.addEventListener("change", changed, { once: true });
|
|
1413
|
+
this.#unwatchDensity = () => density.removeEventListener("change", changed);
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* Forced colours coming or going draw the ink anew: they need not run the canvas's transition of
|
|
1417
|
+
* its colour.
|
|
1418
|
+
*/
|
|
1419
|
+
#watchColours() {
|
|
1420
|
+
const forced = matchMedia("(forced-colors: active)");
|
|
1421
|
+
const changed = () => this.#request();
|
|
1422
|
+
forced.addEventListener("change", changed);
|
|
1423
|
+
this.#unwatchColours = () => forced.removeEventListener("change", changed);
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* The work has changed — a stroke landed, an erasing, an undo, a clearing: the sheet is drawn
|
|
1427
|
+
* anew, and whoever listens hears of it at once, the history already as it now is, with the
|
|
1428
|
+
* events it has gained (R27). The event goes up from the element itself, and out of a consumer's
|
|
1429
|
+
* own shadow tree too, should the element be in one. An action a listener takes while it hears is
|
|
1430
|
+
* told of next, once every listener has heard of the one before: each hears the history's events
|
|
1431
|
+
* in its order. A history given meanwhile is no action, and what it replaced is told of no more.
|
|
1432
|
+
*/
|
|
1433
|
+
#changed() {
|
|
1434
|
+
this.#request();
|
|
1435
|
+
// The boxes and the row before the event, and for an action a listener takes as well.
|
|
1436
|
+
this.#fill();
|
|
1437
|
+
this.#refresh();
|
|
1438
|
+
if (this.#telling)
|
|
1439
|
+
return;
|
|
1440
|
+
this.#telling = true;
|
|
1441
|
+
try {
|
|
1442
|
+
let history = this.#work.history;
|
|
1443
|
+
while (this.#told < history.length) {
|
|
1444
|
+
const from = this.#told;
|
|
1445
|
+
const events = Object.freeze(history.slice(from));
|
|
1446
|
+
this.#told = history.length;
|
|
1447
|
+
const detail = Object.freeze({ events, from });
|
|
1448
|
+
this.dispatchEvent(new CustomEvent(HISTORY_CHANGE, { bubbles: true, composed: true, detail }));
|
|
1449
|
+
history = this.#work.history;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
finally {
|
|
1453
|
+
this.#telling = false;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* The learner chose a tool with the element's own button (R28): it is the tool now, and whoever
|
|
1458
|
+
* listens hears of it. A consumer that gives the tool follows it, or its next render would set
|
|
1459
|
+
* back what the learner chose.
|
|
1460
|
+
*/
|
|
1461
|
+
#choose(tool) {
|
|
1462
|
+
if (tool === this.#tool || !this.#shownTemplate.tools.includes(tool))
|
|
1463
|
+
return;
|
|
1464
|
+
this.#tool = tool;
|
|
1465
|
+
this.#refresh();
|
|
1466
|
+
const detail = Object.freeze({ tool });
|
|
1467
|
+
this.dispatchEvent(new CustomEvent(TOOL_CHANGE, { bubbles: true, composed: true, detail }));
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* The learner opened the next part of the demo with the row's Next (R42). The list grows above
|
|
1471
|
+
* the sheet, and the layout keeps a sheet scrolled into sight where it was at once, before the
|
|
1472
|
+
* scroll of the container is heard; then the part comes into sight below the row of buttons, the
|
|
1473
|
+
* region tells of it, and whoever listens hears of the new state. The focus stays on Next, which
|
|
1474
|
+
* opens the part after it. Nothing happens while the demo is hidden, or open whole.
|
|
1475
|
+
*/
|
|
1476
|
+
#openNext() {
|
|
1477
|
+
const example = this.#example;
|
|
1478
|
+
const part = this.#nextPart();
|
|
1479
|
+
if (example === undefined || part === undefined)
|
|
1480
|
+
return;
|
|
1481
|
+
const opened = (this.#demoState?.opened ?? 0) + 1;
|
|
1482
|
+
this.#showDemo(example, Object.freeze({ opened, hidden: false }));
|
|
1483
|
+
const target = this.#parts.target(opened);
|
|
1484
|
+
if (target !== undefined)
|
|
1485
|
+
this.#bringIntoSight(target);
|
|
1486
|
+
const { lang, labels } = labelsFor(this.getAttribute("lang"));
|
|
1487
|
+
const number = `${labels.part} ${opened} ${labels.of} ${example.demo.length}.`;
|
|
1488
|
+
this.#parts.tell(part, { number, lang });
|
|
1489
|
+
this.#demoChanged();
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* The learner hid the whole demo with the row's Demo, or showed it again (R42): its open parts
|
|
1493
|
+
* stay open, out of sight, and the region tells of none. A sheet scrolled into sight stays where
|
|
1494
|
+
* it was, and nothing else scrolls.
|
|
1495
|
+
*/
|
|
1496
|
+
#toggleDemo() {
|
|
1497
|
+
const example = this.#example;
|
|
1498
|
+
const state = this.#demoState;
|
|
1499
|
+
if (example === undefined || state === undefined)
|
|
1500
|
+
return;
|
|
1501
|
+
this.#parts.quiet();
|
|
1502
|
+
this.#showDemo(example, Object.freeze({ opened: state.opened, hidden: !state.hidden }));
|
|
1503
|
+
this.#demoChanged();
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* The demo as a state has it: the list, laid out at once, so that the sheet's place is the new
|
|
1507
|
+
* one before any scroll is heard (R42), and the row.
|
|
1508
|
+
*/
|
|
1509
|
+
#showDemo(example, state) {
|
|
1510
|
+
this.#demoState = state;
|
|
1511
|
+
this.#parts.show(example.demo, state);
|
|
1512
|
+
this.#layout();
|
|
1513
|
+
this.#refresh();
|
|
1514
|
+
}
|
|
1515
|
+
/** Whoever listens hears of the demo's new state, which the learner chose (R42). */
|
|
1516
|
+
#demoChanged() {
|
|
1517
|
+
const detail = this.#demoState;
|
|
1518
|
+
this.dispatchEvent(new CustomEvent(DEMO_CHANGE, { bubbles: true, composed: true, detail }));
|
|
1519
|
+
}
|
|
1520
|
+
/** The part Next opens: the one after those open, while the demo is shown (R42). */
|
|
1521
|
+
#nextPart() {
|
|
1522
|
+
const state = this.#demoState;
|
|
1523
|
+
if (state === undefined || state.hidden)
|
|
1524
|
+
return undefined;
|
|
1525
|
+
return this.#example?.demo[state.opened];
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* An item of the list, or an area of the sheet, comes into sight at once, as near as it is (R42),
|
|
1529
|
+
* and then out from under the row of buttons, should the row stick over where it came to: what
|
|
1530
|
+
* scrolls back up goes back as far as the row covers it. A margin of scrolling would hold for
|
|
1531
|
+
* every scroller of the chain alike — the sheet's container, which the row stands above, and the
|
|
1532
|
+
* page, which it sticks over. An area of the sheet taller than what is in sight comes by its top
|
|
1533
|
+
* from below, but by its bottom from above, and then the row's cover brings its top back into
|
|
1534
|
+
* sight, under the row (T61).
|
|
1535
|
+
*/
|
|
1536
|
+
#bringIntoSight(item) {
|
|
1537
|
+
item.scrollIntoView({ block: "nearest", behavior: "instant" });
|
|
1538
|
+
// A scroller that comes to its top hands the rest on to the next, as Up does (R33): each of
|
|
1539
|
+
// the chain goes once.
|
|
1540
|
+
const gone = new Set();
|
|
1541
|
+
for (;;) {
|
|
1542
|
+
const row = this.#tools.element.getBoundingClientRect();
|
|
1543
|
+
const cover = row.bottom - item.getBoundingClientRect().top;
|
|
1544
|
+
const latch = cover > 0.5 ? this.#scrollerFor(false) : undefined;
|
|
1545
|
+
if (latch === undefined || gone.has(latch.target))
|
|
1546
|
+
return;
|
|
1547
|
+
gone.add(latch.target);
|
|
1548
|
+
latch.target.scrollBy({ top: -cover / latch.scale, behavior: "instant" });
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* The row of buttons as the sheet now is: the tools the task allows, the one in use, whether
|
|
1553
|
+
* undo and clearing have anything to do (R28), and a worked example's demo (R42).
|
|
1554
|
+
*/
|
|
1555
|
+
#refresh() {
|
|
1556
|
+
// The finger's button comes and goes between the strokes of the whole page alone: a row that
|
|
1557
|
+
// wraps would move this sheet, and the sheets below it, under the pen. It is there in pen mode,
|
|
1558
|
+
// on a device that has touch (R29).
|
|
1559
|
+
if (this.#writer() === undefined && !PEN_MODE.writing) {
|
|
1560
|
+
this.#finger = PEN_MODE.on && navigator.maxTouchPoints > 0;
|
|
1561
|
+
}
|
|
1562
|
+
const demo = this.#demoState;
|
|
1563
|
+
this.#tools.show({
|
|
1564
|
+
kind: this.#rowKind(),
|
|
1565
|
+
tools: this.#shownTemplate.tools,
|
|
1566
|
+
tool: this.#tool,
|
|
1567
|
+
undoable: this.#work.undoable,
|
|
1568
|
+
clearable: this.#work.strokes.length > 0,
|
|
1569
|
+
finger: this.#finger,
|
|
1570
|
+
opens: this.#nextPart() !== undefined,
|
|
1571
|
+
expanded: demo?.hidden !== true,
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
/** What the row of buttons is of, and is named by: a worked example, or the canvas kind (R42). */
|
|
1575
|
+
#rowKind() {
|
|
1576
|
+
if (this.#example !== undefined)
|
|
1577
|
+
return "example";
|
|
1578
|
+
return isWorkbook(this.#shownTemplate) ? "workbook" : "sheet";
|
|
1579
|
+
}
|
|
1580
|
+
#request() {
|
|
1581
|
+
if (this.#frame !== 0 || !this.isConnected)
|
|
1582
|
+
return;
|
|
1583
|
+
this.#frame = requestAnimationFrame(() => {
|
|
1584
|
+
this.#frame = 0;
|
|
1585
|
+
this.#draw();
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* The layers show the visible part of the sheet: each `<svg>` layer by its view box, in sheet
|
|
1590
|
+
* units, which the browser scales to the stage (R23). Before any is drawn, so that none goes on
|
|
1591
|
+
* showing a part of the sheet the others have left.
|
|
1592
|
+
*/
|
|
1593
|
+
#place(screen) {
|
|
1594
|
+
const height = screen.height / screen.k;
|
|
1595
|
+
if (!(height > 0))
|
|
1596
|
+
return;
|
|
1597
|
+
const viewBox = `0 ${screen.vy} ${SHEET.width} ${height}`;
|
|
1598
|
+
if (viewBox === this.#viewBox)
|
|
1599
|
+
return;
|
|
1600
|
+
this.#viewBox = viewBox;
|
|
1601
|
+
for (const layer of [this.#template, this.#show, this.#sketch, this.#marksLayer]) {
|
|
1602
|
+
layer.setAttribute("viewBox", viewBox);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* The frames of the marks, around their strokes as the sheet shows them now (R26): off their
|
|
1607
|
+
* points by the padding, in sheet units, and kept to the sheet by half the frame's line, so that
|
|
1608
|
+
* a frame at its edge stays whole. A frame the stroke under way comes into fades till the stroke
|
|
1609
|
+
* lands, and only what has changed is written.
|
|
1610
|
+
*/
|
|
1611
|
+
#drawMarks(screen) {
|
|
1612
|
+
const fresh = this.#freshPoints();
|
|
1613
|
+
if (this.#shown.length === 0)
|
|
1614
|
+
return;
|
|
1615
|
+
const byId = this.#byId();
|
|
1616
|
+
const pending = this.#eraser.pending;
|
|
1617
|
+
const onScreen = (id) => {
|
|
1618
|
+
const stroke = pending.has(id) ? undefined : byId.get(id);
|
|
1619
|
+
return stroke && this.#pointBoxOf(stroke);
|
|
1620
|
+
};
|
|
1621
|
+
const half = 1 / screen.k;
|
|
1622
|
+
for (const mark of this.#shown) {
|
|
1623
|
+
const shown = shownOf(mark.placed, onScreen);
|
|
1624
|
+
const frame = shown && {
|
|
1625
|
+
minX: Math.max(shown.minX - MARK_PADDING, half),
|
|
1626
|
+
minY: Math.max(shown.minY - MARK_PADDING, half),
|
|
1627
|
+
maxX: Math.min(shown.maxX + MARK_PADDING, SHEET.width - half),
|
|
1628
|
+
maxY: Math.min(shown.maxY + MARK_PADDING, SHEET.height - half),
|
|
1629
|
+
};
|
|
1630
|
+
if (this.#writing === undefined || frame === null)
|
|
1631
|
+
mark.under = false;
|
|
1632
|
+
else if (!mark.under) {
|
|
1633
|
+
// A frame that came or moved while the stroke was under way is checked against all of it.
|
|
1634
|
+
const points = sameBox(frame, mark.frame) ? fresh : this.#writing.stroke.points;
|
|
1635
|
+
mark.under = points.some((point) => near(point, frame, INK_REACH + half));
|
|
1636
|
+
}
|
|
1637
|
+
mark.frame = frame;
|
|
1638
|
+
const drawn = frame === null
|
|
1639
|
+
? "none"
|
|
1640
|
+
: `${frame.minX} ${frame.minY} ${frame.maxX} ${frame.maxY} ${mark.under}`;
|
|
1641
|
+
if (drawn === mark.drawn)
|
|
1642
|
+
continue;
|
|
1643
|
+
mark.drawn = drawn;
|
|
1644
|
+
const { rect } = mark;
|
|
1645
|
+
if (frame === null) {
|
|
1646
|
+
rect.setAttribute("display", "none");
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
rect.setAttribute("x", String(frame.minX));
|
|
1650
|
+
rect.setAttribute("y", String(frame.minY));
|
|
1651
|
+
rect.setAttribute("width", String(frame.maxX - frame.minX));
|
|
1652
|
+
rect.setAttribute("height", String(frame.maxY - frame.minY));
|
|
1653
|
+
rect.classList.toggle("under", mark.under);
|
|
1654
|
+
rect.removeAttribute("display");
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* The points the stroke under way has gained since the marks last looked at it. A new stroke
|
|
1659
|
+
* starts with every mark as it is, unfaded.
|
|
1660
|
+
*/
|
|
1661
|
+
#freshPoints() {
|
|
1662
|
+
const live = this.#recorder.live;
|
|
1663
|
+
if (live === undefined) {
|
|
1664
|
+
this.#writing = undefined;
|
|
1665
|
+
return [];
|
|
1666
|
+
}
|
|
1667
|
+
if (this.#writing?.stroke !== live) {
|
|
1668
|
+
this.#writing = { stroke: live, seen: 0 };
|
|
1669
|
+
for (const mark of this.#shown)
|
|
1670
|
+
mark.under = false;
|
|
1671
|
+
}
|
|
1672
|
+
const fresh = live.points.slice(this.#writing.seen);
|
|
1673
|
+
this.#writing.seen = live.points.length;
|
|
1674
|
+
return fresh;
|
|
1675
|
+
}
|
|
1676
|
+
/** The strokes on the sheet by ID: each action on the work gives a new array of them (R19). */
|
|
1677
|
+
#byId() {
|
|
1678
|
+
const strokes = this.#work.strokes;
|
|
1679
|
+
if (this.#onSheet.strokes !== strokes) {
|
|
1680
|
+
this.#onSheet = { strokes, byId: new Map(strokes.map((stroke) => [stroke.id, stroke])) };
|
|
1681
|
+
}
|
|
1682
|
+
return this.#onSheet.byId;
|
|
1683
|
+
}
|
|
1684
|
+
#pointBoxOf(stroke) {
|
|
1685
|
+
let box = this.#pointBoxes.get(stroke);
|
|
1686
|
+
if (box === undefined) {
|
|
1687
|
+
box = boxOf(stroke.points);
|
|
1688
|
+
this.#pointBoxes.set(stroke, box);
|
|
1689
|
+
}
|
|
1690
|
+
return box;
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* The layers in the visible part, and the strokes there, in device pixels: each finished stroke
|
|
1694
|
+
* by the path it was given once, the stroke under way outlined anew. A stroke whose points lie
|
|
1695
|
+
* far out of sight is not outlined till it comes into sight: a history given to the sheet may
|
|
1696
|
+
* hold thousands (R27). What the eraser under way has come near is out of sight already, though
|
|
1697
|
+
* still on the sheet until it lifts. The ink's colour is the canvas's, as the theme makes it.
|
|
1698
|
+
*/
|
|
1699
|
+
#draw() {
|
|
1700
|
+
const screen = this.#view();
|
|
1701
|
+
if (screen === undefined)
|
|
1702
|
+
return;
|
|
1703
|
+
// Read before anything is written: the colour as the style now computes it.
|
|
1704
|
+
const colour = this.#canvasStyle.color;
|
|
1705
|
+
this.#place(screen);
|
|
1706
|
+
this.#drawMarks(screen);
|
|
1707
|
+
const context = this.#context;
|
|
1708
|
+
const { width, height } = this.#canvas;
|
|
1709
|
+
if (context === null || width === 0 || height === 0)
|
|
1710
|
+
return;
|
|
1711
|
+
// Device pixels to a unit, across and down. The canvas is as wide as the sheet, and the ratio
|
|
1712
|
+
// of its rectangles holds under any scale the page puts on the element.
|
|
1713
|
+
const across = width / SHEET.width;
|
|
1714
|
+
const down = (height / screen.height) * screen.k;
|
|
1715
|
+
context.setTransform(1, 0, 0, 1, 0, 0);
|
|
1716
|
+
context.clearRect(0, 0, width, height);
|
|
1717
|
+
context.setTransform(across, 0, 0, down, 0, -screen.vy * down);
|
|
1718
|
+
context.fillStyle = colour;
|
|
1719
|
+
const bottom = screen.vy + screen.height / screen.k;
|
|
1720
|
+
const erasing = this.#eraser.pending;
|
|
1721
|
+
for (const stroke of this.#work.strokes) {
|
|
1722
|
+
if (erasing.has(stroke.id))
|
|
1723
|
+
continue;
|
|
1724
|
+
// The outline keeps within half the widest line of the points: twice that is out of sight.
|
|
1725
|
+
const box = this.#pointBoxOf(stroke);
|
|
1726
|
+
if (box.maxY + 2 * INK_REACH < screen.vy || box.minY - 2 * INK_REACH > bottom)
|
|
1727
|
+
continue;
|
|
1728
|
+
const ink = this.#finished(stroke);
|
|
1729
|
+
if (ink.bottom >= screen.vy && ink.top <= bottom)
|
|
1730
|
+
context.fill(ink.path);
|
|
1731
|
+
}
|
|
1732
|
+
const live = this.#recorder.live;
|
|
1733
|
+
if (live !== undefined)
|
|
1734
|
+
context.fill(pathOf(this.#outline(live, false)));
|
|
1735
|
+
}
|
|
1736
|
+
#finished(stroke) {
|
|
1737
|
+
let ink = this.#inks.get(stroke);
|
|
1738
|
+
if (ink === undefined) {
|
|
1739
|
+
const polygon = this.#outline(stroke, true);
|
|
1740
|
+
const ys = polygon.map(([, y]) => y);
|
|
1741
|
+
ink = { path: pathOf(polygon), top: Math.min(...ys), bottom: Math.max(...ys) };
|
|
1742
|
+
this.#inks.set(stroke, ink);
|
|
1743
|
+
}
|
|
1744
|
+
return ink;
|
|
1745
|
+
}
|
|
1746
|
+
#outline(stroke, complete) {
|
|
1747
|
+
this.#outlines++;
|
|
1748
|
+
return outline(stroke, complete);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
/**
|
|
1752
|
+
* Whether a pointer event is over the part of the sheet the container shows — the canvas, cut by
|
|
1753
|
+
* the container — rather than beyond it, where a captured pointer goes on and its place is clamped
|
|
1754
|
+
* to the sheet's edge.
|
|
1755
|
+
*/
|
|
1756
|
+
function inSight(event, screen) {
|
|
1757
|
+
const { shown } = screen;
|
|
1758
|
+
return (event.clientX >= shown.left &&
|
|
1759
|
+
event.clientX <= shown.right &&
|
|
1760
|
+
event.clientY >= shown.top &&
|
|
1761
|
+
event.clientY <= shown.bottom);
|
|
1762
|
+
}
|
|
1763
|
+
/** Whether a point lies within a box widened by `reach`, in sheet units. */
|
|
1764
|
+
function near({ x, y }, box, reach) {
|
|
1765
|
+
return (x >= box.minX - reach && x <= box.maxX + reach && y >= box.minY - reach && y <= box.maxY + reach);
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* An item of a workbook's lines (R36): its number, then its text and its boxes in the order they
|
|
1769
|
+
* are read, each where the layout put it. A piece of text is fitted to its width by SVG, whatever
|
|
1770
|
+
* the font, and its size is never read.
|
|
1771
|
+
*/
|
|
1772
|
+
function itemOf(item) {
|
|
1773
|
+
const g = document.createElementNS(SVG, "g");
|
|
1774
|
+
g.setAttribute("role", "listitem");
|
|
1775
|
+
g.append(textOf(item.number), ...item.line.map((part) => (isBox(part) ? rectOf(part) : textOf(part))));
|
|
1776
|
+
return g;
|
|
1777
|
+
}
|
|
1778
|
+
function textOf({ text, left, baseline, width }) {
|
|
1779
|
+
const element = document.createElementNS(SVG, "text");
|
|
1780
|
+
element.setAttribute("x", String(left));
|
|
1781
|
+
element.setAttribute("y", String(baseline));
|
|
1782
|
+
element.setAttribute("textLength", String(width));
|
|
1783
|
+
element.setAttribute("lengthAdjust", "spacingAndGlyphs");
|
|
1784
|
+
element.textContent = text;
|
|
1785
|
+
return element;
|
|
1786
|
+
}
|
|
1787
|
+
/** A box, a picture to a screen reader, which its name tells of (R34). */
|
|
1788
|
+
function rectOf(box) {
|
|
1789
|
+
const rect = document.createElementNS(SVG, "rect");
|
|
1790
|
+
placeOver(rect, box);
|
|
1791
|
+
rect.setAttribute("role", "img");
|
|
1792
|
+
return rect;
|
|
1793
|
+
}
|
|
1794
|
+
/** The bytes of a PNG as a Blob, for the consumer to send or show. */
|
|
1795
|
+
function blobOf(png) {
|
|
1796
|
+
return new Blob([png], { type: "image/png" });
|
|
1797
|
+
}
|
|
1798
|
+
function sameBox(a, b) {
|
|
1799
|
+
if (a === null || b === null)
|
|
1800
|
+
return a === b;
|
|
1801
|
+
return a.minX === b.minX && a.minY === b.minY && a.maxX === b.maxX && a.maxY === b.maxY;
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* The events coalesced into a pointer event, in order, or the event alone. `getCoalescedEvents`
|
|
1805
|
+
* is there only in a secure context, and not in every browser; pointerup's list is empty.
|
|
1806
|
+
*/
|
|
1807
|
+
function coalesced(event) {
|
|
1808
|
+
const events = typeof event.getCoalescedEvents === "function" ? event.getCoalescedEvents() : [];
|
|
1809
|
+
return events.length > 0 ? events : [event];
|
|
1810
|
+
}
|
|
1811
|
+
/** What the hands see of a pointer event (R29): where on the screen, how big, when. */
|
|
1812
|
+
function contactOf(event) {
|
|
1813
|
+
return {
|
|
1814
|
+
pointerId: event.pointerId,
|
|
1815
|
+
kind: event.pointerType,
|
|
1816
|
+
x: event.clientX,
|
|
1817
|
+
y: event.clientY,
|
|
1818
|
+
width: event.width,
|
|
1819
|
+
height: event.height,
|
|
1820
|
+
time: event.timeStamp,
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
/** The values of `overflow-y` that make an element scroll its content. */
|
|
1824
|
+
const SCROLLING = /^(auto|scroll|overlay)$/;
|
|
1825
|
+
/** Whether an element can scroll on down its content, for `onward`, or back up it. */
|
|
1826
|
+
function scrollsOn(element, onward) {
|
|
1827
|
+
const end = element.scrollHeight - element.clientHeight;
|
|
1828
|
+
return onward ? element.scrollTop < end - 0.5 : element.scrollTop > 0.5;
|
|
1829
|
+
}
|
|
1830
|
+
/** Whether an element holds another in the flat tree, across the borders of shadow trees. */
|
|
1831
|
+
function holds(ancestor, element) {
|
|
1832
|
+
for (let node = parentOf(element); node !== null; node = parentOf(node)) {
|
|
1833
|
+
if (node === ancestor)
|
|
1834
|
+
return true;
|
|
1835
|
+
}
|
|
1836
|
+
return false;
|
|
1837
|
+
}
|
|
1838
|
+
/** A node's parent in the flat tree: the slot it is in, its parent, or its shadow root's host. */
|
|
1839
|
+
function parentOf(element) {
|
|
1840
|
+
if (element.assignedSlot !== null)
|
|
1841
|
+
return element.assignedSlot;
|
|
1842
|
+
if (element.parentElement !== null)
|
|
1843
|
+
return element.parentElement;
|
|
1844
|
+
const root = element.parentNode;
|
|
1845
|
+
return root instanceof ShadowRoot ? root.host : null;
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* An element's client box on the screen — what it shows of its content, within its borders and
|
|
1849
|
+
* without its scroll bar — and how much the page scales it down the screen: 1 unless an ancestor
|
|
1850
|
+
* transforms it. The sheet's container has one, and so has any ancestor Up and Down scroll (R33).
|
|
1851
|
+
*/
|
|
1852
|
+
function clientBoxOf(element) {
|
|
1853
|
+
const box = element.getBoundingClientRect();
|
|
1854
|
+
const wide = element instanceof HTMLElement ? element.offsetWidth : 0;
|
|
1855
|
+
const across = wide > 0 ? box.width / wide : 1;
|
|
1856
|
+
const down = scaleOf(element, box);
|
|
1857
|
+
const left = box.left + element.clientLeft * across;
|
|
1858
|
+
const top = box.top + element.clientTop * down;
|
|
1859
|
+
const right = left + element.clientWidth * across;
|
|
1860
|
+
const bottom = top + element.clientHeight * down;
|
|
1861
|
+
return { left, top, right, bottom, down };
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* How much the page scales an element down the screen: 1 unless an ancestor transforms it. `box` is
|
|
1865
|
+
* its rectangle on the screen, when the caller has it already.
|
|
1866
|
+
*/
|
|
1867
|
+
function scaleOf(element, box) {
|
|
1868
|
+
const tall = element instanceof HTMLElement ? element.offsetHeight : 0;
|
|
1869
|
+
return tall > 0 ? (box ?? element.getBoundingClientRect()).height / tall : 1;
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* A path around an outline: a closed curve through the midpoints of its edges, each point of the
|
|
1873
|
+
* outline the control point between two of them.
|
|
1874
|
+
*/
|
|
1875
|
+
function pathOf(polygon) {
|
|
1876
|
+
const path = new Path2D();
|
|
1877
|
+
const first = polygon[0];
|
|
1878
|
+
if (first === undefined)
|
|
1879
|
+
return path;
|
|
1880
|
+
const last = polygon[polygon.length - 1] ?? first;
|
|
1881
|
+
path.moveTo((last[0] + first[0]) / 2, (last[1] + first[1]) / 2);
|
|
1882
|
+
for (const [i, [x, y]] of polygon.entries()) {
|
|
1883
|
+
const [nextX, nextY] = polygon[i + 1] ?? first;
|
|
1884
|
+
path.quadraticCurveTo(x, y, (x + nextX) / 2, (y + nextY) / 2);
|
|
1885
|
+
}
|
|
1886
|
+
path.closePath();
|
|
1887
|
+
return path;
|
|
1888
|
+
}
|
|
1889
|
+
// A second copy of this module, from a second copy of the package, finds the name taken and
|
|
1890
|
+
// leaves the first registration in place, rather than throwing when the page imports it. It says
|
|
1891
|
+
// so, though: elements of the page are then built by the other copy, of whatever version it is.
|
|
1892
|
+
const registered = customElements.get(TAG);
|
|
1893
|
+
if (registered === undefined) {
|
|
1894
|
+
customElements.define(TAG, NovatrailCanvas);
|
|
1895
|
+
}
|
|
1896
|
+
else if (registered !== NovatrailCanvas) {
|
|
1897
|
+
console.warn(`<${TAG}> is already registered by another copy of @altnovatrail/canvas; this copy's element is not used`);
|
|
1898
|
+
}
|