slidev-theme-practicum 0.1.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 (88) hide show
  1. package/README.md +270 -0
  2. package/components/DebugGrid.vue +50 -0
  3. package/components/DecorLibrary.vue +1117 -0
  4. package/components/Header.vue +124 -0
  5. package/components/Image.vue +82 -0
  6. package/components/ImageRenderer.vue +126 -0
  7. package/components/Logo.vue +142 -0
  8. package/components/Person.vue +89 -0
  9. package/components/Slide.vue +324 -0
  10. package/components/Slot.vue +419 -0
  11. package/components/Text.vue +216 -0
  12. package/components/Timeline.vue +202 -0
  13. package/composables/decor-catalog.mjs +339 -0
  14. package/composables/decor-config.mjs +488 -0
  15. package/composables/decor-file-store.ts +192 -0
  16. package/composables/decor-http-store.ts +25 -0
  17. package/composables/decor-store.ts +39 -0
  18. package/composables/decor-tuning-overrides.mjs +2 -0
  19. package/composables/decor-workbench.ts +799 -0
  20. package/composables/grid-placement.ts +396 -0
  21. package/composables/image-config.mjs +484 -0
  22. package/composables/layout-authoring.ts +372 -0
  23. package/composables/layout-markdown.mjs +56 -0
  24. package/composables/layout-recipes.ts +347 -0
  25. package/composables/layout-registry.ts +47 -0
  26. package/composables/layout-shorthands.ts +891 -0
  27. package/composables/layout-vnode.ts +43 -0
  28. package/composables/logo-mark.mjs +5 -0
  29. package/composables/slide-layout.ts +43 -0
  30. package/composables/slot-placement.ts +195 -0
  31. package/composables/slot-rules.ts +147 -0
  32. package/composables/text-fit-runtime.ts +577 -0
  33. package/composables/text-priority-fit.mjs +166 -0
  34. package/composables/text-typography.mjs +138 -0
  35. package/composables/theme-foundation.ts +192 -0
  36. package/composables/theme-media.mjs +202 -0
  37. package/composables/use-theme-config.ts +71 -0
  38. package/decor-library.md +10 -0
  39. package/env.d.ts +6 -0
  40. package/example.md +786 -0
  41. package/layouts/collection.vue +5 -0
  42. package/layouts/cover.vue +5 -0
  43. package/layouts/default.vue +5 -0
  44. package/layouts/explainer.vue +5 -0
  45. package/layouts/message.vue +5 -0
  46. package/layouts/none.vue +5 -0
  47. package/layouts/section.vue +5 -0
  48. package/layouts/two-cols.vue +11 -0
  49. package/package.json +84 -0
  50. package/public/decor/decor-1.svg +83 -0
  51. package/public/decor/decor-10.svg +40 -0
  52. package/public/decor/decor-11.svg +162 -0
  53. package/public/decor/decor-12.svg +83 -0
  54. package/public/decor/decor-13.svg +5 -0
  55. package/public/decor/decor-14.svg +7 -0
  56. package/public/decor/decor-15.svg +10 -0
  57. package/public/decor/decor-16.svg +9 -0
  58. package/public/decor/decor-17.png +0 -0
  59. package/public/decor/decor-18.png +0 -0
  60. package/public/decor/decor-19.svg +5 -0
  61. package/public/decor/decor-2.png +0 -0
  62. package/public/decor/decor-3.svg +9 -0
  63. package/public/decor/decor-4.svg +31 -0
  64. package/public/decor/decor-5.png +0 -0
  65. package/public/decor/decor-6.png +0 -0
  66. package/public/decor/decor-7.png +0 -0
  67. package/public/decor/decor-8.svg +21 -0
  68. package/public/decor/decor-9.svg +38 -0
  69. package/public/favicon.svg +11 -0
  70. package/public/photos/photo-1.png +0 -0
  71. package/public/photos/photo-10.png +0 -0
  72. package/public/photos/photo-2.png +0 -0
  73. package/public/photos/photo-3.png +0 -0
  74. package/public/photos/photo-4.png +0 -0
  75. package/public/photos/photo-5.png +0 -0
  76. package/public/photos/photo-6.png +0 -0
  77. package/public/photos/photo-7.png +0 -0
  78. package/public/photos/photo-8.png +0 -0
  79. package/public/photos/photo-9.png +0 -0
  80. package/setup/main.ts +3 -0
  81. package/setup/shiki.ts +10 -0
  82. package/setup/vite-plugins.ts +106 -0
  83. package/slidev-theme-practicum.png +0 -0
  84. package/slidev-theme-practicum.svg +102 -0
  85. package/styles/example.css +0 -0
  86. package/styles/index.css +192 -0
  87. package/styles/vars.css +119 -0
  88. package/types/slidev-client.d.ts +15 -0
