@ideare/kit 0.0.0-stage → 0.1.0-next.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.
- 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 +492 -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/svg.d.ts +46 -0
- package/dist/marks/svg.js +341 -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 +49 -0
- package/dist/scene/brand-art.js +154 -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,621 @@
|
|
|
1
|
+
import { fmt } from "../text/engine.js";
|
|
2
|
+
import { boundsOf, centerOf, frameOf, isCorner, localFrame, pageFrame, resizeFrame, rotatePoint, snapGuides, unionOf, withHeight, } from "../scene/frame.js";
|
|
3
|
+
/** How far a copy lands from its original, down and to the right, in design units. */
|
|
4
|
+
export const DUPLICATE_OFFSET = 24;
|
|
5
|
+
/** The most pages a scene holds, as its schema says. */
|
|
6
|
+
export const MAX_PAGES = 20;
|
|
7
|
+
const FULL_LOCK = { move: true, resize: true, edit: true, style: true, delete: true };
|
|
8
|
+
/** The picked layers in drawing order. A layer inside a picked group is left to its group. */
|
|
9
|
+
function pick(scene, page, ids) {
|
|
10
|
+
const wanted = new Set(ids);
|
|
11
|
+
const found = [];
|
|
12
|
+
const walk = (layers, groups) => {
|
|
13
|
+
for (const layer of layers) {
|
|
14
|
+
if (wanted.has(layer.id))
|
|
15
|
+
found.push({ layer, groups });
|
|
16
|
+
else if (layer.type === "group")
|
|
17
|
+
walk(layer.layers, [...groups, layer]);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
walk(scene.pages[page]?.layers ?? [], []);
|
|
21
|
+
return found;
|
|
22
|
+
}
|
|
23
|
+
/** A lock on the layer or on any group around it. */
|
|
24
|
+
function locked(found, action) {
|
|
25
|
+
return [found.layer, ...found.groups].some((layer) => layer.lock?.[action]);
|
|
26
|
+
}
|
|
27
|
+
const groupFrames = (found) => found.groups.map((group) => group.frame);
|
|
28
|
+
const onPage = (found) => pageFrame(found.layer.frame, groupFrames(found));
|
|
29
|
+
/** The layer with a frame given in the page's space. */
|
|
30
|
+
const placed = (found, frame) => ({
|
|
31
|
+
...found.layer,
|
|
32
|
+
frame: localFrame(frame, groupFrames(found)),
|
|
33
|
+
});
|
|
34
|
+
/** The layer moved by dx and dy on the page, whatever groups it sits in. */
|
|
35
|
+
function shifted(found, dx, dy) {
|
|
36
|
+
const frame = onPage(found);
|
|
37
|
+
return placed(found, { ...frame, x: frame.x + dx, y: frame.y + dy });
|
|
38
|
+
}
|
|
39
|
+
/** The layers with some of them replaced by id, sharing every list that did not change. */
|
|
40
|
+
function replaceLayers(layers, next) {
|
|
41
|
+
let changed = false;
|
|
42
|
+
const out = layers.map((layer) => {
|
|
43
|
+
const replaced = next.get(layer.id);
|
|
44
|
+
if (replaced) {
|
|
45
|
+
changed = true;
|
|
46
|
+
return replaced;
|
|
47
|
+
}
|
|
48
|
+
if (layer.type !== "group")
|
|
49
|
+
return layer;
|
|
50
|
+
const children = replaceLayers(layer.layers, next);
|
|
51
|
+
if (children === layer.layers)
|
|
52
|
+
return layer;
|
|
53
|
+
changed = true;
|
|
54
|
+
return { ...layer, layers: children };
|
|
55
|
+
});
|
|
56
|
+
return changed ? out : layers;
|
|
57
|
+
}
|
|
58
|
+
function withPage(scene, index, page) {
|
|
59
|
+
return { ...scene, pages: scene.pages.map((each, at) => (at === index ? page : each)) };
|
|
60
|
+
}
|
|
61
|
+
function withLayers(scene, page, layers) {
|
|
62
|
+
const current = scene.pages[page];
|
|
63
|
+
if (!current || layers === current.layers)
|
|
64
|
+
return scene;
|
|
65
|
+
return withPage(scene, page, { ...current, layers });
|
|
66
|
+
}
|
|
67
|
+
function commit(scene, page, next) {
|
|
68
|
+
const current = scene.pages[page];
|
|
69
|
+
if (!current || next.size === 0)
|
|
70
|
+
return scene;
|
|
71
|
+
return withLayers(scene, page, replaceLayers(current.layers, next));
|
|
72
|
+
}
|
|
73
|
+
const commitOne = (scene, page, layer) => commit(scene, page, new Map([[layer.id, layer]]));
|
|
74
|
+
/** Every id on a page, groups and their layers included. */
|
|
75
|
+
function idsOn(layers, into = new Set()) {
|
|
76
|
+
for (const layer of layers) {
|
|
77
|
+
into.add(layer.id);
|
|
78
|
+
if (layer.type === "group")
|
|
79
|
+
idsOn(layer.layers, into);
|
|
80
|
+
}
|
|
81
|
+
return into;
|
|
82
|
+
}
|
|
83
|
+
/** `base`, or `base-2`, `base-3`... whichever is free, and mark it taken. */
|
|
84
|
+
function freeId(taken, base) {
|
|
85
|
+
const stem = base.replace(/-\d+$/, "").slice(0, 56) || "layer";
|
|
86
|
+
let id = taken.has(base) || base.length > 64 ? `${stem}-2` : base;
|
|
87
|
+
for (let n = 3; taken.has(id); n++)
|
|
88
|
+
id = `${stem}-${n}`;
|
|
89
|
+
taken.add(id);
|
|
90
|
+
return id;
|
|
91
|
+
}
|
|
92
|
+
/** The layer with ids that are free on its page, its group's layers included. */
|
|
93
|
+
function withFreeIds(layer, taken) {
|
|
94
|
+
const id = freeId(taken, layer.id);
|
|
95
|
+
if (layer.type !== "group")
|
|
96
|
+
return { ...layer, id };
|
|
97
|
+
return { ...layer, id, layers: layer.layers.map((child) => withFreeIds(child, taken)) };
|
|
98
|
+
}
|
|
99
|
+
const SCALED_AXES = {
|
|
100
|
+
m: ["x", "y"],
|
|
101
|
+
l: ["x", "y"],
|
|
102
|
+
t: ["x", "y"],
|
|
103
|
+
h: ["x"],
|
|
104
|
+
v: ["y"],
|
|
105
|
+
c: ["x", "y", "x", "y", "x", "y"],
|
|
106
|
+
s: ["x", "y", "x", "y"],
|
|
107
|
+
q: ["x", "y", "x", "y"],
|
|
108
|
+
a: ["x", "y", "-", "-", "-", "x", "y"],
|
|
109
|
+
z: [],
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Path data scaled by kx across and ky down. ponytail: an arc's radii scale with the axes, which
|
|
113
|
+
* is exact for upright arcs only, and data whose numbers do not split into whole commands (arc
|
|
114
|
+
* flags written with no space, like `a1 1 0 0110 10`) comes back unchanged.
|
|
115
|
+
*/
|
|
116
|
+
export function scalePath(d, kx, ky) {
|
|
117
|
+
const tokens = d.match(/[a-zA-Z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g) ?? [];
|
|
118
|
+
const parts = [];
|
|
119
|
+
let index = 0;
|
|
120
|
+
while (index < tokens.length) {
|
|
121
|
+
const command = tokens[index++] ?? "";
|
|
122
|
+
const axes = SCALED_AXES[command.toLowerCase()];
|
|
123
|
+
if (!axes)
|
|
124
|
+
return d;
|
|
125
|
+
const numbers = [];
|
|
126
|
+
while (index < tokens.length && !/[a-zA-Z]/.test(tokens[index] ?? "")) {
|
|
127
|
+
numbers.push(Number(tokens[index++]));
|
|
128
|
+
}
|
|
129
|
+
if (axes.length === 0 ? numbers.length > 0 : numbers.length % axes.length !== 0)
|
|
130
|
+
return d;
|
|
131
|
+
const scaled = numbers.map((value, at) => {
|
|
132
|
+
const axis = axes[at % axes.length];
|
|
133
|
+
return fmt(axis === "x" ? value * kx : axis === "y" ? value * ky : value);
|
|
134
|
+
});
|
|
135
|
+
parts.push(`${command}${scaled.join(" ")}`);
|
|
136
|
+
}
|
|
137
|
+
return parts.join("");
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* A layer's content scaled with its frame by `k`, as a corner drag does: type sizes, corner
|
|
141
|
+
* radii, strokes, shadows, path data and a group's layers. Its own frame is the caller's.
|
|
142
|
+
*/
|
|
143
|
+
function scaleContent(layer, k) {
|
|
144
|
+
if (k === 1)
|
|
145
|
+
return layer;
|
|
146
|
+
switch (layer.type) {
|
|
147
|
+
case "text":
|
|
148
|
+
return {
|
|
149
|
+
...layer,
|
|
150
|
+
style: { ...layer.style, size: layer.style.size * k },
|
|
151
|
+
fit: layer.fit && { ...layer.fit, min: layer.fit.min * k },
|
|
152
|
+
};
|
|
153
|
+
case "shape":
|
|
154
|
+
return {
|
|
155
|
+
...layer,
|
|
156
|
+
radius: layer.radius && layer.radius * k,
|
|
157
|
+
d: layer.d && scalePath(layer.d, k, k),
|
|
158
|
+
stroke: layer.stroke && {
|
|
159
|
+
...layer.stroke,
|
|
160
|
+
width: layer.stroke.width * k,
|
|
161
|
+
dash: layer.stroke.dash?.map((step) => step * k),
|
|
162
|
+
},
|
|
163
|
+
shadow: layer.shadow && {
|
|
164
|
+
...layer.shadow,
|
|
165
|
+
x: layer.shadow.x * k,
|
|
166
|
+
y: layer.shadow.y * k,
|
|
167
|
+
blur: layer.shadow.blur * k,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
case "image":
|
|
171
|
+
case "art":
|
|
172
|
+
return { ...layer, radius: layer.radius && layer.radius * k };
|
|
173
|
+
case "group":
|
|
174
|
+
return {
|
|
175
|
+
...layer,
|
|
176
|
+
layers: layer.layers.map((child) => scaleContent({
|
|
177
|
+
...child,
|
|
178
|
+
frame: {
|
|
179
|
+
...child.frame,
|
|
180
|
+
x: child.frame.x * k,
|
|
181
|
+
y: child.frame.y * k,
|
|
182
|
+
w: child.frame.w * k,
|
|
183
|
+
h: child.frame.h * k,
|
|
184
|
+
},
|
|
185
|
+
}, k)),
|
|
186
|
+
};
|
|
187
|
+
default:
|
|
188
|
+
return layer;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** The page-space frame of a layer and the layer itself, found by id anywhere on the page. */
|
|
192
|
+
export function findLayer(scene, page, id) {
|
|
193
|
+
const found = pick(scene, page, [id])[0];
|
|
194
|
+
return found && { layer: found.layer, frame: onPage(found) };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The box the editor draws around the pick: one layer's own frame, turned with it, or the
|
|
198
|
+
* upright box around several. Its `rotate` is where a turn gesture starts.
|
|
199
|
+
*/
|
|
200
|
+
export function selectionFrame(scene, page, ids) {
|
|
201
|
+
const found = pick(scene, page, ids);
|
|
202
|
+
const first = found[0];
|
|
203
|
+
if (!first)
|
|
204
|
+
return undefined;
|
|
205
|
+
if (found.length === 1)
|
|
206
|
+
return onPage(first);
|
|
207
|
+
const box = unionOf(found.map((each) => boundsOf(onPage(each))));
|
|
208
|
+
return frameOf(box.x, box.y, box.w, box.h);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Where a moving pick can land: the page's edges and middle, and the edges and middles of the
|
|
212
|
+
* other visible layers on the page. Feed it to snapBox with the stage's scale.
|
|
213
|
+
*/
|
|
214
|
+
export function snapTargets(scene, page, ids) {
|
|
215
|
+
const moving = new Set(ids);
|
|
216
|
+
const holdsMoving = (layer) => moving.has(layer.id) || (layer.type === "group" && layer.layers.some(holdsMoving));
|
|
217
|
+
const others = (scene.pages[page]?.layers ?? [])
|
|
218
|
+
.filter((layer) => !layer.hidden && !holdsMoving(layer))
|
|
219
|
+
.map((layer) => boundsOf(layer.frame));
|
|
220
|
+
return snapGuides({ x: 0, y: 0, w: scene.size.w, h: scene.size.h }, others);
|
|
221
|
+
}
|
|
222
|
+
/** Put a layer on top of the page. Ids already taken on the page get a number. */
|
|
223
|
+
export function addLayer(scene, page, layer) {
|
|
224
|
+
const current = scene.pages[page];
|
|
225
|
+
if (!current)
|
|
226
|
+
return { scene, selection: [] };
|
|
227
|
+
const added = withFreeIds(layer, idsOn(current.layers));
|
|
228
|
+
return { scene: withLayers(scene, page, [...current.layers, added]), selection: [added.id] };
|
|
229
|
+
}
|
|
230
|
+
/** Take layers off the page. Locked ones stay, and stay picked. */
|
|
231
|
+
export function removeLayers(scene, page, ids) {
|
|
232
|
+
const current = scene.pages[page];
|
|
233
|
+
if (!current)
|
|
234
|
+
return { scene, selection: [] };
|
|
235
|
+
const kept = [];
|
|
236
|
+
const gone = new Set();
|
|
237
|
+
for (const found of pick(scene, page, ids)) {
|
|
238
|
+
if (locked(found, "delete"))
|
|
239
|
+
kept.push(found.layer.id);
|
|
240
|
+
else
|
|
241
|
+
gone.add(found.layer.id);
|
|
242
|
+
}
|
|
243
|
+
if (gone.size === 0)
|
|
244
|
+
return { scene, selection: kept };
|
|
245
|
+
const prune = (layers) => layers.flatMap((layer) => {
|
|
246
|
+
if (gone.has(layer.id))
|
|
247
|
+
return [];
|
|
248
|
+
if (layer.type !== "group")
|
|
249
|
+
return [layer];
|
|
250
|
+
const children = prune(layer.layers);
|
|
251
|
+
// A group with nothing left in it goes too.
|
|
252
|
+
if (children.length === 0)
|
|
253
|
+
return [];
|
|
254
|
+
return [
|
|
255
|
+
children.length === layer.layers.length ? layer : { ...layer, layers: children },
|
|
256
|
+
];
|
|
257
|
+
});
|
|
258
|
+
return { scene: withLayers(scene, page, prune(current.layers)), selection: kept };
|
|
259
|
+
}
|
|
260
|
+
/** A copy of each picked layer right above it, moved by `offset` on the page. */
|
|
261
|
+
export function duplicateLayers(scene, page, ids, offset = DUPLICATE_OFFSET) {
|
|
262
|
+
const current = scene.pages[page];
|
|
263
|
+
if (!current)
|
|
264
|
+
return { scene, selection: [] };
|
|
265
|
+
const taken = idsOn(current.layers);
|
|
266
|
+
const copies = new Map();
|
|
267
|
+
for (const found of pick(scene, page, ids))
|
|
268
|
+
copies.set(found.layer.id, withFreeIds(shifted(found, offset, offset), taken));
|
|
269
|
+
if (copies.size === 0)
|
|
270
|
+
return { scene, selection: [] };
|
|
271
|
+
const insert = (layers) => layers.flatMap((layer) => {
|
|
272
|
+
const copy = copies.get(layer.id);
|
|
273
|
+
if (copy)
|
|
274
|
+
return [layer, copy];
|
|
275
|
+
return layer.type === "group" ? [{ ...layer, layers: insert(layer.layers) }] : [layer];
|
|
276
|
+
});
|
|
277
|
+
return {
|
|
278
|
+
scene: withLayers(scene, page, insert(current.layers)),
|
|
279
|
+
selection: [...copies.values()].map((copy) => copy.id),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/** Move the picked layers by dx and dy on the page. Layers locked in place stay. */
|
|
283
|
+
export function moveLayers(scene, page, ids, dx, dy) {
|
|
284
|
+
if (!dx && !dy)
|
|
285
|
+
return scene;
|
|
286
|
+
const next = new Map();
|
|
287
|
+
for (const found of pick(scene, page, ids))
|
|
288
|
+
if (!locked(found, "move"))
|
|
289
|
+
next.set(found.layer.id, shifted(found, dx, dy));
|
|
290
|
+
return commit(scene, page, next);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Pull a handle of the pick to `to`, a point on the page (the handle's start plus the pointer's
|
|
294
|
+
* travel), from the scene at the press. One layer resizes in its own turned frame with the
|
|
295
|
+
* opposite corner or side held still; a corner also scales its type, radii and strokes, and a
|
|
296
|
+
* group's layers. Several layers, and a group, resize from the corners only: the box around
|
|
297
|
+
* them scales, and every layer in it, about the opposite corner.
|
|
298
|
+
*/
|
|
299
|
+
export function resizeLayers(start, page, ids, handle, to, options = {}) {
|
|
300
|
+
const found = pick(start, page, ids).filter((each) => !locked(each, "resize"));
|
|
301
|
+
const only = found[0];
|
|
302
|
+
if (!only)
|
|
303
|
+
return start;
|
|
304
|
+
if (found.length === 1) {
|
|
305
|
+
// A group's layers cannot stretch one way only, so a group keeps its proportions.
|
|
306
|
+
const isGroup = only.layer.type === "group";
|
|
307
|
+
if (isGroup && !isCorner(handle))
|
|
308
|
+
return start;
|
|
309
|
+
const keepRatio = isGroup || options.keepRatio !== false;
|
|
310
|
+
const frame = onPage(only);
|
|
311
|
+
const resized = resizeFrame(frame, handle, to, { min: options.min, keepRatio });
|
|
312
|
+
let layer = placed(only, resized);
|
|
313
|
+
if (isCorner(handle) && keepRatio)
|
|
314
|
+
layer = scaleContent(layer, resized.w / frame.w);
|
|
315
|
+
else if (layer.type === "shape" && layer.d)
|
|
316
|
+
layer = { ...layer, d: scalePath(layer.d, resized.w / frame.w, resized.h / frame.h) };
|
|
317
|
+
return commitOne(start, page, layer);
|
|
318
|
+
}
|
|
319
|
+
if (!isCorner(handle))
|
|
320
|
+
return start;
|
|
321
|
+
const next = new Map();
|
|
322
|
+
const frames = found.map(onPage);
|
|
323
|
+
const box = unionOf(frames.map(boundsOf));
|
|
324
|
+
const resized = resizeFrame(frameOf(box.x, box.y, box.w, box.h), handle, to, {
|
|
325
|
+
min: options.min,
|
|
326
|
+
});
|
|
327
|
+
const k = resized.w / box.w;
|
|
328
|
+
const was = centerOf(box);
|
|
329
|
+
const now = centerOf(resized);
|
|
330
|
+
found.forEach((each, index) => {
|
|
331
|
+
const frame = frames[index];
|
|
332
|
+
if (!frame)
|
|
333
|
+
return;
|
|
334
|
+
// Scaling about the held corner moves every center k times as far from the box's center.
|
|
335
|
+
const center = centerOf(frame);
|
|
336
|
+
const w = frame.w * k;
|
|
337
|
+
const h = frame.h * k;
|
|
338
|
+
const x = now.x + (center.x - was.x) * k - w / 2;
|
|
339
|
+
const y = now.y + (center.y - was.y) * k - h / 2;
|
|
340
|
+
next.set(each.layer.id, scaleContent(placed(each, frameOf(x, y, w, h, frame.rotate)), k));
|
|
341
|
+
});
|
|
342
|
+
return commit(start, page, next);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Turn the pick to `angle` degrees, from the scene at the press. One layer turns about its own
|
|
346
|
+
* center to that angle. Several turn together by `angle` about the center of the box around
|
|
347
|
+
* them, which starts every gesture upright (selectionFrame's `rotate` is 0). Use turnAngle for
|
|
348
|
+
* the angle a handle gives.
|
|
349
|
+
*/
|
|
350
|
+
export function rotateLayers(start, page, ids, angle) {
|
|
351
|
+
const found = pick(start, page, ids).filter((each) => !locked(each, "resize"));
|
|
352
|
+
const only = found[0];
|
|
353
|
+
if (!only)
|
|
354
|
+
return start;
|
|
355
|
+
if (found.length === 1) {
|
|
356
|
+
const frame = onPage(only);
|
|
357
|
+
return commitOne(start, page, placed(only, frameOf(frame.x, frame.y, frame.w, frame.h, angle)));
|
|
358
|
+
}
|
|
359
|
+
const next = new Map();
|
|
360
|
+
const frames = found.map(onPage);
|
|
361
|
+
const pivot = centerOf(unionOf(frames.map(boundsOf)));
|
|
362
|
+
found.forEach((each, index) => {
|
|
363
|
+
const frame = frames[index];
|
|
364
|
+
if (!frame)
|
|
365
|
+
return;
|
|
366
|
+
const center = centerOf(frame);
|
|
367
|
+
const turned = rotatePoint({ x: center.x - pivot.x, y: center.y - pivot.y }, angle);
|
|
368
|
+
const x = pivot.x + turned.x - frame.w / 2;
|
|
369
|
+
const y = pivot.y + turned.y - frame.h / 2;
|
|
370
|
+
next.set(each.layer.id, placed(each, frameOf(x, y, frame.w, frame.h, (frame.rotate ?? 0) + angle)));
|
|
371
|
+
});
|
|
372
|
+
return commit(start, page, next);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Line the pick up: one layer with the page, several with the box around them. It uses each
|
|
376
|
+
* layer's upright box, so a turned layer lines up by what it covers.
|
|
377
|
+
*/
|
|
378
|
+
export function alignLayers(scene, page, ids, mode) {
|
|
379
|
+
const found = pick(scene, page, ids);
|
|
380
|
+
if (found.length === 0)
|
|
381
|
+
return scene;
|
|
382
|
+
const boxes = found.map((each) => boundsOf(onPage(each)));
|
|
383
|
+
const ref = found.length === 1 ? { x: 0, y: 0, w: scene.size.w, h: scene.size.h } : unionOf(boxes);
|
|
384
|
+
const next = new Map();
|
|
385
|
+
found.forEach((each, index) => {
|
|
386
|
+
const box = boxes[index];
|
|
387
|
+
if (!box || locked(each, "move"))
|
|
388
|
+
return;
|
|
389
|
+
const delta = alignDelta(box, ref, mode);
|
|
390
|
+
if (delta.x || delta.y)
|
|
391
|
+
next.set(each.layer.id, shifted(each, delta.x, delta.y));
|
|
392
|
+
});
|
|
393
|
+
return commit(scene, page, next);
|
|
394
|
+
}
|
|
395
|
+
function alignDelta(box, ref, mode) {
|
|
396
|
+
switch (mode) {
|
|
397
|
+
case "left":
|
|
398
|
+
return { x: ref.x - box.x, y: 0 };
|
|
399
|
+
case "center":
|
|
400
|
+
return { x: ref.x + ref.w / 2 - (box.x + box.w / 2), y: 0 };
|
|
401
|
+
case "right":
|
|
402
|
+
return { x: ref.x + ref.w - (box.x + box.w), y: 0 };
|
|
403
|
+
case "top":
|
|
404
|
+
return { x: 0, y: ref.y - box.y };
|
|
405
|
+
case "middle":
|
|
406
|
+
return { x: 0, y: ref.y + ref.h / 2 - (box.y + box.h / 2) };
|
|
407
|
+
case "bottom":
|
|
408
|
+
return { x: 0, y: ref.y + ref.h - (box.y + box.h) };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Space three or more layers evenly: the first and the last stay, and the gaps between the
|
|
413
|
+
* others' upright boxes become equal. Layers locked in place are left out.
|
|
414
|
+
*/
|
|
415
|
+
export function distributeLayers(scene, page, ids, direction) {
|
|
416
|
+
const found = pick(scene, page, ids).filter((each) => !locked(each, "move"));
|
|
417
|
+
if (found.length < 3)
|
|
418
|
+
return scene;
|
|
419
|
+
const start = direction === "horizontal" ? "x" : "y";
|
|
420
|
+
const size = direction === "horizontal" ? "w" : "h";
|
|
421
|
+
const items = found
|
|
422
|
+
.map((each) => ({ each, box: boundsOf(onPage(each)) }))
|
|
423
|
+
.sort((a, b) => a.box[start] - b.box[start]);
|
|
424
|
+
const first = items[0];
|
|
425
|
+
const last = items[items.length - 1];
|
|
426
|
+
if (!first || !last)
|
|
427
|
+
return scene;
|
|
428
|
+
const span = last.box[start] + last.box[size] - first.box[start];
|
|
429
|
+
const filled = items.reduce((sum, item) => sum + item.box[size], 0);
|
|
430
|
+
const gap = (span - filled) / (items.length - 1);
|
|
431
|
+
const next = new Map();
|
|
432
|
+
let at = first.box[start];
|
|
433
|
+
for (const { each, box } of items) {
|
|
434
|
+
const delta = at - box[start];
|
|
435
|
+
at += box[size] + gap;
|
|
436
|
+
if (Math.abs(delta) < 1e-9)
|
|
437
|
+
continue;
|
|
438
|
+
const layer = direction === "horizontal" ? shifted(each, delta, 0) : shifted(each, 0, delta);
|
|
439
|
+
next.set(each.layer.id, layer);
|
|
440
|
+
}
|
|
441
|
+
return commit(scene, page, next);
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Change the drawing order: to the front or the back of their list, or one step past the next
|
|
445
|
+
* layer that is not picked. Picked layers keep their order among themselves, and a layer inside
|
|
446
|
+
* a group moves within its group.
|
|
447
|
+
*/
|
|
448
|
+
export function reorderLayers(scene, page, ids, order) {
|
|
449
|
+
const picked = new Set(ids);
|
|
450
|
+
const isPicked = (layer) => picked.has(layer.id);
|
|
451
|
+
const reorder = (layers) => {
|
|
452
|
+
const nested = replaceGroups(layers, reorder);
|
|
453
|
+
let out;
|
|
454
|
+
if (order === "front")
|
|
455
|
+
out = [...nested.filter((l) => !isPicked(l)), ...nested.filter(isPicked)];
|
|
456
|
+
else if (order === "back")
|
|
457
|
+
out = [...nested.filter(isPicked), ...nested.filter((l) => !isPicked(l))];
|
|
458
|
+
else {
|
|
459
|
+
out = [...nested];
|
|
460
|
+
const swap = (i, j) => {
|
|
461
|
+
const a = out[i];
|
|
462
|
+
const b = out[j];
|
|
463
|
+
if (a && b)
|
|
464
|
+
[out[i], out[j]] = [b, a];
|
|
465
|
+
};
|
|
466
|
+
if (order === "forward") {
|
|
467
|
+
for (let i = out.length - 2; i >= 0; i--) {
|
|
468
|
+
const here = out[i];
|
|
469
|
+
const above = out[i + 1];
|
|
470
|
+
if (here && above && isPicked(here) && !isPicked(above))
|
|
471
|
+
swap(i, i + 1);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
for (let i = 1; i < out.length; i++) {
|
|
476
|
+
const here = out[i];
|
|
477
|
+
const below = out[i - 1];
|
|
478
|
+
if (here && below && isPicked(here) && !isPicked(below))
|
|
479
|
+
swap(i, i - 1);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return out.every((layer, index) => layer === layers[index]) ? layers : out;
|
|
484
|
+
};
|
|
485
|
+
const current = scene.pages[page];
|
|
486
|
+
return current ? withLayers(scene, page, reorder(current.layers)) : scene;
|
|
487
|
+
}
|
|
488
|
+
/** Each group's list run through `fn`, keeping the objects of groups that did not change. */
|
|
489
|
+
function replaceGroups(layers, fn) {
|
|
490
|
+
return layers.map((layer) => {
|
|
491
|
+
if (layer.type !== "group")
|
|
492
|
+
return layer;
|
|
493
|
+
const children = fn(layer.layers);
|
|
494
|
+
return children === layer.layers ? layer : { ...layer, layers: children };
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Put two or more layers that share a list into one group, where the top one of them was. The
|
|
499
|
+
* group is the upright box around them, and they keep their place on the page.
|
|
500
|
+
*/
|
|
501
|
+
export function groupLayers(scene, page, ids, id = "group") {
|
|
502
|
+
const current = scene.pages[page];
|
|
503
|
+
const found = pick(scene, page, ids);
|
|
504
|
+
const parent = found[0]?.groups.at(-1);
|
|
505
|
+
const sameList = found.every((each) => each.groups.at(-1) === parent);
|
|
506
|
+
if (!current || found.length < 2 || !sameList)
|
|
507
|
+
return { scene, selection: [...ids] };
|
|
508
|
+
const members = new Set(found.map((each) => each.layer.id));
|
|
509
|
+
const box = unionOf(found.map((each) => boundsOf(each.layer.frame)));
|
|
510
|
+
const group = {
|
|
511
|
+
id: freeId(idsOn(current.layers), id),
|
|
512
|
+
type: "group",
|
|
513
|
+
frame: frameOf(box.x, box.y, box.w, box.h),
|
|
514
|
+
layers: found.map(({ layer }) => ({
|
|
515
|
+
...layer,
|
|
516
|
+
frame: { ...layer.frame, x: layer.frame.x - box.x, y: layer.frame.y - box.y },
|
|
517
|
+
})),
|
|
518
|
+
};
|
|
519
|
+
const top = found[found.length - 1]?.layer.id;
|
|
520
|
+
const regroup = (layers) => layers.flatMap((layer) => {
|
|
521
|
+
if (layer.id === top)
|
|
522
|
+
return [group];
|
|
523
|
+
if (members.has(layer.id))
|
|
524
|
+
return [];
|
|
525
|
+
return layer.type === "group" ? [{ ...layer, layers: regroup(layer.layers) }] : [layer];
|
|
526
|
+
});
|
|
527
|
+
return { scene: withLayers(scene, page, regroup(current.layers)), selection: [group.id] };
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Break picked groups apart: their layers take the group's place, keep their place on the page,
|
|
531
|
+
* and take on the group's opacity and hidden state. ponytail: a group's blend mode goes to each
|
|
532
|
+
* layer that has none, which blends them one by one instead of as one picture.
|
|
533
|
+
*/
|
|
534
|
+
export function ungroupLayers(scene, page, ids) {
|
|
535
|
+
const current = scene.pages[page];
|
|
536
|
+
const groups = new Set(pick(scene, page, ids)
|
|
537
|
+
.filter((each) => each.layer.type === "group")
|
|
538
|
+
.map((each) => each.layer.id));
|
|
539
|
+
if (!current || groups.size === 0)
|
|
540
|
+
return { scene, selection: [...ids] };
|
|
541
|
+
const freed = [];
|
|
542
|
+
const lift = (layers) => layers.flatMap((layer) => {
|
|
543
|
+
if (layer.type !== "group")
|
|
544
|
+
return [layer];
|
|
545
|
+
if (!groups.has(layer.id))
|
|
546
|
+
return [{ ...layer, layers: lift(layer.layers) }];
|
|
547
|
+
return layer.layers.map((child) => {
|
|
548
|
+
freed.push(child.id);
|
|
549
|
+
const opacity = (layer.opacity ?? 1) * (child.opacity ?? 1);
|
|
550
|
+
return {
|
|
551
|
+
...child,
|
|
552
|
+
frame: pageFrame(child.frame, [layer.frame]),
|
|
553
|
+
opacity: opacity < 1 ? opacity : undefined,
|
|
554
|
+
hidden: child.hidden || layer.hidden || undefined,
|
|
555
|
+
blend: child.blend ?? layer.blend,
|
|
556
|
+
};
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
return { scene: withLayers(scene, page, lift(current.layers)), selection: freed };
|
|
560
|
+
}
|
|
561
|
+
/** Lock the picked layers (true: all five locks), unlock them (false), or set some locks. */
|
|
562
|
+
export function lockLayers(scene, page, ids, lock) {
|
|
563
|
+
const next = new Map();
|
|
564
|
+
for (const { layer } of pick(scene, page, ids)) {
|
|
565
|
+
const { lock: _old, ...rest } = layer;
|
|
566
|
+
next.set(layer.id, lock === false ? rest : { ...rest, lock: lock === true ? FULL_LOCK : lock });
|
|
567
|
+
}
|
|
568
|
+
return commit(scene, page, next);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* A text box's new height (from its lines, as someone types), with its turned top-left corner
|
|
572
|
+
* held still, so turned text never slides (rule E2).
|
|
573
|
+
*/
|
|
574
|
+
export function setTextHeight(scene, page, id, h) {
|
|
575
|
+
const found = pick(scene, page, [id])[0];
|
|
576
|
+
if (!found || found.layer.type !== "text" || found.layer.frame.h === h)
|
|
577
|
+
return scene;
|
|
578
|
+
return commitOne(scene, page, { ...found.layer, frame: withHeight(found.layer.frame, h) });
|
|
579
|
+
}
|
|
580
|
+
function freePageId(scene, base) {
|
|
581
|
+
return freeId(new Set(scene.pages.map((page) => page.id)), base);
|
|
582
|
+
}
|
|
583
|
+
/** A blank page at `at` (the end by default), on the same background as the page before it. */
|
|
584
|
+
export function addPage(scene, at = scene.pages.length) {
|
|
585
|
+
if (scene.pages.length >= MAX_PAGES)
|
|
586
|
+
return scene;
|
|
587
|
+
const index = Math.max(0, Math.min(at, scene.pages.length));
|
|
588
|
+
const like = scene.pages[index - 1] ?? scene.pages[0];
|
|
589
|
+
const page = {
|
|
590
|
+
id: freePageId(scene, "page"),
|
|
591
|
+
background: like?.background ?? { token: "ground" },
|
|
592
|
+
layers: [],
|
|
593
|
+
};
|
|
594
|
+
return { ...scene, pages: [...scene.pages.slice(0, index), page, ...scene.pages.slice(index)] };
|
|
595
|
+
}
|
|
596
|
+
/** A copy of a page, right after it. */
|
|
597
|
+
export function duplicatePage(scene, index) {
|
|
598
|
+
const page = scene.pages[index];
|
|
599
|
+
if (!page || scene.pages.length >= MAX_PAGES)
|
|
600
|
+
return scene;
|
|
601
|
+
const copy = { ...page, id: freePageId(scene, page.id) };
|
|
602
|
+
return {
|
|
603
|
+
...scene,
|
|
604
|
+
pages: [...scene.pages.slice(0, index + 1), copy, ...scene.pages.slice(index + 1)],
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
/** Take a page out. The last page always stays. */
|
|
608
|
+
export function removePage(scene, index) {
|
|
609
|
+
if (scene.pages.length <= 1 || !scene.pages[index])
|
|
610
|
+
return scene;
|
|
611
|
+
return { ...scene, pages: scene.pages.filter((_, at) => at !== index) };
|
|
612
|
+
}
|
|
613
|
+
/** Move a page from one place in the order to another. */
|
|
614
|
+
export function movePage(scene, from, to) {
|
|
615
|
+
const page = scene.pages[from];
|
|
616
|
+
if (!page || from === to || to < 0 || to >= scene.pages.length)
|
|
617
|
+
return scene;
|
|
618
|
+
const pages = scene.pages.filter((_, at) => at !== from);
|
|
619
|
+
pages.splice(to, 0, page);
|
|
620
|
+
return { ...scene, pages };
|
|
621
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Brand, Locale } from "../brand/schema.ts";
|
|
2
|
+
/** The brand's slug, or one made from its name for a draft. */
|
|
3
|
+
export declare function slugOf(brand: Brand): string;
|
|
4
|
+
/** The brand's copy in a language, falling back to the default language. */
|
|
5
|
+
export declare function copyFor(brand: Brand, locale?: Locale): {
|
|
6
|
+
tagline: string;
|
|
7
|
+
description: string;
|
|
8
|
+
card: {
|
|
9
|
+
title: string;
|
|
10
|
+
subtitle?: string | undefined;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
/** brand.md: YAML tokens, then the design.md sections in their fixed order. */
|
|
14
|
+
export declare function brandMd(brand: Brand): string;
|
|
15
|
+
/**
|
|
16
|
+
* The public brand.json: what anyone may read from the CDN and the brand page (plan D5). Voice
|
|
17
|
+
* notes, art references and every link without a public tick are left out, and the marks are
|
|
18
|
+
* written again by the kit, so only clean SVG is ever published.
|
|
19
|
+
*/
|
|
20
|
+
export declare function publicBrand(brand: Brand): Brand;
|
|
21
|
+
export type HeadOptions = {
|
|
22
|
+
/** Where the web files are served, like "/" or "/docs/". Default "/". */
|
|
23
|
+
base?: string;
|
|
24
|
+
/** The absolute URL of the share card, like https://acme.com/og.png. */
|
|
25
|
+
ogImage?: string;
|
|
26
|
+
locale?: Locale;
|
|
27
|
+
/** The page title, when it is not the brand's name. */
|
|
28
|
+
title?: string;
|
|
29
|
+
/** Like generate's `dark`: "none" is a light-only page, so it gets one light theme color. */
|
|
30
|
+
dark?: "auto" | "attribute" | "none";
|
|
31
|
+
};
|
|
32
|
+
/** The head tags for the icons, the manifest, the theme colors and the share card. */
|
|
33
|
+
export declare function headTags(brand: Brand, options?: HeadOptions): string[];
|
|
34
|
+
/** The share card's alt text, in the card's language. */
|
|
35
|
+
export declare function ogAlt(brand: Brand, locale?: Locale, title?: string): string;
|
|
36
|
+
/** site.webmanifest, with the fleet's icon names so a sibling can switch with no head edits. */
|
|
37
|
+
export declare function webManifest(brand: Brand, options?: {
|
|
38
|
+
base?: string;
|
|
39
|
+
}): Record<string, unknown>;
|
|
40
|
+
/** prompt.md: one paste-ready prompt for an AI site builder (Lovable, v0, Bolt and the like). */
|
|
41
|
+
export declare function promptMd(brand: Brand, options?: {
|
|
42
|
+
filesUrl?: string;
|
|
43
|
+
}): string;
|
|
44
|
+
/** signature.html: a table-based email signature with the opaque logo bar (Gmail's dark mode). */
|
|
45
|
+
export declare function signatureHtml(brand: Brand, options: {
|
|
46
|
+
logoUrl: string;
|
|
47
|
+
person?: string;
|
|
48
|
+
role?: string;
|
|
49
|
+
}): string;
|