@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,686 @@
1
+ import { msg } from "@lit/localize";
2
+ import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js";
3
+ import { ActionDeclaration, LitElementWw, OptionDeclaration } from "@webwriter/lit";
4
+ import { css, html, nothing, PropertyValues } from "lit";
5
+ import { property, queryAll } from "lit/decorators.js";
6
+ import { createRef, ref } from "lit/directives/ref.js";
7
+ import { styleMap } from "lit/directives/style-map.js";
8
+
9
+ type Geometry = { x: number; y: number; width: number; height: number };
10
+ type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
11
+
12
+ const CLICK_SLOP = 4;
13
+ const RESIZE_DIRECTIONS: ResizeDirection[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
14
+
15
+ export abstract class ArtboardContainerElement extends LitElementWw {
16
+ static shadowRootOptions: ShadowRootInit = {
17
+ ...LitElementWw.shadowRootOptions,
18
+ slotAssignment: "manual",
19
+ };
20
+
21
+ static scopedElements = {
22
+ "sl-button": SlButton,
23
+ };
24
+
25
+ static styles = css`
26
+ .artboard-container {
27
+ border: 1px solid var(--sl-color-neutral-300);
28
+ box-sizing: border-box;
29
+ width: 100%;
30
+ border-radius: var(--sl-border-radius-medium);
31
+ overflow: hidden;
32
+ }
33
+
34
+ .file-input {
35
+ display: none;
36
+ }
37
+
38
+ header {
39
+ padding: var(--sl-spacing-x-small);
40
+ border-bottom: 1px solid var(--sl-color-neutral-300);
41
+ }
42
+
43
+ .background-chooser {
44
+ background-color: var(--sl-color-neutral-100);
45
+ aspect-ratio: 16 / 9;
46
+ display: flex;
47
+ align-items: center;
48
+ justify-content: center;
49
+ }
50
+
51
+ .background {
52
+ display: block;
53
+ width: 100%;
54
+ pointer-events: none;
55
+ }
56
+
57
+ .artboard {
58
+ container-type: inline-size;
59
+ position: relative;
60
+ touch-action: none;
61
+ overflow: hidden;
62
+ }
63
+
64
+ .overlay {
65
+ position: absolute;
66
+ z-index: 100;
67
+ inset: 0;
68
+ }
69
+
70
+ .item {
71
+ position: absolute;
72
+ }
73
+
74
+ .item.editing {
75
+ z-index: 101;
76
+ }
77
+
78
+ .marquee,
79
+ .selection-box {
80
+ position: absolute;
81
+ box-sizing: border-box;
82
+ border: var(--sl-input-border-width) solid var(--sl-color-primary-600);
83
+ pointer-events: none;
84
+ }
85
+
86
+ .marquee {
87
+ z-index: 200;
88
+ background: color-mix(in srgb, var(--sl-color-primary-600) 12%, transparent);
89
+ }
90
+
91
+ .selection-box {
92
+ z-index: 201;
93
+ }
94
+
95
+ .handle {
96
+ position: absolute;
97
+ width: 8px;
98
+ height: 8px;
99
+ box-sizing: border-box;
100
+ background: var(--sl-color-neutral-0);
101
+ border: var(--sl-input-border-width) solid var(--sl-color-primary-600);
102
+ pointer-events: auto;
103
+ }
104
+
105
+ /* prettier-ignore */
106
+ .handle {
107
+ &[data-direction="nw"] { left: -5px; top: -5px; cursor: nwse-resize; }
108
+ &[data-direction="n" ] { left: calc(50% - 4px); top: -5px; cursor: ns-resize; }
109
+ &[data-direction="ne"] { right: -5px; top: -5px; cursor: nesw-resize; }
110
+ &[data-direction="e" ] { right: -5px; top: calc(50% - 4px); cursor: ew-resize; }
111
+ &[data-direction="se"] { right: -5px; bottom: -5px; cursor: nwse-resize; }
112
+ &[data-direction="s" ] { left: calc(50% - 4px); bottom: -5px; cursor: ns-resize; }
113
+ &[data-direction="sw"] { left: -5px; bottom: -5px; cursor: nesw-resize; }
114
+ &[data-direction="w" ] { left: -5px; top: calc(50% - 4px); cursor: ew-resize; }
115
+ }
116
+ `;
117
+
118
+ private imageInputRef = createRef<HTMLInputElement>();
119
+
120
+ @property({ type: String, attribute: true, reflect: true })
121
+ accessor backgroundSrc: string | null = null;
122
+
123
+ @property({ type: Boolean, attribute: "snap-to-grid", reflect: true })
124
+ accessor snapToGrid = false;
125
+
126
+ @property({ type: Number, attribute: "grid-columns", reflect: true })
127
+ accessor gridColumns = 100;
128
+
129
+ get dynamicOptions() {
130
+ const options: Record<string, OptionDeclaration> = {
131
+ "snap-to-grid": { type: "boolean", label: { _: msg("Snap to grid") } },
132
+ };
133
+ if (this.snapToGrid) {
134
+ options["grid-columns"] = { type: "number", label: { _: msg("Grid columns") }, min: 1, step: 1 };
135
+ }
136
+ return options;
137
+ }
138
+
139
+ protected abstract readonly itemElementTags: readonly string[];
140
+
141
+ private containerRef = createRef<HTMLDivElement>();
142
+ private items: ArtboardItemElement[] = [];
143
+ private itemObserver = new MutationObserver(() => this.updateItems());
144
+
145
+ private selection = new Set<string>();
146
+ private editingId: string | null = null;
147
+ private preview = new Map<string, Geometry>();
148
+ private marquee: Geometry | null = null;
149
+ private stopGesture: (() => void) | null = null;
150
+
151
+ private async updateItems() {
152
+ // Wait until every supported child has been registered, so that properties can be read from them.
153
+ await Promise.all(this.itemElementTags.map(tag => customElements.whenDefined(tag)));
154
+ this.items = Array.from(this.children).filter((child): child is ArtboardItemElement =>
155
+ this.itemElementTags.includes(child.localName),
156
+ );
157
+ const itemIds = new Set(this.items.map(item => item.id));
158
+ this.selection = new Set([...this.selection].filter(id => itemIds.has(id)));
159
+ if (this.editingId && !itemIds.has(this.editingId)) this.editingId = null;
160
+ if (this.snapToGrid) this.snapAllItems();
161
+ this.requestUpdate();
162
+ }
163
+
164
+ connectedCallback(): void {
165
+ super.connectedCallback();
166
+ this.updateItems();
167
+ this.itemObserver.observe(this, { childList: true });
168
+ }
169
+
170
+ disconnectedCallback(): void {
171
+ super.disconnectedCallback();
172
+ this.itemObserver.disconnect();
173
+ this.stopGesture?.();
174
+ }
175
+
176
+ @queryAll("slot")
177
+ accessor slots!: NodeListOf<HTMLSlotElement>;
178
+
179
+ protected updated(changed: PropertyValues): void {
180
+ const children = Array.from(this.children);
181
+ this.slots.forEach(slot => {
182
+ const refId = slot.dataset.childId;
183
+ const item = children.find(child => child.id === refId);
184
+ if (item) slot.assign(item);
185
+ });
186
+ if (this.snapToGrid && (changed.has("snapToGrid") || changed.has("gridColumns")) && this.snapAllItems()) {
187
+ this.requestUpdate();
188
+ }
189
+ }
190
+
191
+ render() {
192
+ return html`
193
+ <div
194
+ ${ref(this.containerRef)}
195
+ class="artboard-container"
196
+ tabindex="0"
197
+ @keydown=${this.onKeyDown}
198
+ @focusout=${this.onFocusOut}
199
+ >
200
+ <input
201
+ class="file-input"
202
+ type="file"
203
+ accept="image/*"
204
+ ${ref(this.imageInputRef)}
205
+ @change=${this.onBackgroundFileChange}
206
+ />
207
+ ${this.Header()} ${this.backgroundSrc ? this.Artboard() : this.BackgroundChooser()}
208
+ </div>
209
+ `;
210
+ }
211
+
212
+ private Header() {
213
+ if (!this.isContentEditable) return nothing;
214
+
215
+ return html`<header>
216
+ <sl-button size="small">${msg("Debug")}</sl-button>
217
+ </header>`;
218
+ }
219
+
220
+ private Artboard() {
221
+ const selection = this.renderedSelectionBounds();
222
+
223
+ return html`<div class="artboard" @pointerdown=${this.onPointerDown}>
224
+ <div class="overlay" style=${styleMap({ pointerEvents: this.isContentEditable ? "auto" : "none" })}></div>
225
+
226
+ ${this.items.map(item => {
227
+ const preview = this.preview.get(item.id);
228
+ const geometry = preview ? this.snapGeometry(preview, item) : item;
229
+ return html`<div
230
+ class=${`item${item.id === this.editingId ? " editing" : ""}`}
231
+ data-item-id=${item.id}
232
+ ?inert=${this.isContentEditable && item.id !== this.editingId}
233
+ style=${styleMap({
234
+ left: `${geometry.x}cqw`,
235
+ top: `${geometry.y}cqw`,
236
+ width: `${geometry.width}cqw`,
237
+ height: `${geometry.height}cqw`,
238
+ })}
239
+ >
240
+ <slot data-child-id=${item.id}></slot>
241
+ </div>`;
242
+ })}
243
+ ${this.marquee ? html`<div class="marquee" style=${this.geometryStyle(this.marquee)}></div>` : nothing}
244
+ ${selection
245
+ ? html`<div class="selection-box" style=${this.geometryStyle(selection)}>
246
+ ${RESIZE_DIRECTIONS.map(direction => html`<div class="handle" data-direction=${direction}></div>`)}
247
+ </div>`
248
+ : nothing}
249
+
250
+ <img class="background" src=${this.backgroundSrc!} alt="" />
251
+ </div> `;
252
+ }
253
+
254
+ focus() {
255
+ this.containerRef.value?.focus({ preventScroll: true });
256
+ }
257
+
258
+ private geometryStyle(geometry: Geometry) {
259
+ return styleMap({
260
+ left: `${geometry.x}cqw`,
261
+ top: `${geometry.y}cqw`,
262
+ width: `${geometry.width}cqw`,
263
+ height: `${geometry.height}cqw`,
264
+ });
265
+ }
266
+
267
+ private snapGeometry(geometry: Geometry, item: ArtboardItemElement): Geometry {
268
+ if (!this.snapToGrid || this.gridColumns < 1) return geometry;
269
+
270
+ const cellSize = 100 / Math.round(this.gridColumns);
271
+ const x = Math.round(geometry.x / cellSize) * cellSize;
272
+ const y = Math.round(geometry.y / cellSize) * cellSize;
273
+ const minimumWidth = Math.ceil(item.minimumWidth / cellSize) * cellSize;
274
+ const minimumHeight = Math.ceil(item.minimumHeight / cellSize) * cellSize;
275
+ const right = Math.max(x + minimumWidth, Math.round((geometry.x + geometry.width) / cellSize) * cellSize);
276
+ const bottom = Math.max(y + minimumHeight, Math.round((geometry.y + geometry.height) / cellSize) * cellSize);
277
+ return { x, y, width: right - x, height: bottom - y };
278
+ }
279
+
280
+ private snapAllItems() {
281
+ let changed = false;
282
+ for (const item of this.items) {
283
+ const geometry = this.snapGeometry(item, item);
284
+ if (
285
+ geometry.x === item.x &&
286
+ geometry.y === item.y &&
287
+ geometry.width === item.width &&
288
+ geometry.height === item.height
289
+ )
290
+ continue;
291
+ Object.assign(item, geometry);
292
+ changed = true;
293
+ }
294
+ return changed;
295
+ }
296
+
297
+ private renderedSelectionBounds() {
298
+ const selected = this.items
299
+ .filter(item => this.selection.has(item.id))
300
+ .map(item => {
301
+ const preview = this.preview.get(item.id);
302
+ return preview ? this.snapGeometry(preview, item) : item;
303
+ });
304
+ if (!selected.length) return null;
305
+
306
+ const x = Math.min(...selected.map(item => item.x));
307
+ const y = Math.min(...selected.map(item => item.y));
308
+ const right = Math.max(...selected.map(item => item.x + item.width));
309
+ const bottom = Math.max(...selected.map(item => item.y + item.height));
310
+ return { x, y, width: right - x, height: bottom - y };
311
+ }
312
+
313
+ private selectionBounds(geometry = this.preview) {
314
+ const selected = this.items.filter(item => this.selection.has(item.id)).map(item => geometry.get(item.id) ?? item);
315
+ if (!selected.length) return null;
316
+
317
+ const x = Math.min(...selected.map(item => item.x));
318
+ const y = Math.min(...selected.map(item => item.y));
319
+ const right = Math.max(...selected.map(item => item.x + item.width));
320
+ const bottom = Math.max(...selected.map(item => item.y + item.height));
321
+ return { x, y, width: right - x, height: bottom - y };
322
+ }
323
+
324
+ private itemGeometry(item: ArtboardItemElement): Geometry {
325
+ return { x: item.x, y: item.y, width: item.width, height: item.height };
326
+ }
327
+
328
+ private point(event: PointerEvent, artboard: HTMLElement) {
329
+ const rect = artboard.getBoundingClientRect();
330
+ return {
331
+ x: ((event.clientX - rect.left) / rect.width) * 100,
332
+ y: ((event.clientY - rect.top) / rect.width) * 100,
333
+ };
334
+ }
335
+
336
+ private startGesture(
337
+ artboard: HTMLElement,
338
+ pointerId: number,
339
+ cursor: string,
340
+ onMove: (event: PointerEvent) => void,
341
+ onEnd: (event: PointerEvent, cancelled: boolean) => void,
342
+ ) {
343
+ const previousCursor = document.documentElement.style.cursor;
344
+ const move = (event: PointerEvent) => {
345
+ if (event.pointerId === pointerId) onMove(event);
346
+ };
347
+ const end = (event: PointerEvent) => {
348
+ if (event.pointerId !== pointerId) return;
349
+ this.stopGesture?.();
350
+ onEnd(event, event.type !== "pointerup");
351
+ };
352
+ this.stopGesture = () => {
353
+ artboard.removeEventListener("pointermove", move);
354
+ artboard.removeEventListener("pointerup", end);
355
+ artboard.removeEventListener("pointercancel", end);
356
+ artboard.removeEventListener("lostpointercapture", end);
357
+ if (artboard.hasPointerCapture(pointerId)) artboard.releasePointerCapture(pointerId);
358
+ document.documentElement.style.cursor = previousCursor;
359
+ this.stopGesture = null;
360
+ };
361
+ artboard.addEventListener("pointermove", move);
362
+ artboard.addEventListener("pointerup", end);
363
+ artboard.addEventListener("pointercancel", end);
364
+ artboard.addEventListener("lostpointercapture", end);
365
+ document.documentElement.style.cursor = cursor;
366
+ artboard.setPointerCapture(pointerId);
367
+ }
368
+
369
+ private onPointerDown = (event: PointerEvent) => {
370
+ if (!this.isContentEditable || event.button !== 0 || this.stopGesture) return;
371
+
372
+ const artboard = event.currentTarget as HTMLElement;
373
+ const path = event.composedPath();
374
+ const itemWrapper = path.find(target => target instanceof HTMLElement && target.classList.contains("item")) as
375
+ | HTMLElement
376
+ | undefined;
377
+ if (itemWrapper?.dataset.itemId === this.editingId) return;
378
+
379
+ event.preventDefault();
380
+ this.focus();
381
+ this.exitEditing();
382
+
383
+ const handle = path.find(target => target instanceof HTMLElement && target.classList.contains("handle")) as
384
+ | HTMLElement
385
+ | undefined;
386
+ if (handle) {
387
+ this.startResize(event, artboard, handle.dataset.direction as ResizeDirection, getComputedStyle(handle).cursor);
388
+ return;
389
+ }
390
+
391
+ const point = this.point(event, artboard);
392
+ const item = [...this.items].reverse().find(item => {
393
+ return (
394
+ point.x >= item.x && point.x <= item.x + item.width && point.y >= item.y && point.y <= item.y + item.height
395
+ );
396
+ });
397
+ if (item) this.startMove(event, artboard, item);
398
+ else this.startMarquee(event, artboard);
399
+ };
400
+
401
+ private startMove(event: PointerEvent, artboard: HTMLElement, item: ArtboardItemElement) {
402
+ const shift = event.shiftKey;
403
+ const wasSelected = this.selection.has(item.id);
404
+ const wasSoleSelection = wasSelected && this.selection.size === 1;
405
+
406
+ if (shift) {
407
+ if (!wasSelected) this.selection.add(item.id);
408
+ } else if (!wasSelected) {
409
+ this.selection = new Set([item.id]);
410
+ }
411
+ this.requestUpdate();
412
+
413
+ const startPointer = this.point(event, artboard);
414
+ const startClient = { x: event.clientX, y: event.clientY };
415
+ const start = new Map(
416
+ this.items
417
+ .filter(candidate => this.selection.has(candidate.id))
418
+ .map(candidate => [candidate.id, this.itemGeometry(candidate)] as const),
419
+ );
420
+ let dragged = false;
421
+ const updatePreview = (pointerEvent: PointerEvent) => {
422
+ const current = this.point(pointerEvent, artboard);
423
+ if (
424
+ !dragged &&
425
+ Math.hypot(pointerEvent.clientX - startClient.x, pointerEvent.clientY - startClient.y) < CLICK_SLOP
426
+ )
427
+ return;
428
+ dragged = true;
429
+ this.preview = new Map(
430
+ [...start].map(([id, geometry]) => [
431
+ id,
432
+ {
433
+ ...geometry,
434
+ x: geometry.x + current.x - startPointer.x,
435
+ y: geometry.y + current.y - startPointer.y,
436
+ },
437
+ ]),
438
+ );
439
+ this.requestUpdate();
440
+ };
441
+
442
+ this.startGesture(artboard, event.pointerId, "default", updatePreview, (upEvent, cancelled) => {
443
+ if (!cancelled) updatePreview(upEvent);
444
+ if (dragged && !cancelled) this.commitPreview();
445
+ else this.preview.clear();
446
+
447
+ if (!dragged && !cancelled) {
448
+ if (shift && wasSelected) this.selection.delete(item.id);
449
+ else if (!shift && wasSoleSelection && item.hasEditableContent) {
450
+ this.enterEditing(item, upEvent.clientX, upEvent.clientY);
451
+ return;
452
+ } else if (!shift) this.selection = new Set([item.id]);
453
+ }
454
+ this.requestUpdate();
455
+ });
456
+ }
457
+
458
+ private startResize(event: PointerEvent, artboard: HTMLElement, direction: ResizeDirection, cursor: string) {
459
+ const bounds = this.selectionBounds();
460
+ if (!bounds) return;
461
+
462
+ const startPointer = this.point(event, artboard);
463
+ const start = new Map(
464
+ this.items.filter(item => this.selection.has(item.id)).map(item => [item.id, this.itemGeometry(item)] as const),
465
+ );
466
+ const original = {
467
+ x1: bounds.x,
468
+ y1: bounds.y,
469
+ x2: bounds.x + bounds.width,
470
+ y2: bounds.y + bounds.height,
471
+ };
472
+ const minimumScaleX = Math.max(
473
+ ...this.items
474
+ .filter(item => this.selection.has(item.id))
475
+ .map(item => item.minimumWidth / start.get(item.id)!.width),
476
+ );
477
+ const minimumScaleY = Math.max(
478
+ ...this.items
479
+ .filter(item => this.selection.has(item.id))
480
+ .map(item => item.minimumHeight / start.get(item.id)!.height),
481
+ );
482
+ const updatePreview = (pointerEvent: PointerEvent) => {
483
+ const current = this.point(pointerEvent, artboard);
484
+ const next = { ...original };
485
+ if (direction.includes("w")) next.x1 += current.x - startPointer.x;
486
+ if (direction.includes("e")) next.x2 += current.x - startPointer.x;
487
+ if (direction.includes("n")) next.y1 += current.y - startPointer.y;
488
+ if (direction.includes("s")) next.y2 += current.y - startPointer.y;
489
+
490
+ let scaleX = (next.x2 - next.x1) / bounds.width;
491
+ let scaleY = (next.y2 - next.y1) / bounds.height;
492
+ if ((direction.includes("w") || direction.includes("e")) && scaleX < minimumScaleX) {
493
+ scaleX = minimumScaleX;
494
+ if (direction.includes("w")) next.x1 = original.x2 - bounds.width * scaleX;
495
+ else next.x2 = original.x1 + bounds.width * scaleX;
496
+ }
497
+ if ((direction.includes("n") || direction.includes("s")) && scaleY < minimumScaleY) {
498
+ scaleY = minimumScaleY;
499
+ if (direction.includes("n")) next.y1 = original.y2 - bounds.height * scaleY;
500
+ else next.y2 = original.y1 + bounds.height * scaleY;
501
+ }
502
+ this.preview = new Map(
503
+ [...start].map(([id, geometry]) => {
504
+ const x1 = next.x1 + (geometry.x - original.x1) * scaleX;
505
+ const x2 = next.x1 + (geometry.x + geometry.width - original.x1) * scaleX;
506
+ const y1 = next.y1 + (geometry.y - original.y1) * scaleY;
507
+ const y2 = next.y1 + (geometry.y + geometry.height - original.y1) * scaleY;
508
+ return [
509
+ id,
510
+ {
511
+ x: Math.min(x1, x2),
512
+ y: Math.min(y1, y2),
513
+ width: Math.abs(x2 - x1),
514
+ height: Math.abs(y2 - y1),
515
+ },
516
+ ] as const;
517
+ }),
518
+ );
519
+ this.requestUpdate();
520
+ };
521
+
522
+ this.startGesture(artboard, event.pointerId, cursor, updatePreview, (upEvent, cancelled) => {
523
+ if (!cancelled) {
524
+ updatePreview(upEvent);
525
+ this.commitPreview();
526
+ } else {
527
+ this.preview.clear();
528
+ this.requestUpdate();
529
+ }
530
+ });
531
+ }
532
+
533
+ private startMarquee(event: PointerEvent, artboard: HTMLElement) {
534
+ const base = event.shiftKey ? new Set(this.selection) : new Set<string>();
535
+ if (!event.shiftKey) this.selection.clear();
536
+ const start = this.point(event, artboard);
537
+ const startClient = { x: event.clientX, y: event.clientY };
538
+ let dragged = false;
539
+ this.requestUpdate();
540
+ const updateMarquee = (pointerEvent: PointerEvent) => {
541
+ const current = this.point(pointerEvent, artboard);
542
+ if (
543
+ !dragged &&
544
+ Math.hypot(pointerEvent.clientX - startClient.x, pointerEvent.clientY - startClient.y) < CLICK_SLOP
545
+ )
546
+ return;
547
+ dragged = true;
548
+ this.marquee = {
549
+ x: Math.min(start.x, current.x),
550
+ y: Math.min(start.y, current.y),
551
+ width: Math.abs(current.x - start.x),
552
+ height: Math.abs(current.y - start.y),
553
+ };
554
+ this.selection = new Set(base);
555
+ for (const item of this.items) {
556
+ if (
557
+ item.x < this.marquee.x + this.marquee.width &&
558
+ item.x + item.width > this.marquee.x &&
559
+ item.y < this.marquee.y + this.marquee.height &&
560
+ item.y + item.height > this.marquee.y
561
+ )
562
+ this.selection.add(item.id);
563
+ }
564
+ this.requestUpdate();
565
+ };
566
+
567
+ this.startGesture(artboard, event.pointerId, "default", updateMarquee, (upEvent, cancelled) => {
568
+ if (!cancelled) updateMarquee(upEvent);
569
+ if (cancelled) this.selection = base;
570
+ this.marquee = null;
571
+ this.requestUpdate();
572
+ });
573
+ }
574
+
575
+ private commitPreview() {
576
+ for (const [id, geometry] of this.preview) {
577
+ const item = this.items.find(item => item.id === id);
578
+ if (item) Object.assign(item, this.snapGeometry(geometry, item));
579
+ }
580
+ this.preview.clear();
581
+ this.requestUpdate();
582
+ }
583
+
584
+ private async enterEditing(item: ArtboardItemElement, clientX: number, clientY: number) {
585
+ this.editingId = item.id;
586
+ this.requestUpdate();
587
+ await this.updateComplete;
588
+
589
+ item.focus({ preventScroll: true });
590
+
591
+ const position = document.caretPositionFromPoint?.(clientX, clientY);
592
+ if (position) {
593
+ const range = document.createRange();
594
+ range.setStart(position.offsetNode, position.offset);
595
+ range.collapse(true);
596
+ if (item.contains(range.startContainer)) {
597
+ const selection = document.getSelection();
598
+ selection?.removeAllRanges();
599
+ selection?.addRange(range);
600
+ }
601
+ }
602
+ }
603
+
604
+ private exitEditing() {
605
+ if (!this.editingId) return;
606
+ const item = this.items.find(item => item.id === this.editingId);
607
+ const activeElement = document.activeElement;
608
+ if (activeElement instanceof HTMLElement && item?.contains(activeElement)) activeElement.blur();
609
+ const selection = document.getSelection();
610
+ if (item && (item.contains(selection?.anchorNode ?? null) || item.contains(selection?.focusNode ?? null))) {
611
+ selection?.removeAllRanges();
612
+ }
613
+ this.editingId = null;
614
+ this.requestUpdate();
615
+ }
616
+
617
+ private onKeyDown = (event: KeyboardEvent) => {
618
+ if ((event.key === "Delete" || event.key === "Backspace") && this.selection.size) {
619
+ event.preventDefault();
620
+ event.stopPropagation();
621
+ for (const item of this.items) if (this.selection.has(item.id)) item.remove();
622
+ this.selection.clear();
623
+ this.requestUpdate();
624
+ }
625
+ };
626
+
627
+ private onFocusOut = () => {
628
+ setTimeout(() => {
629
+ if (this.classList.contains("ww-selected") || this.classList.contains("ww-selected-text-within")) return;
630
+ this.exitEditing();
631
+ this.selection.clear();
632
+ this.requestUpdate();
633
+ }, 10);
634
+ };
635
+
636
+ private openBackgroundFileDialog() {
637
+ this.imageInputRef.value?.click();
638
+ }
639
+
640
+ private onBackgroundFileChange(event: Event) {
641
+ const input = event.target as HTMLInputElement;
642
+ if (input.files && input.files.length > 0) {
643
+ const file = input.files[0];
644
+ const reader = new FileReader();
645
+ reader.onload = () => {
646
+ this.backgroundSrc = reader.result as string;
647
+ input.value = ""; // Reset the input value to allow re-selecting the same file
648
+ };
649
+ reader.readAsDataURL(file);
650
+ }
651
+ }
652
+
653
+ get dynamicActions() {
654
+ return {
655
+ openBackgroundFileDialog: { label: { _: msg("Choose Background") } },
656
+ } as Record<string, ActionDeclaration>;
657
+ }
658
+
659
+ private BackgroundChooser() {
660
+ // TODO: Make more beautiful
661
+
662
+ return html`
663
+ <div class="background-chooser">
664
+ <sl-button @click=${this.openBackgroundFileDialog}>${msg("Choose Background")}</sl-button>
665
+ </div>
666
+ `;
667
+ }
668
+ }
669
+
670
+ export abstract class ArtboardItemElement extends LitElementWw {
671
+ abstract hasEditableContent: boolean;
672
+ abstract minimumWidth: number;
673
+ abstract minimumHeight: number;
674
+
675
+ @property({ type: Number, attribute: true, reflect: true })
676
+ accessor x!: number;
677
+
678
+ @property({ type: Number, attribute: true, reflect: true })
679
+ accessor y!: number;
680
+
681
+ @property({ type: Number, attribute: "w", reflect: true })
682
+ accessor width!: number;
683
+
684
+ @property({ type: Number, attribute: "h", reflect: true })
685
+ accessor height!: number;
686
+ }