@sealpixel/ui 1.0.0-rc.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 (196) hide show
  1. package/LICENSE.md +59 -0
  2. package/README.md +10 -0
  3. package/dist/components/lumbra-button.d.ts +28 -0
  4. package/dist/components/lumbra-button.d.ts.map +1 -0
  5. package/dist/components/lumbra-button.js +87 -0
  6. package/dist/components/lumbra-button.js.map +1 -0
  7. package/dist/components/lumbra-editor.d.ts +226 -0
  8. package/dist/components/lumbra-editor.d.ts.map +1 -0
  9. package/dist/components/lumbra-editor.js +873 -0
  10. package/dist/components/lumbra-editor.js.map +1 -0
  11. package/dist/components/lumbra-nav.d.ts +48 -0
  12. package/dist/components/lumbra-nav.d.ts.map +1 -0
  13. package/dist/components/lumbra-nav.js +159 -0
  14. package/dist/components/lumbra-nav.js.map +1 -0
  15. package/dist/components/lumbra-slider.d.ts +48 -0
  16. package/dist/components/lumbra-slider.d.ts.map +1 -0
  17. package/dist/components/lumbra-slider.js +168 -0
  18. package/dist/components/lumbra-slider.js.map +1 -0
  19. package/dist/components/lumbra-stage.d.ts +92 -0
  20. package/dist/components/lumbra-stage.d.ts.map +1 -0
  21. package/dist/components/lumbra-stage.js +344 -0
  22. package/dist/components/lumbra-stage.js.map +1 -0
  23. package/dist/components/lumbra-toolbar.d.ts +30 -0
  24. package/dist/components/lumbra-toolbar.d.ts.map +1 -0
  25. package/dist/components/lumbra-toolbar.js +127 -0
  26. package/dist/components/lumbra-toolbar.js.map +1 -0
  27. package/dist/components/pincel-button.d.ts +28 -0
  28. package/dist/components/pincel-button.d.ts.map +1 -0
  29. package/dist/components/pincel-button.js +87 -0
  30. package/dist/components/pincel-button.js.map +1 -0
  31. package/dist/components/pincel-editor.d.ts +195 -0
  32. package/dist/components/pincel-editor.d.ts.map +1 -0
  33. package/dist/components/pincel-editor.js +794 -0
  34. package/dist/components/pincel-editor.js.map +1 -0
  35. package/dist/components/pincel-nav.d.ts +33 -0
  36. package/dist/components/pincel-nav.d.ts.map +1 -0
  37. package/dist/components/pincel-nav.js +147 -0
  38. package/dist/components/pincel-nav.js.map +1 -0
  39. package/dist/components/pincel-slider.d.ts +48 -0
  40. package/dist/components/pincel-slider.d.ts.map +1 -0
  41. package/dist/components/pincel-slider.js +168 -0
  42. package/dist/components/pincel-slider.js.map +1 -0
  43. package/dist/components/pincel-stage.d.ts +66 -0
  44. package/dist/components/pincel-stage.d.ts.map +1 -0
  45. package/dist/components/pincel-stage.js +233 -0
  46. package/dist/components/pincel-stage.js.map +1 -0
  47. package/dist/components/pincel-toolbar.d.ts +30 -0
  48. package/dist/components/pincel-toolbar.d.ts.map +1 -0
  49. package/dist/components/pincel-toolbar.js +127 -0
  50. package/dist/components/pincel-toolbar.js.map +1 -0
  51. package/dist/components/sealpixel-button.d.ts +28 -0
  52. package/dist/components/sealpixel-button.d.ts.map +1 -0
  53. package/dist/components/sealpixel-button.js +117 -0
  54. package/dist/components/sealpixel-button.js.map +1 -0
  55. package/dist/components/sealpixel-editor.d.ts +226 -0
  56. package/dist/components/sealpixel-editor.d.ts.map +1 -0
  57. package/dist/components/sealpixel-editor.js +1245 -0
  58. package/dist/components/sealpixel-editor.js.map +1 -0
  59. package/dist/components/sealpixel-nav.d.ts +48 -0
  60. package/dist/components/sealpixel-nav.d.ts.map +1 -0
  61. package/dist/components/sealpixel-nav.js +159 -0
  62. package/dist/components/sealpixel-nav.js.map +1 -0
  63. package/dist/components/sealpixel-slider.d.ts +75 -0
  64. package/dist/components/sealpixel-slider.d.ts.map +1 -0
  65. package/dist/components/sealpixel-slider.js +269 -0
  66. package/dist/components/sealpixel-slider.js.map +1 -0
  67. package/dist/components/sealpixel-stage.d.ts +118 -0
  68. package/dist/components/sealpixel-stage.d.ts.map +1 -0
  69. package/dist/components/sealpixel-stage.js +409 -0
  70. package/dist/components/sealpixel-stage.js.map +1 -0
  71. package/dist/components/sealpixel-stepper.d.ts +69 -0
  72. package/dist/components/sealpixel-stepper.d.ts.map +1 -0
  73. package/dist/components/sealpixel-stepper.js +248 -0
  74. package/dist/components/sealpixel-stepper.js.map +1 -0
  75. package/dist/components/sealpixel-toolbar.d.ts +30 -0
  76. package/dist/components/sealpixel-toolbar.d.ts.map +1 -0
  77. package/dist/components/sealpixel-toolbar.js +149 -0
  78. package/dist/components/sealpixel-toolbar.js.map +1 -0
  79. package/dist/controllers/gesture-controller.d.ts +65 -0
  80. package/dist/controllers/gesture-controller.d.ts.map +1 -0
  81. package/dist/controllers/gesture-controller.js +125 -0
  82. package/dist/controllers/gesture-controller.js.map +1 -0
  83. package/dist/controllers/session-controller.d.ts +15 -0
  84. package/dist/controllers/session-controller.d.ts.map +1 -0
  85. package/dist/controllers/session-controller.js +37 -0
  86. package/dist/controllers/session-controller.js.map +1 -0
  87. package/dist/define.d.ts +21 -0
  88. package/dist/define.d.ts.map +1 -0
  89. package/dist/define.js +27 -0
  90. package/dist/define.js.map +1 -0
  91. package/dist/i18n.d.ts +11 -0
  92. package/dist/i18n.d.ts.map +1 -0
  93. package/dist/i18n.js +27 -0
  94. package/dist/i18n.js.map +1 -0
  95. package/dist/icons.d.ts +32 -0
  96. package/dist/icons.d.ts.map +1 -0
  97. package/dist/icons.js +45 -0
  98. package/dist/icons.js.map +1 -0
  99. package/dist/index.d.ts +18 -0
  100. package/dist/index.d.ts.map +1 -0
  101. package/dist/index.js +17 -0
  102. package/dist/index.js.map +1 -0
  103. package/dist/locales/de.d.ts +3 -0
  104. package/dist/locales/de.d.ts.map +1 -0
  105. package/dist/locales/de.js +132 -0
  106. package/dist/locales/de.js.map +1 -0
  107. package/dist/locales/en.d.ts +3 -0
  108. package/dist/locales/en.d.ts.map +1 -0
  109. package/dist/locales/en.js +132 -0
  110. package/dist/locales/en.js.map +1 -0
  111. package/dist/locales/es.d.ts +3 -0
  112. package/dist/locales/es.d.ts.map +1 -0
  113. package/dist/locales/es.js +132 -0
  114. package/dist/locales/es.js.map +1 -0
  115. package/dist/locales/fr.d.ts +3 -0
  116. package/dist/locales/fr.d.ts.map +1 -0
  117. package/dist/locales/fr.js +132 -0
  118. package/dist/locales/fr.js.map +1 -0
  119. package/dist/locales/index.d.ts +6 -0
  120. package/dist/locales/index.d.ts.map +1 -0
  121. package/dist/locales/index.js +13 -0
  122. package/dist/locales/index.js.map +1 -0
  123. package/dist/locales/ja.d.ts +3 -0
  124. package/dist/locales/ja.d.ts.map +1 -0
  125. package/dist/locales/ja.js +132 -0
  126. package/dist/locales/ja.js.map +1 -0
  127. package/dist/locales/pt.d.ts +3 -0
  128. package/dist/locales/pt.d.ts.map +1 -0
  129. package/dist/locales/pt.js +132 -0
  130. package/dist/locales/pt.js.map +1 -0
  131. package/dist/theme.d.ts +19 -0
  132. package/dist/theme.d.ts.map +1 -0
  133. package/dist/theme.js +150 -0
  134. package/dist/theme.js.map +1 -0
  135. package/dist/tool.d.ts +70 -0
  136. package/dist/tool.d.ts.map +1 -0
  137. package/dist/tool.js +2 -0
  138. package/dist/tool.js.map +1 -0
  139. package/dist/tools/annotate.d.ts +3 -0
  140. package/dist/tools/annotate.d.ts.map +1 -0
  141. package/dist/tools/annotate.js +436 -0
  142. package/dist/tools/annotate.js.map +1 -0
  143. package/dist/tools/crop-mask.d.ts +29 -0
  144. package/dist/tools/crop-mask.d.ts.map +1 -0
  145. package/dist/tools/crop-mask.js +75 -0
  146. package/dist/tools/crop-mask.js.map +1 -0
  147. package/dist/tools/crop.d.ts +11 -0
  148. package/dist/tools/crop.d.ts.map +1 -0
  149. package/dist/tools/crop.js +361 -0
  150. package/dist/tools/crop.js.map +1 -0
  151. package/dist/tools/delete-handle.d.ts +18 -0
  152. package/dist/tools/delete-handle.d.ts.map +1 -0
  153. package/dist/tools/delete-handle.js +61 -0
  154. package/dist/tools/delete-handle.js.map +1 -0
  155. package/dist/tools/filter.d.ts +3 -0
  156. package/dist/tools/filter.d.ts.map +1 -0
  157. package/dist/tools/filter.js +174 -0
  158. package/dist/tools/filter.js.map +1 -0
  159. package/dist/tools/finetune.d.ts +11 -0
  160. package/dist/tools/finetune.d.ts.map +1 -0
  161. package/dist/tools/finetune.js +98 -0
  162. package/dist/tools/finetune.js.map +1 -0
  163. package/dist/tools/frame.d.ts +3 -0
  164. package/dist/tools/frame.d.ts.map +1 -0
  165. package/dist/tools/frame.js +57 -0
  166. package/dist/tools/frame.js.map +1 -0
  167. package/dist/tools/index.d.ts +14 -0
  168. package/dist/tools/index.d.ts.map +1 -0
  169. package/dist/tools/index.js +41 -0
  170. package/dist/tools/index.js.map +1 -0
  171. package/dist/tools/redact.d.ts +3 -0
  172. package/dist/tools/redact.d.ts.map +1 -0
  173. package/dist/tools/redact.js +252 -0
  174. package/dist/tools/redact.js.map +1 -0
  175. package/dist/tools/resize.d.ts +3 -0
  176. package/dist/tools/resize.d.ts.map +1 -0
  177. package/dist/tools/resize.js +91 -0
  178. package/dist/tools/resize.js.map +1 -0
  179. package/dist/tools/sticker.d.ts +8 -0
  180. package/dist/tools/sticker.d.ts.map +1 -0
  181. package/dist/tools/sticker.js +156 -0
  182. package/dist/tools/sticker.js.map +1 -0
  183. package/dist/tools/watermark.d.ts +3 -0
  184. package/dist/tools/watermark.d.ts.map +1 -0
  185. package/dist/tools/watermark.js +102 -0
  186. package/dist/tools/watermark.js.map +1 -0
  187. package/dist/view-mapping.d.ts +23 -0
  188. package/dist/view-mapping.d.ts.map +1 -0
  189. package/dist/view-mapping.js +72 -0
  190. package/dist/view-mapping.js.map +1 -0
  191. package/package.json +55 -0
  192. package/themes/default.css +23 -0
  193. package/themes/high-contrast.css +30 -0
  194. package/themes/minimal.css +32 -0
  195. package/themes/nocturne.css +21 -0
  196. package/themes/studio.css +23 -0