@@ -0,0 +1,799 @@
1
+ import { computed, reactive, ref, watch } from 'vue'
2
+ import type {
3
+ DecorImage,
4
+ DecorSize,
5
+ DecorStore,
6
+ DecorVariant,
7
+ ImageFit,
8
+ RatioRange,
9
+ SizeRange,
10
+ } from './decor-store'
11
+
12
+ export type {
13
+ DecorImage,
14
+ DecorSize,
15
+ DecorStore,
16
+ DecorVariant,
17
+ ImageFit,
18
+ RatioRange,
19
+ SizeRange,
20
+ } from './decor-store'
21
+
22
+ export type DecorSizeDraft = {
23
+ colsFrom: number
24
+ colsTo: number
25
+ rowsFrom: number
26
+ rowsTo: number
27
+ ratioFrom: number | null
28
+ ratioTo: number | null
29
+ }
30
+ export type DecorDraft = {
31
+ cols: number
32
+ rows: number
33
+ sizes: DecorSizeDraft[]
34
+ fit: ImageFit
35
+ positionX: number
36
+ positionY: number
37
+ anchor: string
38
+ offsetX: number
39
+ offsetY: number
40
+ zoom: number
41
+ rotate: number
42
+ opacity: number
43
+ color: string
44
+ backgroundColor: string
45
+ }
46
+ export type DecorPreviewFrame = {
47
+ key: string
48
+ cols: number
49
+ rows: number
50
+ label: string
51
+ detail: string
52
+ sizeIndex: number
53
+ }
54
+ export type DecorSaveToken = {
55
+ selectedId: string
56
+ version: number
57
+ }
58
+ type DecorFrameCandidate = {
59
+ cols: number
60
+ rows: number
61
+ area: number
62
+ ratio: number
63
+ score: number
64
+ }
65
+ type DecorPreviewStyle = {
66
+ gridColumn: string
67
+ gridRow: string
68
+ }
69
+
70
+ const EMPTY_DECOR: DecorVariant = Object.freeze({
71
+ id: '',
72
+ src: '',
73
+ })
74
+
75
+ export class StaleDecorSaveError extends Error {
76
+ constructor() {
77
+ super('Decor save is stale')
78
+ this.name = 'StaleDecorSaveError'
79
+ }
80
+ }
81
+
82
+ export function isStaleDecorSaveError(error: unknown): error is StaleDecorSaveError {
83
+ return error instanceof StaleDecorSaveError
84
+ }
85
+
86
+ export function clamp(value: number, min: number, max: number) {
87
+ return Math.min(Math.max(value, min), max)
88
+ }
89
+
90
+ export function formatNumber(value: number, precision = 2) {
91
+ const normalized = Number(value.toFixed(precision))
92
+ return Object.is(normalized, -0) ? 0 : normalized
93
+ }
94
+
95
+ export function normalizeGridValue(value: unknown, fallback: number) {
96
+ const normalized = Number(value)
97
+ if (!Number.isFinite(normalized))
98
+ return fallback
99
+
100
+ return clamp(Math.round(normalized), 1, 12)
101
+ }
102
+
103
+ export function normalizeGridRange(from: unknown, to: unknown, fallback: number): [number, number] {
104
+ const normalizedFrom = normalizeGridValue(from, fallback)
105
+ const normalizedTo = normalizeGridValue(to, normalizedFrom)
106
+
107
+ return normalizedFrom <= normalizedTo
108
+ ? [normalizedFrom, normalizedTo]
109
+ : [normalizedTo, normalizedFrom]
110
+ }
111
+
112
+ export function normalizeRatioValue(value: unknown) {
113
+ const normalized = Number(value)
114
+
115
+ if (!Number.isFinite(normalized) || normalized <= 0)
116
+ return null
117
+
118
+ return clamp(formatNumber(normalized, 2), 0.08, 12)
119
+ }
120
+
121
+ export function normalizeRatioRange(from: unknown, to: unknown): [number | null, number | null] {
122
+ const normalizedFrom = normalizeRatioValue(from)
123
+ const normalizedTo = normalizeRatioValue(to)
124
+
125
+ if (normalizedFrom === null && normalizedTo === null)
126
+ return [null, null]
127
+
128
+ const fromValue = normalizedFrom ?? normalizedTo ?? 1
129
+ const toValue = normalizedTo ?? normalizedFrom ?? 1
130
+
131
+ return fromValue <= toValue
132
+ ? [fromValue, toValue]
133
+ : [toValue, fromValue]
134
+ }
135
+
136
+ function rangeBounds(value: SizeRange | undefined, fallback: number): [number, number] {
137
+ if (typeof value === 'number') {
138
+ const normalized = normalizeGridValue(value, fallback)
139
+ return [normalized, normalized]
140
+ }
141
+
142
+ if (!Array.isArray(value))
143
+ return [fallback, fallback]
144
+
145
+ return normalizeGridRange(value[0], value[1], fallback)
146
+ }
147
+
148
+ function ratioBounds(value: RatioRange | undefined): [number | null, number | null] {
149
+ if (typeof value === 'number') {
150
+ const normalized = normalizeRatioValue(value)
151
+ return [normalized, normalized]
152
+ }
153
+
154
+ if (!Array.isArray(value))
155
+ return [null, null]
156
+
157
+ return normalizeRatioRange(value[0], value[1])
158
+ }
159
+
160
+ function rangePreviewValue(value: SizeRange | undefined, fallback: number) {
161
+ const [from, to] = rangeBounds(value, fallback)
162
+ return normalizeGridValue((from + to) / 2, fallback)
163
+ }
164
+
165
+ export function createSizeDraft(size: DecorSize | undefined, fallbackCols = 4, fallbackRows = 4): DecorSizeDraft {
166
+ const [colsFrom, colsTo] = rangeBounds(size?.cols, fallbackCols)
167
+ const [rowsFrom, rowsTo] = rangeBounds(size?.rows, fallbackRows)
168
+ const [ratioFrom, ratioTo] = ratioBounds(size?.ratio ?? size?.aspectRatio)
169
+
170
+ return { colsFrom, colsTo, rowsFrom, rowsTo, ratioFrom, ratioTo }
171
+ }
172
+
173
+ export function normalizeSizeDraft(size: DecorSizeDraft) {
174
+ const [colsFrom, colsTo] = normalizeGridRange(size.colsFrom, size.colsTo, 4)
175
+ const [rowsFrom, rowsTo] = normalizeGridRange(size.rowsFrom, size.rowsTo, 4)
176
+ const [ratioFrom, ratioTo] = normalizeRatioRange(size.ratioFrom, size.ratioTo)
177
+
178
+ size.colsFrom = colsFrom
179
+ size.colsTo = colsTo
180
+ size.rowsFrom = rowsFrom
181
+ size.rowsTo = rowsTo
182
+ size.ratioFrom = ratioFrom
183
+ size.ratioTo = ratioTo
184
+ }
185
+
186
+ export function formatSizeRange(from: number, to: number) {
187
+ return from === to ? String(from) : `${from}-${to}`
188
+ }
189
+
190
+ export function formatRatioRange(from: number | null, to: number | null) {
191
+ if (from === null || to === null)
192
+ return ''
193
+
194
+ return from === to ? String(from) : `${from}-${to}`
195
+ }
196
+
197
+ export function formatSizeDraft(size: DecorSizeDraft) {
198
+ const ratio = formatRatioRange(size.ratioFrom, size.ratioTo)
199
+ const dimensions = `${formatSizeRange(size.colsFrom, size.colsTo)}x${formatSizeRange(size.rowsFrom, size.rowsTo)}`
200
+
201
+ return ratio ? `${dimensions} · r ${ratio}` : dimensions
202
+ }
203
+
204
+ function rangeToDecorValue(from: number, to: number): SizeRange {
205
+ return from === to ? from : [from, to]
206
+ }
207
+
208
+ function ratioToDecorValue(from: number | null, to: number | null): RatioRange | null {
209
+ if (from === null || to === null)
210
+ return null
211
+
212
+ return from === to ? from : [from, to]
213
+ }
214
+
215
+ function formatDecorRange(from: number, to: number) {
216
+ const value = rangeToDecorValue(from, to)
217
+ return Array.isArray(value) ? `[${value.join(', ')}]` : String(value)
218
+ }
219
+
220
+ function formatDecorRatio(from: number | null, to: number | null) {
221
+ const value = ratioToDecorValue(from, to)
222
+
223
+ if (value === null)
224
+ return ''
225
+
226
+ return Array.isArray(value) ? `[${value.join(', ')}]` : String(value)
227
+ }
228
+
229
+ function formatDecorSize(size: DecorSizeDraft) {
230
+ const normalized = normalizeDecorSizeDraft(size)
231
+
232
+ const fields = [
233
+ `cols: ${formatDecorRange(normalized.colsFrom, normalized.colsTo)}`,
234
+ `rows: ${formatDecorRange(normalized.rowsFrom, normalized.rowsTo)}`,
235
+ ]
236
+ const ratio = formatDecorRatio(normalized.ratioFrom, normalized.ratioTo)
237
+
238
+ if (ratio)
239
+ fields.push(`ratio: ${ratio}`)
240
+
241
+ return `{ ${fields.join(', ')} }`
242
+ }
243
+
244
+ function axisTokenToPercent(token: string, axis: 'x' | 'y') {
245
+ const normalized = token.trim().toLowerCase()
246
+ const percent = Number(normalized.replace('%', ''))
247
+
248
+ if (normalized.endsWith('%') && Number.isFinite(percent))
249
+ return clamp(percent, 0, 100)
250
+
251
+ if (normalized === 'center')
252
+ return 50
253
+
254
+ if (axis === 'x') {
255
+ if (normalized === 'left')
256
+ return 0
257
+ if (normalized === 'right')
258
+ return 100
259
+ }
260
+
261
+ if (normalized === 'top')
262
+ return 0
263
+ if (normalized === 'bottom')
264
+ return 100
265
+
266
+ return 50
267
+ }
268
+
269
+ function parsePosition(position: unknown): [number, number] {
270
+ if (typeof position !== 'string')
271
+ return [50, 50]
272
+
273
+ const tokens = position.trim().split(/\s+/u).filter(Boolean)
274
+ if (tokens.length === 0)
275
+ return [50, 50]
276
+
277
+ if (tokens.length === 1) {
278
+ const token = tokens[0]
279
+ if (token === 'top' || token === 'bottom')
280
+ return [50, axisTokenToPercent(token, 'y')]
281
+
282
+ return [axisTokenToPercent(token, 'x'), 50]
283
+ }
284
+
285
+ return [axisTokenToPercent(tokens[0], 'x'), axisTokenToPercent(tokens[1], 'y')]
286
+ }
287
+
288
+ function parseNumeric(value: unknown, fallback: number) {
289
+ if (value === undefined || value === null || value === '')
290
+ return fallback
291
+
292
+ const normalized = Number(String(value).replace(/deg|px|%/gu, '').trim())
293
+ return Number.isFinite(normalized) ? normalized : fallback
294
+ }
295
+
296
+ function cloneSizeDraft(size: DecorSizeDraft): DecorSizeDraft {
297
+ return { ...size }
298
+ }
299
+
300
+ function normalizeDecorSizeDraft(size: DecorSizeDraft): DecorSizeDraft {
301
+ const normalized = cloneSizeDraft(size)
302
+ normalizeSizeDraft(normalized)
303
+
304
+ return normalized
305
+ }
306
+
307
+ function matchesRatio(cols: number, rows: number, size: DecorSizeDraft) {
308
+ if (size.ratioFrom === null || size.ratioTo === null)
309
+ return true
310
+
311
+ const ratio = cols / rows
312
+ return ratio >= size.ratioFrom && ratio <= size.ratioTo
313
+ }
314
+
315
+ function sizeDraftFrame(size: DecorSizeDraft, fallbackCols: number, fallbackRows: number) {
316
+ normalizeSizeDraft(size)
317
+
318
+ const centerCols = (size.colsFrom + size.colsTo) / 2
319
+ const centerRows = (size.rowsFrom + size.rowsTo) / 2
320
+ const centerRatio = size.ratioFrom !== null && size.ratioTo !== null
321
+ ? (size.ratioFrom + size.ratioTo) / 2
322
+ : centerCols / centerRows
323
+ let best = {
324
+ cols: rangePreviewValue([size.colsFrom, size.colsTo], fallbackCols),
325
+ rows: rangePreviewValue([size.rowsFrom, size.rowsTo], fallbackRows),
326
+ score: Number.POSITIVE_INFINITY,
327
+ }
328
+
329
+ for (let cols = size.colsFrom; cols <= size.colsTo; cols += 1) {
330
+ for (let rows = size.rowsFrom; rows <= size.rowsTo; rows += 1) {
331
+ if (!matchesRatio(cols, rows, size))
332
+ continue
333
+
334
+ const score = Math.abs(cols - centerCols)
335
+ + Math.abs(rows - centerRows)
336
+ + Math.abs(cols / rows - centerRatio)
337
+
338
+ if (score < best.score)
339
+ best = { cols, rows, score }
340
+ }
341
+ }
342
+
343
+ return {
344
+ cols: best.cols,
345
+ rows: best.rows,
346
+ }
347
+ }
348
+
349
+ function createSizeFrameCandidates(size: DecorSizeDraft) {
350
+ const normalized = cloneSizeDraft(size)
351
+ normalizeSizeDraft(normalized)
352
+
353
+ const centerCols = (normalized.colsFrom + normalized.colsTo) / 2
354
+ const centerRows = (normalized.rowsFrom + normalized.rowsTo) / 2
355
+ const centerRatio = normalized.ratioFrom !== null && normalized.ratioTo !== null
356
+ ? (normalized.ratioFrom + normalized.ratioTo) / 2
357
+ : centerCols / centerRows
358
+ const candidates: DecorFrameCandidate[] = []
359
+
360
+ for (let cols = normalized.colsFrom; cols <= normalized.colsTo; cols += 1) {
361
+ for (let rows = normalized.rowsFrom; rows <= normalized.rowsTo; rows += 1) {
362
+ if (!matchesRatio(cols, rows, normalized))
363
+ continue
364
+
365
+ const ratio = cols / rows
366
+ candidates.push({
367
+ cols,
368
+ rows,
369
+ area: cols * rows,
370
+ ratio,
371
+ score: Math.abs(cols - centerCols) + Math.abs(rows - centerRows) + Math.abs(ratio - centerRatio),
372
+ })
373
+ }
374
+ }
375
+
376
+ return candidates
377
+ }
378
+
379
+ function sizePreviewCandidates(size: DecorSizeDraft) {
380
+ const candidates = createSizeFrameCandidates(size)
381
+
382
+ if (candidates.length === 0) {
383
+ const frame = sizeDraftFrame(cloneSizeDraft(size), 4, 4)
384
+ return [{ ...frame, area: frame.cols * frame.rows, ratio: frame.cols / frame.rows, score: 0 }]
385
+ }
386
+
387
+ return candidates.sort((a, b) => a.area - b.area || a.ratio - b.ratio || a.cols - b.cols || a.rows - b.rows)
388
+ }
389
+
390
+ function firstFrame(decor: DecorVariant) {
391
+ const size = decor.sizes?.[0] ?? {}
392
+ const draft = createSizeDraft(size, 4, 4)
393
+
394
+ return sizeDraftFrame(draft, 4, 4)
395
+ }
396
+
397
+ function createSizeDrafts(decor: DecorVariant) {
398
+ const frame = firstFrame(decor)
399
+ const sizes = decor.sizes ?? []
400
+
401
+ return sizes.length
402
+ ? sizes.map(size => createSizeDraft(size, frame.cols, frame.rows))
403
+ : [createSizeDraft({ cols: frame.cols, rows: frame.rows }, frame.cols, frame.rows)]
404
+ }
405
+
406
+ function createDraft(decor: DecorVariant): DecorDraft {
407
+ const image = decor.image ?? {}
408
+ const [positionX, positionY] = parsePosition(image.position)
409
+ const frame = firstFrame(decor)
410
+
411
+ return {
412
+ cols: frame.cols,
413
+ rows: frame.rows,
414
+ sizes: createSizeDrafts(decor),
415
+ fit: image.fit ?? 'contain',
416
+ positionX,
417
+ positionY,
418
+ anchor: image.anchor ?? 'center',
419
+ offsetX: parseNumeric(image.x, 0),
420
+ offsetY: parseNumeric(image.y, 0),
421
+ zoom: parseNumeric(image.zoom, 1),
422
+ rotate: parseNumeric(image.rotate, 0),
423
+ opacity: clamp(parseNumeric(image.opacity, 1), 0, 1),
424
+ color: image.color ?? '',
425
+ backgroundColor: image.backgroundColor ?? '',
426
+ }
427
+ }
428
+
429
+ function normalizeList(value: unknown) {
430
+ return Array.isArray(value)
431
+ ? value.filter(item => typeof item === 'string').map(item => item.trim()).filter(Boolean)
432
+ : []
433
+ }
434
+
435
+ function normalizeCatalogVariant(variant: DecorVariant) {
436
+ const normalized: DecorVariant = {
437
+ ...variant,
438
+ id: variant.id.trim(),
439
+ }
440
+
441
+ if ((!Array.isArray(normalized.sizes) || normalized.sizes.length === 0)
442
+ && normalized.cols !== undefined
443
+ && normalized.rows !== undefined) {
444
+ normalized.sizes = [{
445
+ cols: normalized.cols,
446
+ rows: normalized.rows,
447
+ ratio: normalized.ratio ?? normalized.aspectRatio,
448
+ }]
449
+ }
450
+
451
+ if (normalized.sizes) {
452
+ normalized.sizes = normalized.sizes.map((size) => {
453
+ const next = { ...size }
454
+ next.ratio ??= next.aspectRatio
455
+ delete next.aspectRatio
456
+
457
+ return next
458
+ })
459
+ }
460
+
461
+ if (normalizeList(normalized.meanings).length === 0 && typeof normalized.meaning === 'string' && normalized.meaning.trim())
462
+ normalized.meanings = [normalized.meaning.trim()]
463
+
464
+ if (normalizeList(normalized.tones).length === 0 && typeof normalized.tone === 'string' && normalized.tone.trim())
465
+ normalized.tones = [normalized.tone.trim()]
466
+
467
+ const toneTags = normalizeList(normalized.tones)
468
+ const explicitTags = normalizeList(normalized.tags)
469
+ normalized.tags = [...new Set([...explicitTags, ...toneTags])]
470
+
471
+ return normalized
472
+ }
473
+
474
+ function mergeDecorVariants(catalog: readonly DecorVariant[], overrides: readonly DecorVariant[]) {
475
+ const variantsById = new Map<string, DecorVariant>()
476
+
477
+ for (const variant of [...catalog, ...overrides]) {
478
+ if (!variant?.id?.trim())
479
+ continue
480
+
481
+ variantsById.set(variant.id.trim(), normalizeCatalogVariant(variant))
482
+ }
483
+
484
+ return [...variantsById.values()]
485
+ }
486
+
487
+ function getImagePatch(current: DecorDraft): DecorImage {
488
+ return {
489
+ fit: current.fit,
490
+ position: `${formatNumber(current.positionX, 1)}% ${formatNumber(current.positionY, 1)}%`,
491
+ anchor: current.anchor,
492
+ x: `${formatNumber(current.offsetX, 1)}%`,
493
+ y: `${formatNumber(current.offsetY, 1)}%`,
494
+ zoom: formatNumber(current.zoom, 2),
495
+ rotate: `${formatNumber(current.rotate, 1)}deg`,
496
+ color: current.color,
497
+ opacity: formatNumber(current.opacity, 2),
498
+ backgroundColor: current.backgroundColor,
499
+ }
500
+ }
501
+
502
+ function buildOverrideVariant(decor: DecorVariant, current: DecorDraft): DecorVariant {
503
+ const sizes = current.sizes.map((size) => {
504
+ const normalized = normalizeDecorSizeDraft(size)
505
+ const ratio = ratioToDecorValue(normalized.ratioFrom, normalized.ratioTo)
506
+ const nextSize: DecorSize = {
507
+ cols: rangeToDecorValue(normalized.colsFrom, normalized.colsTo),
508
+ rows: rangeToDecorValue(normalized.rowsFrom, normalized.rowsTo),
509
+ }
510
+
511
+ if (ratio !== null)
512
+ nextSize.ratio = ratio
513
+
514
+ return nextSize
515
+ })
516
+
517
+ return {
518
+ id: decor.id,
519
+ src: decor.src,
520
+ meanings: [...(decor.meanings ?? [])],
521
+ tones: [...(decor.tones ?? [])],
522
+ tags: [...(decor.tags ?? [])],
523
+ sizes,
524
+ image: getImagePatch(current),
525
+ }
526
+ }
527
+
528
+ export function createPreviewStyle(colsInput: unknown, rowsInput: unknown): DecorPreviewStyle {
529
+ const cols = normalizeGridValue(colsInput, 4)
530
+ const rows = normalizeGridValue(rowsInput, 4)
531
+ const colStart = Math.floor((12 - cols) / 2) + 1
532
+ const rowStart = Math.floor((12 - rows) / 2) + 1
533
+
534
+ return {
535
+ gridColumn: `${colStart} / span ${cols}`,
536
+ gridRow: `${rowStart} / span ${rows}`,
537
+ }
538
+ }
539
+
540
+ export function createDecorWorkbench(input: {
541
+ catalog: readonly DecorVariant[]
542
+ overrides: readonly DecorVariant[]
543
+ store: DecorStore
544
+ }) {
545
+ const overrides = ref<DecorVariant[]>([...input.overrides])
546
+ const selectedId = ref(input.catalog[0]?.id ?? input.overrides[0]?.id ?? '')
547
+ const drafts = reactive<Record<string, DecorDraft>>({})
548
+ const variants = computed(() => mergeDecorVariants(input.catalog, overrides.value))
549
+ const selected = computed(() => {
550
+ return variants.value.find(decor => decor.id === selectedId.value) ?? variants.value[0] ?? EMPTY_DECOR
551
+ })
552
+ const draft = computed(() => {
553
+ const decor = selected.value
554
+ drafts[decor.id] ??= createDraft(decor)
555
+ return drafts[decor.id]
556
+ })
557
+ const draftSignature = computed(() => {
558
+ const current = draft.value
559
+
560
+ return JSON.stringify({
561
+ selectedId: selected.value.id,
562
+ cols: current.cols,
563
+ rows: current.rows,
564
+ sizes: current.sizes.map(size => ({
565
+ colsFrom: size.colsFrom,
566
+ colsTo: size.colsTo,
567
+ rowsFrom: size.rowsFrom,
568
+ rowsTo: size.rowsTo,
569
+ ratioFrom: size.ratioFrom,
570
+ ratioTo: size.ratioTo,
571
+ })),
572
+ fit: current.fit,
573
+ positionX: current.positionX,
574
+ positionY: current.positionY,
575
+ anchor: current.anchor,
576
+ offsetX: current.offsetX,
577
+ offsetY: current.offsetY,
578
+ zoom: current.zoom,
579
+ rotate: current.rotate,
580
+ opacity: current.opacity,
581
+ color: current.color,
582
+ backgroundColor: current.backgroundColor,
583
+ })
584
+ })
585
+ const draftVersion = ref(0)
586
+ let saveTail: Promise<unknown> | null = null
587
+
588
+ watch(draftSignature, () => {
589
+ draftVersion.value += 1
590
+ }, { flush: 'sync' })
591
+
592
+ const previewStyle = computed(() => {
593
+ return createPreviewStyle(draft.value.cols, draft.value.rows)
594
+ })
595
+ const activePreviewFrameKey = computed(() => {
596
+ return `${normalizeGridValue(draft.value.cols, 4)}x${normalizeGridValue(draft.value.rows, 4)}`
597
+ })
598
+ const currentRatio = computed(() => {
599
+ const cols = normalizeGridValue(draft.value.cols, 4)
600
+ const rows = normalizeGridValue(draft.value.rows, 4)
601
+
602
+ return formatNumber(cols / rows, 2)
603
+ })
604
+ const previewFrames = computed(() => {
605
+ const seen = new Set<string>()
606
+ const frames: DecorPreviewFrame[] = []
607
+
608
+ for (const [sizeIndex, size] of draft.value.sizes.entries()) {
609
+ for (const frame of sizePreviewCandidates(size)) {
610
+ const dimensionsKey = `${frame.cols}x${frame.rows}`
611
+ if (seen.has(dimensionsKey))
612
+ continue
613
+
614
+ seen.add(dimensionsKey)
615
+ frames.push({
616
+ key: `${sizeIndex}-${dimensionsKey}`,
617
+ cols: frame.cols,
618
+ rows: frame.rows,
619
+ label: dimensionsKey,
620
+ detail: `r ${formatNumber(frame.ratio, 2)}`,
621
+ sizeIndex,
622
+ })
623
+ }
624
+ }
625
+
626
+ return frames
627
+ })
628
+ const selectedMeta = computed(() => {
629
+ return [
630
+ ...(selected.value.meanings ?? []),
631
+ ...(selected.value.tones ?? []),
632
+ ]
633
+ })
634
+ const catalogSnippet = computed(() => {
635
+ const current = draft.value
636
+ const sizes = current.sizes
637
+ .map(size => formatDecorSize(size))
638
+ .join(', ')
639
+ const fields: string[] = [
640
+ `fit: '${current.fit}'`,
641
+ `position: '${formatNumber(current.positionX, 1)}% ${formatNumber(current.positionY, 1)}%'`,
642
+ `zoom: ${formatNumber(current.zoom, 2)}`,
643
+ ]
644
+
645
+ if (current.anchor !== 'center')
646
+ fields.push(`anchor: '${current.anchor}'`)
647
+ if (current.offsetX !== 0)
648
+ fields.push(`x: '${formatNumber(current.offsetX, 1)}%'`)
649
+ if (current.offsetY !== 0)
650
+ fields.push(`y: '${formatNumber(current.offsetY, 1)}%'`)
651
+ if (current.rotate !== 0)
652
+ fields.push(`rotate: '${formatNumber(current.rotate, 1)}deg'`)
653
+ if (current.color)
654
+ fields.push(`color: '${current.color}'`)
655
+ if (current.backgroundColor)
656
+ fields.push(`backgroundColor: '${current.backgroundColor}'`)
657
+ if (current.opacity !== 1)
658
+ fields.push(`opacity: ${formatNumber(current.opacity, 2)}`)
659
+
660
+ return `sizes: [${sizes}]\nimage: {\n ${fields.join(',\n ')},\n}`
661
+ })
662
+ const slideSnippet = computed(() => {
663
+ const current = draft.value
664
+ const fields: string[] = [
665
+ `id: '${selected.value.id}'`,
666
+ `position: '${formatNumber(current.positionX, 1)}% ${formatNumber(current.positionY, 1)}%'`,
667
+ `zoom: ${formatNumber(current.zoom, 2)}`,
668
+ ]
669
+
670
+ if (current.color)
671
+ fields.push(`color: '${current.color}'`)
672
+ if (current.backgroundColor)
673
+ fields.push(`backgroundColor: '${current.backgroundColor}'`)
674
+ if (current.opacity !== 1)
675
+ fields.push(`opacity: ${formatNumber(current.opacity, 2)}`)
676
+
677
+ return `:decor="{ ${fields.join(', ')} }"`
678
+ })
679
+
680
+ function select(id: string) {
681
+ selectedId.value = id
682
+ }
683
+
684
+ function update(patch: Partial<DecorDraft>) {
685
+ Object.assign(draft.value, patch)
686
+ }
687
+
688
+ function reset() {
689
+ drafts[selected.value.id] = createDraft(selected.value)
690
+ }
691
+
692
+ function buildOverride() {
693
+ return buildOverrideVariant(selected.value, draft.value)
694
+ }
695
+
696
+ function exportOverrides(next = buildOverride()) {
697
+ const byId = new Map(overrides.value.map(variant => [variant.id, variant]))
698
+ byId.set(next.id, next)
699
+
700
+ return [...byId.values()]
701
+ }
702
+
703
+ function createSaveToken(): DecorSaveToken {
704
+ return {
705
+ selectedId: selected.value.id,
706
+ version: draftVersion.value,
707
+ }
708
+ }
709
+
710
+ function isCurrentSaveToken(token: DecorSaveToken) {
711
+ return token.selectedId === selected.value.id && token.version === draftVersion.value
712
+ }
713
+
714
+ async function save(token = createSaveToken()) {
715
+ const next = buildOverride()
716
+ const saveCurrent = async () => {
717
+ const nextOverrides = exportOverrides(next)
718
+ const result = await input.store.save(nextOverrides)
719
+
720
+ overrides.value = exportOverrides(next)
721
+
722
+ if (!isCurrentSaveToken(token))
723
+ throw new StaleDecorSaveError()
724
+
725
+ return result
726
+ }
727
+ const pendingSave = saveTail === null
728
+ ? saveCurrent()
729
+ : saveTail.then(saveCurrent)
730
+ const tail = pendingSave.catch(() => {}).finally(() => {
731
+ if (saveTail === tail)
732
+ saveTail = null
733
+ })
734
+ saveTail = tail
735
+
736
+ return pendingSave
737
+ }
738
+
739
+ function applyFrame(size: DecorSizeDraft) {
740
+ const frame = sizeDraftFrame(size, draft.value.cols, draft.value.rows)
741
+
742
+ draft.value.cols = frame.cols
743
+ draft.value.rows = frame.rows
744
+ }
745
+
746
+ function applyPreviewFrame(frame: DecorPreviewFrame) {
747
+ draft.value.cols = frame.cols
748
+ draft.value.rows = frame.rows
749
+ }
750
+
751
+ function previewFrameStyle(frame: DecorPreviewFrame) {
752
+ return createPreviewStyle(frame.cols, frame.rows)
753
+ }
754
+
755
+ function addCurrentSize() {
756
+ const ratio = formatNumber(draft.value.cols / draft.value.rows, 2)
757
+ const size = createSizeDraft({ cols: draft.value.cols, rows: draft.value.rows, ratio }, draft.value.cols, draft.value.rows)
758
+ const duplicate = draft.value.sizes.some(item => formatSizeDraft(item) === formatSizeDraft(size))
759
+
760
+ if (!duplicate)
761
+ draft.value.sizes.push(size)
762
+ }
763
+
764
+ function removeSize(index: number) {
765
+ if (draft.value.sizes.length <= 1)
766
+ return
767
+
768
+ draft.value.sizes.splice(index, 1)
769
+ }
770
+
771
+ return {
772
+ overrides,
773
+ variants,
774
+ selectedId,
775
+ selected,
776
+ draft,
777
+ draftVersion,
778
+ previewStyle,
779
+ activePreviewFrameKey,
780
+ currentRatio,
781
+ previewFrames,
782
+ selectedMeta,
783
+ catalogSnippet,
784
+ slideSnippet,
785
+ select,
786
+ update,
787
+ reset,
788
+ buildOverride,
789
+ exportOverrides,
790
+ createSaveToken,
791
+ isCurrentSaveToken,
792
+ save,
793
+ applyFrame,
794
+ applyPreviewFrame,
795
+ previewFrameStyle,
796
+ addCurrentSize,
797
+ removeSize,
798
+ }
799
+ }