@ideare/kit 0.0.0-stage → 0.1.0-next.1

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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -2
  3. package/THIRD_PARTY.md +52 -0
  4. package/dist/brand/schema.d.ts +265 -0
  5. package/dist/brand/schema.js +271 -0
  6. package/dist/check/copy.d.ts +27 -0
  7. package/dist/check/copy.js +139 -0
  8. package/dist/check/index.d.ts +54 -0
  9. package/dist/check/index.js +568 -0
  10. package/dist/color/contrast.d.ts +41 -0
  11. package/dist/color/contrast.js +84 -0
  12. package/dist/color/palette.d.ts +27 -0
  13. package/dist/color/palette.js +114 -0
  14. package/dist/color/wcag.d.ts +22 -0
  15. package/dist/color/wcag.js +50 -0
  16. package/dist/draft/draft.d.ts +61 -0
  17. package/dist/draft/draft.js +383 -0
  18. package/dist/editor/history.d.ts +78 -0
  19. package/dist/editor/history.js +221 -0
  20. package/dist/editor/operations.d.ts +112 -0
  21. package/dist/editor/operations.js +621 -0
  22. package/dist/files/docs.d.ts +49 -0
  23. package/dist/files/docs.js +392 -0
  24. package/dist/files/generate.d.ts +47 -0
  25. package/dist/files/generate.js +254 -0
  26. package/dist/files/png.d.ts +40 -0
  27. package/dist/files/png.js +168 -0
  28. package/dist/files/raster.d.ts +14 -0
  29. package/dist/files/raster.js +0 -0
  30. package/dist/files/templates.d.ts +162 -0
  31. package/dist/files/templates.js +390 -0
  32. package/dist/files/tokens.d.ts +22 -0
  33. package/dist/files/tokens.js +204 -0
  34. package/dist/fonts/catalog.d.ts +3 -0
  35. package/dist/fonts/catalog.js +345 -0
  36. package/dist/fonts/ofl.d.ts +5 -0
  37. package/dist/fonts/ofl.js +5 -0
  38. package/dist/fonts/pick.d.ts +33 -0
  39. package/dist/fonts/pick.js +73 -0
  40. package/dist/imports/folder.d.ts +80 -0
  41. package/dist/imports/folder.js +124 -0
  42. package/dist/imports/unzip.d.ts +32 -0
  43. package/dist/imports/unzip.js +128 -0
  44. package/dist/index.d.ts +59 -0
  45. package/dist/index.js +34 -0
  46. package/dist/marks/draw.d.ts +48 -0
  47. package/dist/marks/draw.js +149 -0
  48. package/dist/marks/icons.data.d.ts +5 -0
  49. package/dist/marks/icons.data.js +600 -0
  50. package/dist/marks/one-color.d.ts +45 -0
  51. package/dist/marks/one-color.js +304 -0
  52. package/dist/marks/svg.d.ts +53 -0
  53. package/dist/marks/svg.js +393 -0
  54. package/dist/node/index.d.ts +13 -0
  55. package/dist/node/index.js +41 -0
  56. package/dist/posts/pix.d.ts +19 -0
  57. package/dist/posts/pix.js +49 -0
  58. package/dist/posts/templates.d.ts +45 -0
  59. package/dist/posts/templates.js +868 -0
  60. package/dist/resvg/index.d.ts +2 -0
  61. package/dist/resvg/index.js +22 -0
  62. package/dist/scene/brand-art.d.ts +61 -0
  63. package/dist/scene/brand-art.js +217 -0
  64. package/dist/scene/frame.d.ts +89 -0
  65. package/dist/scene/frame.js +213 -0
  66. package/dist/scene/grain.d.ts +7 -0
  67. package/dist/scene/grain.js +7 -0
  68. package/dist/scene/qr.d.ts +10 -0
  69. package/dist/scene/qr.js +359 -0
  70. package/dist/scene/render.d.ts +76 -0
  71. package/dist/scene/render.js +557 -0
  72. package/dist/scene/schema.d.ts +271 -0
  73. package/dist/scene/schema.js +257 -0
  74. package/dist/sign.d.ts +47 -0
  75. package/dist/sign.js +88 -0
  76. package/dist/text/engine.d.ts +128 -0
  77. package/dist/text/engine.js +245 -0
  78. package/dist/text/fonts.d.ts +56 -0
  79. package/dist/text/fonts.js +65 -0
  80. package/dist/version.d.ts +2 -0
  81. package/dist/version.js +2 -0
  82. package/dist/wasm/index.d.ts +15 -0
  83. package/dist/wasm/index.js +46 -0
  84. package/package.json +78 -5
  85. package/schema/brand.v1.json +442 -0