@@ -0,0 +1,1245 @@
1
+ import { canonicalJson, configurePlugins, createAssetResolver, createEditorSession, SealpixelError, } from '@sealpixel/core';
2
+ import { defaultPlugins } from '@sealpixel/plugins';
3
+ import { css, html, LitElement, nothing } from 'lit';
4
+ import { SessionController } from '../controllers/session-controller.js';
5
+ import { createI18n } from '../i18n.js';
6
+ import { resolveLocale } from '../locales/index.js';
7
+ import { base, tokens } from '../theme.js';
8
+ import { toolsFor } from '../tools/index.js';
9
+ /** `plugin-options='{"filter":{...}}'` attribute ↔ property. */
10
+ const jsonAttribute = {
11
+ fromAttribute: (value) => {
12
+ if (value === null || value.trim() === '')
13
+ return null;
14
+ try {
15
+ return JSON.parse(value);
16
+ }
17
+ catch {
18
+ console.warn('[sealpixel] plugin-options is not valid JSON');
19
+ return null;
20
+ }
21
+ },
22
+ toAttribute: (value) => (value ? JSON.stringify(value) : null),
23
+ };
24
+ /**
25
+ * The default editor. Headless `EditorSession` underneath; this element adds
26
+ * the responsive shell, tools and events.
27
+ *
28
+ * Events: `sealpixel:ready`, `sealpixel:load`, `sealpixel:change` ({ log, index }),
29
+ * `sealpixel:process` (ExportResult), `sealpixel:error` (SealpixelError).
30
+ */
31
+ export class SealpixelEditor extends LitElement {
32
+ static { this.styles = [
33
+ tokens,
34
+ base,
35
+ css `
36
+ :host {
37
+ display: block;
38
+ container-type: inline-size;
39
+ background: var(--sealpixel-color-bg);
40
+ color: var(--sealpixel-color-fg);
41
+ height: 100%;
42
+ min-height: 480px;
43
+ min-width: 0;
44
+ overflow: hidden;
45
+ }
46
+ :host(:focus) {
47
+ outline: none;
48
+ }
49
+ .root {
50
+ position: relative;
51
+ display: grid;
52
+ height: 100%;
53
+ min-width: 0;
54
+ grid-template:
55
+ 'nav' auto
56
+ 'stage' 1fr
57
+ 'panel' auto
58
+ 'toolbar' auto / 1fr;
59
+ padding-bottom: env(safe-area-inset-bottom);
60
+ }
61
+ .root > * {
62
+ min-width: 0;
63
+ min-height: 0;
64
+ }
65
+ sealpixel-stage {
66
+ grid-area: stage;
67
+ min-height: 160px;
68
+ }
69
+ sealpixel-nav {
70
+ grid-area: nav;
71
+ }
72
+ sealpixel-toolbar {
73
+ grid-area: toolbar;
74
+ }
75
+ .panel {
76
+ grid-area: panel;
77
+ background: var(--sealpixel-color-surface);
78
+ border-top: 1px solid var(--sealpixel-color-border);
79
+ padding: var(--sealpixel-space) calc(var(--sealpixel-space) * 1.5);
80
+ /*
81
+ * A fixed height, not a fitted one. A bar that grew and shrank as the
82
+ * user moved between tools would resize the stage under them, and the
83
+ * image would shift every time they changed their mind.
84
+ */
85
+ height: min(34cqh, var(--sealpixel-panel-size));
86
+ overflow: auto;
87
+ /*
88
+ * A bottom bar lays its blocks across the width and wraps, where the
89
+ * old right-hand column stacked them. Stacking five blocks in a bar
90
+ * that is one or two rows tall would push the last of them out of
91
+ * sight, so a block takes a full row only when it asks for one.
92
+ */
93
+ display: flex;
94
+ flex-wrap: wrap;
95
+ /*
96
+ * Safe centring places the blocks in the middle while they fit and
97
+ * at the start once they do not. Plain centring overflows a fixed bar
98
+ * at both ends, and nothing scrolls back to what sits above the top.
99
+ */
100
+ align-items: center;
101
+ align-content: safe center;
102
+ justify-content: safe center;
103
+ gap: var(--sealpixel-space) calc(var(--sealpixel-space) * 2);
104
+ scrollbar-width: thin;
105
+ scroll-behavior: smooth;
106
+ }
107
+ /*
108
+ * A dial needs room to be worth dragging, but it must not grow into all
109
+ * of it: a dial that stretches pushes the controls beside it against the
110
+ * two edges of the bar, and they stop reading as one group.
111
+ */
112
+ .panel > sealpixel-slider {
113
+ flex: 0 1 360px;
114
+ min-width: 240px;
115
+ }
116
+ /* Chips, presets and asset libraries always get their own full row. */
117
+ .panel > .strip,
118
+ .panel > .grid {
119
+ flex: 1 1 100%;
120
+ }
121
+ .panel > .row,
122
+ .panel > .rows,
123
+ .panel > .field,
124
+ .panel > .hint {
125
+ flex: 0 1 auto;
126
+ }
127
+ /*
128
+ * A tool panel has two kinds of control: the choice of what to work with
129
+ * (a shape, a frame style, a preset, a parameter) and the settings for
130
+ * whatever is chosen. The choice belongs at the bottom, next to the tool
131
+ * rail it continues, and its settings appear above it. Side by side they
132
+ * read as one undifferentiated row of buttons and it is not obvious which
133
+ * of them changes what.
134
+ *
135
+ * Ordering here rather than in each tool keeps a panel's source in
136
+ * reading order and lets a third-party tool join in with one class.
137
+ */
138
+ .panel > * {
139
+ order: 1;
140
+ }
141
+ /*
142
+ * Switching tools rebuilds the panel, so the rows rise on the new
143
+ * children and never on a value change inside the open tool, where Lit
144
+ * patches the same nodes.
145
+ *
146
+ * Transform only, deliberately. A fade would put the labels at partial
147
+ * opacity for a moment, and a contrast audit that samples the panel
148
+ * during that moment reports text it can read on screen as failing.
149
+ * Transform also keeps the panel's own height still, which the layout
150
+ * around it is measured against.
151
+ */
152
+ .panel > * {
153
+ animation: sealpixel-panel-in var(--sealpixel-duration) var(--sealpixel-ease-out) both;
154
+ }
155
+ .panel > *:nth-child(2) {
156
+ animation-delay: 25ms;
157
+ }
158
+ .panel > *:nth-child(n + 3) {
159
+ animation-delay: 50ms;
160
+ }
161
+ @keyframes sealpixel-panel-in {
162
+ from {
163
+ transform: translateY(8px);
164
+ }
165
+ }
166
+ /* Phones: option grids become one horizontal strip so the stage keeps its height. */
167
+ .panel .grid,
168
+ .panel .strip {
169
+ display: flex;
170
+ gap: var(--sealpixel-space);
171
+ overflow-x: auto;
172
+ scrollbar-width: none;
173
+ padding-bottom: 2px;
174
+ scroll-snap-type: x proximity;
175
+ }
176
+ .panel .grid::-webkit-scrollbar,
177
+ .panel .strip::-webkit-scrollbar {
178
+ display: none;
179
+ }
180
+ .panel .grid > *,
181
+ .panel .strip > * {
182
+ flex: 0 0 auto;
183
+ min-width: 96px;
184
+ scroll-snap-align: start;
185
+ }
186
+ .panel .strip > * {
187
+ min-width: 0;
188
+ }
189
+ /*
190
+ * The strip centres while it fits and scrolls from the first item once
191
+ * it does not, so a short row of presets is not stranded at the left
192
+ * edge of a wide bar.
193
+ */
194
+ .panel .strip {
195
+ justify-content: safe center;
196
+ align-items: end;
197
+ scroll-behavior: smooth;
198
+ }
199
+ .panel .chips {
200
+ gap: 2px;
201
+ }
202
+ /*
203
+ * A segmented control: one pill holds the row, the segments share it. Six
204
+ * separate buttons put six borders in a row and made the choice look like
205
+ * six unrelated actions; one container says they are one decision with
206
+ * one answer at a time.
207
+ */
208
+ .panel .group {
209
+ display: inline-flex;
210
+ gap: 2px;
211
+ padding: 2px;
212
+ background: var(--sealpixel-color-surface-2);
213
+ border-radius: var(--sealpixel-radius-pill);
214
+ flex: 0 0 auto;
215
+ }
216
+ .panel .group sealpixel-button::part(button) {
217
+ background: transparent;
218
+ border-color: transparent;
219
+ min-height: calc(var(--sealpixel-control-height) - 4px);
220
+ min-width: calc(var(--sealpixel-control-height) - 4px);
221
+ padding: 0 calc(var(--sealpixel-space) * 1.25);
222
+ }
223
+ .panel .group sealpixel-button:hover::part(button) {
224
+ background: color-mix(in srgb, var(--sealpixel-color-fg) 7%, transparent);
225
+ }
226
+ .panel .group sealpixel-button[pressed]::part(button) {
227
+ background: var(--sealpixel-color-surface);
228
+ color: var(--sealpixel-color-accent);
229
+ box-shadow: var(--sealpixel-shadow-sm, none);
230
+ }
231
+
232
+ /* Filter tiles: the image is the control, the label only names it. */
233
+ .panel .tiles {
234
+ gap: calc(var(--sealpixel-space) * 0.75);
235
+ }
236
+ /*
237
+ * The one control in the bar that gets bigger. Everywhere else the chrome
238
+ * shrank; here the chrome is the point, because a preset is judged by
239
+ * looking at it.
240
+ */
241
+ .panel .thumb {
242
+ display: block;
243
+ width: 100%;
244
+ height: 64px;
245
+ object-fit: cover;
246
+ border-radius: var(--sealpixel-radius);
247
+ background: var(--sealpixel-color-surface-2);
248
+ box-shadow: 0 0 0 1px var(--sealpixel-color-border);
249
+ transition:
250
+ box-shadow var(--sealpixel-duration) var(--sealpixel-ease),
251
+ transform var(--sealpixel-duration) var(--sealpixel-ease-spring);
252
+ }
253
+ .panel .tiles sealpixel-button {
254
+ min-width: 84px;
255
+ max-width: 84px;
256
+ }
257
+ .panel .tiles sealpixel-button::part(button) {
258
+ flex-direction: column;
259
+ gap: 6px;
260
+ padding: 0;
261
+ min-height: 0;
262
+ height: auto;
263
+ font-size: 11px;
264
+ background: transparent;
265
+ color: var(--sealpixel-color-fg-muted);
266
+ }
267
+ .panel .tiles sealpixel-button:hover .thumb {
268
+ transform: translateY(-2px);
269
+ }
270
+ .panel .tiles sealpixel-button[pressed] .thumb {
271
+ box-shadow:
272
+ 0 0 0 2px var(--sealpixel-color-surface),
273
+ 0 0 0 4px var(--sealpixel-color-accent);
274
+ }
275
+ .panel .tiles sealpixel-button[pressed]::part(button) {
276
+ background: transparent;
277
+ border-color: transparent;
278
+ color: var(--sealpixel-color-fg);
279
+ }
280
+ /* Separates the untouched image from the presets. */
281
+ .panel .divider {
282
+ flex: 0 0 auto;
283
+ align-self: stretch;
284
+ width: 1px;
285
+ min-width: 1px;
286
+ margin: 6px calc(var(--sealpixel-space) * 0.5);
287
+ background: var(--sealpixel-color-border);
288
+ opacity: 0.55;
289
+ }
290
+ .panel .row.compact > sealpixel-button {
291
+ flex: 0 0 auto;
292
+ min-width: 72px;
293
+ }
294
+ /* Phones: sibling groups share one line and hints make room for the stage. */
295
+ .panel .rows {
296
+ display: flex;
297
+ gap: var(--sealpixel-space);
298
+ align-items: end;
299
+ }
300
+ /*
301
+ * Content width, not an equal share. An equal share squeezed a text field
302
+ * and a colour well down to a few pixels each and their labels ran into
303
+ * one another; only a dial has a reason to take the leftover room.
304
+ */
305
+ .panel .rows > * {
306
+ flex: 0 1 auto;
307
+ min-width: 0;
308
+ }
309
+ /*
310
+ * A dial needs travel to be worth dragging. Inside a settings row it was
311
+ * sharing the width equally with a colour swatch and a checkbox and ended
312
+ * up a few ticks wide, which is not a control any more. The row grows to
313
+ * give it that width instead of shrinking to its contents.
314
+ */
315
+ /*
316
+ * A settings row scrolls sideways when it is wider than the bar, the way
317
+ * the choice row does. Letting it overflow instead left the last control
318
+ * cut off at the edge with nothing to say it was there.
319
+ */
320
+ .panel > .rows {
321
+ flex: 0 1 auto;
322
+ max-width: 100%;
323
+ overflow-x: auto;
324
+ overflow-y: hidden;
325
+ scrollbar-width: none;
326
+ scroll-behavior: smooth;
327
+ padding-bottom: 1px;
328
+ }
329
+ .panel > .rows::-webkit-scrollbar {
330
+ display: none;
331
+ }
332
+ .panel > .rows > * {
333
+ flex-shrink: 0;
334
+ }
335
+ .panel .rows > sealpixel-slider {
336
+ flex: 0 1 260px;
337
+ min-width: 220px;
338
+ }
339
+ .panel .rows .row {
340
+ flex-wrap: nowrap;
341
+ }
342
+ .panel .rows .row > sealpixel-button {
343
+ flex: 1 1 0;
344
+ min-width: 0;
345
+ }
346
+ /*
347
+ * A field fills its label, but never shrinks to nothing: a text box a
348
+ * few pixels wide hides what was typed into it, and a select that narrow
349
+ * hides which option is chosen.
350
+ */
351
+ .panel .rows select,
352
+ .panel .rows input:not([type='checkbox']):not([type='color']) {
353
+ width: 100%;
354
+ min-width: 7em;
355
+ }
356
+ .panel .rows input[type='text'],
357
+ .panel .rows textarea {
358
+ min-width: 11em;
359
+ }
360
+ .panel .rows > .field:has(input[type='color']),
361
+ .panel .rows > .field.check {
362
+ flex: 0 0 auto;
363
+ }
364
+ /* A colour well is square, which makes the disc round. */
365
+ .panel .rows > .field:has(input[type='color']) input {
366
+ width: var(--sealpixel-control-height);
367
+ aspect-ratio: 1;
368
+ }
369
+ .panel .swatches {
370
+ display: flex;
371
+ flex-wrap: wrap;
372
+ gap: 6px;
373
+ margin-bottom: 6px;
374
+ }
375
+ .panel .swatch {
376
+ width: 24px;
377
+ height: 24px;
378
+ border-radius: 50%;
379
+ border: 0;
380
+ box-shadow: 0 0 0 1px var(--sealpixel-color-border);
381
+ cursor: pointer;
382
+ padding: 0;
383
+ transition:
384
+ transform var(--sealpixel-duration) var(--sealpixel-ease-spring),
385
+ border-color var(--sealpixel-transition),
386
+ box-shadow var(--sealpixel-duration) var(--sealpixel-ease);
387
+ }
388
+ .panel .swatch:hover {
389
+ transform: scale(1.12);
390
+ }
391
+ .panel .swatch:active {
392
+ transform: scale(0.94);
393
+ }
394
+ /* The ring sits off the disc, so the colour itself is never covered. */
395
+ .panel .swatch[aria-pressed='true'] {
396
+ box-shadow:
397
+ 0 0 0 2px var(--sealpixel-color-surface),
398
+ 0 0 0 3.5px var(--sealpixel-color-accent);
399
+ }
400
+ .panel:empty {
401
+ display: none;
402
+ }
403
+ .toast {
404
+ position: absolute;
405
+ left: 50%;
406
+ bottom: calc(var(--sealpixel-toolbar-size) + var(--sealpixel-space) * 2);
407
+ /* Sits above the stage, never over the bar the user is working in. */
408
+ transform: translateX(-50%);
409
+ display: flex;
410
+ align-items: center;
411
+ gap: var(--sealpixel-space);
412
+ max-width: calc(100% - var(--sealpixel-space) * 4);
413
+ padding: calc(var(--sealpixel-space) * 1.25) calc(var(--sealpixel-space) * 2);
414
+ border-radius: 999px;
415
+ background: color-mix(in srgb, var(--sealpixel-color-surface) 92%, transparent);
416
+ border: 1px solid var(--sealpixel-color-border);
417
+ box-shadow: var(--sealpixel-shadow, 0 8px 24px rgba(0, 0, 0, 0.3));
418
+ color: var(--sealpixel-color-fg);
419
+ font-size: 13px;
420
+ white-space: nowrap;
421
+ backdrop-filter: blur(8px);
422
+ cursor: pointer;
423
+ z-index: 2;
424
+ animation: sealpixel-toast-in var(--sealpixel-duration) var(--sealpixel-ease-out) both;
425
+ }
426
+ .toast:hover {
427
+ border-color: var(--sealpixel-color-fg-muted);
428
+ }
429
+ .toast code {
430
+ font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
431
+ color: var(--sealpixel-color-fg-muted);
432
+ padding: 2px 8px;
433
+ border-radius: 999px;
434
+ background: var(--sealpixel-color-surface-2);
435
+ }
436
+ @keyframes sealpixel-toast-in {
437
+ from {
438
+ opacity: 0;
439
+ transform: translate(-50%, 12px) scale(0.96);
440
+ }
441
+ }
442
+ /*
443
+ * Wide layouts move the tool rail to the left edge and nothing else. The
444
+ * panel stays a bottom bar at every width, so a control sits in the same
445
+ * place on a phone and on a desktop, and the stage keeps the full width
446
+ * it needs for a landscape image.
447
+ */
448
+ @container (min-width: 48rem) {
449
+ /*
450
+ * The rail runs the full height beside both the stage and the bar.
451
+ * Stopping it above the bar cost it the bar's height, and with ten or
452
+ * more tools the last of them ended up scrolled out of sight behind
453
+ * the settings of the one already open.
454
+ */
455
+ .root {
456
+ grid-template:
457
+ 'nav nav' auto
458
+ 'toolbar stage' 1fr
459
+ 'toolbar panel' auto / var(--sealpixel-toolbar-size) 1fr;
460
+ }
461
+ .panel {
462
+ height: var(--sealpixel-panel-size);
463
+ padding: var(--sealpixel-space) calc(var(--sealpixel-space) * 2);
464
+ }
465
+ .toast {
466
+ bottom: calc(var(--sealpixel-space) * 2);
467
+ }
468
+ }
469
+ .panel .row {
470
+ display: flex;
471
+ gap: var(--sealpixel-space);
472
+ flex-wrap: wrap;
473
+ }
474
+ .panel > .choice {
475
+ order: 2;
476
+ flex: 1 1 100%;
477
+ justify-content: safe center;
478
+ /*
479
+ * Pinned to the foot of the bar. The choice is what gets the user out
480
+ * of the tool they are in, so it has to stay reachable even when a
481
+ * busy panel scrolls its settings.
482
+ */
483
+ position: sticky;
484
+ bottom: 0;
485
+ z-index: 1;
486
+ background: var(--sealpixel-color-surface);
487
+ padding-top: 4px;
488
+ /*
489
+ * One line that scrolls, never two that wrap. A wrapped second line
490
+ * falls below the bar and takes whatever was on it out of reach, and
491
+ * on a phone that was the reset button.
492
+ */
493
+ flex-wrap: nowrap;
494
+ overflow-x: auto;
495
+ overflow-y: hidden;
496
+ scrollbar-width: none;
497
+ scroll-behavior: smooth;
498
+ }
499
+ .panel > .choice::-webkit-scrollbar {
500
+ display: none;
501
+ }
502
+ .panel > .choice > * {
503
+ flex-shrink: 0;
504
+ }
505
+ .panel .field {
506
+ display: grid;
507
+ gap: 3px;
508
+ justify-items: center;
509
+ color: var(--sealpixel-color-fg-muted);
510
+ }
511
+ /* The label names the control; it is not the thing being read. */
512
+ .panel .field > span,
513
+ .panel .field > .lab {
514
+ font-size: 11px;
515
+ letter-spacing: 0.012em;
516
+ line-height: 1.2;
517
+ }
518
+ .panel .hint {
519
+ margin: 0;
520
+ color: var(--sealpixel-color-fg-muted);
521
+ font-size: 12px;
522
+ }
523
+ /*
524
+ * Width and height are one measurement, so they sit against the link that
525
+ * binds them rather than being two fields that happen to be adjacent.
526
+ */
527
+ .panel .dims {
528
+ display: inline-flex;
529
+ align-items: center;
530
+ gap: 4px;
531
+ }
532
+ .panel .dims input[type='number'] {
533
+ width: 5.5em;
534
+ min-width: 0;
535
+ text-align: center;
536
+ font-variant-numeric: tabular-nums;
537
+ appearance: textfield;
538
+ }
539
+ .panel .dims input::-webkit-outer-spin-button,
540
+ .panel .dims input::-webkit-inner-spin-button {
541
+ appearance: none;
542
+ margin: 0;
543
+ }
544
+ /* A value the link worked out, not one that was typed. */
545
+ .panel .dims input.derived {
546
+ color: var(--sealpixel-color-fg-muted);
547
+ font-weight: 400;
548
+ }
549
+ .panel .dims sealpixel-button::part(button) {
550
+ min-width: calc(var(--sealpixel-control-height) - 6px);
551
+ min-height: calc(var(--sealpixel-control-height) - 6px);
552
+ padding: 0;
553
+ background: transparent;
554
+ color: var(--sealpixel-color-fg-muted);
555
+ }
556
+ .panel .dims sealpixel-button[pressed]::part(button) {
557
+ background: var(--sealpixel-color-accent-soft);
558
+ color: var(--sealpixel-color-accent);
559
+ }
560
+ .panel .anchors {
561
+ display: grid;
562
+ grid-template-columns: repeat(3, 1fr);
563
+ gap: 4px;
564
+ max-width: 180px;
565
+ }
566
+ .panel .field.check {
567
+ grid-template-columns: auto 1fr;
568
+ align-items: center;
569
+ }
570
+ .panel textarea {
571
+ font: inherit;
572
+ color: var(--sealpixel-color-fg);
573
+ background: var(--sealpixel-color-surface-2);
574
+ border: 1px solid var(--sealpixel-color-border);
575
+ border-radius: var(--sealpixel-radius-sm);
576
+ padding: var(--sealpixel-space);
577
+ resize: vertical;
578
+ }
579
+ /* A colour is a disc, not a box: it shows the colour and nothing else. */
580
+ .panel input[type='color'] {
581
+ /* Without this the engine keeps its own swatch metrics inside the box
582
+ and the disc comes out an ellipse. */
583
+ appearance: none;
584
+ min-height: var(--sealpixel-control-height);
585
+ height: var(--sealpixel-control-height);
586
+ width: var(--sealpixel-control-height);
587
+ padding: 0;
588
+ border: 0;
589
+ border-radius: 50%;
590
+ background: transparent;
591
+ cursor: pointer;
592
+ overflow: hidden;
593
+ }
594
+ /* The swatch has to be told to fill the box; it does not by default. */
595
+ .panel input[type='color']::-webkit-color-swatch-wrapper {
596
+ padding: 0;
597
+ width: 100%;
598
+ height: 100%;
599
+ }
600
+ .panel input[type='color']::-webkit-color-swatch {
601
+ width: 100%;
602
+ height: 100%;
603
+ border: 0;
604
+ border-radius: 50%;
605
+ box-shadow: 0 0 0 1px var(--sealpixel-color-border);
606
+ }
607
+ .panel input[type='color']::-moz-color-swatch {
608
+ width: 100%;
609
+ height: 100%;
610
+ border: 0;
611
+ border-radius: 50%;
612
+ box-shadow: 0 0 0 1px var(--sealpixel-color-border);
613
+ }
614
+ .panel input:not([type='checkbox']):not([type='color']),
615
+ .panel select {
616
+ font: inherit;
617
+ font-size: 13px;
618
+ color: var(--sealpixel-color-fg);
619
+ background: var(--sealpixel-color-surface-2);
620
+ border: 1px solid transparent;
621
+ border-radius: var(--sealpixel-radius-pill);
622
+ min-height: var(--sealpixel-control-height);
623
+ padding: 0 calc(var(--sealpixel-space) * 1.5);
624
+ }
625
+ /*
626
+ * The chevron is drawn rather than left to the platform, so a select sits
627
+ * next to a pill button instead of looking like a form field that walked
628
+ * in from another page. The right padding keeps the text off it.
629
+ */
630
+ .panel select {
631
+ appearance: none;
632
+ padding-right: calc(var(--sealpixel-space) * 3);
633
+ background-image:
634
+ linear-gradient(45deg, transparent 50%, currentColor 50%),
635
+ linear-gradient(135deg, currentColor 50%, transparent 50%);
636
+ background-size: 5px 5px, 5px 5px;
637
+ background-position:
638
+ right 14px top 52%,
639
+ right 10px top 52%;
640
+ background-repeat: no-repeat;
641
+ color: var(--sealpixel-color-fg);
642
+ }
643
+ .empty {
644
+ grid-area: stage;
645
+ display: grid;
646
+ place-content: center;
647
+ gap: var(--sealpixel-space);
648
+ text-align: center;
649
+ color: var(--sealpixel-color-fg-muted);
650
+ padding: var(--sealpixel-space);
651
+ }
652
+ .empty {
653
+ transition:
654
+ background var(--sealpixel-duration) var(--sealpixel-ease),
655
+ color var(--sealpixel-duration) var(--sealpixel-ease);
656
+ }
657
+ .empty.dragover {
658
+ outline: 2px dashed var(--sealpixel-color-accent);
659
+ outline-offset: -8px;
660
+ background: var(--sealpixel-color-accent-soft, transparent);
661
+ color: var(--sealpixel-color-fg);
662
+ }
663
+ .error {
664
+ grid-area: nav;
665
+ align-self: end;
666
+ justify-self: center;
667
+ margin-top: calc(var(--sealpixel-toolbar-size) + 4px);
668
+ z-index: 1;
669
+ padding: 6px 10px;
670
+ border-radius: var(--sealpixel-radius);
671
+ background: var(--sealpixel-color-danger);
672
+ color: white;
673
+ font-size: 12px;
674
+ box-shadow: var(--sealpixel-shadow-raised, none);
675
+ animation: sealpixel-panel-in var(--sealpixel-duration) var(--sealpixel-ease-out) both;
676
+ }
677
+ `,
678
+ ]; }
679
+ static { this.properties = {
680
+ plugins: { attribute: false },
681
+ pluginOptions: { attribute: 'plugin-options', converter: jsonAttribute },
682
+ session: { attribute: false },
683
+ renderer: { attribute: false },
684
+ assets: { attribute: false },
685
+ server: { attribute: false },
686
+ serverEndpoint: { type: String, attribute: 'server-endpoint' },
687
+ src: { attribute: false },
688
+ editLog: { attribute: false },
689
+ defaultEditLog: { attribute: false },
690
+ output: { attribute: false },
691
+ theme: { type: String, reflect: true },
692
+ locale: { type: String },
693
+ dictionary: { attribute: false },
694
+ tools: { attribute: false },
695
+ activeTool: { type: String, attribute: 'active-tool' },
696
+ showExport: { type: String, attribute: 'show-export' },
697
+ showDownload: { type: String, attribute: 'show-download' },
698
+ showHistory: { type: String, attribute: 'show-history' },
699
+ downloadLabel: { type: String, attribute: 'download-label' },
700
+ _busy: { state: true },
701
+ _processListeners: { state: true },
702
+ _toast: { state: true },
703
+ _dragover: { state: true },
704
+ _wide: { state: true },
705
+ _pluginTools: { state: true },
706
+ }; }
707
+ /**
708
+ * Used when no `renderer` property is set. `@sealpixel/element` installs a
709
+ * factory that starts the bundled worker; without it the editor renders inline.
710
+ */
711
+ static { this.defaultRendererFactory = null; }
712
+ constructor() {
713
+ super();
714
+ this.resizeObserver = null;
715
+ this.toolLoads = new Set();
716
+ this.ctl = new SessionController(this, null);
717
+ this.i18n = createI18n('en');
718
+ this.ownsSession = false;
719
+ this.lastEmittedVersion = -1;
720
+ this.lastAppliedControlled = '';
721
+ this.hint = null;
722
+ this.previousTool = null;
723
+ this.loadedSrc = null;
724
+ this.lastError = null;
725
+ this.sessionRenderer = null;
726
+ this.sessionAssets = null;
727
+ this.sessionServer = null;
728
+ this.onPointerDown = () => {
729
+ if (!this.matches(':focus-within'))
730
+ this.focus({ preventScroll: true });
731
+ };
732
+ this.onKeydown = (e) => {
733
+ const session = this.ctl.session;
734
+ if (!session || session.getState().status !== 'ready')
735
+ return;
736
+ const mod = e.metaKey || e.ctrlKey;
737
+ if (this.showHistory === 'none')
738
+ return;
739
+ if (mod && e.key.toLowerCase() === 'z') {
740
+ e.preventDefault();
741
+ if (e.shiftKey)
742
+ session.redo();
743
+ else
744
+ session.undo();
745
+ }
746
+ else if (mod && e.key.toLowerCase() === 'y') {
747
+ e.preventDefault();
748
+ session.redo();
749
+ }
750
+ };
751
+ this.toastTimer = null;
752
+ this.plugins = null;
753
+ this.pluginOptions = null;
754
+ this.session = null;
755
+ this.renderer = null;
756
+ this.assets = null;
757
+ this.server = null;
758
+ this.serverEndpoint = null;
759
+ this.src = null;
760
+ this.editLog = undefined;
761
+ this.defaultEditLog = undefined;
762
+ this.output = { format: 'image/png' };
763
+ this.theme = 'dark';
764
+ this.locale = 'en';
765
+ this.dictionary = null;
766
+ this.tools = null;
767
+ this.activeTool = null;
768
+ this.showExport = 'auto';
769
+ this.showDownload = 'always';
770
+ this.showHistory = 'all';
771
+ this.downloadLabel = null;
772
+ this._busy = false;
773
+ this._processListeners = 0;
774
+ this._toast = null;
775
+ this._dragover = false;
776
+ this._wide = false;
777
+ this._pluginTools = {};
778
+ }
779
+ addEventListener(type, listener, options) {
780
+ super.addEventListener(type, listener, options);
781
+ if (type === 'sealpixel:process')
782
+ this._processListeners += 1;
783
+ }
784
+ removeEventListener(type, listener, options) {
785
+ super.removeEventListener(type, listener, options);
786
+ if (type === 'sealpixel:process' && this._processListeners > 0)
787
+ this._processListeners -= 1;
788
+ }
789
+ /** A renderer the editor made through the factory dies with the session; a host's renderer lives on. */
790
+ disposeOwnedRenderer() {
791
+ if (this.sessionRenderer && this.sessionRenderer !== this.renderer) {
792
+ this.sessionRenderer.dispose();
793
+ }
794
+ this.sessionRenderer = null;
795
+ }
796
+ /** `plugins` (or the defaults) with `pluginOptions` applied. Invalid options raise `sealpixel:error`. */
797
+ effectivePlugins() {
798
+ const base = this.plugins ?? defaultPlugins;
799
+ if (!this.pluginOptions)
800
+ return base;
801
+ try {
802
+ return configurePlugins(base, this.pluginOptions);
803
+ }
804
+ catch (error) {
805
+ this.dispatchEvent(new CustomEvent('sealpixel:error', { detail: error, bubbles: true, composed: true }));
806
+ return base;
807
+ }
808
+ }
809
+ /** The live session, created on first connection when none was passed. */
810
+ getSession() {
811
+ if (!this.ctl.session)
812
+ this.ensureSession();
813
+ return this.ctl.session;
814
+ }
815
+ getEditLog() {
816
+ return this.getSession().getEditLog();
817
+ }
818
+ setEditLog(saved, index) {
819
+ this.getSession().setEditLog(saved, index);
820
+ }
821
+ undo() {
822
+ this.getSession().undo();
823
+ }
824
+ redo() {
825
+ this.getSession().redo();
826
+ }
827
+ timeTravel(index) {
828
+ this.getSession().timeTravel(index);
829
+ }
830
+ async export(output = this.output, options = {}) {
831
+ return this.getSession().export({ output, ...options });
832
+ }
833
+ async download(options = {}) {
834
+ return this.getSession().download({ output: this.output, ...options });
835
+ }
836
+ ensureSession() {
837
+ if (this.ctl.session)
838
+ return;
839
+ if (this.session) {
840
+ this.ctl.session = this.session;
841
+ return;
842
+ }
843
+ const plugins = this.effectivePlugins();
844
+ const renderer = this.renderer ?? SealpixelEditor.defaultRendererFactory?.(plugins) ?? null;
845
+ this.sessionRenderer = renderer;
846
+ const assets = this.assets ?? createAssetResolver({ assets: plugins.flatMap((p) => p.assets ?? []) });
847
+ const server = this.server ?? (this.serverEndpoint ? { endpoint: this.serverEndpoint } : null);
848
+ this.sessionServer = server;
849
+ this.sessionAssets = this.assets;
850
+ const session = createEditorSession({
851
+ plugins,
852
+ assets,
853
+ ...(renderer ? { renderer } : {}),
854
+ ...(server ? { server } : {}),
855
+ });
856
+ this.ownsSession = true;
857
+ this.ctl.session = session;
858
+ this.dispatchEvent(new CustomEvent('sealpixel:ready', { bubbles: true, composed: true }));
859
+ }
860
+ connectedCallback() {
861
+ super.connectedCallback();
862
+ this.ensureSession();
863
+ // Keyboard shortcuts need focus inside the editor. WebKit does not focus
864
+ // buttons on click, so pull focus in on any pointer interaction.
865
+ if (!this.hasAttribute('tabindex'))
866
+ this.tabIndex = -1;
867
+ this.addEventListener('keydown', this.onKeydown);
868
+ this.addEventListener('pointerdown', this.onPointerDown);
869
+ this.resizeObserver = new ResizeObserver((entries) => {
870
+ const width = entries[0]?.contentRect.width ?? this.clientWidth;
871
+ this._wide = width >= 768;
872
+ });
873
+ this.resizeObserver.observe(this);
874
+ }
875
+ disconnectedCallback() {
876
+ super.disconnectedCallback();
877
+ this.removeEventListener('keydown', this.onKeydown);
878
+ this.removeEventListener('pointerdown', this.onPointerDown);
879
+ this.resizeObserver?.disconnect();
880
+ if (this.ownsSession) {
881
+ this.ctl.session?.dispose();
882
+ this.disposeOwnedRenderer();
883
+ this.ctl.session = null;
884
+ this.ownsSession = false;
885
+ }
886
+ }
887
+ willUpdate(changed) {
888
+ if (changed.has('locale') || changed.has('dictionary')) {
889
+ this.i18n = createI18n(this.locale, this.dictionary ?? resolveLocale(this.locale));
890
+ }
891
+ if (changed.has('session') && this.session && this.session !== this.ctl.session) {
892
+ if (this.ownsSession) {
893
+ this.ctl.session?.dispose();
894
+ this.disposeOwnedRenderer();
895
+ }
896
+ this.ownsSession = false;
897
+ this.ctl.session = this.session;
898
+ }
899
+ // A new plugin list or renderer needs a new session; reload the current source into it.
900
+ if ((changed.has('plugins') ||
901
+ changed.has('pluginOptions') ||
902
+ changed.has('renderer') ||
903
+ changed.has('assets') ||
904
+ changed.has('server') ||
905
+ changed.has('serverEndpoint')) &&
906
+ this.ownsSession &&
907
+ !this.sessionMatchesProps()) {
908
+ this.ctl.session?.dispose();
909
+ this.disposeOwnedRenderer();
910
+ this.ctl.session = null;
911
+ this.ownsSession = false;
912
+ this.toolLoads.clear();
913
+ this._pluginTools = {};
914
+ this.lastEmittedVersion = -1;
915
+ this.ensureSession();
916
+ if (this.loadedSrc)
917
+ void this.loadSource(this.loadedSrc);
918
+ }
919
+ if (changed.has('src') && this.src && this.src !== this.loadedSrc) {
920
+ this.loadedSrc = this.src;
921
+ void this.loadSource(this.src);
922
+ }
923
+ if (changed.has('editLog') && this.editLog !== undefined)
924
+ this.applyControlled();
925
+ }
926
+ updated() {
927
+ const state = this.ctl.state;
928
+ const session = this.ctl.session;
929
+ if (!state || !session)
930
+ return;
931
+ if (state.version !== this.lastEmittedVersion && state.status === 'ready') {
932
+ this.lastEmittedVersion = state.version;
933
+ const detail = session.getEditLog();
934
+ this.lastAppliedControlled = canonicalJson(detail);
935
+ this.dispatchEvent(new CustomEvent('sealpixel:change', { detail, bubbles: true, composed: true }));
936
+ }
937
+ if (state.error && state.error !== this.lastError) {
938
+ this.lastError = state.error;
939
+ this.dispatchEvent(new CustomEvent('sealpixel:error', { detail: state.error, bubbles: true, composed: true }));
940
+ }
941
+ this.syncActiveTool();
942
+ }
943
+ async loadSource(src) {
944
+ const session = this.getSession();
945
+ try {
946
+ const saved = this.editLog !== undefined ? this.editLog : this.defaultEditLog;
947
+ const bytes = src instanceof Uint8Array ? src : src;
948
+ const descriptor = await session.load(bytes, saved === undefined ? {} : { editLog: saved });
949
+ // Option defaults become real ops in an otherwise empty log.
950
+ if (saved === undefined && session.getState().history.log.ops.length === 0) {
951
+ for (const op of session.registry.initialOps(descriptor)) {
952
+ session.dispatch({
953
+ ...op,
954
+ id: `init-${Math.random().toString(36).slice(2, 8)}`,
955
+ });
956
+ }
957
+ }
958
+ this.dispatchEvent(new CustomEvent('sealpixel:load', { detail: descriptor, bubbles: true, composed: true }));
959
+ if (!this.activeTool) {
960
+ const first = this.currentTools()[0];
961
+ if (first)
962
+ this.activeTool = first.id;
963
+ }
964
+ }
965
+ catch (error) {
966
+ this.dispatchEvent(new CustomEvent('sealpixel:error', { detail: error, bubbles: true, composed: true }));
967
+ }
968
+ }
969
+ applyControlled() {
970
+ const session = this.ctl.session;
971
+ if (!session || session.getState().status !== 'ready' || this.editLog === undefined)
972
+ return;
973
+ const wanted = this.editLog;
974
+ if (wanted === null)
975
+ return;
976
+ const key = canonicalJson(wanted);
977
+ if (key === this.lastAppliedControlled)
978
+ return;
979
+ try {
980
+ session.setEditLog(wanted, wanted.index);
981
+ this.lastAppliedControlled = key;
982
+ }
983
+ catch (error) {
984
+ this.dispatchEvent(new CustomEvent('sealpixel:error', { detail: error, bubbles: true, composed: true }));
985
+ }
986
+ }
987
+ /** Whether the owned session was built from the current `plugins` and `renderer`. */
988
+ sessionMatchesProps() {
989
+ const session = this.ctl.session;
990
+ if (!session)
991
+ return false;
992
+ if (this.renderer && this.renderer !== this.sessionRenderer)
993
+ return false;
994
+ if (this.assets && this.assets !== this.sessionAssets)
995
+ return false;
996
+ const wantServer = this.server ?? (this.serverEndpoint ? { endpoint: this.serverEndpoint } : null);
997
+ if (JSON.stringify(wantServer?.endpoint ?? null) !==
998
+ JSON.stringify(this.sessionServer?.endpoint ?? null))
999
+ return false;
1000
+ if (wantServer !== this.sessionServer && wantServer?.fetch !== this.sessionServer?.fetch)
1001
+ return false;
1002
+ // Compare id, version and resolved options (options JSON may contain commas, so no string splitting).
1003
+ const key = (plugins) => canonicalJson([...plugins]
1004
+ .sort((a, b) => a.id.localeCompare(b.id))
1005
+ .map((p) => [p.id, p.version, p.options ?? null]));
1006
+ return key(this.effectivePlugins()) === key(session.registry.plugins);
1007
+ }
1008
+ currentTools() {
1009
+ const plugins = this.effectivePlugins();
1010
+ this.loadPluginTools(plugins);
1011
+ return toolsFor(plugins, { ...this._pluginTools, ...(this.tools ?? {}) });
1012
+ }
1013
+ /** Plugins may ship their own tool; load each once and re-render when it arrives. */
1014
+ loadPluginTools(plugins) {
1015
+ for (const plugin of plugins) {
1016
+ if (!plugin.tool || this.toolLoads.has(plugin.id))
1017
+ continue;
1018
+ this.toolLoads.add(plugin.id);
1019
+ plugin
1020
+ .tool()
1021
+ .then((tool) => {
1022
+ this._pluginTools = { ...this._pluginTools, [plugin.id]: tool };
1023
+ })
1024
+ .catch((error) => {
1025
+ this.dispatchEvent(new CustomEvent('sealpixel:error', { detail: error, bubbles: true, composed: true }));
1026
+ });
1027
+ }
1028
+ }
1029
+ get stage() {
1030
+ return this.renderRoot.querySelector('sealpixel-stage');
1031
+ }
1032
+ toolContext(state) {
1033
+ const session = this.getSession();
1034
+ const stage = this.stage;
1035
+ return {
1036
+ session,
1037
+ state,
1038
+ document: state.document,
1039
+ dispatch: (op) => {
1040
+ try {
1041
+ session.dispatch(op);
1042
+ }
1043
+ catch (error) {
1044
+ // A tool asked for something the options forbid: tell the user, keep the log intact.
1045
+ if (error instanceof SealpixelError && error.name === 'OPTION_VIOLATION') {
1046
+ this._toast = { text: error.message, hash: '' };
1047
+ this.announce(error.message);
1048
+ return;
1049
+ }
1050
+ throw error;
1051
+ }
1052
+ },
1053
+ t: this.i18n.t,
1054
+ stage: {
1055
+ width: state.preview?.image.width ?? 0,
1056
+ height: state.preview?.image.height ?? 0,
1057
+ zoom: stage?.zoom ?? 1,
1058
+ geometry: state.preview?.geometry ?? null,
1059
+ toImage: (x, y) => stage?.toImage(x, y) ?? { x: 0, y: 0 },
1060
+ },
1061
+ hint: (hint) => {
1062
+ this.hint = hint;
1063
+ const s = this.stage;
1064
+ if (s) {
1065
+ s.hintFilter = hint?.filter ?? '';
1066
+ s.hintTransform = hint?.transform ?? '';
1067
+ }
1068
+ },
1069
+ refresh: () => this.requestUpdate(),
1070
+ animateNextView: () => this.stage?.glideNextView(),
1071
+ clipPreview: (clip) => {
1072
+ const s = this.stage;
1073
+ if (s)
1074
+ s.clip = clip ?? '';
1075
+ },
1076
+ layout: this._wide ? 'wide' : 'narrow',
1077
+ options: (pluginId) => session.registry.byId.get(pluginId)?.options,
1078
+ };
1079
+ }
1080
+ syncActiveTool() {
1081
+ const state = this.ctl.state;
1082
+ if (!state || state.status !== 'ready')
1083
+ return;
1084
+ const tool = this.currentTools().find((t) => t.id === this.activeTool) ?? null;
1085
+ if (tool === this.previousTool)
1086
+ return;
1087
+ const ctx = this.toolContext(state);
1088
+ this.previousTool?.deactivate?.(ctx);
1089
+ tool?.activate?.(ctx);
1090
+ this.previousTool = tool;
1091
+ }
1092
+ async onExport() {
1093
+ const session = this.getSession();
1094
+ this._busy = true;
1095
+ try {
1096
+ const result = await session.export({ output: this.output });
1097
+ this.dispatchEvent(new CustomEvent('sealpixel:process', { detail: result, bubbles: true, composed: true }));
1098
+ this.toast('toast.exported', result);
1099
+ }
1100
+ catch (error) {
1101
+ this.dispatchEvent(new CustomEvent('sealpixel:error', { detail: error, bubbles: true, composed: true }));
1102
+ }
1103
+ finally {
1104
+ this._busy = false;
1105
+ }
1106
+ }
1107
+ async onDownload() {
1108
+ const session = this.getSession();
1109
+ this._busy = true;
1110
+ try {
1111
+ const { result } = await session.download({ output: this.output });
1112
+ this.dispatchEvent(new CustomEvent('sealpixel:process', { detail: result, bubbles: true, composed: true }));
1113
+ this.toast('toast.downloaded', result);
1114
+ }
1115
+ catch (error) {
1116
+ this.dispatchEvent(new CustomEvent('sealpixel:error', { detail: error, bubbles: true, composed: true }));
1117
+ }
1118
+ finally {
1119
+ this._busy = false;
1120
+ }
1121
+ }
1122
+ announce(text) {
1123
+ const live = this.renderRoot.querySelector('#live');
1124
+ if (live)
1125
+ live.textContent = text;
1126
+ }
1127
+ /** Brief confirmation over the stage: format, size and hash of the result. */
1128
+ toast(key, result) {
1129
+ const format = result.mimeType.replace('image/', '').toUpperCase();
1130
+ const size = result.bytes ? formatBytes(result.bytes.byteLength) : 'server';
1131
+ const text = this.i18n.t(key, { format, width: result.width, height: result.height, size });
1132
+ this._toast = { text, hash: result.hash.slice(0, 12) };
1133
+ this.announce(text);
1134
+ if (this.toastTimer)
1135
+ clearTimeout(this.toastTimer);
1136
+ this.toastTimer = setTimeout(() => {
1137
+ this._toast = null;
1138
+ this.toastTimer = null;
1139
+ }, 5000);
1140
+ }
1141
+ onFiles(files) {
1142
+ const file = files?.[0];
1143
+ if (file)
1144
+ this.src = file;
1145
+ }
1146
+ render() {
1147
+ const state = this.ctl.state;
1148
+ const tools = this.currentTools();
1149
+ const tool = tools.find((t) => t.id === this.activeTool) ?? null;
1150
+ const ready = state?.status === 'ready';
1151
+ const ctx = state && ready ? this.toolContext(state) : null;
1152
+ const wide = this._wide;
1153
+ return html `
1154
+ <div class="root" part="root">
1155
+ <sealpixel-nav
1156
+ part="nav"
1157
+ exportparts="undo, redo, history, export, download, dimensions, button"
1158
+ .state=${state}
1159
+ .t=${this.i18n.t}
1160
+ .busy=${this._busy}
1161
+ .canExport=${(state?.resident ?? false) || this.server !== null || this.serverEndpoint !== null}
1162
+ .showExport=${this.showExport === 'always' || (this.showExport === 'auto' && this._processListeners > 0)}
1163
+ .showDownload=${this.showDownload !== 'never'}
1164
+ .showHistory=${this.showHistory}
1165
+ .downloadLabel=${this.downloadLabel}
1166
+ @nav-undo=${() => this.getSession().undo()}
1167
+ @nav-redo=${() => this.getSession().redo()}
1168
+ @nav-time-travel=${(e) => this.getSession().timeTravel(e.detail)}
1169
+ @nav-export=${() => this.onExport()}
1170
+ @nav-download=${() => this.onDownload()}
1171
+ ></sealpixel-nav>
1172
+ ${ready
1173
+ ? html `<sealpixel-stage
1174
+ part="stage"
1175
+ exportparts="viewport, canvas, overlay, status"
1176
+ .preview=${state?.preview ?? null}
1177
+ .t=${this.i18n.t}
1178
+ .pending=${state?.previewPending ?? false}
1179
+ .overlay=${ctx && tool?.overlay ? tool.overlay(ctx) : null}
1180
+ @stage-view=${() => this.requestUpdate()}
1181
+ ></sealpixel-stage>`
1182
+ : this.renderEmpty(state)}
1183
+ <div class="panel" part="panel">${ctx && tool ? tool.panel(ctx) : nothing}</div>
1184
+ <sealpixel-toolbar
1185
+ part="toolbar"
1186
+ exportparts="tool"
1187
+ .tools=${tools}
1188
+ .active=${this.activeTool}
1189
+ .t=${this.i18n.t}
1190
+ .orientation=${wide ? 'vertical' : 'horizontal'}
1191
+ @tool-select=${(e) => {
1192
+ this.activeTool = e.detail;
1193
+ }}
1194
+ ></sealpixel-toolbar>
1195
+ ${state?.error ? html `<div class="error" role="alert">${this.i18n.t('editor.error', { message: state.error.message })}</div>` : nothing}
1196
+ ${this._toast
1197
+ ? html `<div class="toast" part="toast" data-testid="toast" @click=${() => {
1198
+ this._toast = null;
1199
+ }}>
1200
+ <span>${this._toast.text}</span>
1201
+ ${this._toast.hash ? html `<code>${this._toast.hash}</code>` : nothing}
1202
+ </div>`
1203
+ : nothing}
1204
+ <span id="live" class="visually-hidden" role="status" aria-live="polite"></span>
1205
+ </div>
1206
+ `;
1207
+ }
1208
+ renderEmpty(state) {
1209
+ if (state?.status === 'loading') {
1210
+ return html `<div class="empty" part="empty" role="status">${this.i18n.t('editor.loading')}</div>`;
1211
+ }
1212
+ return html `<div
1213
+ class="empty ${this._dragover ? 'dragover' : ''}"
1214
+ part="empty"
1215
+ @dragover=${(e) => {
1216
+ e.preventDefault();
1217
+ this._dragover = true;
1218
+ }}
1219
+ @dragleave=${() => {
1220
+ this._dragover = false;
1221
+ }}
1222
+ @drop=${(e) => {
1223
+ e.preventDefault();
1224
+ this._dragover = false;
1225
+ this.onFiles(e.dataTransfer?.files ?? null);
1226
+ }}
1227
+ >
1228
+ <p>${this.i18n.t('editor.empty')}</p>
1229
+ <label>
1230
+ <input class="visually-hidden" type="file" accept="image/png,image/jpeg,image/webp" @change=${(e) => this.onFiles(e.target.files)} />
1231
+ <sealpixel-button variant="primary" @click=${(e) => e.currentTarget.previousElementSibling.click()}>
1232
+ ${this.i18n.t('editor.choose')}
1233
+ </sealpixel-button>
1234
+ </label>
1235
+ </div>`;
1236
+ }
1237
+ }
1238
+ function formatBytes(n) {
1239
+ if (n < 1024)
1240
+ return `${n} B`;
1241
+ if (n < 1024 * 1024)
1242
+ return `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB`;
1243
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
1244
+ }
1245
+ //# sourceMappingURL=sealpixel-editor.js.map