@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.
- package/LICENSE +21 -0
- package/README.md +148 -2
- package/THIRD_PARTY.md +52 -0
- package/dist/brand/schema.d.ts +265 -0
- package/dist/brand/schema.js +271 -0
- package/dist/check/copy.d.ts +27 -0
- package/dist/check/copy.js +139 -0
- package/dist/check/index.d.ts +54 -0
- package/dist/check/index.js +568 -0
- package/dist/color/contrast.d.ts +41 -0
- package/dist/color/contrast.js +84 -0
- package/dist/color/palette.d.ts +27 -0
- package/dist/color/palette.js +114 -0
- package/dist/color/wcag.d.ts +22 -0
- package/dist/color/wcag.js +50 -0
- package/dist/draft/draft.d.ts +61 -0
- package/dist/draft/draft.js +383 -0
- package/dist/editor/history.d.ts +78 -0
- package/dist/editor/history.js +221 -0
- package/dist/editor/operations.d.ts +112 -0
- package/dist/editor/operations.js +621 -0
- package/dist/files/docs.d.ts +49 -0
- package/dist/files/docs.js +392 -0
- package/dist/files/generate.d.ts +47 -0
- package/dist/files/generate.js +254 -0
- package/dist/files/png.d.ts +40 -0
- package/dist/files/png.js +168 -0
- package/dist/files/raster.d.ts +14 -0
- package/dist/files/raster.js +0 -0
- package/dist/files/templates.d.ts +162 -0
- package/dist/files/templates.js +390 -0
- package/dist/files/tokens.d.ts +22 -0
- package/dist/files/tokens.js +204 -0
- package/dist/fonts/catalog.d.ts +3 -0
- package/dist/fonts/catalog.js +345 -0
- package/dist/fonts/ofl.d.ts +5 -0
- package/dist/fonts/ofl.js +5 -0
- package/dist/fonts/pick.d.ts +33 -0
- package/dist/fonts/pick.js +73 -0
- package/dist/imports/folder.d.ts +80 -0
- package/dist/imports/folder.js +124 -0
- package/dist/imports/unzip.d.ts +32 -0
- package/dist/imports/unzip.js +128 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +34 -0
- package/dist/marks/draw.d.ts +48 -0
- package/dist/marks/draw.js +149 -0
- package/dist/marks/icons.data.d.ts +5 -0
- package/dist/marks/icons.data.js +600 -0
- package/dist/marks/one-color.d.ts +45 -0
- package/dist/marks/one-color.js +304 -0
- package/dist/marks/svg.d.ts +53 -0
- package/dist/marks/svg.js +393 -0
- package/dist/node/index.d.ts +13 -0
- package/dist/node/index.js +41 -0
- package/dist/posts/pix.d.ts +19 -0
- package/dist/posts/pix.js +49 -0
- package/dist/posts/templates.d.ts +45 -0
- package/dist/posts/templates.js +868 -0
- package/dist/resvg/index.d.ts +2 -0
- package/dist/resvg/index.js +22 -0
- package/dist/scene/brand-art.d.ts +61 -0
- package/dist/scene/brand-art.js +217 -0
- package/dist/scene/frame.d.ts +89 -0
- package/dist/scene/frame.js +213 -0
- package/dist/scene/grain.d.ts +7 -0
- package/dist/scene/grain.js +7 -0
- package/dist/scene/qr.d.ts +10 -0
- package/dist/scene/qr.js +359 -0
- package/dist/scene/render.d.ts +76 -0
- package/dist/scene/render.js +557 -0
- package/dist/scene/schema.d.ts +271 -0
- package/dist/scene/schema.js +257 -0
- package/dist/sign.d.ts +47 -0
- package/dist/sign.js +88 -0
- package/dist/text/engine.d.ts +128 -0
- package/dist/text/engine.js +245 -0
- package/dist/text/fonts.d.ts +56 -0
- package/dist/text/fonts.js +65 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/wasm/index.d.ts +15 -0
- package/dist/wasm/index.js +46 -0
- package/package.json +78 -5
- 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
|
+
}
|