@@ -0,0 +1,383 @@
1
+ import { BRAND_SCHEMA_URL, LOCALES, parseBrand } from "../brand/schema.js";
2
+ import { MOODS, generatePalette } from "../color/palette.js";
3
+ import { catalogFont } from "../fonts/pick.js";
4
+ import { iconMark, initials, matchIcons, monogramMark } from "../marks/draw.js";
5
+ import { engineFor } from "../scene/render.js";
6
+ /** The longest name (the wordmark's limit) and line (the copy check's tagline limit) a draft
7
+ * takes: every file the kit makes has room for them. */
8
+ export const DRAFT_LIMITS = { name: 40, idea: 60 };
9
+ /**
10
+ * The font pairs a draft picks from. Few on purpose: a browser carries every file
11
+ * (`draftFontFiles`), so the try-it draws with no font server. Each display face has one weight,
12
+ * and the text faces are sans or serif opposites of their display.
13
+ */
14
+ export const DRAFT_FONT_PAIRS = [
15
+ { display: "calistoga", weight: 400, text: "instrument-sans", moods: ["warm", "playful"] },
16
+ { display: "fraunces", weight: 600, text: "instrument-sans", moods: ["warm", "classic"] },
17
+ { display: "bodoni-moda", weight: 600, text: "karla", moods: ["classic", "calm"] },
18
+ { display: "pacifico", weight: 400, text: "karla", moods: ["playful", "warm"] },
19
+ { display: "syne", weight: 700, text: "instrument-sans", moods: ["bold", "fresh"] },
20
+ { display: "bebas-neue", weight: 400, text: "karla", moods: ["bold"] },
21
+ { display: "fredoka", weight: 600, text: "karla", moods: ["playful", "fresh"] },
22
+ { display: "eb-garamond", weight: 500, text: "instrument-sans", moods: ["classic", "calm"] },
23
+ { display: "outfit", weight: 700, text: "literata", moods: ["fresh", "calm"] },
24
+ { display: "caprasimo", weight: 400, text: "instrument-sans", moods: ["warm", "bold"] },
25
+ ];
26
+ const TEXT_WEIGHT = 400;
27
+ /** Every font file a draft can ask for, so a page can serve them from its own origin. */
28
+ export function draftFontFiles() {
29
+ const files = new Map();
30
+ for (const pair of DRAFT_FONT_PAIRS) {
31
+ files.set(`${pair.display}-${pair.weight}`, { id: pair.display, weight: pair.weight });
32
+ files.set(`${pair.text}-${TEXT_WEIGHT}`, { id: pair.text, weight: TEXT_WEIGHT });
33
+ }
34
+ return [...files.values()];
35
+ }
36
+ /**
37
+ * What a line about the business says about its look: a mood and the hues that suit it. Words are
38
+ * matched without accents at the start of a word, in pt-BR, en and es. The first row that matches
39
+ * wins; a line with none of them leaves the mood to the seed.
40
+ */
41
+ const MOOD_WORDS = [
42
+ {
43
+ mood: "warm",
44
+ hues: [15, 60],
45
+ words: [
46
+ "pao",
47
+ "paes",
48
+ "padar",
49
+ "bolo",
50
+ "doce",
51
+ "confeit",
52
+ "cafe",
53
+ "bread",
54
+ "bak",
55
+ "cake",
56
+ "pastr",
57
+ "coffee",
58
+ "sweet",
59
+ "pan ",
60
+ "panad",
61
+ "pastel",
62
+ "dulce",
63
+ "comida",
64
+ "food",
65
+ "restaur",
66
+ "pizza",
67
+ "lanche",
68
+ ],
69
+ },
70
+ {
71
+ mood: "fresh",
72
+ hues: [95, 175],
73
+ words: [
74
+ "planta",
75
+ "flor",
76
+ "jardi",
77
+ "horta",
78
+ "verde",
79
+ "organic",
80
+ "plant",
81
+ "flower",
82
+ "garden",
83
+ "green",
84
+ "vegan",
85
+ "natural",
86
+ "suco",
87
+ "juice",
88
+ "salad",
89
+ ],
90
+ },
91
+ {
92
+ mood: "calm",
93
+ hues: [185, 330],
94
+ words: [
95
+ "salao",
96
+ "salon",
97
+ "beleza",
98
+ "beauty",
99
+ "belleza",
100
+ "estetic",
101
+ "spa",
102
+ "massag",
103
+ "yoga",
104
+ "unha",
105
+ "nail",
106
+ "cabel",
107
+ "hair",
108
+ "pelo",
109
+ "cor ",
110
+ "escova",
111
+ "terapia",
112
+ "therap",
113
+ "saude",
114
+ "health",
115
+ "salud",
116
+ ],
117
+ },
118
+ {
119
+ mood: "bold",
120
+ hues: [0, 360],
121
+ words: [
122
+ "barb",
123
+ "academia",
124
+ "gym",
125
+ "gimnas",
126
+ "treino",
127
+ "fitness",
128
+ "oficina",
129
+ "reform",
130
+ "repair",
131
+ "repar",
132
+ "arreglo",
133
+ "conserto",
134
+ "pintura",
135
+ "paint",
136
+ "obra",
137
+ "constru",
138
+ "tattoo",
139
+ "tatu",
140
+ ],
141
+ },
142
+ {
143
+ mood: "classic",
144
+ hues: [200, 260],
145
+ words: [
146
+ "advoga",
147
+ "law ",
148
+ "lawyer",
149
+ "abogad",
150
+ "contab",
151
+ "account",
152
+ "contad",
153
+ "consult",
154
+ "foto",
155
+ "photo",
156
+ "imobili",
157
+ "real estate",
158
+ "inmobili",
159
+ "arquitet",
160
+ "architect",
161
+ "arquitect",
162
+ ],
163
+ },
164
+ {
165
+ mood: "playful",
166
+ hues: [0, 360],
167
+ words: [
168
+ "crianca",
169
+ "infant",
170
+ "kid",
171
+ "nino",
172
+ "pet ",
173
+ "cachorr",
174
+ "dog",
175
+ "perro",
176
+ "gato",
177
+ "cat ",
178
+ "festa",
179
+ "party",
180
+ "fiesta",
181
+ "brinqu",
182
+ "toy",
183
+ "juguet",
184
+ "brecho",
185
+ "thrift",
186
+ "bazar",
187
+ ],
188
+ },
189
+ ];
190
+ /** Lowercase, without accents, padded with spaces, so words match at their starts. */
191
+ function plain(text) {
192
+ return ` ${text
193
+ .normalize("NFD")
194
+ .replace(/\p{M}/gu, "")
195
+ .toLowerCase()
196
+ .replace(/[^a-z0-9]+/g, " ")} `;
197
+ }
198
+ function lineMood(idea) {
199
+ const haystack = plain(idea);
200
+ return MOOD_WORDS.find((row) => row.words.some((word) => haystack.includes(` ${word}`)));
201
+ }
202
+ /** FNV-1a, 32 bits: a stable number for a string. */
203
+ function hash(text) {
204
+ let h = 0x811c9dc5;
205
+ for (let i = 0; i < text.length; i++) {
206
+ h ^= text.charCodeAt(i);
207
+ h = Math.imul(h, 0x01000193);
208
+ }
209
+ return h >>> 0;
210
+ }
211
+ /** A number in [0, 1) from the seed and a label, so each part of the draft rolls its own die. */
212
+ function roll(seed, label) {
213
+ let t = (seed ^ hash(label)) >>> 0;
214
+ // mulberry32, one step.
215
+ t = (t + 0x6d2b79f5) >>> 0;
216
+ let r = Math.imul(t ^ (t >>> 15), 1 | t);
217
+ r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
218
+ return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
219
+ }
220
+ /** A fresh seed for a new draft. */
221
+ export function newDraftSeed() {
222
+ const value = new Uint32Array(1);
223
+ crypto.getRandomValues(value);
224
+ return ((value[0] ?? 1) % 2147483646) + 1;
225
+ }
226
+ /** The golden angle: each "Try another" lands far from the colors before it. */
227
+ const GOLDEN_ANGLE = 137.508;
228
+ function draftColors(input) {
229
+ const fromLine = lineMood(`${input.name} ${input.idea}`);
230
+ const base = fromLine?.mood ?? MOODS[Math.floor(roll(input.seed, "mood") * MOODS.length)];
231
+ const mood = base ?? "calm";
232
+ const [from, to] = fromLine?.hues ?? [0, 360];
233
+ const firstHue = from + roll(input.seed, "hue") * (to - from);
234
+ const k = input.picks.colors;
235
+ // The business's own mood holds for two presses, then the moods take turns.
236
+ const moodNow = MOODS[(MOODS.indexOf(mood) + Math.floor(k / 2)) % MOODS.length] ?? mood;
237
+ const hue = (firstHue + k * GOLDEN_ANGLE) % 360;
238
+ return { mood, palette: generatePalette({ hue, mood: moodNow }) };
239
+ }
240
+ function draftFonts(input, mood) {
241
+ // Pairs that suit the mood first, then the rest, each group in a seed-picked order.
242
+ const order = [...DRAFT_FONT_PAIRS].sort((a, b) => {
243
+ const fit = Number(b.moods.includes(mood)) - Number(a.moods.includes(mood));
244
+ return fit || roll(input.seed, a.display) - roll(input.seed, b.display);
245
+ });
246
+ const pair = order[input.picks.fonts % order.length] ?? DRAFT_FONT_PAIRS[0];
247
+ if (!pair)
248
+ throw new Error("No draft fonts");
249
+ const font = (id, weight) => {
250
+ const found = catalogFont(id);
251
+ if (!found)
252
+ throw new Error(`${id} is not in the kit's font catalog`);
253
+ return {
254
+ family: found.family,
255
+ id,
256
+ weights: [weight],
257
+ license: "OFL-1.1",
258
+ };
259
+ };
260
+ return { pair, display: font(pair.display, pair.weight), text: font(pair.text, TEXT_WEIGHT) };
261
+ }
262
+ const BADGES = ["circle", "rounded", "hexagon", "square"];
263
+ /** The logo for a pick: icon marks and monograms take turns, each in the next badge shape. */
264
+ function draftLogo(input) {
265
+ const icons = matchIcons(`${input.name} ${input.idea}`, 4);
266
+ const matched = (icons[0]?.score ?? 0) > 0;
267
+ const k = input.picks.logo + (matched ? 0 : 1);
268
+ const turn = Math.floor(k / 2);
269
+ const badge = BADGES[(turn + Math.floor(roll(input.seed, "badge") * BADGES.length)) % 4];
270
+ const icon = icons[turn % Math.max(1, icons.length)]?.name ?? "sparkles";
271
+ return {
272
+ kind: k % 2 === 0 ? "icon" : "monogram",
273
+ badge: badge ?? "circle",
274
+ icon,
275
+ };
276
+ }
277
+ /** The short name under a home-screen icon: the name, or its first word, or its first letters. */
278
+ function shortNameOf(name) {
279
+ if (name.length <= 12)
280
+ return name;
281
+ const first = name.split(/\s+/)[0] ?? name;
282
+ return (first.length <= 12 ? first : name.slice(0, 12)).trim();
283
+ }
284
+ /**
285
+ * Draw the draft. The name and the line are trimmed and cut to DRAFT_LIMITS; an empty line makes
286
+ * the name the tagline. Loads the display font (for a monogram) through `fonts`.
287
+ */
288
+ export async function draftBrand(input, ctx) {
289
+ const name = input.name.trim().slice(0, DRAFT_LIMITS.name) || "?";
290
+ const idea = input.idea.trim().slice(0, DRAFT_LIMITS.idea);
291
+ const clean = { ...input, name, idea };
292
+ const { mood, palette } = draftColors(clean);
293
+ const fonts = draftFonts(clean, mood);
294
+ const logo = draftLogo(clean);
295
+ const light = palette.light;
296
+ let svg;
297
+ if (logo.kind === "icon") {
298
+ svg = iconMark(logo.icon, { badge: logo.badge, fill: light.primary, ink: light.onPrimary });
299
+ }
300
+ else {
301
+ const engine = await engineFor(ctx.fonts);
302
+ const font = await engine.font({ id: fonts.display.id, weight: fonts.pair.weight });
303
+ svg = monogramMark(engine, font, {
304
+ letters: initials(name),
305
+ badge: logo.badge,
306
+ fill: light.primary,
307
+ ink: light.onPrimary,
308
+ });
309
+ }
310
+ const tagline = idea || name;
311
+ const draft = {
312
+ $schema: BRAND_SCHEMA_URL,
313
+ schemaVersion: 1,
314
+ name,
315
+ shortName: shortNameOf(name),
316
+ locales: [input.locale],
317
+ defaultLocale: input.locale,
318
+ copy: {
319
+ [input.locale]: {
320
+ tagline,
321
+ description: tagline,
322
+ card: { title: name, ...(idea ? { subtitle: idea } : {}) },
323
+ },
324
+ },
325
+ mark: {
326
+ svg,
327
+ tile: {
328
+ fill: "ground",
329
+ shape: logo.badge === "circle" ? "circle" : "rounded",
330
+ padding: 0.04,
331
+ },
332
+ source: logo.kind === "icon"
333
+ ? { kind: "icon", ref: `lucide:${logo.icon}` }
334
+ : { kind: "monogram" },
335
+ },
336
+ wordmark: { text: name, font: "display", weight: fonts.pair.weight, tracking: -0.01 },
337
+ colors: palette,
338
+ fonts: { display: fonts.display, text: fonts.text },
339
+ };
340
+ const parsed = parseBrand(draft);
341
+ if (!parsed.ok) {
342
+ throw new Error(`The draft did not make a valid brand: ${parsed.issues.map((issue) => `${issue.path} ${issue.message}`).join("; ")}`);
343
+ }
344
+ return parsed.brand;
345
+ }
346
+ const PICK_KEYS = ["colors", "fonts", "logo"];
347
+ /** The draft as link parameters: name, idea, locale, seed, colors, fonts and logo. */
348
+ export function draftParams(input) {
349
+ const params = new URLSearchParams({
350
+ name: input.name.trim().slice(0, DRAFT_LIMITS.name),
351
+ idea: input.idea.trim().slice(0, DRAFT_LIMITS.idea),
352
+ locale: input.locale,
353
+ seed: String(input.seed),
354
+ });
355
+ for (const key of PICK_KEYS)
356
+ params.set(key, String(input.picks[key]));
357
+ return params;
358
+ }
359
+ function count(value, max) {
360
+ if (value === null || !/^\d{1,10}$/.test(value))
361
+ return null;
362
+ const number = Number(value);
363
+ return number <= max ? number : null;
364
+ }
365
+ /** A draft read back from link parameters, or null when any part is missing or out of range. */
366
+ export function readDraftParams(params) {
367
+ const name = params.get("name")?.trim() ?? "";
368
+ const idea = params.get("idea")?.trim() ?? "";
369
+ const locale = LOCALES.find((code) => code === params.get("locale"));
370
+ const seed = count(params.get("seed"), 2147483647);
371
+ if (!name || name.length > DRAFT_LIMITS.name || idea.length > DRAFT_LIMITS.idea)
372
+ return null;
373
+ if (!locale || !seed)
374
+ return null;
375
+ const picks = { colors: 0, fonts: 0, logo: 0 };
376
+ for (const key of PICK_KEYS) {
377
+ const value = count(params.get(key) ?? "0", 9999);
378
+ if (value === null)
379
+ return null;
380
+ picks[key] = value;
381
+ }
382
+ return { name, idea, locale, seed, picks };
383
+ }
@@ -0,0 +1,78 @@
1
+ import { type Scene } from "../scene/schema.ts";
2
+ /**
3
+ * Undo and redo for the editor, and the merge with a newer copy from the server (the ideas of
4
+ * smartgenius's editor draft). A step is a list of patches, one per field it changed, never a
5
+ * whole scene: a change that came from the server stays when someone undoes their own edit, and
6
+ * a server write takes only the fields it changed out of the history.
7
+ *
8
+ * A patch's path names one field:
9
+ * - `["scene", prop]`: size, theme, locale, slots, meta...
10
+ * - `["pages"]`: the order of the page ids
11
+ * - `["page", pageId, prop]`: a page's background, grain or name
12
+ * - `["layers", pageId]` and `["layers", pageId, groupId]`: the order of a list of layer ids
13
+ * - `["layer", pageId, layerId, prop]`: one prop of one layer, like its frame or its text
14
+ *
15
+ * A gesture records once, at release, with the scene from the press (editor.md rule E4). Edits
16
+ * that share a group (a typing session, a held arrow key) become one step, until the code that
17
+ * owns the group ends it with endEditGroup.
18
+ */
19
+ export declare const HISTORY_LIMIT = 50;
20
+ /** One field before and after an edit. A side is missing when the field did not exist. */
21
+ export type ScenePatch = {
22
+ path: readonly string[];
23
+ before?: unknown;
24
+ after?: unknown;
25
+ };
26
+ export type EditHistory = {
27
+ /** Oldest first. */
28
+ undo: ScenePatch[][];
29
+ /** The next step to redo first. */
30
+ redo: ScenePatch[][];
31
+ /** The run of edits the last step belongs to, while it is open. */
32
+ group: string | null;
33
+ };
34
+ export type SceneMerge = {
35
+ /** Your scene with their changes in it, ready to show and save. */
36
+ scene: Scene;
37
+ /** What to compare against from now on: theirs, with the colliding fields left as they were,
38
+ * so your side of a collision still counts as unsaved. */
39
+ base: Scene;
40
+ /** Fields both sides changed in different ways: their change, which `scene` does not have. */
41
+ conflicts: ScenePatch[];
42
+ };
43
+ /** Every field that differs between two scenes. */
44
+ export declare function diffScenes(before: Scene, after: Scene): ScenePatch[];
45
+ /**
46
+ * The scene with each patch's `before` or `after` side set. If the result is no longer a valid
47
+ * scene, the scene comes back unchanged.
48
+ */
49
+ export declare function applyPatches(scene: Scene, patches: readonly ScenePatch[], side: "before" | "after"): Scene;
50
+ export declare function emptyHistory(): EditHistory;
51
+ /**
52
+ * Record an edit from `before` to `after`. With a `group` equal to the open one, the edit joins
53
+ * the last step, so a typing session or a held arrow key is one Undo.
54
+ */
55
+ export declare function recordEdit(history: EditHistory, before: Scene, after: Scene, group?: string | null): EditHistory;
56
+ /** Close the open run: the next edit starts a new step, even in the same group. */
57
+ export declare function endEditGroup(history: EditHistory): EditHistory;
58
+ export declare function undoEdit(history: EditHistory, scene: Scene): {
59
+ history: EditHistory;
60
+ scene: Scene;
61
+ };
62
+ export declare function redoEdit(history: EditHistory, scene: Scene): {
63
+ history: EditHistory;
64
+ scene: Scene;
65
+ };
66
+ /**
67
+ * After the server's copy moved from `before` to `server`, keep the history for every field the
68
+ * server did not change, so an unrelated edit stays undoable after a picture lands.
69
+ */
70
+ export declare function rebaseHistory(history: EditHistory, before: Scene, server: Scene): EditHistory;
71
+ /**
72
+ * Bring a newer server copy into your unsaved scene. `base` is the copy your edits started from.
73
+ * A field only they changed comes in; a field only you changed stays yours; a field you both
74
+ * changed differently keeps yours and comes back in `conflicts`, so the editor can show their
75
+ * change and offer "Use theirs" (applyPatches with the conflict's `after`) or "Keep mine".
76
+ * Lists of layers and pages merge as a whole: when you both changed one, yours stays.
77
+ */
78
+ export declare function mergeScenes(draft: Scene, base: Scene, server: Scene): SceneMerge;
@@ -0,0 +1,221 @@
1
+ import { canonical } from "../scene/render.js";
2
+ import { layerSchema, sceneSchema } from "../scene/schema.js";
3
+ /**
4
+ * Undo and redo for the editor, and the merge with a newer copy from the server (the ideas of
5
+ * smartgenius's editor draft). A step is a list of patches, one per field it changed, never a
6
+ * whole scene: a change that came from the server stays when someone undoes their own edit, and
7
+ * a server write takes only the fields it changed out of the history.
8
+ *
9
+ * A patch's path names one field:
10
+ * - `["scene", prop]`: size, theme, locale, slots, meta...
11
+ * - `["pages"]`: the order of the page ids
12
+ * - `["page", pageId, prop]`: a page's background, grain or name
13
+ * - `["layers", pageId]` and `["layers", pageId, groupId]`: the order of a list of layer ids
14
+ * - `["layer", pageId, layerId, prop]`: one prop of one layer, like its frame or its text
15
+ *
16
+ * A gesture records once, at release, with the scene from the press (editor.md rule E4). Edits
17
+ * that share a group (a typing session, a held arrow key) become one step, until the code that
18
+ * owns the group ends it with endEditGroup.
19
+ */
20
+ export const HISTORY_LIMIT = 50;
21
+ const keyOf = (path) => JSON.stringify(path);
22
+ function same(a, b) {
23
+ return a === b || canonical(a) === canonical(b);
24
+ }
25
+ function put(fields, path, value) {
26
+ if (value === undefined)
27
+ fields.delete(keyOf(path));
28
+ else
29
+ fields.set(keyOf(path), { path, value });
30
+ }
31
+ /** The scene as one entry per field. Values are the scene's own objects, never copies. */
32
+ function flatten(scene) {
33
+ const fields = new Map();
34
+ const props = (prefix, object, skip) => {
35
+ for (const [prop, value] of Object.entries(object))
36
+ if (!skip.includes(prop))
37
+ put(fields, [...prefix, prop], value);
38
+ };
39
+ props(["scene"], scene, ["pages"]);
40
+ put(fields, ["pages"], scene.pages.map((page) => page.id));
41
+ for (const page of scene.pages) {
42
+ props(["page", page.id], page, ["id", "layers"]);
43
+ const walk = (layers, list) => {
44
+ put(fields, list, layers.map((layer) => layer.id));
45
+ for (const layer of layers) {
46
+ props(["layer", page.id, layer.id], layer, ["id", "layers"]);
47
+ if (layer.type === "group")
48
+ walk(layer.layers, ["layers", page.id, layer.id]);
49
+ }
50
+ };
51
+ walk(page.layers, ["layers", page.id]);
52
+ }
53
+ return fields;
54
+ }
55
+ const idsIn = (value) => Array.isArray(value) ? value.filter((id) => typeof id === "string") : [];
56
+ /**
57
+ * The scene the fields describe, or undefined when they no longer make one. A layer whose
58
+ * fields no longer make a valid layer is left out, and so is one that no list holds.
59
+ */
60
+ function unflatten(fields) {
61
+ const scene = {};
62
+ const pages = new Map();
63
+ const layers = new Map();
64
+ const objectFor = (map, key) => {
65
+ let object = map.get(key);
66
+ if (!object) {
67
+ object = {};
68
+ map.set(key, object);
69
+ }
70
+ return object;
71
+ };
72
+ for (const { path, value } of fields.values()) {
73
+ const [kind, a, b, c] = path;
74
+ if (kind === "scene" && a)
75
+ scene[a] = value;
76
+ else if (kind === "page" && a && b)
77
+ objectFor(pages, a)[b] = value;
78
+ else if (kind === "layer" && a && b && c)
79
+ objectFor(layers, keyOf([a, b]))[c] = value;
80
+ }
81
+ const placed = new Set();
82
+ /** The layers a list of ids names, at `path` (as flatten wrote it). */
83
+ const list = (pageId, path) => idsIn(fields.get(keyOf(path))?.value).flatMap((id) => {
84
+ const key = keyOf([pageId, id]);
85
+ const props = layers.get(key);
86
+ if (!props || placed.has(key))
87
+ return [];
88
+ placed.add(key);
89
+ const layer = props.type === "group"
90
+ ? { ...props, id, layers: list(pageId, ["layers", pageId, id]) }
91
+ : { ...props, id };
92
+ const parsed = layerSchema.safeParse(layer);
93
+ return parsed.success ? [parsed.data] : [];
94
+ });
95
+ const parsed = sceneSchema.safeParse({
96
+ ...scene,
97
+ pages: idsIn(fields.get(keyOf(["pages"]))?.value).map((id) => ({
98
+ ...pages.get(id),
99
+ id,
100
+ layers: list(id, ["layers", id]),
101
+ })),
102
+ });
103
+ return parsed.success ? parsed.data : undefined;
104
+ }
105
+ function diffFields(before, after) {
106
+ const patches = [];
107
+ for (const [key, field] of before) {
108
+ const next = after.get(key);
109
+ if (!same(field.value, next?.value))
110
+ patches.push({ path: field.path, before: field.value, after: next?.value });
111
+ }
112
+ for (const [key, field] of after)
113
+ if (!before.has(key))
114
+ patches.push({ path: field.path, after: field.value });
115
+ return patches;
116
+ }
117
+ /** Every field that differs between two scenes. */
118
+ export function diffScenes(before, after) {
119
+ return diffFields(flatten(before), flatten(after));
120
+ }
121
+ /**
122
+ * The scene with each patch's `before` or `after` side set. If the result is no longer a valid
123
+ * scene, the scene comes back unchanged.
124
+ */
125
+ export function applyPatches(scene, patches, side) {
126
+ if (patches.length === 0)
127
+ return scene;
128
+ const fields = flatten(scene);
129
+ for (const patch of patches)
130
+ put(fields, patch.path, patch[side]);
131
+ return unflatten(fields) ?? scene;
132
+ }
133
+ export function emptyHistory() {
134
+ return { undo: [], redo: [], group: null };
135
+ }
136
+ /**
137
+ * Record an edit from `before` to `after`. With a `group` equal to the open one, the edit joins
138
+ * the last step, so a typing session or a held arrow key is one Undo.
139
+ */
140
+ export function recordEdit(history, before, after, group = null) {
141
+ const previous = group !== null && group === history.group ? history.undo.at(-1) : undefined;
142
+ const start = flatten(before);
143
+ // Where the run began: this edit's start with the run's earlier changes taken back.
144
+ if (previous)
145
+ for (const patch of previous)
146
+ put(start, patch.path, patch.before);
147
+ const step = diffFields(start, flatten(after));
148
+ const undo = previous ? history.undo.slice(0, -1) : history.undo;
149
+ // A run that came back to where it began leaves no step.
150
+ if (step.length === 0)
151
+ return previous ? { undo, redo: [], group: null } : history;
152
+ return { undo: [...undo, step].slice(-HISTORY_LIMIT), redo: [], group };
153
+ }
154
+ /** Close the open run: the next edit starts a new step, even in the same group. */
155
+ export function endEditGroup(history) {
156
+ return history.group === null ? history : { ...history, group: null };
157
+ }
158
+ export function undoEdit(history, scene) {
159
+ const step = history.undo.at(-1);
160
+ if (!step)
161
+ return { history, scene };
162
+ return {
163
+ history: { undo: history.undo.slice(0, -1), redo: [step, ...history.redo], group: null },
164
+ scene: applyPatches(scene, step, "before"),
165
+ };
166
+ }
167
+ export function redoEdit(history, scene) {
168
+ const [step, ...rest] = history.redo;
169
+ if (!step)
170
+ return { history, scene };
171
+ return {
172
+ history: { undo: [...history.undo, step], redo: rest, group: null },
173
+ scene: applyPatches(scene, step, "after"),
174
+ };
175
+ }
176
+ /**
177
+ * After the server's copy moved from `before` to `server`, keep the history for every field the
178
+ * server did not change, so an unrelated edit stays undoable after a picture lands.
179
+ */
180
+ export function rebaseHistory(history, before, server) {
181
+ const changed = new Set(diffScenes(before, server).map((patch) => keyOf(patch.path)));
182
+ if (changed.size === 0)
183
+ return endEditGroup(history);
184
+ const keep = (steps) => steps
185
+ .map((step) => step.filter((patch) => !changed.has(keyOf(patch.path))))
186
+ .filter((step) => step.length > 0);
187
+ return { undo: keep(history.undo), redo: keep(history.redo), group: null };
188
+ }
189
+ /**
190
+ * Bring a newer server copy into your unsaved scene. `base` is the copy your edits started from.
191
+ * A field only they changed comes in; a field only you changed stays yours; a field you both
192
+ * changed differently keeps yours and comes back in `conflicts`, so the editor can show their
193
+ * change and offer "Use theirs" (applyPatches with the conflict's `after`) or "Keep mine".
194
+ * Lists of layers and pages merge as a whole: when you both changed one, yours stays.
195
+ */
196
+ export function mergeScenes(draft, base, server) {
197
+ const mine = flatten(draft);
198
+ const was = flatten(base);
199
+ const theirs = flatten(server);
200
+ if (diffFields(was, mine).length === 0)
201
+ return { scene: server, base: server, conflicts: [] };
202
+ const merged = new Map(mine);
203
+ const nextBase = new Map(theirs);
204
+ const conflicts = [];
205
+ const remote = diffFields(was, theirs);
206
+ for (const patch of remote) {
207
+ const own = mine.get(keyOf(patch.path))?.value;
208
+ if (same(own, patch.before))
209
+ put(merged, patch.path, patch.after);
210
+ else if (!same(own, patch.after)) {
211
+ conflicts.push(patch);
212
+ put(nextBase, patch.path, patch.before);
213
+ }
214
+ }
215
+ const scene = unflatten(merged);
216
+ const sceneBase = unflatten(nextBase);
217
+ // ponytail: fields that no longer make a scene together keep yours whole, against theirs.
218
+ if (!scene || !sceneBase)
219
+ return { scene: draft, base: server, conflicts: remote };
220
+ return { scene, base: sceneBase, conflicts };
221
+ }