@webwriter/quiz 1.1.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/.github/workflows/publish.yml +8 -0
  2. package/LICENSE +6 -6
  3. package/README.md +778 -0
  4. package/assets/icons/grip-horizontal.svg +8 -0
  5. package/assets/icons/grip-vertical.svg +8 -0
  6. package/assets/icons/truefalse.svg +6 -0
  7. package/custom-elements.json +2779 -0
  8. package/custom.d.ts +180 -176
  9. package/dist/api/index.d.ts +27 -0
  10. package/dist/api/index.js +40 -0
  11. package/dist/migrate.js +1434 -0
  12. package/dist/widgets/webwriter-choice-item.js +4036 -3895
  13. package/dist/widgets/webwriter-choice.js +2269 -5821
  14. package/dist/widgets/webwriter-gap-item.js +3987 -0
  15. package/dist/widgets/webwriter-gap.js +2908 -0
  16. package/dist/widgets/webwriter-mark.js +4335 -3489
  17. package/dist/widgets/webwriter-order-item.js +4014 -3306
  18. package/dist/widgets/webwriter-order.js +2944 -4386
  19. package/dist/widgets/webwriter-pairing-item.js +1058 -2421
  20. package/dist/widgets/webwriter-pairing.js +2993 -4383
  21. package/dist/widgets/webwriter-quiz-hint.js +2469 -0
  22. package/dist/widgets/webwriter-quiz.js +4933 -6099
  23. package/dist/widgets/webwriter-speech.js +6077 -5230
  24. package/dist/widgets/webwriter-task-prompt.js +4775 -2165
  25. package/dist/widgets/webwriter-task.js +2364 -8382
  26. package/dist/widgets/webwriter-text.js +4169 -4337
  27. package/dist/widgets/webwriter-true-false.js +4674 -0
  28. package/editing-config.json +106 -0
  29. package/lit-localize.json +21 -15
  30. package/localization/de.xlf +407 -74
  31. package/localization/es.xlf +407 -69
  32. package/localization/fr.xlf +407 -69
  33. package/localization/generated/de.ts +112 -29
  34. package/localization/generated/es.ts +112 -29
  35. package/localization/generated/fr.ts +112 -29
  36. package/localization/generated/index.d.ts +5 -0
  37. package/localization/generated/index.js +2 -2
  38. package/localization/generated/it.ts +112 -29
  39. package/localization/generated/locale-codes.js +31 -79
  40. package/localization/generated/nl.ts +112 -29
  41. package/localization/it.xlf +407 -69
  42. package/localization/nl.xlf +407 -69
  43. package/package.json +119 -252
  44. package/scripts/apply-translations.mjs +97 -0
  45. package/src/api/index.ts +72 -0
  46. package/src/lib/artboard.ts +686 -0
  47. package/src/lib/draggable-grid.ts +432 -0
  48. package/src/lib/encrypted-property.ts +202 -0
  49. package/src/lib/hint-popup.ts +152 -0
  50. package/src/lib/utils.ts +97 -0
  51. package/src/lib/webwriter-quirks.ts +121 -0
  52. package/src/migrate/index.ts +622 -0
  53. package/src/migrate/legacy-solution.ts +362 -0
  54. package/src/snippets/order.html +9 -8
  55. package/src/widgets/choice/webwriter-choice-item.ts +316 -0
  56. package/src/widgets/choice/webwriter-choice.ts +328 -0
  57. package/src/widgets/choice/webwriter-true-false.ts +113 -0
  58. package/src/widgets/dnd/webwriter-dnd-draggable.ts +28 -0
  59. package/src/widgets/dnd/webwriter-dnd-dropzone.ts +9 -0
  60. package/src/widgets/dnd/webwriter-dnd.ts +36 -0
  61. package/src/widgets/gap/webwriter-gap-item.ts +250 -0
  62. package/src/widgets/gap/webwriter-gap.ts +447 -0
  63. package/src/widgets/order/webwriter-order-item.ts +243 -0
  64. package/src/widgets/order/webwriter-order.ts +282 -0
  65. package/src/widgets/pairing/webwriter-pairing-item.ts +46 -0
  66. package/src/widgets/pairing/webwriter-pairing.ts +934 -0
  67. package/src/widgets/speech/audio-player.ts +325 -0
  68. package/src/widgets/speech/audio-recorder.ts +285 -0
  69. package/src/widgets/speech/waveform.ts +113 -0
  70. package/src/widgets/speech/webwriter-speech.ts +87 -0
  71. package/src/widgets/webwriter-mark.ts +374 -432
  72. package/src/widgets/webwriter-quiz-hint.ts +36 -0
  73. package/src/widgets/webwriter-quiz.ts +320 -282
  74. package/src/widgets/webwriter-task-prompt.ts +49 -24
  75. package/src/widgets/webwriter-task.ts +216 -547
  76. package/src/widgets/webwriter-text.ts +291 -156
  77. package/tsconfig.api.json +12 -0
  78. package/tsconfig.json +8 -7
  79. package/dist/widgets/webwriter-choice.css +0 -444
  80. package/dist/widgets/webwriter-cloze-gap.css +0 -444
  81. package/dist/widgets/webwriter-cloze-gap.js +0 -5866
  82. package/dist/widgets/webwriter-cloze.css +0 -444
  83. package/dist/widgets/webwriter-cloze.js +0 -3786
  84. package/dist/widgets/webwriter-mark.css +0 -444
  85. package/dist/widgets/webwriter-order.css +0 -444
  86. package/dist/widgets/webwriter-pairing-item.css +0 -444
  87. package/dist/widgets/webwriter-pairing.css +0 -444
  88. package/dist/widgets/webwriter-quiz.css +0 -444
  89. package/dist/widgets/webwriter-task-explainer.css +0 -444
  90. package/dist/widgets/webwriter-task-explainer.js +0 -2872
  91. package/dist/widgets/webwriter-task-hint.css +0 -444
  92. package/dist/widgets/webwriter-task-hint.js +0 -2811
  93. package/dist/widgets/webwriter-task.css +0 -444
  94. package/dist/widgets/webwriter-text.css +0 -444
  95. package/localization/bg.xlf +0 -69
  96. package/localization/cs.xlf +0 -69
  97. package/localization/da.xlf +0 -69
  98. package/localization/el.xlf +0 -69
  99. package/localization/et.xlf +0 -69
  100. package/localization/fi.xlf +0 -69
  101. package/localization/generated/bg.ts +0 -30
  102. package/localization/generated/cs.ts +0 -30
  103. package/localization/generated/da.ts +0 -30
  104. package/localization/generated/el.ts +0 -30
  105. package/localization/generated/et.ts +0 -30
  106. package/localization/generated/fi.ts +0 -30
  107. package/localization/generated/hu.ts +0 -30
  108. package/localization/generated/id.ts +0 -30
  109. package/localization/generated/ja.ts +0 -30
  110. package/localization/generated/ko.ts +0 -30
  111. package/localization/generated/lt.ts +0 -30
  112. package/localization/generated/lv.ts +0 -30
  113. package/localization/generated/nb.ts +0 -30
  114. package/localization/generated/pl.ts +0 -30
  115. package/localization/generated/pt-BR.ts +0 -30
  116. package/localization/generated/pt-PT.ts +0 -30
  117. package/localization/generated/ro.ts +0 -30
  118. package/localization/generated/ru.ts +0 -30
  119. package/localization/generated/sk.ts +0 -30
  120. package/localization/generated/sl.ts +0 -30
  121. package/localization/generated/sv.ts +0 -30
  122. package/localization/generated/tr.ts +0 -30
  123. package/localization/generated/uk.ts +0 -30
  124. package/localization/generated/zh-hans.ts +0 -30
  125. package/localization/hu.xlf +0 -69
  126. package/localization/id.xlf +0 -69
  127. package/localization/ja.xlf +0 -69
  128. package/localization/ko.xlf +0 -69
  129. package/localization/lt.xlf +0 -69
  130. package/localization/lv.xlf +0 -69
  131. package/localization/nb.xlf +0 -69
  132. package/localization/pl.xlf +0 -69
  133. package/localization/pt-BR.xlf +0 -69
  134. package/localization/pt-PT.xlf +0 -69
  135. package/localization/ro.xlf +0 -69
  136. package/localization/ru.xlf +0 -69
  137. package/localization/sk.xlf +0 -69
  138. package/localization/sl.xlf +0 -69
  139. package/localization/sv.xlf +0 -69
  140. package/localization/tr.xlf +0 -69
  141. package/localization/uk.xlf +0 -69
  142. package/localization/zh-hans.xlf +0 -69
  143. package/src/lib/combobox.ts +0 -456
  144. package/src/lib/highlighter-fill.svg +0 -6
  145. package/src/snippets/Beispiel-Choice.html +0 -57
  146. package/src/snippets/Beispiel-Mark.html +0 -14
  147. package/src/snippets/Beispiel-Order.html +0 -52
  148. package/src/snippets/Beispiel-Text.html +0 -20
  149. package/src/snippets/blank.html +0 -1
  150. package/src/snippets/choice.html +0 -8
  151. package/src/snippets/cloze.html +0 -4
  152. package/src/snippets/mark.html +0 -4
  153. package/src/snippets/pairing.html +0 -10
  154. package/src/snippets/speech.html +0 -4
  155. package/src/snippets/text.html +0 -4
  156. package/src/snippets/wordsearch.html +0 -4
  157. package/src/widgets/webwriter-choice-item.ts +0 -254
  158. package/src/widgets/webwriter-choice.ts +0 -353
  159. package/src/widgets/webwriter-cloze-gap.ts +0 -216
  160. package/src/widgets/webwriter-cloze.ts +0 -136
  161. package/src/widgets/webwriter-order-item.ts +0 -441
  162. package/src/widgets/webwriter-order.ts +0 -327
  163. package/src/widgets/webwriter-pairing-item.ts +0 -161
  164. package/src/widgets/webwriter-pairing.ts +0 -193
  165. package/src/widgets/webwriter-speech.ts +0 -273
  166. package/src/widgets/webwriter-task-explainer.ts +0 -43
  167. package/src/widgets/webwriter-task-hint.ts +0 -23
