@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,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A touch wider or taller than this, in CSS pixels, is a palm (R29). WebKit on iPadOS gives a touch
|
|
3
|
+
* twice its contact's radius, a fingertip's 40–60 px by one report, so canvas-api's 30 px would
|
|
4
|
+
* take most fingers there for palms. T22 measures it on the devices.
|
|
5
|
+
*/
|
|
6
|
+
export const PALM_PX = 100;
|
|
7
|
+
/**
|
|
8
|
+
* Two touches that come down at most this many milliseconds apart are two fingers of one gesture
|
|
9
|
+
* (R29): Android's tap timeout, 100 ms, with a margin for children's fingers.
|
|
10
|
+
*/
|
|
11
|
+
export const PAIR_MS = 150;
|
|
12
|
+
/**
|
|
13
|
+
* How far a touch may wander, in CSS pixels, before it is going somewhere (R29): Android's touch
|
|
14
|
+
* slop, 8 dp. A finger that has gone further is writing, and two fingers scroll once their middle
|
|
15
|
+
* has gone further.
|
|
16
|
+
*/
|
|
17
|
+
export const SLOP_PX = 8;
|
|
18
|
+
/**
|
|
19
|
+
* The pen mode (R29): on from a pen's touch on a sheet, off when the learner asks for the finger.
|
|
20
|
+
* While it is on, a finger neither writes nor erases. One is shared by every sheet of a page, and
|
|
21
|
+
* it lives in memory alone: nothing is stored or sent, and a new page starts without it.
|
|
22
|
+
*
|
|
23
|
+
* It knows, too, whether any sheet of the page is written on: a row of buttons changes between the
|
|
24
|
+
* strokes of the whole page alone, for a row that wraps moves the sheets below it as well.
|
|
25
|
+
*/
|
|
26
|
+
export class PenMode {
|
|
27
|
+
#on = false;
|
|
28
|
+
#watchers = new Set();
|
|
29
|
+
#sheets = new Set();
|
|
30
|
+
get on() {
|
|
31
|
+
return this.#on;
|
|
32
|
+
}
|
|
33
|
+
/** Every watcher hears of a change. */
|
|
34
|
+
set on(on) {
|
|
35
|
+
if (on === this.#on)
|
|
36
|
+
return;
|
|
37
|
+
this.#on = on;
|
|
38
|
+
this.#tell();
|
|
39
|
+
}
|
|
40
|
+
/** Whether a pointer writes or erases now on a sheet of the page. */
|
|
41
|
+
get writing() {
|
|
42
|
+
for (const writes of this.#sheets)
|
|
43
|
+
if (writes())
|
|
44
|
+
return true;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `watcher` hears of every change from now on, and of every stroke that ends, until the function
|
|
49
|
+
* this gives is called.
|
|
50
|
+
*/
|
|
51
|
+
watch(watcher) {
|
|
52
|
+
this.#watchers.add(watcher);
|
|
53
|
+
return () => {
|
|
54
|
+
this.#watchers.delete(watcher);
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** A sheet tells whether it is written on, until the function this gives is called. */
|
|
58
|
+
enlist(writes) {
|
|
59
|
+
this.#sheets.add(writes);
|
|
60
|
+
return () => {
|
|
61
|
+
this.#sheets.delete(writes);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** A pointer lifted off a sheet: once no sheet is written on, every watcher hears of it. */
|
|
65
|
+
settle() {
|
|
66
|
+
if (!this.writing)
|
|
67
|
+
this.#tell();
|
|
68
|
+
}
|
|
69
|
+
#tell() {
|
|
70
|
+
for (const watcher of [...this.#watchers])
|
|
71
|
+
watcher();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The hands on a sheet (R29): which pointer may write, which is a palm, and which two fingers
|
|
76
|
+
* scroll, for a pen, fingers and a mouse on one screen.
|
|
77
|
+
* - One pointer writes at a time, as before (R18), and the sheet's recorder and eraser say which.
|
|
78
|
+
* - A pen's touch puts the page in pen mode and takes away the stroke of a touch under way: it was
|
|
79
|
+
* a palm. While a pen or a mouse writes, a touch does nothing until it lifts.
|
|
80
|
+
* - A touch wider or taller than `PALM_PX`, at its `pointerdown` or at any move, is a palm: it does
|
|
81
|
+
* nothing until it lifts, and what it was writing goes.
|
|
82
|
+
* - Without pen mode the first finger writes. A second that comes down within `PAIR_MS`, before the
|
|
83
|
+
* first has gone `SLOP_PX`, takes the first one's stroke away, and the two scroll. A second that
|
|
84
|
+
* comes later does nothing.
|
|
85
|
+
* - In pen mode a finger writes nothing, and two that come down within `PAIR_MS` scroll.
|
|
86
|
+
* - A third finger does nothing. When one of the two lifts, the other does nothing until it lifts.
|
|
87
|
+
* - Two fingers scroll by the travel of their middle down the screen, past the first `SLOP_PX`.
|
|
88
|
+
* - Any other pointer, a mouse or one of no type, writes when nothing does, whatever the mode.
|
|
89
|
+
*/
|
|
90
|
+
export class Hands {
|
|
91
|
+
#sheet;
|
|
92
|
+
#mode;
|
|
93
|
+
#hands = new Map();
|
|
94
|
+
#gesture;
|
|
95
|
+
constructor(sheet, mode) {
|
|
96
|
+
this.#sheet = sheet;
|
|
97
|
+
this.#mode = mode;
|
|
98
|
+
}
|
|
99
|
+
/** What each pointer down on the sheet does now. */
|
|
100
|
+
get roles() {
|
|
101
|
+
return new Map([...this.#hands].map(([id, hand]) => [id, hand.role]));
|
|
102
|
+
}
|
|
103
|
+
/** Whether a pointer is down on the sheet: the hands saw it come down, and not yet lift. */
|
|
104
|
+
knows(pointerId) {
|
|
105
|
+
return this.#hands.has(pointerId);
|
|
106
|
+
}
|
|
107
|
+
/** Whether two fingers make a gesture now: they wait for their slop, or scroll. */
|
|
108
|
+
get scrolling() {
|
|
109
|
+
return this.#gesture !== undefined;
|
|
110
|
+
}
|
|
111
|
+
/** `pointerdown`: whether this pointer may write or erase. */
|
|
112
|
+
down(contact) {
|
|
113
|
+
// A pointer that comes down again was lost on its way up.
|
|
114
|
+
this.lift(contact.pointerId);
|
|
115
|
+
if (contact.kind === "pen")
|
|
116
|
+
return this.#pen(contact);
|
|
117
|
+
if (contact.kind === "touch")
|
|
118
|
+
return this.#touch(contact);
|
|
119
|
+
return this.#add(contact, this.#sheet.writer() === undefined ? "writes" : "idle");
|
|
120
|
+
}
|
|
121
|
+
/** `pointermove`, once for each coalesced event: whether this pointer writes on. */
|
|
122
|
+
move(contact) {
|
|
123
|
+
const hand = this.#hands.get(contact.pointerId);
|
|
124
|
+
if (hand === undefined)
|
|
125
|
+
return false;
|
|
126
|
+
if (hand.kind === "touch" && hand.role !== "palm" && isPalm(contact)) {
|
|
127
|
+
// A palm that has grown: what it was doing goes.
|
|
128
|
+
if (this.#sheet.writer() === contact.pointerId)
|
|
129
|
+
this.#sheet.drop(contact.pointerId);
|
|
130
|
+
if (hand.role === "scrolls")
|
|
131
|
+
this.#end();
|
|
132
|
+
hand.role = "palm";
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
hand.y = contact.y;
|
|
136
|
+
hand.travel = Math.max(hand.travel, Math.hypot(contact.x - hand.x0, contact.y - hand.y0));
|
|
137
|
+
if (hand.role === "scrolls")
|
|
138
|
+
this.#step();
|
|
139
|
+
return hand.role === "writes" && this.#sheet.writer() === contact.pointerId;
|
|
140
|
+
}
|
|
141
|
+
/** `pointerup`, `pointercancel` or a lost capture: the pointer has gone, and its gesture too. */
|
|
142
|
+
lift(pointerId) {
|
|
143
|
+
const hand = this.#hands.get(pointerId);
|
|
144
|
+
if (hand === undefined)
|
|
145
|
+
return;
|
|
146
|
+
if (hand.role === "scrolls")
|
|
147
|
+
this.#end();
|
|
148
|
+
this.#hands.delete(pointerId);
|
|
149
|
+
}
|
|
150
|
+
/** The sheet has left the page, and no pointer is down on it now. */
|
|
151
|
+
reset() {
|
|
152
|
+
this.#hands.clear();
|
|
153
|
+
this.#gesture = undefined;
|
|
154
|
+
}
|
|
155
|
+
#pen(contact) {
|
|
156
|
+
this.#mode.on = true;
|
|
157
|
+
// A touch writing when a pen comes was a palm, and its stroke goes. A gesture under way ends,
|
|
158
|
+
// and every touch down now does nothing until it lifts.
|
|
159
|
+
const writer = this.#sheet.writer();
|
|
160
|
+
if (writer !== undefined && this.#hands.get(writer)?.kind === "touch")
|
|
161
|
+
this.#sheet.drop(writer);
|
|
162
|
+
this.#gesture = undefined;
|
|
163
|
+
for (const hand of this.#hands.values()) {
|
|
164
|
+
if (hand.kind === "touch" && hand.role !== "palm")
|
|
165
|
+
hand.role = "idle";
|
|
166
|
+
}
|
|
167
|
+
return this.#add(contact, this.#sheet.writer() === undefined ? "writes" : "idle");
|
|
168
|
+
}
|
|
169
|
+
#touch(contact) {
|
|
170
|
+
if (isPalm(contact))
|
|
171
|
+
return this.#add(contact, "palm");
|
|
172
|
+
const writer = this.#sheet.writer();
|
|
173
|
+
// While a pen or a mouse writes, or two fingers scroll, a touch does nothing.
|
|
174
|
+
const writing = writer === undefined ? undefined : this.#hands.get(writer);
|
|
175
|
+
if ((writer !== undefined && writing?.kind !== "touch") || this.#gesture !== undefined) {
|
|
176
|
+
return this.#add(contact, "idle");
|
|
177
|
+
}
|
|
178
|
+
if (this.#mode.on) {
|
|
179
|
+
// In pen mode a finger writes nothing: it waits for a second one to scroll with.
|
|
180
|
+
const partner = this.#latest("waits", contact);
|
|
181
|
+
this.#add(contact, partner === undefined ? "waits" : "scrolls");
|
|
182
|
+
if (partner !== undefined)
|
|
183
|
+
this.#pair(partner, contact.pointerId);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
// Without it the first finger writes, and a second soon after makes the two a scroll.
|
|
187
|
+
const first = this.#latest("writes", contact);
|
|
188
|
+
if (first !== undefined && (this.#hands.get(first)?.travel ?? 0) <= SLOP_PX) {
|
|
189
|
+
if (writer === first)
|
|
190
|
+
this.#sheet.drop(first);
|
|
191
|
+
this.#add(contact, "scrolls");
|
|
192
|
+
this.#pair(first, contact.pointerId);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
return this.#add(contact, writer === undefined ? "writes" : "idle");
|
|
196
|
+
}
|
|
197
|
+
#add(contact, role) {
|
|
198
|
+
this.#hands.set(contact.pointerId, {
|
|
199
|
+
kind: contact.kind,
|
|
200
|
+
role,
|
|
201
|
+
x0: contact.x,
|
|
202
|
+
y0: contact.y,
|
|
203
|
+
t0: contact.time,
|
|
204
|
+
y: contact.y,
|
|
205
|
+
travel: 0,
|
|
206
|
+
});
|
|
207
|
+
return role === "writes";
|
|
208
|
+
}
|
|
209
|
+
/** The touch in `role` that came down last, at most `PAIR_MS` before this one, if any. */
|
|
210
|
+
#latest(role, contact) {
|
|
211
|
+
let found;
|
|
212
|
+
let at = -Infinity;
|
|
213
|
+
for (const [id, hand] of this.#hands) {
|
|
214
|
+
if (hand.kind !== "touch" || hand.role !== role || id === contact.pointerId)
|
|
215
|
+
continue;
|
|
216
|
+
if (contact.time - hand.t0 > PAIR_MS || hand.t0 < at)
|
|
217
|
+
continue;
|
|
218
|
+
found = id;
|
|
219
|
+
at = hand.t0;
|
|
220
|
+
}
|
|
221
|
+
return found;
|
|
222
|
+
}
|
|
223
|
+
#pair(first, second) {
|
|
224
|
+
const one = this.#hands.get(first);
|
|
225
|
+
const two = this.#hands.get(second);
|
|
226
|
+
if (one === undefined || two === undefined)
|
|
227
|
+
return;
|
|
228
|
+
one.role = "scrolls";
|
|
229
|
+
two.role = "scrolls";
|
|
230
|
+
this.#gesture = { fingers: [first, second], from: (one.y + two.y) / 2, last: undefined };
|
|
231
|
+
}
|
|
232
|
+
/** The gesture ends: its fingers do nothing until they lift. */
|
|
233
|
+
#end() {
|
|
234
|
+
const gesture = this.#gesture;
|
|
235
|
+
if (gesture === undefined)
|
|
236
|
+
return;
|
|
237
|
+
this.#gesture = undefined;
|
|
238
|
+
for (const id of gesture.fingers) {
|
|
239
|
+
const hand = this.#hands.get(id);
|
|
240
|
+
if (hand?.role === "scrolls")
|
|
241
|
+
hand.role = "idle";
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/** The two fingers' middle has moved: past the slop, the sheet goes with it. */
|
|
245
|
+
#step() {
|
|
246
|
+
const gesture = this.#gesture;
|
|
247
|
+
if (gesture === undefined)
|
|
248
|
+
return;
|
|
249
|
+
const one = this.#hands.get(gesture.fingers[0]);
|
|
250
|
+
const two = this.#hands.get(gesture.fingers[1]);
|
|
251
|
+
if (one === undefined || two === undefined)
|
|
252
|
+
return;
|
|
253
|
+
const middle = (one.y + two.y) / 2;
|
|
254
|
+
if (gesture.last === undefined) {
|
|
255
|
+
const gone = middle - gesture.from;
|
|
256
|
+
if (Math.abs(gone) <= SLOP_PX)
|
|
257
|
+
return;
|
|
258
|
+
// The slop is where the gesture starts: the sheet moves from there, without a jump.
|
|
259
|
+
gesture.last = middle;
|
|
260
|
+
this.#sheet.scroll(gone - Math.sign(gone) * SLOP_PX, true);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (middle === gesture.last)
|
|
264
|
+
return;
|
|
265
|
+
const dy = middle - gesture.last;
|
|
266
|
+
gesture.last = middle;
|
|
267
|
+
this.#sheet.scroll(dy, false);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** Whether a touch is too big for a finger: a palm, or the side of a hand. */
|
|
271
|
+
function isPalm(contact) {
|
|
272
|
+
return contact.width > PALM_PX || contact.height > PALM_PX;
|
|
273
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { HistoryEvent } from "@altnovatrail/spec/canvas";
|
|
2
|
+
import type { Stroke } from "./ink.js";
|
|
3
|
+
/**
|
|
4
|
+
* An event of the history of writing (R09, R22): spec's type, of the contract of canvas and app
|
|
5
|
+
* (R74 spec; R07). What an action on the sheet did, at the sheet's time `t` when it landed, in the
|
|
6
|
+
* order the actions landed; each event says which strokes it put on the sheet or took off, so
|
|
7
|
+
* whoever reads the history replays it without the rules of the stack of undo (R19). Those the
|
|
8
|
+
* sheet hands out are frozen through (R27).
|
|
9
|
+
*/
|
|
10
|
+
export type { HistoryEvent };
|
|
11
|
+
/**
|
|
12
|
+
* The strokes a history leaves on the sheet, in the order they were written: what the sheet shows
|
|
13
|
+
* after it (R09).
|
|
14
|
+
*/
|
|
15
|
+
export declare function replay(history: readonly HistoryEvent[]): Stroke[];
|
|
16
|
+
/**
|
|
17
|
+
* The events of a history given to the sheet (R27), each checked field by field and copied into
|
|
18
|
+
* frozen objects of the sheet's own, with only the fields the sheet keeps: others are passed over.
|
|
19
|
+
* A value that is not such a history is a TypeError that names the field. Each event must be one of
|
|
20
|
+
* the four, at a time from 0 that never goes back along the history; a stroke must have a new ID, a
|
|
21
|
+
* whole number from 1, and one point at least, each on the sheet, with a pressure from 0 to 1, its
|
|
22
|
+
* times never going back and none after the time it landed. Times are checked for their order
|
|
23
|
+
* alone: with several pointers at once (T35) strokes may overlap in time. Whether the actions keep
|
|
24
|
+
* the rules of the stack, the work on the sheet checks as it replays them (`Work.restore`).
|
|
25
|
+
*/
|
|
26
|
+
export declare function historyOf(value: unknown): HistoryEvent[];
|
|
27
|
+
/**
|
|
28
|
+
* An event, frozen through: its stroke with its points, or its lists of IDs (R27). Whoever holds
|
|
29
|
+
* an event the sheet handed out cannot change the sheet by it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function frozen(event: HistoryEvent): HistoryEvent;
|
|
32
|
+
/**
|
|
33
|
+
* Whether a value given as a history is the history the sheet has (R27): the very array, or a list
|
|
34
|
+
* whose every event says what the sheet's says, by the fields the sheet keeps — others are passed
|
|
35
|
+
* over. `undefined` is the empty history. It never throws: a value that is no history is simply not
|
|
36
|
+
* the same one.
|
|
37
|
+
*/
|
|
38
|
+
export declare function sameHistory(value: unknown, history: readonly HistoryEvent[]): boolean;
|
|
39
|
+
/** Whether a history is the beginning of another: shorter, and the same event for event. */
|
|
40
|
+
export declare function begins(history: readonly HistoryEvent[], of: readonly HistoryEvent[]): boolean;
|
|
41
|
+
/** Whether a value says what an event of the sheet's history says, by the fields it keeps. */
|
|
42
|
+
export declare function sameEvent(value: unknown, event: HistoryEvent): boolean;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { record, shown } from "./fields.js";
|
|
2
|
+
import { SHEET } from "./sheet.js";
|
|
3
|
+
/**
|
|
4
|
+
* The strokes a history leaves on the sheet, in the order they were written: what the sheet shows
|
|
5
|
+
* after it (R09).
|
|
6
|
+
*/
|
|
7
|
+
export function replay(history) {
|
|
8
|
+
const written = [];
|
|
9
|
+
const visible = new Set();
|
|
10
|
+
for (const event of history) {
|
|
11
|
+
switch (event.kind) {
|
|
12
|
+
case "write":
|
|
13
|
+
written.push(event.stroke);
|
|
14
|
+
visible.add(event.stroke.id);
|
|
15
|
+
break;
|
|
16
|
+
case "erase":
|
|
17
|
+
case "clear":
|
|
18
|
+
for (const id of event.ids)
|
|
19
|
+
visible.delete(id);
|
|
20
|
+
break;
|
|
21
|
+
case "undo":
|
|
22
|
+
for (const id of event.removed)
|
|
23
|
+
visible.delete(id);
|
|
24
|
+
for (const id of event.restored)
|
|
25
|
+
visible.add(id);
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return written.filter((stroke) => visible.has(stroke.id));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The events of a history given to the sheet (R27), each checked field by field and copied into
|
|
33
|
+
* frozen objects of the sheet's own, with only the fields the sheet keeps: others are passed over.
|
|
34
|
+
* A value that is not such a history is a TypeError that names the field. Each event must be one of
|
|
35
|
+
* the four, at a time from 0 that never goes back along the history; a stroke must have a new ID, a
|
|
36
|
+
* whole number from 1, and one point at least, each on the sheet, with a pressure from 0 to 1, its
|
|
37
|
+
* times never going back and none after the time it landed. Times are checked for their order
|
|
38
|
+
* alone: with several pointers at once (T35) strokes may overlap in time. Whether the actions keep
|
|
39
|
+
* the rules of the stack, the work on the sheet checks as it replays them (`Work.restore`).
|
|
40
|
+
*/
|
|
41
|
+
export function historyOf(value) {
|
|
42
|
+
if (!Array.isArray(value))
|
|
43
|
+
throw new TypeError(`history: a list of events, not ${shown(value)}`);
|
|
44
|
+
const ids = new Set();
|
|
45
|
+
let latest = 0;
|
|
46
|
+
// Array.from visits every index, a hole too, which map would pass over unchecked.
|
|
47
|
+
return Array.from(value, (item, n) => {
|
|
48
|
+
const path = `history[${n}]`;
|
|
49
|
+
const fields = record(item, path);
|
|
50
|
+
const t = timeOf(fields.t, `${path}.t`);
|
|
51
|
+
if (t < latest) {
|
|
52
|
+
throw new TypeError(`${path}.t: ${t}, earlier than the event before it, at ${latest}`);
|
|
53
|
+
}
|
|
54
|
+
latest = t;
|
|
55
|
+
switch (fields.kind) {
|
|
56
|
+
case "write": {
|
|
57
|
+
const stroke = strokeOf(fields.stroke, `${path}.stroke`);
|
|
58
|
+
if (ids.has(stroke.id)) {
|
|
59
|
+
throw new TypeError(`${path}.stroke.id: ${stroke.id}, the ID of a stroke written before`);
|
|
60
|
+
}
|
|
61
|
+
ids.add(stroke.id);
|
|
62
|
+
const end = stroke.points.at(-1)?.t ?? 0;
|
|
63
|
+
if (t < end) {
|
|
64
|
+
throw new TypeError(`${path}.t: ${t}, earlier than the stroke's last point, at ${end}`);
|
|
65
|
+
}
|
|
66
|
+
return frozen({ kind: "write", t, stroke });
|
|
67
|
+
}
|
|
68
|
+
case "erase":
|
|
69
|
+
return frozen({ kind: "erase", t, ids: idsOf(fields.ids, `${path}.ids`) });
|
|
70
|
+
case "clear":
|
|
71
|
+
return frozen({ kind: "clear", t, ids: idsOf(fields.ids, `${path}.ids`) });
|
|
72
|
+
case "undo":
|
|
73
|
+
return frozen({
|
|
74
|
+
kind: "undo",
|
|
75
|
+
t,
|
|
76
|
+
removed: idsOf(fields.removed, `${path}.removed`),
|
|
77
|
+
restored: idsOf(fields.restored, `${path}.restored`),
|
|
78
|
+
});
|
|
79
|
+
default:
|
|
80
|
+
throw new TypeError(`${path}.kind: "write", "erase", "clear" or "undo", not ${shown(fields.kind)}`);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* An event, frozen through: its stroke with its points, or its lists of IDs (R27). Whoever holds
|
|
86
|
+
* an event the sheet handed out cannot change the sheet by it.
|
|
87
|
+
*/
|
|
88
|
+
export function frozen(event) {
|
|
89
|
+
if (event.kind === "write") {
|
|
90
|
+
for (const point of event.stroke.points)
|
|
91
|
+
Object.freeze(point);
|
|
92
|
+
Object.freeze(event.stroke.points);
|
|
93
|
+
Object.freeze(event.stroke);
|
|
94
|
+
}
|
|
95
|
+
else if (event.kind === "undo") {
|
|
96
|
+
Object.freeze(event.removed);
|
|
97
|
+
Object.freeze(event.restored);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
Object.freeze(event.ids);
|
|
101
|
+
}
|
|
102
|
+
return Object.freeze(event);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Whether a value given as a history is the history the sheet has (R27): the very array, or a list
|
|
106
|
+
* whose every event says what the sheet's says, by the fields the sheet keeps — others are passed
|
|
107
|
+
* over. `undefined` is the empty history. It never throws: a value that is no history is simply not
|
|
108
|
+
* the same one.
|
|
109
|
+
*/
|
|
110
|
+
export function sameHistory(value, history) {
|
|
111
|
+
if (value === history)
|
|
112
|
+
return true;
|
|
113
|
+
if (value === undefined)
|
|
114
|
+
return history.length === 0;
|
|
115
|
+
if (!Array.isArray(value) || value.length !== history.length)
|
|
116
|
+
return false;
|
|
117
|
+
try {
|
|
118
|
+
return history.every((event, n) => sameEvent(value[n], event));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// A getter of the consumer's that throws: no history the sheet keeps.
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Whether a history is the beginning of another: shorter, and the same event for event. */
|
|
126
|
+
export function begins(history, of) {
|
|
127
|
+
return history.length < of.length && history.every((event, n) => sameEvent(of[n], event));
|
|
128
|
+
}
|
|
129
|
+
/** Whether a value says what an event of the sheet's history says, by the fields it keeps. */
|
|
130
|
+
export function sameEvent(value, event) {
|
|
131
|
+
if (value === event)
|
|
132
|
+
return true;
|
|
133
|
+
if (typeof value !== "object" || value === null)
|
|
134
|
+
return false;
|
|
135
|
+
const given = value;
|
|
136
|
+
if (given.kind !== event.kind || given.t !== event.t)
|
|
137
|
+
return false;
|
|
138
|
+
switch (event.kind) {
|
|
139
|
+
case "write":
|
|
140
|
+
return sameStroke(given.stroke, event.stroke);
|
|
141
|
+
case "erase":
|
|
142
|
+
case "clear":
|
|
143
|
+
return sameIds(given.ids, event.ids);
|
|
144
|
+
case "undo":
|
|
145
|
+
return sameIds(given.removed, event.removed) && sameIds(given.restored, event.restored);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function sameStroke(value, stroke) {
|
|
149
|
+
if (value === stroke)
|
|
150
|
+
return true;
|
|
151
|
+
if (typeof value !== "object" || value === null)
|
|
152
|
+
return false;
|
|
153
|
+
const given = value;
|
|
154
|
+
const points = given.points;
|
|
155
|
+
return (given.id === stroke.id &&
|
|
156
|
+
given.kind === stroke.kind &&
|
|
157
|
+
Array.isArray(points) &&
|
|
158
|
+
points.length === stroke.points.length &&
|
|
159
|
+
stroke.points.every((point, n) => samePoint(points[n], point)));
|
|
160
|
+
}
|
|
161
|
+
function samePoint(value, point) {
|
|
162
|
+
if (value === point)
|
|
163
|
+
return true;
|
|
164
|
+
if (typeof value !== "object" || value === null)
|
|
165
|
+
return false;
|
|
166
|
+
const given = value;
|
|
167
|
+
return given.x === point.x && given.y === point.y && given.t === point.t && given.p === point.p;
|
|
168
|
+
}
|
|
169
|
+
function sameIds(value, ids) {
|
|
170
|
+
return (value === ids ||
|
|
171
|
+
(Array.isArray(value) && value.length === ids.length && ids.every((id, n) => value[n] === id)));
|
|
172
|
+
}
|
|
173
|
+
function strokeOf(value, path) {
|
|
174
|
+
const fields = record(value, path);
|
|
175
|
+
const id = idOf(fields.id, `${path}.id`);
|
|
176
|
+
if (typeof fields.kind !== "string") {
|
|
177
|
+
throw new TypeError(`${path}.kind: the pointer's type, a string, not ${shown(fields.kind)}`);
|
|
178
|
+
}
|
|
179
|
+
if (!Array.isArray(fields.points)) {
|
|
180
|
+
throw new TypeError(`${path}.points: a list of points, not ${shown(fields.points)}`);
|
|
181
|
+
}
|
|
182
|
+
if (fields.points.length === 0) {
|
|
183
|
+
throw new TypeError(`${path}.points: a list of points, one at least, not an empty one`);
|
|
184
|
+
}
|
|
185
|
+
let latest = 0;
|
|
186
|
+
const points = Array.from(fields.points, (item, n) => {
|
|
187
|
+
const at = `${path}.points[${n}]`;
|
|
188
|
+
const point = record(item, at);
|
|
189
|
+
const x = within(point.x, `${at}.x`, 0, SHEET.width);
|
|
190
|
+
const y = within(point.y, `${at}.y`, 0, SHEET.height);
|
|
191
|
+
const t = timeOf(point.t, `${at}.t`);
|
|
192
|
+
if (t < latest) {
|
|
193
|
+
throw new TypeError(`${at}.t: ${t}, earlier than the point before it, at ${latest}`);
|
|
194
|
+
}
|
|
195
|
+
latest = t;
|
|
196
|
+
const p = within(point.p, `${at}.p`, 0, 1);
|
|
197
|
+
return { x, y, t, p };
|
|
198
|
+
});
|
|
199
|
+
return { id, kind: fields.kind, points };
|
|
200
|
+
}
|
|
201
|
+
function idsOf(value, path) {
|
|
202
|
+
if (!Array.isArray(value)) {
|
|
203
|
+
throw new TypeError(`${path}: a list of stroke IDs, not ${shown(value)}`);
|
|
204
|
+
}
|
|
205
|
+
return Array.from(value, (id, n) => idOf(id, `${path}[${n}]`));
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* A stroke's ID: a whole number from 1, and below the largest safe integer, so that the ID the
|
|
209
|
+
* sheet gives the next stroke is a whole number too.
|
|
210
|
+
*/
|
|
211
|
+
function idOf(value, path) {
|
|
212
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value + 1) || value < 1) {
|
|
213
|
+
throw new TypeError(`${path}: a stroke's ID, a whole number from 1 below 2^53 − 1, not ${shown(value)}`);
|
|
214
|
+
}
|
|
215
|
+
return value;
|
|
216
|
+
}
|
|
217
|
+
function timeOf(value, path) {
|
|
218
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
219
|
+
throw new TypeError(`${path}: the sheet's time in milliseconds, from 0, not ${shown(value)}`);
|
|
220
|
+
}
|
|
221
|
+
return value;
|
|
222
|
+
}
|
|
223
|
+
function within(value, path, least, most) {
|
|
224
|
+
if (typeof value !== "number" || !(value >= least && value <= most)) {
|
|
225
|
+
throw new TypeError(`${path}: a number from ${least} to ${most}, not ${shown(value)}`);
|
|
226
|
+
}
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type { Box } from "@altnovatrail/spec/canvas";
|
|
2
|
+
export type { Filling } from "./boxes.js";
|
|
3
|
+
export type { Crop, Frame } from "./crop.js";
|
|
4
|
+
export type { DemoState, WorkedExample } from "./example.js";
|
|
5
|
+
export type { GridKind } from "./grid.js";
|
|
6
|
+
export type { Group } from "./groups.js";
|
|
7
|
+
export { type HistoryEvent, replay } from "./history.js";
|
|
8
|
+
export type { InkPoint, Stroke } from "./ink.js";
|
|
9
|
+
export type { Manifest } from "./manifest.js";
|
|
10
|
+
export type { Anchor, CellPlace, CropNumbers, Mark, Marks, SheetSubmission, Submission, WorkbookSubmission, } from "./marks.js";
|
|
11
|
+
export { SHEET } from "./sheet.js";
|
|
12
|
+
export type { Canvas, Task } from "./task.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The DOM-free part of the canvas, the package's entry point @altnovatrail/canvas/core: plain
|
|
2
|
+
// TypeScript that runs the same in a browser and in Node, for the tests and the synthetic samples
|
|
3
|
+
// too (R05, R07). Importing it registers no element: the element is the other entry point. It
|
|
4
|
+
// gives the sheet's size, the types of the data the element takes and gives — the strokes and the
|
|
5
|
+
// history of writing, the task, the worked example and the manifest, how far the learner has gone
|
|
6
|
+
// through the example's demo (R42) and the context of a submission, of a sheet or of a workbook,
|
|
7
|
+
// all of spec's types (R73 spec, R74 spec), and the tutor's marks, the element's own until spec's
|
|
8
|
+
// contract has them — and the fold of a history into the strokes it leaves, so that a consumer
|
|
9
|
+
// reads a history it keeps without the DOM (R27). The code runs without the DOM, but spec's
|
|
10
|
+
// declarations of the element's contract, which its types come from, name the DOM's types too: a
|
|
11
|
+
// consumer without the DOM's types skips them with skipLibCheck (T69). The rules of the groups, the
|
|
12
|
+
// crop and the snapping stay inside until the eval protocol, which takes them as they are, comes
|
|
13
|
+
// out (epic E3 in spec's RUN.md; R31).
|
|
14
|
+
export { replay } from "./history.js";
|
|
15
|
+
export { SHEET } from "./sheet.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { InkPoint, Stroke } from "@altnovatrail/spec/canvas";
|
package/lib/core/ink.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// A point of a stroke and a stroke (R06, R09, R18, R27): spec's types, of the contract of canvas
|
|
2
|
+
// and app (R74 spec; R07) — where a point lies on the sheet, in logical units, when it was written
|
|
3
|
+
// and how hard the pointer pressed, and a stroke's ID on its sheet, its pointer's type and its
|
|
4
|
+
// points. Only a pen's pressure changes the width of the line (R05, R18).
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version of the module format the element reads: while the format is v0, the platform reads
|
|
3
|
+
* its current version alone, and a module of another is refused with the reason (R44 spec).
|
|
4
|
+
*/
|
|
5
|
+
export declare const FORMAT = "0.2";
|
|
6
|
+
/** The open view of a module's manifest, whole: spec's type (R73 spec). */
|
|
7
|
+
export type { Manifest } from "@altnovatrail/spec/formatv0/open";
|
|
8
|
+
/**
|
|
9
|
+
* What the element makes of a manifest: the height of a digit its crop for checking is drawn at,
|
|
10
|
+
* and the language of the statement — the element's own `lang`, inherited, for `undefined`.
|
|
11
|
+
*/
|
|
12
|
+
export interface Settings {
|
|
13
|
+
readonly digit: number;
|
|
14
|
+
readonly lang: string | undefined;
|
|
15
|
+
}
|
|
16
|
+
/** The settings without a manifest: digits of 24 px, and the element's own language. */
|
|
17
|
+
export declare const NO_MANIFEST: Settings;
|
|
18
|
+
/**
|
|
19
|
+
* The settings of a manifest, or of none for `undefined`. A field the element reads that the
|
|
20
|
+
* schema refuses is a TypeError that names it; `null` is no manifest, and a wrong value.
|
|
21
|
+
*/
|
|
22
|
+
export declare function settingsOf(manifest: unknown): Settings;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The module's manifest as the element reads it (R32): the open view of manifest.yaml, of which it
|
|
2
|
+
// takes three fields — the version of the module format, the height of a digit on the crop for
|
|
3
|
+
// checking and the explanation language, which the task's statement is in (R51 spec). The rest of
|
|
4
|
+
// the manifest it passes over: what a manifest may hold, module-kit checks. Its canvas settings are
|
|
5
|
+
// the canvas's own, though: a setting the element does not know is a TypeError, as a field of a
|
|
6
|
+
// task is, for the element would draw the module's canvas without it. The type is spec's, from
|
|
7
|
+
// its npm package at the version of the spec/ submodule (R73 spec; R41), and app gets the
|
|
8
|
+
// manifest's open view once for a module's package and gives it to the element as it is (R36 spec).
|
|
9
|
+
import { CROP } from "./crop.js";
|
|
10
|
+
import { closed, record, shown, whole } from "./fields.js";
|
|
11
|
+
/**
|
|
12
|
+
* The version of the module format the element reads: while the format is v0, the platform reads
|
|
13
|
+
* its current version alone, and a module of another is refused with the reason (R44 spec).
|
|
14
|
+
*/
|
|
15
|
+
export const FORMAT = "0.2";
|
|
16
|
+
/** The settings without a manifest: digits of 24 px, and the element's own language. */
|
|
17
|
+
export const NO_MANIFEST = Object.freeze({ digit: CROP.digit, lang: undefined });
|
|
18
|
+
/**
|
|
19
|
+
* A language tag of the format: a language, an optional script and an optional region, each in the
|
|
20
|
+
* letter case of the registry — en, zh-Hans, pt-BR, es-419 (language_tag).
|
|
21
|
+
*/
|
|
22
|
+
const LANGUAGE = /^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$/;
|
|
23
|
+
/**
|
|
24
|
+
* The settings of a manifest, or of none for `undefined`. A field the element reads that the
|
|
25
|
+
* schema refuses is a TypeError that names it; `null` is no manifest, and a wrong value.
|
|
26
|
+
*/
|
|
27
|
+
export function settingsOf(manifest) {
|
|
28
|
+
if (manifest === undefined)
|
|
29
|
+
return NO_MANIFEST;
|
|
30
|
+
const fields = record(manifest, "manifest");
|
|
31
|
+
if (fields.module_format_version !== FORMAT) {
|
|
32
|
+
throw new TypeError(`manifest.module_format_version: "${FORMAT}", the version of the module format the element reads, not ${shown(fields.module_format_version)}`);
|
|
33
|
+
}
|
|
34
|
+
const languages = record(fields.languages, "manifest.languages");
|
|
35
|
+
const lang = languages.explanation;
|
|
36
|
+
if (typeof lang !== "string" || !LANGUAGE.test(lang)) {
|
|
37
|
+
throw new TypeError(`manifest.languages.explanation: a language tag, as en, zh-Hans or pt-BR, in the letter case of the registry, not ${shown(lang)}`);
|
|
38
|
+
}
|
|
39
|
+
return { digit: digitOf(fields.canvas), lang };
|
|
40
|
+
}
|
|
41
|
+
/** The height of a digit a manifest's canvas settings give: 24 unless given. */
|
|
42
|
+
function digitOf(value) {
|
|
43
|
+
if (value === undefined)
|
|
44
|
+
return CROP.digit;
|
|
45
|
+
const canvas = closed(value, "manifest.canvas", ["digit_height"], "a module's canvas settings");
|
|
46
|
+
if (canvas.digit_height === undefined) {
|
|
47
|
+
throw new TypeError(`manifest.canvas: the settings of the module's canvas, at least one, not ${shown(value)}`);
|
|
48
|
+
}
|
|
49
|
+
return whole(canvas.digit_height, "manifest.canvas.digit_height", 1, CROP.tallest);
|
|
50
|
+
}
|