windcraft-editor 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -2
- package/core/board-layout.js +71 -0
- package/core/class-groups.js +210 -0
- package/core/design-spec.js +303 -0
- package/core/exporters.js +156 -0
- package/core/pages.js +39 -0
- package/core/prop-tailwind.js +158 -0
- package/core/split-sections.js +99 -0
- package/core/theme.js +87 -0
- package/design/agent.js +2118 -0
- package/design/classes-section.js +103 -0
- package/design/code-editor.js +293 -0
- package/design/code-view.js +158 -0
- package/design/color-picker.js +107 -0
- package/design/config.js +5 -0
- package/design/controls.js +132 -0
- package/design/css-props.js +111 -0
- package/design/design-tab.js +120 -0
- package/design/device-frame.js +40 -0
- package/design/editor/add-menu.js +151 -0
- package/design/editor/api.js +47 -0
- package/design/editor/base.js +55 -0
- package/design/editor/code-panel.js +206 -0
- package/design/editor/color-ui.js +135 -0
- package/design/editor/designs.js +606 -0
- package/design/editor/docs.js +121 -0
- package/design/editor/frame-handles.js +223 -0
- package/design/editor/frame-messages.js +195 -0
- package/design/editor/inspector.js +461 -0
- package/design/editor/layers.js +61 -0
- package/design/editor/pages-panel.js +161 -0
- package/design/editor/panel-resize.js +47 -0
- package/design/editor/plane.js +132 -0
- package/design/editor/present.js +52 -0
- package/design/editor/toolbar.js +267 -0
- package/design/editor.js +47 -0
- package/design/figma-sections.js +440 -0
- package/design/float-toolbar.js +163 -0
- package/design/group-controls.js +52 -0
- package/design/host.js +22 -0
- package/design/panel-model.js +130 -0
- package/design/panel-state.js +40 -0
- package/design/panel.js +212 -0
- package/design/presenter.js +312 -0
- package/design/prop-sections.js +18 -0
- package/design/selection-overlay.js +93 -0
- package/design/store.js +29 -0
- package/design/style-sections.js +315 -0
- package/design/tailwind-classes.js +65 -0
- package/embed-frame.js +43 -0
- package/embed.html +15 -0
- package/embed.js +60 -0
- package/index.js +62 -0
- package/package.json +50 -5
- package/playground/component-source.js +124 -0
- package/styles.css +402 -0
- package/template.js +247 -0
- package/vendor/codemirror.js +24 -0
- package/vendor/preact-htm.js +1 -0
- package/vendor/prettier.js +171 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// The Design tab's Position and Layout sections, laid out as Figma's: always open, icon buttons for the choices, number fields
|
|
2
|
+
// with a letter in front. A value the element sets itself (a class, its style attribute) is drawn solid; a default or inherited
|
|
3
|
+
// one is faint, and typing in it sets it. Every edit goes through the same actions as the other sections, so it is written where
|
|
4
|
+
// the value comes from (setProp in editor/inspector.js):
|
|
5
|
+
// setProp · setProps · resetProp · setGeom · freePosition · align · arrange · group · ungroup · autoLayout · sizeItMyself
|
|
6
|
+
import { html, useState } from "../vendor/preact-htm.js";
|
|
7
|
+
import { srcOf, shownOf, ownsValue, styled, val, variant, modeFor, PRESETS, OPTS } from "./panel-model.js";
|
|
8
|
+
import { parseToken } from "@windcraft/core/class-groups.js";
|
|
9
|
+
import { tokenFor } from "@windcraft/core/prop-tailwind.js";
|
|
10
|
+
import { useUi } from "./panel-state.js";
|
|
11
|
+
import { TextInput } from "./controls.js";
|
|
12
|
+
|
|
13
|
+
const svg = (d, fill) => html`<svg width="14" height="14" viewBox="0 0 16 16" fill=${fill ? "currentColor" : "none"} stroke=${fill ? "none" : "currentColor"} stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" dangerouslySetInnerHTML=${{ __html: d }}></svg>`;
|
|
14
|
+
export const I = {
|
|
15
|
+
alignL: svg('<path d="M2.5 2v12"/><rect x="5" y="4" width="8" height="3" rx=".8"/><rect x="5" y="9" width="5" height="3" rx=".8"/>'),
|
|
16
|
+
alignC: svg('<path d="M8 2v12"/><rect x="3.5" y="4" width="9" height="3" rx=".8"/><rect x="5" y="9" width="6" height="3" rx=".8"/>'),
|
|
17
|
+
alignR: svg('<path d="M13.5 2v12"/><rect x="3" y="4" width="8" height="3" rx=".8"/><rect x="6" y="9" width="5" height="3" rx=".8"/>'),
|
|
18
|
+
alignT: svg('<path d="M2 2.5h12"/><rect x="4" y="5" width="3" height="8" rx=".8"/><rect x="9" y="5" width="3" height="5" rx=".8"/>'),
|
|
19
|
+
alignM: svg('<path d="M2 8h12"/><rect x="4" y="3.5" width="3" height="9" rx=".8"/><rect x="9" y="5" width="3" height="6" rx=".8"/>'),
|
|
20
|
+
alignB: svg('<path d="M2 13.5h12"/><rect x="4" y="3" width="3" height="8" rx=".8"/><rect x="9" y="6" width="3" height="5" rx=".8"/>'),
|
|
21
|
+
free: svg('<rect x="2.5" y="2.5" width="11" height="11" rx="2" stroke-dasharray="2 1.6"/><path d="M8 5.5v5M5.5 8h5"/>'),
|
|
22
|
+
rot90: svg('<path d="M3.5 8a4.5 4.5 0 1 0 1.4-3.3"/><path d="M3.5 2.5v2.6h2.6"/>'),
|
|
23
|
+
flipH: svg('<path d="M8 2v12" stroke-dasharray="1.5 1.5"/><path d="M6 4 2.5 12H6zM10 4l3.5 8H10z"/>'),
|
|
24
|
+
flipV: svg('<path d="M2 8h12" stroke-dasharray="1.5 1.5"/><path d="M4 6 12 2.5V6zM4 10l8 3.5V10z"/>'),
|
|
25
|
+
block: svg('<rect x="3" y="2.5" width="10" height="4.5" rx="1"/><rect x="3" y="9" width="10" height="4.5" rx="1"/>'),
|
|
26
|
+
row: svg('<rect x="2" y="4" width="3.5" height="8" rx="1"/><rect x="6.25" y="4" width="3.5" height="8" rx="1"/><rect x="10.5" y="4" width="3.5" height="8" rx="1"/><path d="M1 14.5h14" opacity=".5"/>'),
|
|
27
|
+
col: svg('<rect x="4" y="2" width="8" height="3.5" rx="1"/><rect x="4" y="6.25" width="8" height="3.5" rx="1"/><rect x="4" y="10.5" width="8" height="3.5" rx="1"/>'),
|
|
28
|
+
grid: svg('<rect x="2.5" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="9" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="2.5" y="9" width="4.5" height="4.5" rx="1"/><rect x="9" y="9" width="4.5" height="4.5" rx="1"/>'),
|
|
29
|
+
wrap: svg('<path d="M2.5 4.5h9a2.5 2.5 0 0 1 0 5H6"/><path d="M7.5 8 6 9.5 7.5 11"/><path d="M2.5 12.5h4"/>'),
|
|
30
|
+
between: svg('<path d="M2.5 2v12M13.5 2v12"/><rect x="4.5" y="5" width="2.5" height="6" rx=".6"/><rect x="9" y="5" width="2.5" height="6" rx=".6"/>'),
|
|
31
|
+
sides: svg('<rect x="2.5" y="2.5" width="11" height="11" rx="2" stroke-dasharray="2.2 1.8"/>'),
|
|
32
|
+
plus: svg('<path d="M8 3.5v9M3.5 8h9"/>'),
|
|
33
|
+
minus: svg('<path d="M3.5 8h9"/>'),
|
|
34
|
+
up: svg('<path d="M8 13V3.5M4 7.5 8 3.5l4 4"/>'),
|
|
35
|
+
down: svg('<path d="M8 3v9.5M4 8.5l4 4 4-4"/>'),
|
|
36
|
+
copy: svg('<rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 3.5V3a.5.5 0 0 0-.5-.5H3a.5.5 0 0 0-.5.5v7a.5.5 0 0 0 .5.5h.5"/>'),
|
|
37
|
+
trash: svg('<path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5"/>'),
|
|
38
|
+
close: svg('<path d="M4.5 4.5l7 7M11.5 4.5l-7 7"/>'),
|
|
39
|
+
dots: svg('<circle cx="3.5" cy="8" r="1.1"/><circle cx="8" cy="8" r="1.1"/><circle cx="12.5" cy="8" r="1.1"/>', true),
|
|
40
|
+
corners: svg('<path d="M2.5 6V4.5a2 2 0 0 1 2-2H6M10 2.5h1.5a2 2 0 0 1 2 2V6M13.5 10v1.5a2 2 0 0 1-2 2H10M6 13.5H4.5a2 2 0 0 1-2-2V10"/>'),
|
|
41
|
+
textL: svg('<path d="M2.5 4h11M2.5 8h7M2.5 12h9"/>'),
|
|
42
|
+
textC: svg('<path d="M2.5 4h11M4.5 8h7M3.5 12h9"/>'),
|
|
43
|
+
textR: svg('<path d="M2.5 4h11M6.5 8h7M4.5 12h9"/>'),
|
|
44
|
+
textJ: svg('<path d="M2.5 4h11M2.5 8h11M2.5 12h11"/>'),
|
|
45
|
+
reverse: svg('<path d="M3 5.5h9.5M10 3l2.5 2.5L10 8"/><path d="M13 10.5H3.5M6 8l-2.5 2.5L6 13"/>'),
|
|
46
|
+
lock: svg('<rect x="3.5" y="7" width="9" height="6.5" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/>'),
|
|
47
|
+
unlock: svg('<rect x="3.5" y="7" width="9" height="6.5" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 0 1 4.8-1"/>'),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** An icon button: `on` is drawn pressed, `faint` greyed (it does not apply here, so it does nothing). */
|
|
51
|
+
export const IconBtn = ({ icon, title, on, faint, onClick }) => html`<button type="button" class=${`ib ${on ? "on" : ""} ${faint ? "faint" : ""}`} title=${title}
|
|
52
|
+
disabled=${faint || undefined} onClick=${onClick}>${icon}</button>`;
|
|
53
|
+
/**
|
|
54
|
+
* A field with a letter (or icon) in front, as Figma draws X, Y, W, H: solid when the element's styling gives the value, faint
|
|
55
|
+
* otherwise. `src` ("tw" or "css") marks where a set value comes from (a blue or amber edge). Clearing the field takes the value
|
|
56
|
+
* off (`onReset`: how, for a field over several properties or classes); a style added as a row of its own (an effect, a
|
|
57
|
+
* transform...) is removed with the − beside it, as everywhere in the panel.
|
|
58
|
+
* `list`: a datalist of suggestions (Tailwind's scale, fonts...).
|
|
59
|
+
*/
|
|
60
|
+
export const NumField = ({ label, value, set, src, onReset, disabled, title, list, placeholder, onCommit, children }) => html`<label class=${`nf ${set ? "" : "unset"} ${disabled ? "off" : ""} ${src ? `src-${src}` : ""}`} title=${title}>
|
|
61
|
+
<span class="nfl">${label}</span><${TextInput} class="nfi" value=${value ?? ""} disabled=${disabled} list=${list} placeholder=${placeholder}
|
|
62
|
+
onCommit=${(t) => (t.trim() === "" && onReset ? onReset() : onCommit && onCommit(t))} />${children}</label>`;
|
|
63
|
+
/** Where a property's value comes from, for a field's edge mark and tooltip: "tw" (a class), "css" (a style), or "". */
|
|
64
|
+
export const srcKind = (s, p) => { if (variant()) return atVariant(s, p) ? "tw" : ""; const k = srcOf(s, p).source; return k === "tailwind" ? "tw" : k === "inline" || k === "design" ? "css" : ""; };
|
|
65
|
+
export const srcNote = (s, p) => {
|
|
66
|
+
if (variant()) { const a = atVariant(s, p); return a ? a.token : `no ${variant()} class: inherits the value shown`; }
|
|
67
|
+
const r = srcOf(s, p); return r.source === "tailwind" ? r.token : r.source === "inline" ? "style attribute" : r.source === "design" ? `design CSS: ${r.selector}` : r.source === "parent" ? `inherited (${r.token})` : "default"; };
|
|
68
|
+
/** A section as Figma draws it: a bold title, buttons on the right, always open. */
|
|
69
|
+
export const FSection = ({ title, extra, children }) => html`<div class="fsec"><div class="fsech"><span>${title}</span><span class="fsx">${extra}</span></div>${children}</div>`;
|
|
70
|
+
export const Sub = ({ children }) => html`<p class="fsub">${children}</p>`;
|
|
71
|
+
|
|
72
|
+
export const num = (v) => { const n = parseFloat(v); return Number.isFinite(n) ? String(Math.round(n * 10) / 10) : ""; };
|
|
73
|
+
// With a breakpoint or state picked in the panel (md:, hover:) a field is about that variant: set when the element has a class
|
|
74
|
+
// for it there, faint (showing the value it inherits) when not. Without one, about the value the element shows.
|
|
75
|
+
/** The element's class for `p` at the picked breakpoint / state, or null (also when none is picked). */
|
|
76
|
+
/** The value a class gives `p`, also through a shorter class (mx-auto -> auto for margin-left, md:px-8 -> 8), or null. */
|
|
77
|
+
function readToken(p, token) {
|
|
78
|
+
if (!token) return null;
|
|
79
|
+
const r = tokenFor(token, p, parseToken(token).variant);
|
|
80
|
+
return r ? String(r.value).replace(/^\[(.*)\]$/, "$1").replace(/_/g, " ") : null;
|
|
81
|
+
}
|
|
82
|
+
/** What a field shows for `p`: the class's own value (8, auto, 1/2, 2xl) where a class gives it, else the computed one (24px -> 24). */
|
|
83
|
+
export function shownValue(s, p) {
|
|
84
|
+
const a = atVariant(s, p);
|
|
85
|
+
if (a) return readToken(p, a.token) ?? "";
|
|
86
|
+
const src = srcOf(s, p);
|
|
87
|
+
const t = src.source === "tailwind" ? readToken(p, src.token) : null;
|
|
88
|
+
if (t !== null) return t;
|
|
89
|
+
const c = shownOf(s, p, src);
|
|
90
|
+
return /^-?[\d.]+px$/.test(c) ? num(c) : c;
|
|
91
|
+
}
|
|
92
|
+
export const atVariant = (s, p) => { const v = variant(); return v ? tokenFor(s.classes, p, v) : null; };
|
|
93
|
+
export const owns = (s, p) => (variant() ? Boolean(atVariant(s, p)) : ownsValue(srcOf(s, p))); // set on this element itself: can be taken off
|
|
94
|
+
/** Takes a style off: its class or inline value comes off; one the design's own CSS rule gives is overridden (text styles go back to inheriting, the rest to their initial value). */
|
|
95
|
+
export const off = (s, actions, p, v = "initial") => (owns(s, p) ? actions.resetProp(p) : has(s, p) ? actions.setProp(p, v) : null);
|
|
96
|
+
export const has = (s, p) => (variant() ? Boolean(atVariant(s, p)) : styled(srcOf(s, p))); // given by its own styling (also the design's CSS): drawn solid
|
|
97
|
+
export const comp = (s, p) => (s.computed || {})[p] || "";
|
|
98
|
+
|
|
99
|
+
/** A child of a flex box that flex sizes along its axis ignores a width (or height) set on it: say so, with a one-click way out. */
|
|
100
|
+
function FlexNote({ s, actions }) {
|
|
101
|
+
const f = s.flexItem;
|
|
102
|
+
if (!f || !f.sized) return null;
|
|
103
|
+
const what = f.dir === "row" ? "width" : "height";
|
|
104
|
+
return html`<p class="hintnote">Its ${what} fills the ${f.dir === "row" ? "row" : "column"} (flex), so a ${what} typed here has no effect. <button type="button" onClick=${actions.sizeItMyself}>Size it myself</button></p>`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Position ────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
/**
|
|
109
|
+
* How the alignment buttons align this element: a free element by its left / top in its parent (or the selection to each other);
|
|
110
|
+
* in a flex box across its axis with align-self; along a column or the page flow with auto margins (mx-auto, ml-auto). Along a
|
|
111
|
+
* flex row the parent places its children, so those buttons are faint.
|
|
112
|
+
*/
|
|
113
|
+
function alignPlan(s) {
|
|
114
|
+
const abs = s.geom && s.geom.abs, lay = s.parentLayout;
|
|
115
|
+
if (abs || (s.count || 1) > 1) return { x: "free", y: "free" };
|
|
116
|
+
return {
|
|
117
|
+
x: lay === "flex column" ? "self" : lay === "page flow" ? "margin" : null,
|
|
118
|
+
y: lay === "flex row" ? "self" : null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function alignTo(s, actions, axis, where) {
|
|
122
|
+
const how = alignPlan(s)[axis];
|
|
123
|
+
if (how === "free") return actions.align(axis === "x" ? { start: "left", center: "hcenter", end: "right" }[where] : { start: "top", center: "vcenter", end: "bottom" }[where]);
|
|
124
|
+
if (how === "self") return actions.setProp("align-self", { start: "flex-start", center: "center", end: "flex-end" }[where]);
|
|
125
|
+
if (how === "margin") {
|
|
126
|
+
// left: no auto margin; centre: both; right: the left one
|
|
127
|
+
if (where === "start") { actions.resetProp("margin-left"); actions.resetProp("margin-right"); }
|
|
128
|
+
if (where === "center") { actions.setProp("margin-left", "auto"); actions.setProp("margin-right", "auto"); }
|
|
129
|
+
if (where === "end") { actions.setProp("margin-left", "auto"); actions.resetProp("margin-right"); }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Which alignment the element has now, for the pressed button. */
|
|
133
|
+
function alignNow(s, axis) {
|
|
134
|
+
const how = alignPlan(s)[axis];
|
|
135
|
+
if (how === "self") { const v = comp(s, "align-self"); return v === "center" ? "center" : /end/.test(v) ? "end" : /start/.test(v) ? "start" : ""; }
|
|
136
|
+
if (how === "margin") {
|
|
137
|
+
const tok = (p) => (srcOf(s, p).token || "").replace(/^(?:[\w-]+:)*/, "");
|
|
138
|
+
const l = /auto/.test(tok("margin-left")), r = /auto/.test(tok("margin-right"));
|
|
139
|
+
return l && r ? "center" : l ? "end" : "";
|
|
140
|
+
}
|
|
141
|
+
return "";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const POSITIONS = [["static", "Static"], ["relative", "Relative"], ["absolute", "Absolute"], ["fixed", "Fixed"], ["sticky", "Sticky"]];
|
|
145
|
+
/** Absolute in or out goes through Free position, which keeps the element where it is on the page; the others are the CSS value. */
|
|
146
|
+
function setPosition(s, actions, v) {
|
|
147
|
+
const now = comp(s, "position");
|
|
148
|
+
if (v === now) return;
|
|
149
|
+
if (v === "absolute") return actions.freePosition(true);
|
|
150
|
+
if (now === "absolute" && v === "static") return actions.freePosition(false);
|
|
151
|
+
if (v === "static" && owns(s, "position")) return actions.resetProp("position");
|
|
152
|
+
return actions.setProp("position", v);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function PositionSection({ s, actions }) {
|
|
156
|
+
const g = s.geom || {}, abs = Boolean(g.abs), count = s.count || 1, plan = alignPlan(s);
|
|
157
|
+
const btn = (axis, where, icon, title) => html`<${IconBtn} icon=${icon} title=${plan[axis] ? title : `${title} (set by the parent's layout)`} faint=${!plan[axis]}
|
|
158
|
+
on=${alignNow(s, axis) === where} onClick=${() => alignTo(s, actions, axis, where)} />`;
|
|
159
|
+
const FLIP = ["flipX", "flipY"];
|
|
160
|
+
const flipped = (i) => { if (val(s, FLIP[i])) return true; const v = comp(s, "scale").split(/\s+/); return parseFloat(v[i] ?? v[0]) < 0; };
|
|
161
|
+
const flip = (i) => {
|
|
162
|
+
// a Tailwind element is mirrored with -scale-x-100 / -scale-y-100; a CSS one with the scale property
|
|
163
|
+
if (modeFor(s) === "tailwind" || val(s, FLIP[i])) return actions.applyValue(FLIP[i], val(s, FLIP[i]) ? "" : i ? "-scale-y-100" : "-scale-x-100");
|
|
164
|
+
const v = comp(s, "scale"), parts = v && v !== "none" ? v.split(/\s+/).map(Number) : [1, 1];
|
|
165
|
+
const xy = [parts[0] ?? 1, parts[1] ?? parts[0] ?? 1];
|
|
166
|
+
xy[i] = -xy[i];
|
|
167
|
+
actions.setProp("scale", xy[0] === 1 && xy[1] === 1 ? "" : `${xy[0]} ${xy[1]}`);
|
|
168
|
+
};
|
|
169
|
+
const rot = Number(g.rot) || 0, pos = comp(s, "position") || "static";
|
|
170
|
+
return html`<${FSection} title="Position">
|
|
171
|
+
<${Sub}>Alignment</${Sub}>
|
|
172
|
+
<div class="ibrow"><div class="ibg">${btn("x", "start", I.alignL, "Align left")}${btn("x", "center", I.alignC, "Align horizontal centres")}${btn("x", "end", I.alignR, "Align right")}</div>
|
|
173
|
+
<div class="ibg">${btn("y", "start", I.alignT, "Align top")}${btn("y", "center", I.alignM, "Align vertical centres")}${btn("y", "end", I.alignB, "Align bottom")}</div></div>
|
|
174
|
+
<${Sub}>Position</${Sub}>
|
|
175
|
+
<div class="frow">
|
|
176
|
+
<label class=${`nf ${has(s, "position") ? "" : "unset"} ${srcKind(s, "position") ? `src-${srcKind(s, "position")}` : ""}`} title=${`position · ${srcNote(s, "position")}`}>
|
|
177
|
+
<select class="nfs wide" onChange=${(e) => setPosition(s, actions, e.target.value)}>
|
|
178
|
+
${POSITIONS.map(([v, l]) => html`<option value=${v} selected=${pos === v}>${l}</option>`)}</select></label>
|
|
179
|
+
<${PropField} s=${s} actions=${actions} p="z-index" label="Z" title="Stacking order (z-index)" list="dl-z" />
|
|
180
|
+
<${IconBtn} icon=${I.free} on=${abs} title=${abs ? "Free position: on (absolute). Click to put it back in the layout" : "Free position: take it out of the layout (absolute), to place it anywhere"} onClick=${() => actions.freePosition(!abs)} />
|
|
181
|
+
</div>
|
|
182
|
+
${pos === "static"
|
|
183
|
+
? html`<div class="frow">
|
|
184
|
+
<${NumField} label="X" value=${num(g.x)} disabled title="Placed by the layout: give it a position (relative, absolute...) to move it with offsets" />
|
|
185
|
+
<${NumField} label="Y" value=${num(g.y)} disabled title="Placed by the layout: give it a position (relative, absolute...) to move it with offsets" /></div>`
|
|
186
|
+
: html`<div class="frow two2">
|
|
187
|
+
<${PropField} s=${s} actions=${actions} p="left" label="L" title="left" list="dl-left" /><${PropField} s=${s} actions=${actions} p="top" label="T" title="top" list="dl-top" />
|
|
188
|
+
<${PropField} s=${s} actions=${actions} p="right" label="R" title="right" list="dl-right" /><${PropField} s=${s} actions=${actions} p="bottom" label="B" title="bottom" list="dl-bottom" /></div>`}
|
|
189
|
+
<${Sub}>Rotation</${Sub}>
|
|
190
|
+
<div class="frow">
|
|
191
|
+
<${NumField} label="∠" value=${String(rot)} set=${rot !== 0} title="Rotation in degrees" onCommit=${(t) => actions.setRotation(t)}><span class="nfu">°</span></${NumField}>
|
|
192
|
+
<div class="ibg">
|
|
193
|
+
<${IconBtn} icon=${I.rot90} title="Rotate 90°" onClick=${() => actions.setRotation((rot + 90) % 360)} />
|
|
194
|
+
<${IconBtn} icon=${I.flipH} title="Flip horizontally" on=${flipped(0)} onClick=${() => flip(0)} />
|
|
195
|
+
<${IconBtn} icon=${I.flipV} title="Flip vertically" on=${flipped(1)} onClick=${() => flip(1)} />
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
${count > 1 && html`<div class="fbtns">
|
|
199
|
+
${count > 2 && html`<button type="button" onClick=${() => actions.align("distribute-h")}>Space across</button><button type="button" onClick=${() => actions.align("distribute-v")}>Space down</button>`}
|
|
200
|
+
<button type="button" title="Ctrl/Cmd+G" onClick=${actions.group}>Group</button>
|
|
201
|
+
<button type="button" title="Shift+A: line them up in a row or column with even spacing" onClick=${actions.autoLayout}>Auto layout</button></div>`}
|
|
202
|
+
${count === 1 && (s.group || (abs && !s.leaf)) && html`<div class="fbtns"><button type="button" title="Ctrl/Cmd+Shift+G" onClick=${actions.ungroup}>Ungroup</button></div>`}
|
|
203
|
+
</${FSection}>`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Layout ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
207
|
+
/** What the element lays its children out as: block (the page flow), row, column or grid. */
|
|
208
|
+
function layoutKind(s) {
|
|
209
|
+
const d = comp(s, "display");
|
|
210
|
+
if (/grid/.test(d)) return "grid";
|
|
211
|
+
if (/flex/.test(d)) return /column/.test(comp(s, "flex-direction")) ? "col" : "row";
|
|
212
|
+
return "block";
|
|
213
|
+
}
|
|
214
|
+
function setKind(s, actions, kind) {
|
|
215
|
+
const now = layoutKind(s);
|
|
216
|
+
if (kind === now) return;
|
|
217
|
+
if (kind === "block") return owns(s, "display") ? actions.resetProp("display") : actions.setProp("display", "block");
|
|
218
|
+
// stacked children made a row, column or grid keep their spacing (the gap takes over from their margins)
|
|
219
|
+
if (now === "block" && s.arrange) return actions.arrange(kind);
|
|
220
|
+
if (kind === "grid") return actions.setProp("display", "grid");
|
|
221
|
+
// display and direction are one click: one undo step
|
|
222
|
+
if (now !== "row" && now !== "col") actions.setProp("display", "flex", undefined, "layout-kind");
|
|
223
|
+
actions.setProp("flex-direction", kind === "col" ? "column" : "row", undefined, "layout-kind");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const POS = ["flex-start", "center", "flex-end"];
|
|
227
|
+
const posOf = (v) => (v === "center" ? 1 : /end/.test(v) ? 2 : 0);
|
|
228
|
+
/** Figma's alignment box: nine dots, one per place the children can sit; one click sets justify-content and align-items. */
|
|
229
|
+
function AlignBox({ s, actions, kind }) {
|
|
230
|
+
const jc = comp(s, "justify-content"), ai = comp(s, "align-items");
|
|
231
|
+
const between = /space-/.test(jc);
|
|
232
|
+
const main = posOf(jc), cross = posOf(ai);
|
|
233
|
+
const [col, row] = kind === "col" ? [cross, main] : [main, cross];
|
|
234
|
+
const pick = (c, r) => {
|
|
235
|
+
const [m, x] = kind === "col" ? [r, c] : [c, r];
|
|
236
|
+
if (!between) actions.setProp("justify-content", POS[m]);
|
|
237
|
+
actions.setProp("align-items", POS[x]);
|
|
238
|
+
};
|
|
239
|
+
return html`<div class="abox">${[0, 1, 2].map((r) => [0, 1, 2].map((c) => html`<button type="button"
|
|
240
|
+
class=${`ad ${(between ? (kind === "col" ? c === col : r === row) : c === col && r === row) ? "on" : ""}`} title=${`${["Top", "Middle", "Bottom"][r]} ${["left", "centre", "right"][c]}`}
|
|
241
|
+
onClick=${() => pick(c, r)}><i></i></button>`))}</div>`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Fixed (a size), Hug (fit its content) or Fill (the space its parent gives), as Figma sizes a layer. */
|
|
245
|
+
function sizing(s, axis) {
|
|
246
|
+
const p = axis === "w" ? "width" : "height", lay = s.parentLayout;
|
|
247
|
+
const alongFlex = (lay === "flex row" && axis === "w") || (lay === "flex column" && axis === "h");
|
|
248
|
+
if (alongFlex && parseFloat(comp(s, "flex-grow")) > 0) return "fill";
|
|
249
|
+
const tok = (srcOf(s, p).token || "").replace(/^(?:[\w-]+:)*/, "");
|
|
250
|
+
const inl = owns(s, p) ? shownOf(s, p, srcOf(s, p)) : "";
|
|
251
|
+
if (/^(w|h)-(full|screen)$/.test(tok) || /^100%$/.test(inl)) return "fill";
|
|
252
|
+
if (/^(w|h)-(fit|auto|min|max)$/.test(tok) || /content|auto/.test(inl)) return "hug";
|
|
253
|
+
if (owns(s, p)) return "fixed";
|
|
254
|
+
// nothing sets it: a block in the flow fills the width, everything else hugs
|
|
255
|
+
if (axis === "w" && lay === "page flow" && !/inline/.test(comp(s, "display"))) return "fill";
|
|
256
|
+
if (axis === "w" && lay === "flex column" && /stretch|normal/.test(comp(s, "align-self") === "auto" ? "stretch" : comp(s, "align-self"))) return "fill";
|
|
257
|
+
return "hug";
|
|
258
|
+
}
|
|
259
|
+
function setSizing(s, actions, axis, mode) {
|
|
260
|
+
const p = axis === "w" ? "width" : "height", lay = s.parentLayout;
|
|
261
|
+
const alongFlex = (lay === "flex row" && axis === "w") || (lay === "flex column" && axis === "h");
|
|
262
|
+
const px = axis === "w" ? s.geom.w : s.geom.h;
|
|
263
|
+
if (mode !== "fill" && alongFlex && parseFloat(comp(s, "flex-grow")) > 0) actions.resetProp("flex-grow");
|
|
264
|
+
if (mode === "fixed") return actions.setGeom(axis, String(Math.round(px)));
|
|
265
|
+
if (mode === "hug") return actions.setProp(p, "fit", "fit-content");
|
|
266
|
+
if (alongFlex) { if (owns(s, p)) actions.resetProp(p); return actions.setProp("flex-grow", "1"); }
|
|
267
|
+
return actions.setProp(p, "full", "100%");
|
|
268
|
+
}
|
|
269
|
+
const SIZING = [["fixed", "Fixed"], ["hug", "Hug"], ["fill", "Fill"]];
|
|
270
|
+
function SizeField({ s, actions, axis }) {
|
|
271
|
+
const mode = sizing(s, axis), p = axis === "w" ? "width" : "height";
|
|
272
|
+
const commit = (t) => {
|
|
273
|
+
const r = ratioOf(s), n = Number(t);
|
|
274
|
+
if (axis === "h" && r && Number.isFinite(n) && n > 0) return actions.setGeom("w", String(Math.round(n * r))); // locked: H moves W, the ratio does the rest
|
|
275
|
+
return actions.setGeom(axis, t);
|
|
276
|
+
};
|
|
277
|
+
return html`<${NumField} label=${axis === "w" ? "W" : "H"} value=${num(axis === "w" ? s.geom.w : s.geom.h)} set=${has(s, p) || (mode === "fill" && has(s, "flex-grow")) || (axis === "h" && Boolean(ratioOf(s)))}
|
|
278
|
+
src=${srcKind(s, p)} title=${`${p}: ${mode} · ${srcNote(s, p)}`} onReset=${has(s, p) ? () => off(s, actions, p) : null} onCommit=${commit}>
|
|
279
|
+
<select class="nfs" title="Fixed, Hug contents or Fill container" onChange=${(e) => setSizing(s, actions, axis, e.target.value)}>
|
|
280
|
+
${SIZING.map(([v, l]) => html`<option value=${v} selected=${mode === v}>${l}</option>`)}</select></${NumField}>`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The width-to-height ratio the element is locked to (its aspect-ratio), or 0 when it is not. */
|
|
284
|
+
function ratioOf(s) {
|
|
285
|
+
const v = comp(s, "aspect-ratio");
|
|
286
|
+
if (!v || v === "auto") return 0;
|
|
287
|
+
const m = v.match(/([\d.]+)\s*(?:\/\s*([\d.]+))?/);
|
|
288
|
+
return m ? Number(m[1]) / Number(m[2] || 1) : 0;
|
|
289
|
+
}
|
|
290
|
+
const gcd = (a, b) => (b ? gcd(b, a % b) : a);
|
|
291
|
+
/**
|
|
292
|
+
* Lock the ratio: the element gets an aspect-ratio of its size now (aspect-video, aspect-square, aspect-[4/3]) and its height
|
|
293
|
+
* comes off, so the ratio sets it: a new width keeps the shape, here and in the page. Unlocked, the height it has is written back.
|
|
294
|
+
*/
|
|
295
|
+
function toggleRatio(s, actions) {
|
|
296
|
+
const w = Math.round(s.geom.w), h = Math.round(s.geom.h);
|
|
297
|
+
if (ratioOf(s)) {
|
|
298
|
+
actions.setGeom("h", String(h));
|
|
299
|
+
if (owns(s, "aspect-ratio")) actions.resetProp("aspect-ratio"); else actions.setProp("aspect-ratio", "auto");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (!w || !h) return;
|
|
303
|
+
const d = gcd(w, h);
|
|
304
|
+
actions.setProp("aspect-ratio", `${w / d} / ${h / d}`);
|
|
305
|
+
if (owns(s, "height")) actions.resetProp("height");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** A property as a Figma field: what the element sets, else its computed value, faint; marked with where it comes from, × to remove it. */
|
|
309
|
+
export const PropField = ({ s, actions, p, label, title, list }) => {
|
|
310
|
+
const value = shownValue(s, p); // a number as one (24px -> 24), a word or a Tailwind value as itself (auto, 2xl, 1/2)
|
|
311
|
+
return html`<${NumField} label=${label} title=${`${title || p} · ${srcNote(s, p)}`} value=${value} list=${list}
|
|
312
|
+
set=${has(s, p)} src=${srcKind(s, p)} onReset=${has(s, p) ? () => off(s, actions, p) : null} onCommit=${(t) => actions.setProp(p, t)} />`;
|
|
313
|
+
};
|
|
314
|
+
/** A choice of CSS values as a Figma field: the current one picked, faint when only a default gives it, × to remove a set one. */
|
|
315
|
+
export const PropSelect = ({ s, actions, p, label, title, options }) => {
|
|
316
|
+
const v = comp(s, p), opts = options.some(([o]) => o === v) || !v ? options : [[v, v], ...options];
|
|
317
|
+
return html`<label class=${`nf ${has(s, p) ? "" : "unset"} ${srcKind(s, p) ? `src-${srcKind(s, p)}` : ""}`} title=${`${title || p} · ${srcNote(s, p)}`}>
|
|
318
|
+
${label && html`<span class="nfl">${label}</span>`}<select class="nfs wide" onChange=${(e) => actions.setProp(p, e.target.value)}>
|
|
319
|
+
${opts.map(([o, l]) => html`<option value=${o} selected=${o === v}>${l}</option>`)}</select></label>`;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const MORE = [["min-width", "Min W", "dl-minW"], ["max-width", "Max W", "dl-maxW"], ["min-height", "Min H", "dl-minH"], ["max-height", "Max H", "dl-maxH"]];
|
|
323
|
+
|
|
324
|
+
const DISPLAYS = [["block", "Block"], ["flex", "Flex"], ["grid", "Grid"], ["inline-block", "Inline block"], ["inline", "Inline"], ["inline-flex", "Inline flex"], ["inline-grid", "Inline grid"], ["flow-root", "Flow root"], ["contents", "Contents"], ["none", "Hidden (none)"]];
|
|
325
|
+
const JUSTIFY = [["flex-start", "Start"], ["center", "Center"], ["flex-end", "End"], ["space-between", "Space between"], ["space-around", "Space around"], ["space-evenly", "Space evenly"], ["stretch", "Stretch"], ["normal", "Normal"]];
|
|
326
|
+
const ALIGN = [["stretch", "Stretch"], ["flex-start", "Start"], ["center", "Center"], ["flex-end", "End"], ["baseline", "Baseline"], ["normal", "Normal"]];
|
|
327
|
+
const OVERFLOW = [["visible", "Visible"], ["hidden", "Hidden (clip)"], ["auto", "Scroll when needed"], ["scroll", "Always scroll"], ["clip", "Clip"]];
|
|
328
|
+
|
|
329
|
+
/** A Tailwind class group as a Figma field (space-x, grid rows, order...): its value at the panel's breakpoint, × to take it off. */
|
|
330
|
+
export const TwField = ({ s, k, label, title, actions }) => {
|
|
331
|
+
const v = val(s, k, useUi());
|
|
332
|
+
return html`<${NumField} label=${label} title=${`${title || k}${v ? "" : " (Tailwind class)"}`} value=${v} placeholder="–" list=${`dl-${PRESETS[k] ? k : "spacing"}`}
|
|
333
|
+
set=${Boolean(v)} src=${v ? "tw" : ""} onReset=${v ? () => actions.applyValue(k, "") : null} onCommit=${(t) => actions.applyValue(k, t.trim())} />`;
|
|
334
|
+
};
|
|
335
|
+
/** A Tailwind class group with a fixed set of values, as a Figma field. */
|
|
336
|
+
export const TwSelect = ({ s, k, label, title, actions }) => {
|
|
337
|
+
const v = val(s, k, useUi());
|
|
338
|
+
return html`<label class=${`nf ${v ? "src-tw" : "unset"}`} title=${title || k}>${label && html`<span class="nfl">${label}</span>`}
|
|
339
|
+
<select class="nfs wide" onChange=${(e) => actions.applyValue(k, e.target.value)}><option value="" selected=${!v}>–</option>
|
|
340
|
+
${(OPTS[k] || []).map(([o, l]) => html`<option value=${o} selected=${o === v}>${l}</option>`)}</select></label>`;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
export function LayoutSection({ s, actions }) {
|
|
344
|
+
const [more, setMore] = useState(false);
|
|
345
|
+
const ui = useUi();
|
|
346
|
+
const kind = layoutKind(s), flex = kind === "row" || kind === "col", display = comp(s, "display");
|
|
347
|
+
const k = (v, icon, title) => html`<${IconBtn} icon=${icon} title=${title} on=${kind === v && !/inline|none|contents/.test(display)} onClick=${() => setKind(s, actions, v)} />`;
|
|
348
|
+
const between = /space-/.test(comp(s, "justify-content"));
|
|
349
|
+
const wrapOn = /^wrap/.test(comp(s, "flex-wrap")), reverse = /reverse/.test(comp(s, "flex-direction"));
|
|
350
|
+
const twoGaps = kind === "grid" || wrapOn;
|
|
351
|
+
const shownMore = MORE.filter(([p]) => more || has(s, p)); // set ones always show; the rest behind +
|
|
352
|
+
const stack = kind === "block" && !s.leaf; // children stacked in the flow: space-x / space-y can space them
|
|
353
|
+
const spacing = stack && (more || val(s, "spaceX", ui) || val(s, "spaceY", ui));
|
|
354
|
+
return html`<${FSection} title="Layout" extra=${html`<${IconBtn} icon=${I.plus} on=${more} title=${more ? "Fewer settings" : "Min and max size, space between children"} onClick=${() => setMore(!more)} />`}>
|
|
355
|
+
<div class="frow"><div class="ibg wide">${k("block", I.block, "Block: children stack in the page flow")}${k("row", I.row, "Row (flex)")}${k("col", I.col, "Column (flex)")}${k("grid", I.grid, "Grid")}</div>
|
|
356
|
+
<label class=${`nf disp ${has(s, "display") ? "" : "unset"} ${srcKind(s, "display") ? `src-${srcKind(s, "display")}` : ""}`} title=${`display · ${srcNote(s, "display")}`}>
|
|
357
|
+
<select class="nfs wide" onChange=${(e) => actions.setProp("display", e.target.value)}>
|
|
358
|
+
${DISPLAYS.map(([v, l]) => html`<option value=${v} selected=${display === v}>${l}</option>`)}</select></label></div>
|
|
359
|
+
${flex && html`<div class="lgrid">
|
|
360
|
+
<${AlignBox} s=${s} actions=${actions} kind=${kind} />
|
|
361
|
+
<div class="lcol">
|
|
362
|
+
${twoGaps ? html`<div class="frow"><${PropField} s=${s} actions=${actions} p="column-gap" label="↔" title="Gap between columns" list="dl-gap" /><${PropField} s=${s} actions=${actions} p="row-gap" label="↕" title="Gap between rows" list="dl-gap" /></div>`
|
|
363
|
+
: html`<${NumField} label=${kind === "col" ? "↕" : "↔"} title="Gap between children" value=${between ? "Auto" : num(comp(s, kind === "col" ? "row-gap" : "column-gap"))} list="dl-gap"
|
|
364
|
+
set=${has(s, "gap") || has(s, kind === "col" ? "row-gap" : "column-gap")} src=${srcKind(s, "gap")} onReset=${has(s, "gap") ? () => off(s, actions, "gap") : null} onCommit=${(t) => actions.setProp("gap", t)} />`}
|
|
365
|
+
<div class="ibg">
|
|
366
|
+
<${IconBtn} icon=${I.between} on=${between} title=${between ? "Space between: on" : "Space between the children (justify-between)"} onClick=${() => actions.setProp("justify-content", between ? "flex-start" : "space-between")} />
|
|
367
|
+
<${IconBtn} icon=${I.wrap} on=${wrapOn} title=${wrapOn ? "Wrap: on" : "Wrap onto more lines"} onClick=${() => actions.setProp("flex-wrap", wrapOn ? "nowrap" : "wrap")} />
|
|
368
|
+
<${IconBtn} icon=${I.reverse} on=${reverse} title=${reverse ? "Reversed: on" : "Reverse the order (row-reverse / column-reverse)"} onClick=${() => actions.setProp("flex-direction", `${kind === "col" ? "column" : "row"}${reverse ? "" : "-reverse"}`)} />
|
|
369
|
+
</div>
|
|
370
|
+
</div></div>
|
|
371
|
+
<div class="frow"><${PropSelect} s=${s} actions=${actions} p="justify-content" label="Justify" options=${JUSTIFY} /><${PropSelect} s=${s} actions=${actions} p="align-items" label="Align" options=${ALIGN} /></div>`}
|
|
372
|
+
${kind === "grid" && html`<div class="frow two2">
|
|
373
|
+
<${PropField} s=${s} actions=${actions} p="grid-template-columns" label="Cols" title="Columns (a number, or a template)" list="dl-gridCols" />
|
|
374
|
+
<${TwField} s=${s} k="gridRows" label="Rows" title="Rows (grid-rows-*)" actions=${actions} />
|
|
375
|
+
<${PropField} s=${s} actions=${actions} p="column-gap" label="↔" title="Gap between columns" list="dl-gap" />
|
|
376
|
+
<${PropField} s=${s} actions=${actions} p="row-gap" label="↕" title="Gap between rows" list="dl-gap" /></div>
|
|
377
|
+
<div class="frow"><${PropSelect} s=${s} actions=${actions} p="justify-items" label="Justify" options=${[["stretch", "Stretch"], ["start", "Start"], ["center", "Center"], ["end", "End"]]} /><${PropSelect} s=${s} actions=${actions} p="align-items" label="Align" options=${ALIGN} /></div>`}
|
|
378
|
+
${spacing && html`<div class="frow two2"><${TwField} s=${s} k="spaceX" label="↔" title="Space between children across (space-x-*)" actions=${actions} /><${TwField} s=${s} k="spaceY" label="↕" title="Space between children down (space-y-*)" actions=${actions} /></div>`}
|
|
379
|
+
${s.tag !== "body" && html`<${Sub}>Size</${Sub}>
|
|
380
|
+
<${FlexNote} s=${s} actions=${actions} />
|
|
381
|
+
<div class="frow"><${SizeField} s=${s} actions=${actions} axis="w" /><${SizeField} s=${s} actions=${actions} axis="h" />
|
|
382
|
+
<${IconBtn} icon=${ratioOf(s) ? I.lock : I.unlock} on=${Boolean(ratioOf(s))} title=${ratioOf(s) ? `Ratio locked (${comp(s, "aspect-ratio")}): click to unlock` : "Lock the aspect ratio: it keeps its shape when the width changes"}
|
|
383
|
+
onClick=${() => toggleRatio(s, actions)} /></div>`}
|
|
384
|
+
${shownMore.length > 0 && html`<div class="frow two2">${shownMore.map(([p, l, list]) => html`<${PropField} s=${s} actions=${actions} p=${p} label=${l} list=${list} />`)}</div>`}
|
|
385
|
+
<div class="frow"><${PropSelect} s=${s} actions=${actions} p="overflow" label="Overflow" title="What happens to content bigger than the box" options=${OVERFLOW} /></div>
|
|
386
|
+
</${FSection}>`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── Flex / grid item: how this element sits in its parent's flex or grid ─────────────────────────────────────────────────────
|
|
390
|
+
const SELF = [["auto", "Auto (parent's)"], ["flex-start", "Start"], ["center", "Center"], ["flex-end", "End"], ["stretch", "Stretch"], ["baseline", "Baseline"]];
|
|
391
|
+
export function ItemSection({ s, actions }) {
|
|
392
|
+
const lay = s.parentLayout, abs = s.geom && s.geom.abs;
|
|
393
|
+
if (abs || !(lay === "flex row" || lay === "flex column" || lay === "grid")) return null;
|
|
394
|
+
const grow = parseFloat(comp(s, "flex-grow")) > 0, shrink = parseFloat(comp(s, "flex-shrink")) > 0;
|
|
395
|
+
const flex = lay !== "grid";
|
|
396
|
+
return html`<${FSection} title=${flex ? "Flex item" : "Grid item"}>
|
|
397
|
+
${flex && html`<div class="frow">
|
|
398
|
+
<div class="ibg wide">
|
|
399
|
+
<button type="button" class=${`ib txt ${grow ? "on" : ""}`} title=${grow ? "Grows to fill the free space (grow): click to stop" : "Grow to fill the free space (grow)"} onClick=${() => actions.setProp("flex-grow", grow ? "0" : "1")}>Grow</button>
|
|
400
|
+
<button type="button" class=${`ib txt ${shrink ? "on" : ""}`} title=${shrink ? "Shrinks when there is no room (the default): click to keep its size (shrink-0)" : "Doesn't shrink (shrink-0): click to let it shrink"} onClick=${() => actions.applyValue("shrink", shrink ? "shrink-0" : "")}>Shrink</button>
|
|
401
|
+
</div>
|
|
402
|
+
<${PropField} s=${s} actions=${actions} p="flex-basis" label="Basis" title="Its starting size along the row or column (flex-basis)" /></div>`}
|
|
403
|
+
<div class="frow"><${PropSelect} s=${s} actions=${actions} p="align-self" label="Self" title="Where it sits across the line, on its own (align-self)" options=${SELF} />
|
|
404
|
+
<${TwField} s=${s} k="order" label="Order" title="Order among its siblings (order-*)" actions=${actions} /></div>
|
|
405
|
+
${!flex && html`<div class="frow two2"><${TwField} s=${s} k="colSpan" label="Cols" title="Columns it spans (col-span-*)" actions=${actions} /><${TwField} s=${s} k="rowSpan" label="Rows" title="Rows it spans (row-span-*)" actions=${actions} /></div>`}
|
|
406
|
+
</${FSection}>`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── Spacing: padding inside the element, margin outside it (the box model), together as a developer tunes them ────────────
|
|
410
|
+
const SPACE = { padding: { short: "p", x: "px", y: "py", sides: ["pt", "pr", "pb", "pl"], list: "dl-p" }, margin: { short: "m", x: "mx", y: "my", sides: ["mt", "mr", "mb", "ml"], list: "dl-m" } };
|
|
411
|
+
/** One of padding / margin: across and down (as px / py, mx / my), or each side on its own; shown per side when they differ. */
|
|
412
|
+
function SpaceRow({ s, actions, kind }) {
|
|
413
|
+
const [each, setEach] = useState(false);
|
|
414
|
+
const k = SPACE[kind], side = (d) => `${kind}-${d}`;
|
|
415
|
+
// these two-side fields take pixels (setProps), so a number shows in pixels; a word (auto) as the class has it
|
|
416
|
+
const one = (d) => { const w = shownValue(s, side(d)); return /^-?[\d.]+$/.test(w) ? num(comp(s, side(d))) : w; };
|
|
417
|
+
const pair = (a, b) => { const x = one(a), y = one(b); return x === y ? x : "Mixed"; };
|
|
418
|
+
const set = (a, b) => has(s, side(a)) || has(s, side(b));
|
|
419
|
+
const src = (a, b) => srcKind(s, side(a)) || srcKind(s, side(b));
|
|
420
|
+
const reset = (a, b) => (has(s, side(a)) || has(s, side(b)) ? () => [side(a), side(b)].forEach((p) => off(s, actions, p)) : null);
|
|
421
|
+
const mixed = pair("left", "right") === "Mixed" || pair("top", "bottom") === "Mixed";
|
|
422
|
+
const label = kind === "padding" ? "Padding" : "Margin";
|
|
423
|
+
return html`<${Sub}>${label}${kind === "margin" ? html` <span class="fsubn">(auto centres)</span>` : ""}</${Sub}>
|
|
424
|
+
${each || mixed ? html`<div class="frow four">
|
|
425
|
+
<${PropField} s=${s} actions=${actions} p=${side("left")} label="L" list=${k.list} /><${PropField} s=${s} actions=${actions} p=${side("top")} label="T" list=${k.list} />
|
|
426
|
+
<${PropField} s=${s} actions=${actions} p=${side("right")} label="R" list=${k.list} /><${PropField} s=${s} actions=${actions} p=${side("bottom")} label="B" list=${k.list} />
|
|
427
|
+
<${IconBtn} icon=${I.sides} on title=${mixed ? "The sides differ" : "One value across and one down"} onClick=${() => setEach(false)} /></div>`
|
|
428
|
+
: html`<div class="frow">
|
|
429
|
+
<${NumField} label="↔" title=${`${label} left and right`} value=${pair("left", "right")} set=${set("left", "right")} src=${src("left", "right")} list=${k.list} onReset=${reset("left", "right")}
|
|
430
|
+
onCommit=${(t) => actions.setProps([side("left"), side("right")], t, k.x, [k.sides[3], k.sides[1]])} />
|
|
431
|
+
<${NumField} label="↕" title=${`${label} top and bottom`} value=${pair("top", "bottom")} set=${set("top", "bottom")} src=${src("top", "bottom")} list=${k.list} onReset=${reset("top", "bottom")}
|
|
432
|
+
onCommit=${(t) => actions.setProps([side("top"), side("bottom")], t, k.y, [k.sides[0], k.sides[2]])} />
|
|
433
|
+
<${IconBtn} icon=${I.sides} title="Each side on its own" onClick=${() => setEach(true)} /></div>`}`;
|
|
434
|
+
}
|
|
435
|
+
export function SpacingSection({ s, actions }) {
|
|
436
|
+
return html`<${FSection} title="Spacing">
|
|
437
|
+
<${SpaceRow} s=${s} actions=${actions} kind="padding" />
|
|
438
|
+
${s.tag !== "body" && html`<${SpaceRow} s=${s} actions=${actions} kind="margin" />`}
|
|
439
|
+
</${FSection}>`;
|
|
440
|
+
}
|