@webwriter/quiz 1.1.5 → 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 (165) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +778 -0
  3. package/assets/icons/grip-horizontal.svg +8 -0
  4. package/assets/icons/grip-vertical.svg +8 -0
  5. package/assets/icons/truefalse.svg +6 -0
  6. package/custom-elements.json +2779 -0
  7. package/custom.d.ts +5 -1
  8. package/dist/api/index.d.ts +27 -0
  9. package/dist/api/index.js +40 -0
  10. package/dist/migrate.js +1434 -0
  11. package/dist/widgets/webwriter-choice-item.js +4095 -3818
  12. package/dist/widgets/webwriter-choice.js +2179 -5598
  13. package/dist/widgets/webwriter-gap-item.js +3987 -0
  14. package/dist/widgets/webwriter-gap.js +2908 -0
  15. package/dist/widgets/webwriter-mark.js +4347 -3368
  16. package/dist/widgets/webwriter-order-item.js +4038 -3197
  17. package/dist/widgets/webwriter-order.js +2885 -4200
  18. package/dist/widgets/webwriter-pairing-item.js +1070 -2330
  19. package/dist/widgets/webwriter-pairing.js +2968 -4231
  20. package/dist/widgets/webwriter-quiz-hint.js +2469 -0
  21. package/dist/widgets/webwriter-quiz.js +3337 -4364
  22. package/dist/widgets/webwriter-speech.js +6100 -5120
  23. package/dist/widgets/webwriter-task-prompt.js +4787 -2068
  24. package/dist/widgets/webwriter-task.js +2264 -8157
  25. package/dist/widgets/webwriter-text.js +4075 -4132
  26. package/dist/widgets/webwriter-true-false.js +4674 -0
  27. package/editing-config.json +106 -0
  28. package/lit-localize.json +20 -14
  29. package/localization/de.xlf +395 -65
  30. package/localization/es.xlf +396 -61
  31. package/localization/fr.xlf +396 -61
  32. package/localization/generated/de.ts +97 -15
  33. package/localization/generated/es.ts +97 -15
  34. package/localization/generated/fr.ts +98 -16
  35. package/localization/generated/index.d.ts +5 -0
  36. package/localization/generated/it.ts +96 -14
  37. package/localization/generated/locale-codes.js +0 -48
  38. package/localization/generated/nl.ts +99 -17
  39. package/localization/it.xlf +396 -61
  40. package/localization/nl.xlf +396 -61
  41. package/package.json +120 -254
  42. package/scripts/apply-translations.mjs +97 -0
  43. package/src/api/index.ts +72 -0
  44. package/src/lib/artboard.ts +686 -0
  45. package/src/lib/draggable-grid.ts +432 -0
  46. package/src/lib/encrypted-property.ts +202 -0
  47. package/src/lib/hint-popup.ts +152 -0
  48. package/src/lib/utils.ts +97 -0
  49. package/src/lib/webwriter-quirks.ts +121 -0
  50. package/src/migrate/index.ts +622 -0
  51. package/src/migrate/legacy-solution.ts +362 -0
  52. package/src/snippets/order.html +8 -7
  53. package/src/widgets/choice/webwriter-choice-item.ts +316 -0
  54. package/src/widgets/choice/webwriter-choice.ts +328 -0
  55. package/src/widgets/choice/webwriter-true-false.ts +113 -0
  56. package/src/widgets/dnd/webwriter-dnd-draggable.ts +28 -0
  57. package/src/widgets/dnd/webwriter-dnd-dropzone.ts +9 -0
  58. package/src/widgets/dnd/webwriter-dnd.ts +36 -0
  59. package/src/widgets/gap/webwriter-gap-item.ts +250 -0
  60. package/src/widgets/gap/webwriter-gap.ts +447 -0
  61. package/src/widgets/order/webwriter-order-item.ts +243 -0
  62. package/src/widgets/order/webwriter-order.ts +282 -0
  63. package/src/widgets/pairing/webwriter-pairing-item.ts +46 -0
  64. package/src/widgets/pairing/webwriter-pairing.ts +934 -0
  65. package/src/widgets/speech/audio-player.ts +325 -0
  66. package/src/widgets/speech/audio-recorder.ts +285 -0
  67. package/src/widgets/speech/waveform.ts +113 -0
  68. package/src/widgets/speech/webwriter-speech.ts +87 -0
  69. package/src/widgets/webwriter-mark.ts +366 -424
  70. package/src/widgets/webwriter-quiz-hint.ts +36 -0
  71. package/src/widgets/webwriter-quiz.ts +318 -280
  72. package/src/widgets/webwriter-task-prompt.ts +43 -18
  73. package/src/widgets/webwriter-task.ts +212 -551
  74. package/src/widgets/webwriter-text.ts +286 -172
  75. package/tsconfig.api.json +12 -0
  76. package/tsconfig.json +7 -6
  77. package/dist/widgets/webwriter-choice.css +0 -444
  78. package/dist/widgets/webwriter-cloze-gap.css +0 -444
  79. package/dist/widgets/webwriter-cloze-gap.js +0 -5727
  80. package/dist/widgets/webwriter-cloze.css +0 -444
  81. package/dist/widgets/webwriter-cloze.js +0 -3653
  82. package/dist/widgets/webwriter-mark.css +0 -444
  83. package/dist/widgets/webwriter-order.css +0 -444
  84. package/dist/widgets/webwriter-pairing-item.css +0 -444
  85. package/dist/widgets/webwriter-pairing.css +0 -444
  86. package/dist/widgets/webwriter-quiz.css +0 -444
  87. package/dist/widgets/webwriter-task-explainer.css +0 -444
  88. package/dist/widgets/webwriter-task-explainer.js +0 -2769
  89. package/dist/widgets/webwriter-task-hint.css +0 -444
  90. package/dist/widgets/webwriter-task-hint.js +0 -2708
  91. package/dist/widgets/webwriter-task.css +0 -444
  92. package/dist/widgets/webwriter-text.css +0 -444
  93. package/localization/bg.xlf +0 -72
  94. package/localization/cs.xlf +0 -72
  95. package/localization/da.xlf +0 -72
  96. package/localization/el.xlf +0 -72
  97. package/localization/et.xlf +0 -72
  98. package/localization/fi.xlf +0 -72
  99. package/localization/generated/bg.ts +0 -31
  100. package/localization/generated/cs.ts +0 -31
  101. package/localization/generated/da.ts +0 -31
  102. package/localization/generated/el.ts +0 -31
  103. package/localization/generated/et.ts +0 -31
  104. package/localization/generated/fi.ts +0 -31
  105. package/localization/generated/hu.ts +0 -31
  106. package/localization/generated/id.ts +0 -31
  107. package/localization/generated/ja.ts +0 -31
  108. package/localization/generated/ko.ts +0 -31
  109. package/localization/generated/lt.ts +0 -31
  110. package/localization/generated/lv.ts +0 -31
  111. package/localization/generated/nb.ts +0 -31
  112. package/localization/generated/pl.ts +0 -31
  113. package/localization/generated/pt-BR.ts +0 -31
  114. package/localization/generated/pt-PT.ts +0 -31
  115. package/localization/generated/ro.ts +0 -31
  116. package/localization/generated/ru.ts +0 -31
  117. package/localization/generated/sk.ts +0 -31
  118. package/localization/generated/sl.ts +0 -31
  119. package/localization/generated/sv.ts +0 -31
  120. package/localization/generated/tr.ts +0 -31
  121. package/localization/generated/uk.ts +0 -31
  122. package/localization/generated/zh-hans.ts +0 -31
  123. package/localization/hu.xlf +0 -72
  124. package/localization/id.xlf +0 -72
  125. package/localization/ja.xlf +0 -72
  126. package/localization/ko.xlf +0 -72
  127. package/localization/lt.xlf +0 -72
  128. package/localization/lv.xlf +0 -72
  129. package/localization/nb.xlf +0 -72
  130. package/localization/pl.xlf +0 -72
  131. package/localization/pt-BR.xlf +0 -72
  132. package/localization/pt-PT.xlf +0 -72
  133. package/localization/ro.xlf +0 -72
  134. package/localization/ru.xlf +0 -72
  135. package/localization/sk.xlf +0 -72
  136. package/localization/sl.xlf +0 -72
  137. package/localization/sv.xlf +0 -72
  138. package/localization/tr.xlf +0 -72
  139. package/localization/uk.xlf +0 -72
  140. package/localization/zh-hans.xlf +0 -72
  141. package/src/lib/combobox.ts +0 -456
  142. package/src/lib/highlighter-fill.svg +0 -6
  143. package/src/snippets/Beispiel-Choice.html +0 -57
  144. package/src/snippets/Beispiel-Mark.html +0 -14
  145. package/src/snippets/Beispiel-Order.html +0 -52
  146. package/src/snippets/Beispiel-Text.html +0 -20
  147. package/src/snippets/blank.html +0 -1
  148. package/src/snippets/choice.html +0 -8
  149. package/src/snippets/cloze.html +0 -4
  150. package/src/snippets/mark.html +0 -4
  151. package/src/snippets/pairing.html +0 -10
  152. package/src/snippets/speech.html +0 -4
  153. package/src/snippets/text.html +0 -4
  154. package/src/snippets/wordsearch.html +0 -4
  155. package/src/widgets/webwriter-choice-item.ts +0 -257
  156. package/src/widgets/webwriter-choice.ts +0 -353
  157. package/src/widgets/webwriter-cloze-gap.ts +0 -216
  158. package/src/widgets/webwriter-cloze.ts +0 -136
  159. package/src/widgets/webwriter-order-item.ts +0 -441
  160. package/src/widgets/webwriter-order.ts +0 -327
  161. package/src/widgets/webwriter-pairing-item.ts +0 -161
  162. package/src/widgets/webwriter-pairing.ts +0 -193
  163. package/src/widgets/webwriter-speech.ts +0 -273
  164. package/src/widgets/webwriter-task-explainer.ts +0 -43
  165. 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
+ }