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
package/design/panel.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// The Properties panel as components (Preact + htm, one vendored file, no build step): the shell and the parts that are
|
|
2
|
+
// not a section of the Design tab. Everything is a component that keeps its own state (what is being typed, the cursor,
|
|
3
|
+
// errors) across redraws, so the editor can redraw the panel whenever it likes. The panel's own state (tab, breakpoint, open
|
|
4
|
+
// sections...) comes from panel-state.js through context; what to show comes from inspectorView (panel-model.js); what the
|
|
5
|
+
// components ask of the editor goes through `actions` (editor.js panelActions).
|
|
6
|
+
// here: the head (tabs), the page panel, the AI tab, the Code tab's element code
|
|
7
|
+
// design-tab.js · figma-sections.js · style-sections.js · prop-sections.js: the Design tab
|
|
8
|
+
// classes-section.js: the Code tab below the element's code; controls.js, group-controls.js: the blocks they are made of
|
|
9
|
+
import { html, render, useState, useEffect, useLayoutEffect, useRef } from "../vendor/preact-htm.js";
|
|
10
|
+
import { UiContext } from "./panel-state.js";
|
|
11
|
+
import { highlight, checkTags } from "./code-view.js";
|
|
12
|
+
import { TextInput, Field } from "./controls.js";
|
|
13
|
+
import { DesignTop } from "./design-tab.js";
|
|
14
|
+
import { PositionSection, LayoutSection, SpacingSection, ItemSection } from "./figma-sections.js";
|
|
15
|
+
import { TypographySection, BackgroundSection, BorderSection, EffectsSection, TransformSection, MotionSection, InteractionSection, AddBox } from "./style-sections.js";
|
|
16
|
+
import { ClassesSection } from "./classes-section.js";
|
|
17
|
+
|
|
18
|
+
/** A labelled text field of the page panel: typed freely, committed on Enter or leaving (controls.js TextInput). */
|
|
19
|
+
const PageField = ({ label, value, placeholder, onCommit, row }) => html`<${Field} label=${label} cell=${!row}><${TextInput} value=${value} placeholder=${placeholder} onCommit=${onCommit} /></${Field}>`;
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
/** Where the text at `offset` of an element's text is on screen, as { top, bottom } in the page's coordinates. */
|
|
23
|
+
function caretRect(root, offset) {
|
|
24
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
25
|
+
let left = offset, node = null;
|
|
26
|
+
while (walker.nextNode()) {
|
|
27
|
+
node = walker.currentNode;
|
|
28
|
+
if (left <= node.length) break;
|
|
29
|
+
left -= node.length;
|
|
30
|
+
}
|
|
31
|
+
if (!node) return null;
|
|
32
|
+
const r = document.createRange();
|
|
33
|
+
r.setStart(node, Math.min(left, node.length));
|
|
34
|
+
r.collapse(true);
|
|
35
|
+
const rect = r.getBoundingClientRect();
|
|
36
|
+
return rect.height ? rect : (node.parentElement || root).getBoundingClientRect();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The selected element's own code, editable. Ctrl/Cmd+Enter or leaving the box applies it (one undo step); Esc puts back
|
|
41
|
+
* what was applied; anything typed is applied to *this* element when another is picked (the component is keyed by the
|
|
42
|
+
* element's path, so it unmounts then). `onApply(path, code, indent, original)` returns { ok, message? }.
|
|
43
|
+
*/
|
|
44
|
+
function CodeTab({ path, source, indent, s, actions }) {
|
|
45
|
+
const onApply = actions.applyElement;
|
|
46
|
+
const [draft, setDraft] = useState(source);
|
|
47
|
+
const [error, setError] = useState("");
|
|
48
|
+
const orig = useRef(source); // the code as last shown or applied
|
|
49
|
+
const ta = useRef(null), box = useRef(null), hl = useRef(null);
|
|
50
|
+
const live = useRef({});
|
|
51
|
+
live.current = { draft, path, indent, onApply, ta };
|
|
52
|
+
|
|
53
|
+
// the element's code changed from elsewhere (undo, the canvas, the code panel): show it, unless it is being typed in
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (source === orig.current) return;
|
|
56
|
+
orig.current = source;
|
|
57
|
+
if (document.activeElement !== ta.current) { setDraft(source); setError(""); }
|
|
58
|
+
}, [source]);
|
|
59
|
+
|
|
60
|
+
// leaving (another element picked, another tab) applies what was typed
|
|
61
|
+
useEffect(() => () => {
|
|
62
|
+
const { draft: d, path: p, indent: i, onApply: apply, ta: t } = live.current;
|
|
63
|
+
const code = t.current ? t.current.value : d;
|
|
64
|
+
if (code.trim() !== orig.current.trim()) apply(p, code, i, orig.current);
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
const dirty = draft.trim() !== orig.current.trim();
|
|
68
|
+
const problem = dirty ? checkTags(draft) : null;
|
|
69
|
+
|
|
70
|
+
// keep the cursor in view: this box scrolls, the textarea inside it does not
|
|
71
|
+
useLayoutEffect(() => {
|
|
72
|
+
const t = ta.current, b = box.current;
|
|
73
|
+
if (!t || document.activeElement !== t) return;
|
|
74
|
+
const r = caretRect(hl.current, t.selectionEnd), br = b.getBoundingClientRect();
|
|
75
|
+
if (!r) return;
|
|
76
|
+
if (r.top < br.top) b.scrollTop -= br.top - r.top + 8;
|
|
77
|
+
else if (r.bottom > br.bottom) b.scrollTop += r.bottom - br.bottom + 8;
|
|
78
|
+
}, [draft]);
|
|
79
|
+
|
|
80
|
+
const apply = () => {
|
|
81
|
+
const code = ta.current ? ta.current.value : draft; // what is in the box now, even if a redraw is still due
|
|
82
|
+
const res = live.current.onApply(path, code, indent, orig.current);
|
|
83
|
+
if (res.ok) { orig.current = code; setError(""); } else setError(res.message || "Not applied.");
|
|
84
|
+
return res.ok;
|
|
85
|
+
};
|
|
86
|
+
const onKeyDown = (e) => {
|
|
87
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); if (apply()) e.target.blur(); }
|
|
88
|
+
else if (e.key === "Escape") { e.preventDefault(); setDraft(orig.current); setError(""); e.target.blur(); }
|
|
89
|
+
else if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); document.execCommand("insertText", false, " "); }
|
|
90
|
+
};
|
|
91
|
+
const copy = (e) => {
|
|
92
|
+
const btn = e.currentTarget, was = btn.textContent;
|
|
93
|
+
const done = (m) => { btn.textContent = m; setTimeout(() => { btn.textContent = was; }, 1200); };
|
|
94
|
+
navigator.clipboard.writeText(draft).then(() => done("Copied"), () => done("Copy failed"));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const note = error || (problem ? `Line ${problem.line}: ${problem.message}` : dirty ? "Ctrl/Cmd+Enter or click away to apply. Esc undoes your typing." : "Edit it, then Ctrl/Cmd+Enter or click away to apply.");
|
|
98
|
+
return html`
|
|
99
|
+
<div class="sec"><div class="secb nopad">
|
|
100
|
+
<div class="elhead"><p class="sub first">Element code</p><button type="button" title="Copy this element's HTML" onClick=${copy}>Copy</button></div>
|
|
101
|
+
<div class="codebox elcode" ref=${box}><div class="elin">
|
|
102
|
+
<pre class="code-layer ck" aria-hidden="true" ref=${hl} dangerouslySetInnerHTML=${{ __html: highlight(draft) }}></pre>
|
|
103
|
+
<textarea class="code-layer" spellcheck="false" autocomplete="off" rows="1" ref=${ta} value=${draft}
|
|
104
|
+
onInput=${(e) => { setDraft(e.target.value); setError(""); }} onKeyDown=${onKeyDown} onBlur=${apply}></textarea>
|
|
105
|
+
</div></div>
|
|
106
|
+
<p class=${`muted ${error || problem ? "bad" : ""}`} data-elnote>${note}</p>
|
|
107
|
+
</div></div>
|
|
108
|
+
<${ClassesSection} s=${s} actions=${actions} />`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A box for asking the AI something: its own text, status and busy state. `run(prompt)` returns { ok, message? }. */
|
|
112
|
+
function AskBox({ title, placeholder, rows, button, help, empty, done, run }) {
|
|
113
|
+
const [prompt, setPrompt] = useState("");
|
|
114
|
+
const [note, setNote] = useState(null);
|
|
115
|
+
const [busy, setBusy] = useState(false);
|
|
116
|
+
const go = async () => {
|
|
117
|
+
if (!prompt.trim()) return setNote({ text: empty, bad: true });
|
|
118
|
+
setBusy(true);
|
|
119
|
+
setNote({ text: "Working..." });
|
|
120
|
+
const res = await run(prompt.trim());
|
|
121
|
+
setBusy(false);
|
|
122
|
+
if (res.ok) { setPrompt(""); setNote(done ? { text: done } : null); } else setNote({ text: res.message || "Something went wrong.", bad: true });
|
|
123
|
+
};
|
|
124
|
+
return html`
|
|
125
|
+
<div class="sec"><div class="secb nopad">
|
|
126
|
+
<p class="sub first">${title}</p>
|
|
127
|
+
<textarea class="fi area" rows=${rows} placeholder=${placeholder} value=${prompt} onInput=${(e) => setPrompt(e.target.value)}></textarea>
|
|
128
|
+
<button type="button" class="primary" disabled=${busy} onClick=${go}>${button}</button>
|
|
129
|
+
${note && html`<p class=${`text-[11px] ${note.bad ? "text-red-700" : "text-neutral-500"}`}>${note.text}</p>`}
|
|
130
|
+
<p class="muted">${help}</p>
|
|
131
|
+
</div></div>`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The AI tab: change the selected element. Keyed by the element, so a request still running applies to the one it was made for. */
|
|
135
|
+
function AiTab({ path, outer, actions }) {
|
|
136
|
+
return html`<${AskBox} title="Ask AI about this element" placeholder="e.g. make this a dark card with a larger title" rows=${4}
|
|
137
|
+
button="Apply to this element" help="Changes only the selected element. Undo with Ctrl/Cmd+Z." empty="Describe the change first."
|
|
138
|
+
done="Applied. Undo with Ctrl/Cmd+Z." run=${(prompt) => actions.askAI(path, outer, prompt)} />`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The page's own panel (no element selected): name, version, size, position, and a draft box while the page is empty. */
|
|
142
|
+
function PagePanel({ page: pg, actions }) {
|
|
143
|
+
const dev = (w, l) => html`<button type="button" onClick=${() => actions.setDevice(w)}
|
|
144
|
+
class=${`flex-1 rounded border py-1 text-[10px] ${pg.w === w ? "border-neutral-900 bg-neutral-900 text-white" : "border-neutral-200 hover:bg-neutral-50"}`}>${l}</button>`;
|
|
145
|
+
const btn = (l, fn) => html`<button type="button" onClick=${fn} class="flex-1 rounded border border-neutral-200 py-1 text-[10px] hover:bg-neutral-50">${l}</button>`;
|
|
146
|
+
const set = (key) => (text) => actions.setPageField(key, text);
|
|
147
|
+
return html`
|
|
148
|
+
<div class="sec"><div class="secb nopad"><p class="sub first">Page</p>
|
|
149
|
+
<${PageField} row label="Name" value=${pg.name} onCommit=${set("name")} />
|
|
150
|
+
<${PageField} row label="Group" value=${pg.group} placeholder="none" onCommit=${set("group")} />
|
|
151
|
+
<div class="row"><span class="lbl">Version</span><span class="text-[11px] text-neutral-700">${pg.isNew ? "not saved yet" : `v${pg.version}`}${pg.project ? ` · ${pg.project}` : ""}</span></div>
|
|
152
|
+
</div></div>
|
|
153
|
+
${pg.empty && actions.canAI && html`<${AskBox} title="Generate with AI" placeholder="e.g. a pricing section with three tiers and a highlighted middle plan" rows=${3}
|
|
154
|
+
button="Generate" help="Or draw with the tools, add blocks from +, or paste code in the code panel." empty="Describe what you want first."
|
|
155
|
+
run=${actions.draftPage} />`}
|
|
156
|
+
<div class="sec"><div class="secb nopad"><p class="sub first">Size</p>
|
|
157
|
+
<div class="flex gap-1">${dev(1280, "Desktop")}${dev(768, "Tablet")}${dev(390, "Mobile")}</div>
|
|
158
|
+
<div class="two"><${PageField} label="W" value=${pg.w} onCommit=${set("w")} /><${PageField} label="H" value=${pg.auto ? "" : pg.h} placeholder="auto" onCommit=${set("h")} /></div>
|
|
159
|
+
<p class="text-[10px] text-neutral-500">Leave the height empty and it follows the content.</p>
|
|
160
|
+
</div></div>
|
|
161
|
+
<div class="sec"><div class="secb nopad"><p class="sub first">Position on the canvas</p>
|
|
162
|
+
<div class="two"><${PageField} label="X" value=${pg.x} onCommit=${set("x")} /><${PageField} label="Y" value=${pg.y} onCommit=${set("y")} /></div>
|
|
163
|
+
</div></div>
|
|
164
|
+
<div class="sec"><div class="secb nopad"><div class="flex gap-1">${btn("Zoom to fit", actions.fit)}${btn("Details…", actions.details)}</div>
|
|
165
|
+
<p class="text-[10px] text-neutral-500">Click an element in the page to edit it.</p>
|
|
166
|
+
</div></div>`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The style sections after Position and Layout, in the order a frontend developer reaches for them (style-sections.js). The page
|
|
171
|
+
* (its body) gets no Border, Effects, Transform or Interaction: those on a whole page are almost always a slip meant for an element,
|
|
172
|
+
* and a border on a page as tall as the screen makes it outgrow its frame.
|
|
173
|
+
*/
|
|
174
|
+
const Styles = ({ k, s, actions, page }) => html`<${TypographySection} key=${`t${k}`} s=${s} actions=${actions} /><${BackgroundSection} key=${`b${k}`} s=${s} actions=${actions} />
|
|
175
|
+
${!page && html`<${BorderSection} key=${`r${k}`} s=${s} actions=${actions} /><${EffectsSection} key=${`e${k}`} s=${s} actions=${actions} />
|
|
176
|
+
<${TransformSection} key=${`x${k}`} s=${s} actions=${actions} /><${MotionSection} key=${`n${k}`} s=${s} actions=${actions} /><${InteractionSection} key=${`i${k}`} s=${s} actions=${actions} />`}
|
|
177
|
+
<${AddBox} key=${`c${k}`} actions=${actions} />`;
|
|
178
|
+
|
|
179
|
+
const CLOSE = html`<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M6 6l12 12M18 6 6 18" /></svg>`;
|
|
180
|
+
const TABS = [["design", "Design"], ["classes", "Code"], ["ai", "AI"]];
|
|
181
|
+
|
|
182
|
+
/** The row at the top: the tabs (or just "Page" when no element is selected) and the close button. */
|
|
183
|
+
function Head({ page, tab, actions }) {
|
|
184
|
+
return html`<div class="itabs">
|
|
185
|
+
${page ? html`<span class="px-1 text-[11px] font-medium text-neutral-800">Page</span>`
|
|
186
|
+
: TABS.filter(([k]) => k !== "ai" || actions.canAI).map(([k, label]) => html`<button type="button" class=${tab === k ? "on" : ""} onClick=${() => actions.setTab(k)}>${label}</button>`)}
|
|
187
|
+
<button type="button" title="Close" style="padding:0" class="ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded text-neutral-500 hover:bg-neutral-100" onClick=${actions.closePanel}>${CLOSE}</button>
|
|
188
|
+
</div>`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The panel: its head, then what the view says to show. The Design tab's parts are keyed by the element: another one gets fresh fields, so nothing typed for one can land on the next. */
|
|
192
|
+
function Panel({ view, tab, actions }) {
|
|
193
|
+
let body;
|
|
194
|
+
if (view.design) {
|
|
195
|
+
const { key, s } = view.design;
|
|
196
|
+
body = html`<${DesignTop} key=${key} s=${s} actions=${actions} />
|
|
197
|
+
${s.tag !== "body" && html`<${PositionSection} key=${`p${key}`} s=${s} actions=${actions} />`}<${LayoutSection} key=${`l${key}`} s=${s} actions=${actions} />
|
|
198
|
+
<${SpacingSection} key=${`s${key}`} s=${s} actions=${actions} /><${ItemSection} key=${`f${key}`} s=${s} actions=${actions} /><${Styles} k=${key} s=${s} actions=${actions} page=${s.tag === "body"} />`;
|
|
199
|
+
}
|
|
200
|
+
else if (view.code) body = html`<${CodeTab} key=${view.code.key} path=${view.code.path} source=${view.code.source} indent=${view.code.indent} s=${view.code.s} actions=${actions} />`;
|
|
201
|
+
else if (view.classes) body = html`<${ClassesSection} key=${view.classes.key} s=${view.classes.s} actions=${actions} />`;
|
|
202
|
+
else if (view.ai) body = html`<${AiTab} key=${view.ai.key} path=${view.ai.path} outer=${view.ai.outer} actions=${actions} />`;
|
|
203
|
+
else if (view.page) body = html`<${PagePanel} page=${view.page} actions=${actions} />${view.pageBody && html`
|
|
204
|
+
<div class="pagehead"><b>Page styling</b><span>Applies to the whole page (its body), not an element. Click an element to style it.</span></div>
|
|
205
|
+
<${LayoutSection} key="page-l" s=${view.pageBody.s} actions=${view.pageBody.actions} /><${SpacingSection} key="page-s" s=${view.pageBody.s} actions=${view.pageBody.actions} /><${Styles} k="page" page s=${view.pageBody.s} actions=${view.pageBody.actions} />`}`;
|
|
206
|
+
return html`<${Head} page=${view.page} tab=${tab} actions=${actions} />${body}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Draws the panel into its element. `view` comes from inspectorView (panel-model.js), `ui` is the panel's state (panel-state.js) plus what is worked out at the time of drawing. */
|
|
210
|
+
export function renderPanel(container, view, actions, ui) {
|
|
211
|
+
render(html`<${UiContext.Provider} value=${ui}><${Panel} view=${view} tab=${ui.tab} actions=${actions} /></${UiContext.Provider}>`, container);
|
|
212
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { buildPreviewDocument, parseComponentSource } from "../playground/component-source.js";
|
|
2
|
+
import { DEVICES, deviceParts } from "./device-frame.js";
|
|
3
|
+
|
|
4
|
+
// ── The prototype viewer: pages shown one at a time, full screen, as a visitor would see them ───────────────────────────────
|
|
5
|
+
// No editor state in here: the editor's Preview (a fallback when the app has no prototype page) and the /prototype page both use it.
|
|
6
|
+
// A toolbar at the top right (like the editor's own): previous / next page with a count, Share, how the page is scaled to the
|
|
7
|
+
// screen, and Share. Hide UI hides it again; ⌘\ or the top right corner brings it back.
|
|
8
|
+
|
|
9
|
+
const SCALES = [
|
|
10
|
+
["actual", "Actual size (100%)", "", '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M9.5 10.5 11 9.5v5"/><path d="M13.5 11.2v.1M13.5 13.1v.1"/>'],
|
|
11
|
+
["responsive", "Responsive", "Z", '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M12 5v14M8.5 10.5 7 12l1.5 1.5M15.5 10.5 17 12l-1.5 1.5"/>'],
|
|
12
|
+
["desktop", "Desktop", "1280", '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>'],
|
|
13
|
+
["tablet", "Tablet", "768", '<rect x="5" y="3" width="14" height="18" rx="2"/><path d="M11 18h2"/>'],
|
|
14
|
+
["mobile", "Mobile", "390", '<rect x="7" y="3" width="10" height="18" rx="2"/><path d="M11 18h2"/>'],
|
|
15
|
+
["fitw", "Fit width", "", '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7.5 12h9M10 9.5 7.5 12l2.5 2.5M14 9.5l2.5 2.5-2.5 2.5"/>'],
|
|
16
|
+
["fitwh", "Fit width and height", "", '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M12 8v8M8 12h8M10.5 9.5 12 8l1.5 1.5M10.5 14.5 12 16l1.5-1.5M9.5 10.5 8 12l1.5 1.5M14.5 10.5 16 12l-1.5 1.5"/>'],
|
|
17
|
+
["fill", "Fill screen", "", '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M8 9.5V8h1.5M14.5 8H16v1.5M16 14.5V16h-1.5M9.5 16H8v-1.5"/>'],
|
|
18
|
+
];
|
|
19
|
+
export const SCALE_NAMES = SCALES.map((s) => s[0]);
|
|
20
|
+
// (The phone and tablet frames are drawn by device-frame.js, shared with the gallery.)
|
|
21
|
+
const DEVICE_W = { desktop: 1280, tablet: 768, mobile: 390 }; // the page is built at that width (its own responsive styles apply), as the editor's page sizes
|
|
22
|
+
const ICON = {
|
|
23
|
+
close: '<path d="M6 6l12 12M18 6 6 18"/>',
|
|
24
|
+
prev: '<path d="m15 6-6 6 6 6"/>',
|
|
25
|
+
next: '<path d="m9 6 6 6-6 6"/>',
|
|
26
|
+
down: '<path d="m6 9 6 6 6-6"/>',
|
|
27
|
+
check: '<path d="m5 12 5 5L20 7"/>',
|
|
28
|
+
hide: '<rect x="3" y="4" width="18" height="6" rx="1.5"/><path d="M3 15h18M3 19h12"/>',
|
|
29
|
+
grip: '<circle cx="9" cy="6" r="1.3"/><circle cx="15" cy="6" r="1.3"/><circle cx="9" cy="12" r="1.3"/><circle cx="15" cy="12" r="1.3"/><circle cx="9" cy="18" r="1.3"/><circle cx="15" cy="18" r="1.3"/>',
|
|
30
|
+
minimize: '<path d="M6 12h12"/>',
|
|
31
|
+
expand: '<path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3"/>',
|
|
32
|
+
share: '<path d="M12 3v12M8 7l4-4 4 4"/><path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7"/>',
|
|
33
|
+
};
|
|
34
|
+
const svg = (paths, size = 18, w = 1.6) => `<svg width="${size}" height="${size}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
35
|
+
const GRIP = `<svg width="14" height="16" viewBox="6 3 12 18" fill="currentColor" aria-hidden="true">${ICON.grip}</svg>`;
|
|
36
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
37
|
+
|
|
38
|
+
// Our own styles, so the viewer looks the same inside the editor and on its own page (which has none of the editor's stylesheet).
|
|
39
|
+
const STYLE = `
|
|
40
|
+
.pvr{position:fixed;inset:0;z-index:200;background:#1c1c1e;display:flex;flex-direction:column;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}
|
|
41
|
+
.pvr *{box-sizing:border-box}
|
|
42
|
+
.pvr button{font:inherit;cursor:pointer;background:none;border:0;padding:0;color:inherit}
|
|
43
|
+
.pvr .pv-pill{background:#fff;border:1px solid #e5e5e5;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,.18)}
|
|
44
|
+
.pvr .pv-grip{display:inline-flex;align-items:center;justify-content:center;width:20px;height:32px;color:#a3a3a3;cursor:grab;touch-action:none}
|
|
45
|
+
.pvr .pv-grip:active{cursor:grabbing}
|
|
46
|
+
.pvr .pv-dock{position:absolute;z-index:206;display:flex;align-items:center;gap:8px;transition:opacity .15s}
|
|
47
|
+
.pvr .pv-b{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:32px;min-width:32px;padding:0;border-radius:8px;color:#404040;font-size:13px}
|
|
48
|
+
.pvr .pv-b:hover{background:#f0f0f0}
|
|
49
|
+
.pvr .pv-b:disabled{opacity:.3;cursor:default;background:none}
|
|
50
|
+
.pvr .pv-sep{width:1px;height:20px;background:#e5e5e5;margin:0 4px}
|
|
51
|
+
.pvr .pv-share{background:#111;color:#fff;padding:0 12px;border-radius:8px}
|
|
52
|
+
.pvr .pv-share:hover{background:#000}
|
|
53
|
+
.pvr .pv-pop{position:absolute;z-index:210;background:#fff;border:1px solid #e5e5e5;border-radius:12px;box-shadow:0 8px 28px rgba(0,0,0,.28);color:#171717}
|
|
54
|
+
.pvr .pv-item{display:flex;align-items:center;gap:10px;width:100%;height:34px;padding:0 10px;border-radius:8px;font-size:13px;color:#171717;text-align:left}
|
|
55
|
+
.pvr .pv-item:hover{background:#f0f0f0}
|
|
56
|
+
`;
|
|
57
|
+
let styled = false;
|
|
58
|
+
function addStyle() {
|
|
59
|
+
if (styled) return;
|
|
60
|
+
styled = true;
|
|
61
|
+
const s = document.createElement("style");
|
|
62
|
+
s.dataset.wcPresenter = "";
|
|
63
|
+
s.textContent = STYLE;
|
|
64
|
+
document.head.appendChild(s);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {object} o
|
|
69
|
+
* @param {{ key: string, title: string, width?: number, height?: number, source: object | string | (() => object | string) }[]} o.pages
|
|
70
|
+
* @param {number} [o.start] the page to begin on
|
|
71
|
+
* @param {string} [o.scaling] one of SCALE_NAMES
|
|
72
|
+
* @param {() => void} [o.onClose] when given, the toolbar has a close button (and Esc closes)
|
|
73
|
+
* @param {{ label?: string, open: (anchor: HTMLElement) => void } | null} [o.share] a Share button
|
|
74
|
+
* @param {string} [o.name] a project name, shown at the top left
|
|
75
|
+
* @param {(state: { key: string, index: number, scaling: string }) => void} [o.onChange]
|
|
76
|
+
*/
|
|
77
|
+
export function createPresenter(o) {
|
|
78
|
+
addStyle();
|
|
79
|
+
const pages = o.pages;
|
|
80
|
+
let at = Math.min(Math.max(0, o.start || 0), pages.length - 1);
|
|
81
|
+
let scaleMode = SCALE_NAMES.includes(o.scaling) ? o.scaling : "", uiHidden = false, menu = null; // "": the page decides (a page as wide as a tablet or more fills the width; a phone-sized one keeps its own size)
|
|
82
|
+
const widthFor = (p) => { const m = modeOf(p); return DEVICE_W[m] || (m === "responsive" ? stage.clientWidth : p.width || 1280); };
|
|
83
|
+
const modeOf = (p) => scaleMode || ((p.width || 1280) >= 768 ? "fitw" : "actual");
|
|
84
|
+
|
|
85
|
+
const box = document.createElement("div");
|
|
86
|
+
box.className = "pvr";
|
|
87
|
+
box.setAttribute("role", "dialog");
|
|
88
|
+
box.setAttribute("aria-label", "Prototype");
|
|
89
|
+
const name = esc(o.name || "");
|
|
90
|
+
box.innerHTML = `<div data-stage style="position:absolute;inset:0;overflow:hidden"><div data-wrap><div data-shell><div data-screen><iframe data-frame title="Prototype" sandbox="allow-scripts allow-forms allow-popups"></iframe></div>
|
|
91
|
+
<span data-notch style="display:none"></span><span data-cam style="display:none"></span><span data-side style="display:none"></span></div></div></div>
|
|
92
|
+
<div data-hot style="position:absolute;left:0;right:0;top:0;height:20px;z-index:205"></div>
|
|
93
|
+
<div data-dock class="pv-dock" style="right:12px;top:12px">
|
|
94
|
+
<div data-full style="display:flex;align-items:center;gap:8px">
|
|
95
|
+
<div class="pv-pill" style="display:flex;align-items:center;gap:2px;padding:4px">
|
|
96
|
+
<span data-grip class="pv-grip" title="Drag to move the toolbar">${GRIP}</span>
|
|
97
|
+
${o.onClose ? `<button type="button" data-close class="pv-b" style="width:32px" title="Close (Esc)" aria-label="Close preview">${svg(ICON.close)}</button><span class="pv-sep"></span>` : ""}
|
|
98
|
+
<button type="button" data-prev class="pv-b" style="width:32px" title="Previous page (Left arrow)" aria-label="Previous page">${svg(ICON.prev)}</button>
|
|
99
|
+
<span style="display:inline-flex;align-items:baseline;gap:8px;padding:0 6px;min-width:60px;justify-content:center" ${name ? `title="${name}"` : ""}>
|
|
100
|
+
<span data-title style="font-size:13px;font-weight:600;color:#171717;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
|
101
|
+
<span data-count style="font-size:12px;color:#737373"></span>
|
|
102
|
+
</span>
|
|
103
|
+
<button type="button" data-next class="pv-b" style="width:32px" title="Next page (Right arrow)" aria-label="Next page">${svg(ICON.next)}</button>
|
|
104
|
+
<span class="pv-sep"></span>
|
|
105
|
+
${o.share ? `<button type="button" data-share class="pv-b pv-share" style="padding:0 14px;gap:6px;font-weight:600;margin-right:2px" title="Get a link to this prototype">${svg(ICON.share, 16)}<span>${esc(o.share.label || "Share")}</span></button>` : ""}
|
|
106
|
+
<button type="button" data-scale class="pv-b" style="padding:0 8px 0 10px;gap:4px;font-weight:500;color:#171717" title="How the page fits the screen" aria-haspopup="menu"><span>100%</span>${svg(ICON.down, 14, 1.8)}</button>
|
|
107
|
+
<span class="pv-sep"></span>
|
|
108
|
+
<button type="button" data-min class="pv-b" style="width:32px" title="Minimize the toolbar" aria-label="Minimize the toolbar">${svg(ICON.minimize)}</button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
<div data-mini class="pv-pill" style="display:none;align-items:center;gap:2px;padding:4px">
|
|
112
|
+
<span data-grip class="pv-grip" title="Drag to move the toolbar">${GRIP}</span>
|
|
113
|
+
<span data-count2 style="font-size:12px;color:#525252;padding:0 4px;min-width:34px;text-align:center"></span>
|
|
114
|
+
<button type="button" data-expand class="pv-b" style="width:32px" title="Show the toolbar" aria-label="Show the toolbar">${svg(ICON.expand, 16)}</button>
|
|
115
|
+
</div>
|
|
116
|
+
</div>`;
|
|
117
|
+
document.body.appendChild(box);
|
|
118
|
+
const q = (s) => box.querySelector(s);
|
|
119
|
+
const stage = q("[data-stage]"), wrap = q("[data-wrap]"), shell = q("[data-shell]"), screen = q("[data-screen]"), notch = q("[data-notch]"), cam = q("[data-cam]"), side = q("[data-side]"), frame = q("[data-frame]"), dock = q("[data-dock]"), full = q("[data-full]"), mini = q("[data-mini]");
|
|
120
|
+
|
|
121
|
+
// A page of its own has none of the editor's script: keys and Ctrl/Cmd + scroll (the browser's zoom) are handed to this window.
|
|
122
|
+
const pageScript = (id) => `<script data-wc-present>(function(){var I=${JSON.stringify(id)};function name(e){var m=e.metaKey||e.ctrlKey;if(m&&e.key==="\\\\")return"hideui";if(m||e.altKey)return"";if(e.key==="ArrowLeft"||e.key==="ArrowRight"||e.key==="Escape")return e.key;if(e.key==="z"||e.key==="Z")return"responsive";return""}addEventListener("keydown",function(e){var k=name(e);if(!k)return;var t=e.target;if(k!=="Escape"&&k!=="hideui"&&t&&(/^(input|textarea|select)$/i.test(t.tagName)||t.isContentEditable))return;e.preventDefault();parent.postMessage({wcPresent:I,key:k},"*")},true);addEventListener("wheel",function(e){if(e.ctrlKey||e.metaKey)e.preventDefault()},{passive:false});["gesturestart","gesturechange","gestureend"].forEach(function(t){addEventListener(t,function(e){e.preventDefault()})})})()<\/script>`;
|
|
123
|
+
|
|
124
|
+
const sourceOf = (p) => {
|
|
125
|
+
const s = typeof p.source === "function" ? p.source() : p.source;
|
|
126
|
+
return typeof s === "string" ? parseComponentSource(s) : s;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Sizes the page for the screen: it keeps its own width (Actual size), takes the window's (Responsive), or is scaled. */
|
|
130
|
+
function layout() {
|
|
131
|
+
const p = pages[at], mode = modeOf(p);
|
|
132
|
+
const availW = stage.clientWidth, availH = stage.clientHeight;
|
|
133
|
+
const dev = DEVICES[mode];
|
|
134
|
+
if (dev) { // a phone or a tablet in its frame, centred, scaled down only when the window is smaller than it
|
|
135
|
+
const d = deviceParts(dev), k = Math.min(1, (availW - 24) / d.w, (availH - 24) / d.h), W = d.w * k, H = d.h * k;
|
|
136
|
+
shell.style.cssText = d.shell + `transform:scale(${k});`;
|
|
137
|
+
screen.style.cssText = d.screen;
|
|
138
|
+
frame.style.cssText = d.frame;
|
|
139
|
+
notch.style.cssText = d.notch;
|
|
140
|
+
cam.style.cssText = d.cam;
|
|
141
|
+
side.style.cssText = d.side;
|
|
142
|
+
wrap.style.cssText = `position:relative;flex:none;width:${W}px;height:${H}px;margin:${(availH - H) / 2}px 0 0 ${(availW - W) / 2}px;`;
|
|
143
|
+
stage.style.overflowX = "hidden";
|
|
144
|
+
q("[data-scale] span").textContent = `${Math.round(k * 100)}%`;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
shell.style.cssText = "position:absolute;inset:0;";
|
|
148
|
+
screen.style.cssText = "position:absolute;inset:0;overflow:hidden;";
|
|
149
|
+
notch.style.display = cam.style.display = side.style.display = "none";
|
|
150
|
+
const w = p.width || 1280, h = p.height || availH;
|
|
151
|
+
let k = 1, fw = w, fh = availH;
|
|
152
|
+
if (mode === "desktop") { fw = DEVICE_W.desktop; k = Math.min(1, availW / fw); fh = availH / k; } // the desktop width, centred; smaller only when the window is narrower
|
|
153
|
+
else if (mode === "responsive") fw = availW;
|
|
154
|
+
else if (mode === "fitw") { k = availW / w; fh = availH / k; }
|
|
155
|
+
else if (mode === "fitwh") { k = Math.min(availW / w, availH / h); fh = h; }
|
|
156
|
+
else if (mode === "fill") { k = Math.max(availW / w, availH / h); fh = h; }
|
|
157
|
+
const W = fw * k, H = fh * k;
|
|
158
|
+
frame.style.cssText = `position:absolute;left:0;top:0;width:${fw}px;height:${fh}px;transform:scale(${k});transform-origin:0 0;border:0;background:#fff;`;
|
|
159
|
+
wrap.style.cssText = `position:relative;flex:none;width:${W}px;height:${H}px;margin:${Math.max(0, (availH - H) / 2)}px 0 0 ${W <= availW || mode === "fill" ? (availW - W) / 2 : 0}px;${W < availW - 1 || H < availH - 1 ? "box-shadow:0 12px 48px rgba(0,0,0,.45);" : ""}overflow:hidden;`;
|
|
160
|
+
stage.style.overflowX = mode === "actual" ? "auto" : "hidden";
|
|
161
|
+
q("[data-scale] span").textContent = `${Math.round(k * 100)}%`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function show(i) {
|
|
165
|
+
at = (i + pages.length) % pages.length;
|
|
166
|
+
const p = pages[at];
|
|
167
|
+
const html = buildPreviewDocument(sourceOf(p), { width: widthFor(p) });
|
|
168
|
+
const cut = html.lastIndexOf("</body>");
|
|
169
|
+
const id = `p${Date.now()}${at}`;
|
|
170
|
+
box.dataset.id = id;
|
|
171
|
+
frame.srcdoc = cut < 0 ? html + pageScript(id) : html.slice(0, cut) + pageScript(id) + html.slice(cut);
|
|
172
|
+
q("[data-title]").textContent = p.title || "Untitled page";
|
|
173
|
+
q("[data-count]").textContent = pages.length > 1 ? `${at + 1} / ${pages.length}` : "";
|
|
174
|
+
q("[data-count2]").textContent = pages.length > 1 ? `${at + 1}/${pages.length}` : "";
|
|
175
|
+
q("[data-prev]").disabled = q("[data-next]").disabled = pages.length < 2;
|
|
176
|
+
layout();
|
|
177
|
+
if (o.onChange) o.onChange({ key: p.key, index: at, scaling: scaleMode });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function setScale(mode) { scaleMode = mode; closeMenu(); show(at); } // Responsive changes the page's own width, so it is built again
|
|
181
|
+
|
|
182
|
+
function setUi(hidden) {
|
|
183
|
+
uiHidden = hidden;
|
|
184
|
+
closeMenu();
|
|
185
|
+
dock.style.opacity = hidden ? "0" : "1";
|
|
186
|
+
dock.style.pointerEvents = hidden ? "none" : "auto";
|
|
187
|
+
layout();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function closeScale() { if (menu) { menu.remove(); menu = null; } }
|
|
191
|
+
function closeMenu() { closeScale(); if (o.share && o.share.close) o.share.close(); }
|
|
192
|
+
|
|
193
|
+
// ── The toolbar floats over the page: drag it by its grip, minimize it to a small chip ──────────────────
|
|
194
|
+
const DOCK_KEY = "wc:presenterDock";
|
|
195
|
+
let saved = {};
|
|
196
|
+
try { saved = JSON.parse(localStorage.getItem(DOCK_KEY) || "{}") || {}; } catch { /* storage is optional */ }
|
|
197
|
+
let minimized = Boolean(saved.min);
|
|
198
|
+
const place = (right, top) => {
|
|
199
|
+
right = Math.min(Math.max(8, right), Math.max(8, innerWidth - dock.offsetWidth - 8));
|
|
200
|
+
top = Math.min(Math.max(8, top), Math.max(8, innerHeight - dock.offsetHeight - 8));
|
|
201
|
+
dock.style.right = `${right}px`;
|
|
202
|
+
dock.style.top = `${top}px`;
|
|
203
|
+
};
|
|
204
|
+
const keepDock = () => { try { localStorage.setItem(DOCK_KEY, JSON.stringify({ right: parseFloat(dock.style.right), top: parseFloat(dock.style.top), min: minimized })); } catch { /* storage is optional */ } };
|
|
205
|
+
function setMin(m) {
|
|
206
|
+
minimized = m;
|
|
207
|
+
closeMenu();
|
|
208
|
+
full.style.display = m ? "none" : "flex";
|
|
209
|
+
mini.style.display = m ? "flex" : "none";
|
|
210
|
+
place(parseFloat(dock.style.right), parseFloat(dock.style.top)); // it grows and shrinks towards the left: keep it on screen
|
|
211
|
+
keepDock();
|
|
212
|
+
}
|
|
213
|
+
box.querySelectorAll("[data-grip]").forEach((grip) => {
|
|
214
|
+
grip.addEventListener("pointerdown", (e) => {
|
|
215
|
+
if (e.button !== 0) return;
|
|
216
|
+
closeMenu();
|
|
217
|
+
e.preventDefault();
|
|
218
|
+
const from = { x: e.clientX, y: e.clientY, right: parseFloat(dock.style.right), top: parseFloat(dock.style.top) };
|
|
219
|
+
// the page under the pointer is a separate document that would take the moves: a see-through cover keeps them here
|
|
220
|
+
const cover = document.createElement("div");
|
|
221
|
+
cover.style.cssText = "position:absolute;inset:0;z-index:300;cursor:grabbing;touch-action:none";
|
|
222
|
+
box.appendChild(cover);
|
|
223
|
+
const move = (ev) => place(from.right - (ev.clientX - from.x), from.top + (ev.clientY - from.y));
|
|
224
|
+
const end = () => {
|
|
225
|
+
cover.removeEventListener("pointermove", move);
|
|
226
|
+
cover.removeEventListener("pointerup", end);
|
|
227
|
+
cover.removeEventListener("pointercancel", end);
|
|
228
|
+
cover.remove();
|
|
229
|
+
keepDock();
|
|
230
|
+
};
|
|
231
|
+
cover.addEventListener("pointermove", move);
|
|
232
|
+
cover.addEventListener("pointerup", end);
|
|
233
|
+
cover.addEventListener("pointercancel", end);
|
|
234
|
+
cover.setPointerCapture(e.pointerId);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
function openMenu() {
|
|
238
|
+
closeMenu();
|
|
239
|
+
const r = q("[data-scale]").getBoundingClientRect();
|
|
240
|
+
const item = (icon, label, hint, on, act) => `<button type="button" role="menuitem" data-act="${act}" class="pv-item"><span style="width:16px;display:inline-flex;justify-content:center">${on ? svg(ICON.check, 14, 2) : ""}</span><span style="display:inline-flex;color:#525252">${icon}</span><span style="flex:1">${label}</span><span style="font-size:12px;color:#737373">${hint}</span></button>`;
|
|
241
|
+
const head = (t) => `<div style="padding:8px 10px 4px;font-size:11px;color:#737373">${t}</div>`;
|
|
242
|
+
const sep = '<div style="height:1px;background:#e5e5e5;margin:6px 4px"></div>';
|
|
243
|
+
const one = (s) => item(svg(s[3], 16), s[1], s[2], modeOf(pages[at]) === s[0], s[0]);
|
|
244
|
+
menu = document.createElement("div");
|
|
245
|
+
menu.className = "pv-pop";
|
|
246
|
+
menu.setAttribute("role", "menu");
|
|
247
|
+
menu.style.cssText = `top:${r.bottom + 6}px;left:${Math.max(8, Math.min(innerWidth - 288, r.right - 280))}px;width:280px;padding:6px;`;
|
|
248
|
+
menu.innerHTML = head("Recommended") + one(SCALES[0]) + one(SCALES[1]) + sep + head("Device") + SCALES.slice(2, 5).map(one).join("") + sep + head("Other scaling options") + SCALES.slice(5).map(one).join("") + sep + item(svg(ICON.hide, 16), "Hide UI", "⌘\\", false, "hideui");
|
|
249
|
+
box.appendChild(menu);
|
|
250
|
+
menu.addEventListener("click", (e) => {
|
|
251
|
+
const b = e.target.closest("[data-act]");
|
|
252
|
+
if (!b) return;
|
|
253
|
+
b.dataset.act === "hideui" ? setUi(true) : setScale(b.dataset.act);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function key(k) {
|
|
258
|
+
if (k === "Escape") return menu ? closeMenu() : o.onClose && o.onClose();
|
|
259
|
+
if (k === "ArrowRight") return show(at + 1);
|
|
260
|
+
if (k === "ArrowLeft") return show(at - 1);
|
|
261
|
+
if (k === "responsive") return setScale("responsive");
|
|
262
|
+
if (k === "hideui") return setUi(!uiHidden);
|
|
263
|
+
}
|
|
264
|
+
const onMessage = (e) => {
|
|
265
|
+
if (e.source !== frame.contentWindow || !e.data || e.data.wcPresent !== box.dataset.id) return;
|
|
266
|
+
key(e.data.key);
|
|
267
|
+
};
|
|
268
|
+
const onKey = (e) => {
|
|
269
|
+
const typing = /^(input|textarea|select)$/i.test(e.target.tagName || "") || e.target.isContentEditable;
|
|
270
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
271
|
+
let k = "";
|
|
272
|
+
if (e.key === "Escape" || e.key === "ArrowRight" || e.key === "ArrowLeft") k = typing && e.key !== "Escape" ? "" : e.key;
|
|
273
|
+
else if (mod && e.key === "\\") k = "hideui";
|
|
274
|
+
else if (!mod && !e.altKey && !typing && /^[zZ]$/.test(e.key)) k = "responsive";
|
|
275
|
+
if (k) { e.preventDefault(); e.stopImmediatePropagation(); key(k); return; }
|
|
276
|
+
if (!mod && !typing) e.stopImmediatePropagation(); // the editor's own shortcuts stay off
|
|
277
|
+
};
|
|
278
|
+
const onResize = () => { layout(); place(parseFloat(dock.style.right), parseFloat(dock.style.top)); };
|
|
279
|
+
const onDown = (e) => { if (menu && !menu.contains(e.target) && !q("[data-scale]").contains(e.target)) closeMenu(); };
|
|
280
|
+
|
|
281
|
+
q("[data-prev]").onclick = () => show(at - 1);
|
|
282
|
+
q("[data-next]").onclick = () => show(at + 1);
|
|
283
|
+
q("[data-scale]").onclick = () => (menu ? closeMenu() : openMenu());
|
|
284
|
+
if (o.onClose) q("[data-close]").onclick = () => o.onClose();
|
|
285
|
+
if (o.share) q("[data-share]").onclick = (e) => { closeScale(); o.share.open(e.currentTarget, box); };
|
|
286
|
+
// with the UI hidden, the bar comes back while the pointer is at the top edge
|
|
287
|
+
q("[data-hot]").addEventListener("mouseenter", () => { if (uiHidden) { dock.style.opacity = "1"; dock.style.pointerEvents = "auto"; } });
|
|
288
|
+
dock.addEventListener("mouseleave", () => { if (uiHidden && !menu) { dock.style.opacity = "0"; dock.style.pointerEvents = "none"; } });
|
|
289
|
+
box.addEventListener("wheel", (e) => { if (e.ctrlKey || e.metaKey) e.preventDefault(); }, { passive: false });
|
|
290
|
+
box.addEventListener("pointerdown", onDown, true);
|
|
291
|
+
window.addEventListener("message", onMessage);
|
|
292
|
+
window.addEventListener("keydown", onKey, true);
|
|
293
|
+
window.addEventListener("resize", onResize);
|
|
294
|
+
|
|
295
|
+
q("[data-min]").onclick = () => setMin(true);
|
|
296
|
+
q("[data-expand]").onclick = () => setMin(false);
|
|
297
|
+
place(saved.right ?? 12, saved.top ?? 12);
|
|
298
|
+
if (minimized) setMin(true);
|
|
299
|
+
show(at);
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
root: box,
|
|
303
|
+
go: (i) => show(i),
|
|
304
|
+
destroy() {
|
|
305
|
+
closeMenu();
|
|
306
|
+
window.removeEventListener("message", onMessage);
|
|
307
|
+
window.removeEventListener("keydown", onKey, true);
|
|
308
|
+
window.removeEventListener("resize", onResize);
|
|
309
|
+
box.remove();
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// One CSS property as a row of the Properties panel, for the settings that have no field of their own (cursor, object fit,
|
|
2
|
+
// text decoration...). It is drawn like every other field: the label inside, a blue edge when a Tailwind class gives the value
|
|
3
|
+
// and amber when CSS does (the tooltip names the class or rule), faint when it is a default. Beside it, as for every style in
|
|
4
|
+
// the panel: − takes a set value off; − (onCancel) also closes a row that was just added and has no value yet.
|
|
5
|
+
// It edits the property where it comes from, through `actions`: setProp(prop, value) · resetProp(prop)
|
|
6
|
+
import { html } from "../vendor/preact-htm.js";
|
|
7
|
+
import { I, IconBtn, PropField, PropSelect, owns, has } from "./figma-sections.js";
|
|
8
|
+
|
|
9
|
+
/** `c`: { p: the CSS property, l: its label, t: "select" with `o` (its values) or "text" }. */
|
|
10
|
+
export function PropRow({ s, c, actions, onCancel }) {
|
|
11
|
+
return html`<div class="frow">${c.t === "select"
|
|
12
|
+
? html`<${PropSelect} s=${s} actions=${actions} p=${c.p} label=${c.l} options=${c.o.map((o) => [o, o])} />`
|
|
13
|
+
: html`<${PropField} s=${s} actions=${actions} p=${c.p} label=${c.l} list=${c.list} />`}
|
|
14
|
+
${owns(s, c.p) ? html`<${IconBtn} icon=${I.minus} title=${`Remove ${c.l.toLowerCase()}`} onClick=${() => { actions.resetProp(c.p); if (onCancel) onCancel(); }} />`
|
|
15
|
+
// given by the design's own CSS rule: it can't be taken off, so it is switched off with the property's initial value
|
|
16
|
+
: has(s, c.p) ? html`<${IconBtn} icon=${I.minus} title=${`Remove ${c.l.toLowerCase()} (overrides the design's CSS)`} onClick=${() => { actions.setProp(c.p, "initial"); if (onCancel) onCancel(); }} />`
|
|
17
|
+
: onCancel ? html`<${IconBtn} icon=${I.minus} title="Remove" onClick=${onCancel} />` : null}</div>`;
|
|
18
|
+
}
|