@@ -0,0 +1,432 @@
1
+ import { css, html, LitElement } from "lit";
2
+ import { property, queryAssignedElements } from "lit/decorators.js";
3
+
4
+ declare global {
5
+ interface HTMLElementTagNameMap {
6
+ "draggable-grid": DraggableGrid;
7
+ }
8
+ }
9
+
10
+ /** Implemented by children that want a nested element to be animated instead of themselves. */
11
+ export interface DraggableGridItem {
12
+ get draggableElement(): HTMLElement;
13
+ }
14
+
15
+ export type DraggableGridReorderDetail = {
16
+ /** The child element that was dragged. */
17
+ item: HTMLElement;
18
+ /** The child element `item` should be inserted before, or `null` to place it last. */
19
+ before: HTMLElement | null;
20
+ /** IDs of the draggable children in their new order; non-draggable children are omitted. */
21
+ order: string[];
22
+ };
23
+
24
+ /** Fired after a drop settled on a position other than the one the drag started from. */
25
+ export class DraggableGridReorderEvent extends CustomEvent<DraggableGridReorderDetail> {
26
+ constructor(detail: DraggableGridReorderDetail) {
27
+ super("reorder", { detail });
28
+ }
29
+ }
30
+
31
+ type Point = { x: number; y: number };
32
+
33
+ type SnapshotItem = Point & {
34
+ draggable: boolean;
35
+ height: number;
36
+ };
37
+
38
+ /** Geometry of the grid and its children, measured once when a drag starts. */
39
+ type Snapshot = {
40
+ columnWidths: number[];
41
+ rowGap: number;
42
+ columnGap: number;
43
+ /** IDs of all children in DOM order, draggable or not. */
44
+ order: string[];
45
+ items: Map<string, SnapshotItem>;
46
+ };
47
+
48
+ type DragSession = {
49
+ /** `dragging` while the pointer is down, `settling` while the drop animation runs. */
50
+ phase: "dragging" | "settling";
51
+ id: string;
52
+ pointerId: number;
53
+ /** Top left corner of the dragged item relative to the grid, when the drag started. */
54
+ startCorner: Point;
55
+ /** Pointer position relative to the grid, when the drag started. */
56
+ startPointer: Point;
57
+ /** Latest pointer position, in client coordinates. */
58
+ pointer: Point;
59
+ /** Order the items are currently laid out in, and the one they settle into on drop. */
60
+ order: string[];
61
+ /** Dispatched once the drop animation finished, unless the order ended up unchanged. */
62
+ reorder: DraggableGridReorderDetail | null;
63
+ frame: number | null;
64
+ maxScrollY: number;
65
+ previousCursor: string;
66
+ };
67
+
68
+ /** Distance from the viewport edge at which auto scrolling kicks in, and its top speed. */
69
+ const AUTO_SCROLL_EDGE = 80;
70
+ const AUTO_SCROLL_SPEED = 18;
71
+ /** How far outside the grid the pointer may stray before auto scrolling stops. */
72
+ const AUTO_SCROLL_MARGIN = 120;
73
+
74
+ /**
75
+ * Walks the grid slot by slot for the given order, yielding the top-left corner of each
76
+ * slot. Emits `order.length + 1` positions: one per item plus the trailing slot, so it can
77
+ * be used both to lay out items and to hit-test insertion points. This is the single source
78
+ * of truth for the grid geometry.
79
+ */
80
+ function* slotPositions(
81
+ snapshot: Snapshot,
82
+ order: string[],
83
+ ): Generator<{ index: number; id: string | undefined; x: number; y: number }> {
84
+ const { columnWidths, columnGap, rowGap } = snapshot;
85
+ const columns = columnWidths.length;
86
+
87
+ let currentRowHeight = 0;
88
+ let x = 0;
89
+ let y = 0;
90
+
91
+ for (let i = 0; i <= order.length; i++) {
92
+ const id = order[i];
93
+ yield { index: i, id, x, y };
94
+
95
+ const item = id !== undefined ? snapshot.items.get(id) : undefined;
96
+ if (!item) continue;
97
+
98
+ currentRowHeight = Math.max(currentRowHeight, item.height);
99
+ x += columnGap + columnWidths[i % columns];
100
+ if ((i + 1) % columns === 0) {
101
+ x = 0;
102
+ y += currentRowHeight + rowGap;
103
+ currentRowHeight = 0;
104
+ }
105
+ }
106
+ }
107
+
108
+ function documentMaxScrollY() {
109
+ const documentHeight = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
110
+ return Math.max(0, documentHeight - window.innerHeight);
111
+ }
112
+
113
+ /** Pixels the page should scroll this frame while the pointer sits near a viewport edge. */
114
+ function autoScrollDelta(pointerY: number) {
115
+ if (pointerY < AUTO_SCROLL_EDGE) {
116
+ return -AUTO_SCROLL_SPEED * ((AUTO_SCROLL_EDGE - pointerY) / AUTO_SCROLL_EDGE);
117
+ }
118
+ const bottomEdge = window.innerHeight - AUTO_SCROLL_EDGE;
119
+ if (pointerY > bottomEdge) {
120
+ return AUTO_SCROLL_SPEED * ((pointerY - bottomEdge) / AUTO_SCROLL_EDGE);
121
+ }
122
+ return 0;
123
+ }
124
+
125
+ export class DraggableGrid extends LitElement {
126
+ static styles = css`
127
+ slot {
128
+ display: contents;
129
+ }
130
+ `;
131
+
132
+ private static readonly TRANSITION_MS = 200;
133
+ private static readonly TRANSITION = `transform ${DraggableGrid.TRANSITION_MS}ms cubic-bezier(0.2, 0, 0, 1)`;
134
+
135
+ private snapshot: Snapshot | null = null;
136
+ private session: DragSession | null = null;
137
+
138
+ @property({ type: String, attribute: true })
139
+ accessor dragHandleSelector: string = "[data-drag-handle]";
140
+
141
+ @property({ type: String, attribute: true })
142
+ accessor dragNoHandleSelector: string = "[data-drag-no-handle]";
143
+
144
+ @property({ type: Boolean, attribute: true })
145
+ accessor vertical: boolean = false;
146
+
147
+ @queryAssignedElements({ flatten: true })
148
+ private accessor slotElements!: HTMLElement[];
149
+
150
+ connectedCallback(): void {
151
+ super.connectedCallback();
152
+ this.addEventListener("pointerdown", this.onPointerDown);
153
+ this.addEventListener("pointermove", this.onPointerMove);
154
+ document.addEventListener("scroll", this.onScroll);
155
+ this.addEventListener("pointerup", this.onPointerUp);
156
+ this.addEventListener("pointercancel", this.onPointerUp);
157
+ this.addEventListener("lostpointercapture", this.onPointerUp);
158
+ }
159
+
160
+ disconnectedCallback(): void {
161
+ super.disconnectedCallback();
162
+ this.removeEventListener("pointerdown", this.onPointerDown);
163
+ this.removeEventListener("pointermove", this.onPointerMove);
164
+ document.removeEventListener("scroll", this.onScroll);
165
+ this.removeEventListener("pointerup", this.onPointerUp);
166
+ this.removeEventListener("pointercancel", this.onPointerUp);
167
+ this.removeEventListener("lostpointercapture", this.onPointerUp);
168
+ }
169
+
170
+ render() {
171
+ return html`<slot></slot>`;
172
+ }
173
+
174
+ // Since we are working with ProseMirror, we always have to freshly resolve elements based
175
+ // on their ID, as ProseMirror may have replaced the element in the DOM. We cannot rely on
176
+ // elements cached in the snapshot or the drag session.
177
+
178
+ private childById(id: string): HTMLElement | undefined {
179
+ return this.slotElements.find(el => el.id === id);
180
+ }
181
+
182
+ /** The element that is transformed for a child: either a nested one it provides, or itself. */
183
+ private animatedElement(id: string): HTMLElement {
184
+ const child = this.childById(id);
185
+ return child && "draggableElement" in child ? (child as DraggableGridItem).draggableElement : child!;
186
+ }
187
+
188
+ private measure(): Snapshot {
189
+ const style = window.getComputedStyle(this);
190
+ const rect = this.getBoundingClientRect();
191
+ const snapshot: Snapshot = {
192
+ columnWidths: this.vertical ? [rect.width] : style.gridTemplateColumns.split(" ").map(parseFloat),
193
+ rowGap: parseFloat(style.rowGap.replace("normal", "0")),
194
+ columnGap: parseFloat(style.columnGap.replace("normal", "0")),
195
+ order: [],
196
+ items: new Map(),
197
+ };
198
+
199
+ for (const child of this.slotElements) {
200
+ if (!child.id) {
201
+ console.warn("DraggableGrid: child element is missing an id", child);
202
+ continue;
203
+ }
204
+ const itemRect = this.animatedElement(child.id).getBoundingClientRect();
205
+ snapshot.items.set(child.id, {
206
+ draggable: !child.hasAttribute("data-not-draggable"),
207
+ height: itemRect.height,
208
+ x: itemRect.x - rect.x,
209
+ y: itemRect.y - rect.y,
210
+ });
211
+ snapshot.order.push(child.id);
212
+ }
213
+
214
+ return snapshot;
215
+ }
216
+
217
+ /** Moves every item to the slot it occupies in `order`, except the one under the pointer. */
218
+ private layout(order: string[]) {
219
+ const snapshot = this.snapshot;
220
+ if (!snapshot) return;
221
+ const draggedId = this.session?.phase === "dragging" ? this.session.id : undefined;
222
+
223
+ for (const { id, x, y } of slotPositions(snapshot, order)) {
224
+ if (id === undefined || id === draggedId) continue;
225
+ const item = snapshot.items.get(id);
226
+ if (!item) continue; // This should not happen
227
+ this.animatedElement(id).style.transform = `translate(${x - item.x}px, ${y - item.y}px)`;
228
+ }
229
+ }
230
+
231
+ private onPointerDown = (event: PointerEvent) => {
232
+ if (this.session) return;
233
+
234
+ const path = event.composedPath();
235
+ if (!path.find(el => el instanceof HTMLElement && el.matches(this.dragHandleSelector))) return;
236
+ if (path.find(el => el instanceof HTMLElement && el.matches(this.dragNoHandleSelector))) return;
237
+
238
+ const child = path.find(
239
+ el => el instanceof HTMLElement && this.slotElements.includes(el) && !el.hasAttribute("data-not-draggable"),
240
+ ) as HTMLElement | undefined;
241
+ if (!child?.id) return;
242
+ const element = this.animatedElement(child.id);
243
+ if (!element) return;
244
+
245
+ event.preventDefault();
246
+
247
+ this.snapshot = this.measure();
248
+ for (const id of this.snapshot.order) this.animatedElement(id).style.transition = DraggableGrid.TRANSITION;
249
+
250
+ element.style.transition = "none";
251
+ element.style.zIndex = "1000";
252
+ element.classList.add("ww-dragging");
253
+
254
+ const rect = this.getBoundingClientRect();
255
+ const itemRect = element.getBoundingClientRect();
256
+ this.session = {
257
+ phase: "dragging",
258
+ id: child.id,
259
+ pointerId: event.pointerId,
260
+ startCorner: { x: itemRect.left - rect.left, y: itemRect.top - rect.top },
261
+ startPointer: { x: event.clientX - rect.left, y: event.clientY - rect.top },
262
+ pointer: { x: event.clientX, y: event.clientY },
263
+ order: [...this.snapshot.order],
264
+ reorder: null,
265
+ frame: null,
266
+ maxScrollY: documentMaxScrollY(),
267
+ previousCursor: document.documentElement.style.cursor,
268
+ };
269
+
270
+ document.documentElement.style.cursor = "grabbing";
271
+ this.setPointerCapture(event.pointerId);
272
+ };
273
+
274
+ private onPointerMove = (event: PointerEvent) => {
275
+ const session = this.session;
276
+ if (session?.phase !== "dragging" || event.pointerId !== session.pointerId) return;
277
+ event.preventDefault();
278
+ session.pointer = { x: event.clientX, y: event.clientY };
279
+ this.scheduleFrame();
280
+ };
281
+
282
+ private onPointerUp = (event: PointerEvent) => {
283
+ const session = this.session;
284
+ if (session?.phase !== "dragging" || event.pointerId !== session.pointerId) return;
285
+ event.preventDefault();
286
+
287
+ this.cancelFrame();
288
+ session.phase = "settling";
289
+ this.animatedElement(session.id).style.transition = DraggableGrid.TRANSITION;
290
+ this.layout(session.order);
291
+
292
+ try {
293
+ this.releasePointerCapture(event.pointerId);
294
+ } catch {
295
+ // Pointer capture may already be gone after a lostpointercapture event.
296
+ }
297
+
298
+ setTimeout(() => this.endDrag(session), DraggableGrid.TRANSITION_MS);
299
+ };
300
+
301
+ private onScroll = () => {
302
+ this.scheduleFrame();
303
+ };
304
+
305
+ /** Drops all drag styling once the settle animation finished and reports the new order. */
306
+ private endDrag(session: DragSession) {
307
+ if (this.session !== session) return;
308
+ this.session = null;
309
+ this.snapshot = null;
310
+
311
+ for (const child of this.slotElements) {
312
+ const element = this.animatedElement(child.id);
313
+ element.style.transition = "";
314
+ element.style.transform = "";
315
+ element.style.zIndex = "";
316
+ element.classList.remove("ww-dragging");
317
+ }
318
+ document.documentElement.style.cursor = session.previousCursor;
319
+
320
+ if (session.reorder) this.dispatchEvent(new DraggableGridReorderEvent(session.reorder));
321
+ }
322
+
323
+ private scheduleFrame() {
324
+ const session = this.session;
325
+ if (session?.phase !== "dragging" || session.frame !== null) return;
326
+ session.frame = requestAnimationFrame(() => this.runFrame());
327
+ }
328
+
329
+ private cancelFrame() {
330
+ const session = this.session;
331
+ if (!session || session.frame === null) return;
332
+ cancelAnimationFrame(session.frame);
333
+ session.frame = null;
334
+ }
335
+
336
+ private runFrame() {
337
+ const session = this.session;
338
+ if (!session) return;
339
+ session.frame = null;
340
+ if (session.phase !== "dragging") return;
341
+
342
+ const rect = this.getBoundingClientRect();
343
+ const deltaX = this.vertical ? 0 : session.pointer.x - rect.left - session.startPointer.x;
344
+ const deltaY = session.pointer.y - rect.top - session.startPointer.y;
345
+ this.animatedElement(session.id).style.transform = `translate(${deltaX}px, ${deltaY}px)`;
346
+
347
+ this.updateDropTarget({ x: session.startCorner.x + deltaX, y: session.startCorner.y + deltaY });
348
+
349
+ // Auto scrolling moves the pointer relative to the grid, so keep the loop running.
350
+ if (this.autoScroll()) this.scheduleFrame();
351
+ }
352
+
353
+ private autoScroll(): boolean {
354
+ const session = this.session;
355
+ if (!session) return false;
356
+
357
+ const pointerY = session.pointer.y;
358
+ const rect = this.getBoundingClientRect();
359
+ if (pointerY < rect.top - AUTO_SCROLL_MARGIN || pointerY > rect.bottom + AUTO_SCROLL_MARGIN) return false;
360
+
361
+ const delta = autoScrollDelta(pointerY);
362
+ if (delta === 0) return false;
363
+
364
+ const nextScrollY = Math.min(session.maxScrollY, Math.max(0, window.scrollY + delta));
365
+ if (nextScrollY === window.scrollY) return false;
366
+
367
+ window.scrollTo(window.scrollX, nextScrollY);
368
+ return true;
369
+ }
370
+
371
+ /** Picks the slot closest to the dragged item and animates the others out of its way. */
372
+ private updateDropTarget(target: Point) {
373
+ const session = this.session;
374
+ const snapshot = this.snapshot;
375
+ if (!session || !snapshot) return;
376
+
377
+ // A single draggable item cannot be reordered.
378
+ const isDraggable = (id: string) => snapshot.items.get(id)?.draggable ?? false;
379
+ if (!snapshot.order.some(id => id !== session.id && isDraggable(id))) {
380
+ session.order = snapshot.order;
381
+ session.reorder = null;
382
+ return;
383
+ }
384
+
385
+ const others = snapshot.order.filter(id => id !== session.id);
386
+ const insertIndex = this.findInsertIndex(others, target);
387
+ if (insertIndex === -1) return;
388
+
389
+ const order = others.toSpliced(insertIndex, 0, session.id);
390
+ this.layout(order);
391
+ session.order = order;
392
+ session.reorder = order.every((id, index) => id === snapshot.order[index]) ? null : this.buildReorder(order);
393
+ }
394
+
395
+ /** Index in `order` at which the dragged item would land, or -1 if there is no valid slot. */
396
+ private findInsertIndex(order: string[], target: Point): number {
397
+ const snapshot = this.snapshot!;
398
+ const isDraggable = (index: number) =>
399
+ index >= 0 && index < order.length && (snapshot.items.get(order[index])?.draggable ?? false);
400
+
401
+ let bestIndex = -1;
402
+ let bestDistance = Infinity;
403
+
404
+ for (const { index, x, y } of slotPositions(snapshot, order)) {
405
+ // Prevent dropping between two non-draggable items
406
+ if (!isDraggable(index - 1) && !isDraggable(index)) continue;
407
+
408
+ const distance = Math.hypot(x - target.x, y - target.y);
409
+ if (distance < bestDistance) {
410
+ bestDistance = distance;
411
+ bestIndex = index;
412
+ }
413
+ }
414
+
415
+ return bestIndex;
416
+ }
417
+
418
+ private buildReorder(order: string[]): DraggableGridReorderDetail | null {
419
+ const snapshot = this.snapshot!;
420
+ const item = this.childById(this.session!.id);
421
+ if (!item) return null;
422
+
423
+ const following = order.slice(order.indexOf(item.id) + 1);
424
+ const anchorId = following.find(id => snapshot.items.get(id)?.draggable);
425
+
426
+ return {
427
+ item,
428
+ before: (anchorId !== undefined && this.childById(anchorId)) || null,
429
+ order: order.filter(id => snapshot.items.get(id)?.draggable),
430
+ };
431
+ }
432
+ }
@@ -0,0 +1,202 @@
1
+ import {
2
+ defaultConverter,
3
+ type ComplexAttributeConverter,
4
+ type ReactiveElement,
5
+ } from "lit";
6
+
7
+ const VERSION = 3;
8
+ const KEY_LENGTH = 16;
9
+ const LENGTH_LENGTH = 4;
10
+ const MIN_PADDING_LENGTH = 2;
11
+ const PADDING_BLOCK_LENGTH = 8;
12
+ const HEADER_LENGTH = 1 + KEY_LENGTH;
13
+ const encoder = new TextEncoder();
14
+ const decoder = new TextDecoder("utf-8", { fatal: true });
15
+
16
+ function randomBytes(length: number) {
17
+ const bytes = new Uint8Array(length);
18
+
19
+ if (globalThis.crypto?.getRandomValues) {
20
+ return globalThis.crypto.getRandomValues(bytes);
21
+ }
22
+
23
+ // This converter only obfuscates values, so Math.random is an acceptable
24
+ // fallback for environments without Web Crypto.
25
+ for (let index = 0; index < length; index++) {
26
+ bytes[index] = Math.floor(Math.random() * 256);
27
+ }
28
+
29
+ return bytes;
30
+ }
31
+
32
+ function paddedLength(plaintextLength: number) {
33
+ const minimumLength = LENGTH_LENGTH + plaintextLength + MIN_PADDING_LENGTH;
34
+ const roundedLength =
35
+ Math.ceil(minimumLength / PADDING_BLOCK_LENGTH) * PADDING_BLOCK_LENGTH;
36
+
37
+ // An occasional extra block prevents equal plaintext lengths from always
38
+ // producing equal attribute lengths.
39
+ return roundedLength + (randomBytes(1)[0] % 2) * PADDING_BLOCK_LENGTH;
40
+ }
41
+
42
+ function applyKeystream(bytes: Uint8Array, key: Uint8Array) {
43
+ const result = new Uint8Array(bytes.length);
44
+ const state = new Uint32Array(4);
45
+ const keyView = new DataView(key.buffer, key.byteOffset, key.byteLength);
46
+
47
+ for (let index = 0; index < state.length; index++) {
48
+ state[index] = keyView.getUint32(index * 4);
49
+ }
50
+
51
+ if (state.every((word) => word === 0)) {
52
+ state[0] = 1;
53
+ }
54
+
55
+ for (let index = 0; index < bytes.length; index++) {
56
+ // xorshift128 produces a deterministic, non-repeating-looking byte
57
+ // stream from the full random key.
58
+ let next = state[3];
59
+ const first = state[0];
60
+ state[3] = state[2];
61
+ state[2] = state[1];
62
+ state[1] = first;
63
+ next ^= next << 11;
64
+ next ^= next >>> 8;
65
+ state[0] = next ^ first ^ (first >>> 19);
66
+ result[index] = bytes[index] ^ state[0];
67
+ }
68
+
69
+ return result;
70
+ }
71
+
72
+ function toBase64(bytes: Uint8Array) {
73
+ let binary = "";
74
+
75
+ for (const byte of bytes) {
76
+ binary += String.fromCharCode(byte);
77
+ }
78
+
79
+ return btoa(binary);
80
+ }
81
+
82
+ function fromBase64(value: string) {
83
+ const binary = atob(value);
84
+ const bytes = new Uint8Array(binary.length);
85
+
86
+ for (let index = 0; index < binary.length; index++) {
87
+ bytes[index] = binary.charCodeAt(index);
88
+ }
89
+
90
+ return bytes;
91
+ }
92
+
93
+ function decryptAttribute(value: string) {
94
+ const payload = fromBase64(value);
95
+
96
+ if (payload[0] !== VERSION || payload.length < HEADER_LENGTH + LENGTH_LENGTH) {
97
+ throw new Error("Not an encrypted property");
98
+ }
99
+
100
+ const key = payload.subarray(1, HEADER_LENGTH);
101
+ const framedPlaintext = applyKeystream(payload.subarray(HEADER_LENGTH), key);
102
+ const plaintextLength = new DataView(
103
+ framedPlaintext.buffer,
104
+ framedPlaintext.byteOffset,
105
+ framedPlaintext.byteLength,
106
+ ).getUint32(0);
107
+
108
+ if (plaintextLength > framedPlaintext.length - LENGTH_LENGTH) {
109
+ throw new Error("Invalid encrypted property");
110
+ }
111
+
112
+ return JSON.parse(
113
+ decoder.decode(
114
+ framedPlaintext.subarray(LENGTH_LENGTH, LENGTH_LENGTH + plaintextLength),
115
+ ),
116
+ ) as unknown;
117
+ }
118
+
119
+ export function isEncryptedProperty(value: string | null) {
120
+ if (value === null) {
121
+ return false;
122
+ }
123
+
124
+ try {
125
+ decryptAttribute(value);
126
+ return true;
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * A Lit property converter that keeps plainly readable values out of HTML.
134
+ *
135
+ * The random key is stored alongside the encrypted value, so this deliberately
136
+ * provides obfuscation rather than security. Plain attributes remain valid for
137
+ * convenient authoring and are encrypted the next time the property reflects.
138
+ */
139
+ export const encryptedProperty: ComplexAttributeConverter<unknown> = {
140
+ toAttribute(value) {
141
+ if (value == null) {
142
+ return value;
143
+ }
144
+
145
+ const plaintext = encoder.encode(JSON.stringify(value));
146
+ const framedPlaintext = randomBytes(paddedLength(plaintext.length));
147
+ new DataView(framedPlaintext.buffer).setUint32(0, plaintext.length);
148
+ framedPlaintext.set(plaintext, LENGTH_LENGTH);
149
+
150
+ const key = randomBytes(KEY_LENGTH);
151
+ const ciphertext = applyKeystream(framedPlaintext, key);
152
+ const payload = new Uint8Array(HEADER_LENGTH + ciphertext.length);
153
+ payload[0] = VERSION;
154
+ payload.set(key, 1);
155
+ payload.set(ciphertext, HEADER_LENGTH);
156
+
157
+ return toBase64(payload);
158
+ },
159
+
160
+ fromAttribute(value, type) {
161
+ if (value == null) {
162
+ return null;
163
+ }
164
+
165
+ try {
166
+ return decryptAttribute(value);
167
+ } catch {
168
+ return defaultConverter.fromAttribute?.(value, type);
169
+ }
170
+ },
171
+ };
172
+
173
+ /**
174
+ * Encrypts human-authored plaintext attributes for properties using
175
+ * `encryptedProperty`. Call this once from the element's `firstUpdated()`.
176
+ */
177
+ export function encryptPlaintextAttributes(host: ReactiveElement) {
178
+ const constructor = host.constructor as typeof ReactiveElement;
179
+
180
+ for (const [property, options] of constructor.elementProperties) {
181
+ if (options.converter !== encryptedProperty || options.attribute === false) {
182
+ continue;
183
+ }
184
+
185
+ const attribute =
186
+ typeof options.attribute === "string"
187
+ ? options.attribute
188
+ : String(property).toLowerCase();
189
+ const currentValue = host.getAttribute(attribute);
190
+
191
+ if (currentValue !== null && !isEncryptedProperty(currentValue)) {
192
+ const encryptedValue = encryptedProperty.toAttribute?.(
193
+ host[property as keyof ReactiveElement],
194
+ options.type,
195
+ );
196
+
197
+ if (typeof encryptedValue === "string") {
198
+ host.setAttribute(attribute, encryptedValue);
199
+ }
200
+ }
201
+ }
202
+ }