@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.
- package/.github/workflows/publish.yml +8 -0
- package/LICENSE +6 -6
- package/README.md +778 -0
- package/assets/icons/grip-horizontal.svg +8 -0
- package/assets/icons/grip-vertical.svg +8 -0
- package/assets/icons/truefalse.svg +6 -0
- package/custom-elements.json +2779 -0
- package/custom.d.ts +180 -176
- package/dist/api/index.d.ts +27 -0
- package/dist/api/index.js +40 -0
- package/dist/migrate.js +1434 -0
- package/dist/widgets/webwriter-choice-item.js +4036 -3895
- package/dist/widgets/webwriter-choice.js +2269 -5821
- package/dist/widgets/webwriter-gap-item.js +3987 -0
- package/dist/widgets/webwriter-gap.js +2908 -0
- package/dist/widgets/webwriter-mark.js +4335 -3489
- package/dist/widgets/webwriter-order-item.js +4014 -3306
- package/dist/widgets/webwriter-order.js +2944 -4386
- package/dist/widgets/webwriter-pairing-item.js +1058 -2421
- package/dist/widgets/webwriter-pairing.js +2993 -4383
- package/dist/widgets/webwriter-quiz-hint.js +2469 -0
- package/dist/widgets/webwriter-quiz.js +4933 -6099
- package/dist/widgets/webwriter-speech.js +6077 -5230
- package/dist/widgets/webwriter-task-prompt.js +4775 -2165
- package/dist/widgets/webwriter-task.js +2364 -8382
- package/dist/widgets/webwriter-text.js +4169 -4337
- package/dist/widgets/webwriter-true-false.js +4674 -0
- package/editing-config.json +106 -0
- package/lit-localize.json +21 -15
- package/localization/de.xlf +407 -74
- package/localization/es.xlf +407 -69
- package/localization/fr.xlf +407 -69
- package/localization/generated/de.ts +112 -29
- package/localization/generated/es.ts +112 -29
- package/localization/generated/fr.ts +112 -29
- package/localization/generated/index.d.ts +5 -0
- package/localization/generated/index.js +2 -2
- package/localization/generated/it.ts +112 -29
- package/localization/generated/locale-codes.js +31 -79
- package/localization/generated/nl.ts +112 -29
- package/localization/it.xlf +407 -69
- package/localization/nl.xlf +407 -69
- package/package.json +119 -252
- package/scripts/apply-translations.mjs +97 -0
- package/src/api/index.ts +72 -0
- package/src/lib/artboard.ts +686 -0
- package/src/lib/draggable-grid.ts +432 -0
- package/src/lib/encrypted-property.ts +202 -0
- package/src/lib/hint-popup.ts +152 -0
- package/src/lib/utils.ts +97 -0
- package/src/lib/webwriter-quirks.ts +121 -0
- package/src/migrate/index.ts +622 -0
- package/src/migrate/legacy-solution.ts +362 -0
- package/src/snippets/order.html +9 -8
- package/src/widgets/choice/webwriter-choice-item.ts +316 -0
- package/src/widgets/choice/webwriter-choice.ts +328 -0
- package/src/widgets/choice/webwriter-true-false.ts +113 -0
- package/src/widgets/dnd/webwriter-dnd-draggable.ts +28 -0
- package/src/widgets/dnd/webwriter-dnd-dropzone.ts +9 -0
- package/src/widgets/dnd/webwriter-dnd.ts +36 -0
- package/src/widgets/gap/webwriter-gap-item.ts +250 -0
- package/src/widgets/gap/webwriter-gap.ts +447 -0
- package/src/widgets/order/webwriter-order-item.ts +243 -0
- package/src/widgets/order/webwriter-order.ts +282 -0
- package/src/widgets/pairing/webwriter-pairing-item.ts +46 -0
- package/src/widgets/pairing/webwriter-pairing.ts +934 -0
- package/src/widgets/speech/audio-player.ts +325 -0
- package/src/widgets/speech/audio-recorder.ts +285 -0
- package/src/widgets/speech/waveform.ts +113 -0
- package/src/widgets/speech/webwriter-speech.ts +87 -0
- package/src/widgets/webwriter-mark.ts +374 -432
- package/src/widgets/webwriter-quiz-hint.ts +36 -0
- package/src/widgets/webwriter-quiz.ts +320 -282
- package/src/widgets/webwriter-task-prompt.ts +49 -24
- package/src/widgets/webwriter-task.ts +216 -547
- package/src/widgets/webwriter-text.ts +291 -156
- package/tsconfig.api.json +12 -0
- package/tsconfig.json +8 -7
- package/dist/widgets/webwriter-choice.css +0 -444
- package/dist/widgets/webwriter-cloze-gap.css +0 -444
- package/dist/widgets/webwriter-cloze-gap.js +0 -5866
- package/dist/widgets/webwriter-cloze.css +0 -444
- package/dist/widgets/webwriter-cloze.js +0 -3786
- package/dist/widgets/webwriter-mark.css +0 -444
- package/dist/widgets/webwriter-order.css +0 -444
- package/dist/widgets/webwriter-pairing-item.css +0 -444
- package/dist/widgets/webwriter-pairing.css +0 -444
- package/dist/widgets/webwriter-quiz.css +0 -444
- package/dist/widgets/webwriter-task-explainer.css +0 -444
- package/dist/widgets/webwriter-task-explainer.js +0 -2872
- package/dist/widgets/webwriter-task-hint.css +0 -444
- package/dist/widgets/webwriter-task-hint.js +0 -2811
- package/dist/widgets/webwriter-task.css +0 -444
- package/dist/widgets/webwriter-text.css +0 -444
- package/localization/bg.xlf +0 -69
- package/localization/cs.xlf +0 -69
- package/localization/da.xlf +0 -69
- package/localization/el.xlf +0 -69
- package/localization/et.xlf +0 -69
- package/localization/fi.xlf +0 -69
- package/localization/generated/bg.ts +0 -30
- package/localization/generated/cs.ts +0 -30
- package/localization/generated/da.ts +0 -30
- package/localization/generated/el.ts +0 -30
- package/localization/generated/et.ts +0 -30
- package/localization/generated/fi.ts +0 -30
- package/localization/generated/hu.ts +0 -30
- package/localization/generated/id.ts +0 -30
- package/localization/generated/ja.ts +0 -30
- package/localization/generated/ko.ts +0 -30
- package/localization/generated/lt.ts +0 -30
- package/localization/generated/lv.ts +0 -30
- package/localization/generated/nb.ts +0 -30
- package/localization/generated/pl.ts +0 -30
- package/localization/generated/pt-BR.ts +0 -30
- package/localization/generated/pt-PT.ts +0 -30
- package/localization/generated/ro.ts +0 -30
- package/localization/generated/ru.ts +0 -30
- package/localization/generated/sk.ts +0 -30
- package/localization/generated/sl.ts +0 -30
- package/localization/generated/sv.ts +0 -30
- package/localization/generated/tr.ts +0 -30
- package/localization/generated/uk.ts +0 -30
- package/localization/generated/zh-hans.ts +0 -30
- package/localization/hu.xlf +0 -69
- package/localization/id.xlf +0 -69
- package/localization/ja.xlf +0 -69
- package/localization/ko.xlf +0 -69
- package/localization/lt.xlf +0 -69
- package/localization/lv.xlf +0 -69
- package/localization/nb.xlf +0 -69
- package/localization/pl.xlf +0 -69
- package/localization/pt-BR.xlf +0 -69
- package/localization/pt-PT.xlf +0 -69
- package/localization/ro.xlf +0 -69
- package/localization/ru.xlf +0 -69
- package/localization/sk.xlf +0 -69
- package/localization/sl.xlf +0 -69
- package/localization/sv.xlf +0 -69
- package/localization/tr.xlf +0 -69
- package/localization/uk.xlf +0 -69
- package/localization/zh-hans.xlf +0 -69
- package/src/lib/combobox.ts +0 -456
- package/src/lib/highlighter-fill.svg +0 -6
- package/src/snippets/Beispiel-Choice.html +0 -57
- package/src/snippets/Beispiel-Mark.html +0 -14
- package/src/snippets/Beispiel-Order.html +0 -52
- package/src/snippets/Beispiel-Text.html +0 -20
- package/src/snippets/blank.html +0 -1
- package/src/snippets/choice.html +0 -8
- package/src/snippets/cloze.html +0 -4
- package/src/snippets/mark.html +0 -4
- package/src/snippets/pairing.html +0 -10
- package/src/snippets/speech.html +0 -4
- package/src/snippets/text.html +0 -4
- package/src/snippets/wordsearch.html +0 -4
- package/src/widgets/webwriter-choice-item.ts +0 -254
- package/src/widgets/webwriter-choice.ts +0 -353
- package/src/widgets/webwriter-cloze-gap.ts +0 -216
- package/src/widgets/webwriter-cloze.ts +0 -136
- package/src/widgets/webwriter-order-item.ts +0 -441
- package/src/widgets/webwriter-order.ts +0 -327
- package/src/widgets/webwriter-pairing-item.ts +0 -161
- package/src/widgets/webwriter-pairing.ts +0 -193
- package/src/widgets/webwriter-speech.ts +0 -273
- package/src/widgets/webwriter-task-explainer.ts +0 -43
- 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
|
+
}
|