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,151 @@
|
|
|
1
|
+
import { host } from "../host.js";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
import { parseComponentSource } from "../../playground/component-source.js";
|
|
4
|
+
import { $, S, data, esc, model } from "./base.js";
|
|
5
|
+
import { cmd } from "./frame-messages.js";
|
|
6
|
+
import { assemble, commit, loadFrame, updateChrome } from "./docs.js";
|
|
7
|
+
import { renderInspector } from "./inspector.js";
|
|
8
|
+
import { anyDirty, boardPayload, docs, pinBoard, remapKey, saveBoard } from "./designs.js";
|
|
9
|
+
|
|
10
|
+
// ── Add menu: build from blocks ──────────────────────────────────────────────
|
|
11
|
+
const BLOCKS = [
|
|
12
|
+
["Section", '<section class="p-10"><h2 class="text-2xl font-bold">Section title</h2><p class="mt-2 text-gray-600">Describe this section.</p></section>'],
|
|
13
|
+
["Container", '<div class="mx-auto max-w-5xl p-6"></div>'],
|
|
14
|
+
["Columns (2)", '<div class="grid grid-cols-1 gap-6 md:grid-cols-2"><div class="p-6">Column one</div><div class="p-6">Column two</div></div>'],
|
|
15
|
+
["Heading", '<h2 class="text-3xl font-bold">Heading</h2>'],
|
|
16
|
+
["Text", '<p class="text-gray-600">Write something here.</p>'],
|
|
17
|
+
["Button", '<a href="#" class="inline-block rounded-md bg-black px-5 py-2.5 text-sm font-medium text-white">Button</a>'],
|
|
18
|
+
["Image", '<img src="https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&w=800&q=60" alt="Image" class="rounded-lg">'],
|
|
19
|
+
["Card", '<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm"><h3 class="font-semibold">Card title</h3><p class="mt-1 text-sm text-gray-600">Card text.</p></div>'],
|
|
20
|
+
["Input", '<input type="text" placeholder="Type here" class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm">'],
|
|
21
|
+
];
|
|
22
|
+
const CONTAINERS = new Set(["body", "div", "section", "header", "footer", "main", "nav", "ul", "ol", "form", "article", "aside"]);
|
|
23
|
+
function addToEmpty(html) {
|
|
24
|
+
model.markup = html;
|
|
25
|
+
commit(html);
|
|
26
|
+
loadFrame();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── An empty page: draft it with AI (the Page panel's Generate) ──────────────
|
|
30
|
+
/** Drafts the whole (empty) page from a description. Returns { ok, message? } for the page panel to show. */
|
|
31
|
+
export async function draftPage(prompt) {
|
|
32
|
+
try {
|
|
33
|
+
const html = await host.generate({ prompt, useLibrary: true });
|
|
34
|
+
if (!html.startsWith("<")) throw new Error("The AI didn't return HTML. Try rephrasing.");
|
|
35
|
+
S.parsed = parseComponentSource(html);
|
|
36
|
+
commit(S.parsed.markup);
|
|
37
|
+
loadFrame();
|
|
38
|
+
if (!data.title) data.title = prompt.slice(0, 60);
|
|
39
|
+
updateChrome();
|
|
40
|
+
renderInspector();
|
|
41
|
+
return { ok: true };
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return { ok: false, message: err.message };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Details dialog: name, category, tags, visibility ─────────────────────────
|
|
48
|
+
const dlg = $("detailsDlg");
|
|
49
|
+
|
|
50
|
+
function fillProjects() {
|
|
51
|
+
const cur = data.project ? data.project.id : "";
|
|
52
|
+
$("dProject").innerHTML = `<option value="" disabled>Choose a project</option>` + (data.projects || []).map((p) => `<option value="${p.id}">${esc(p.name)}</option>`).join("") + `<option value="__new__">New project...</option>`;
|
|
53
|
+
const first = (data.projects || [])[0];
|
|
54
|
+
$("dProject").value = cur || (first ? first.id : ""); // every design belongs to a project
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What the details dialog shows now: the design's name, category, tags... (also what the app's own details form starts from). */
|
|
58
|
+
export const detailsOf = () => ({
|
|
59
|
+
title: data.title || "", category: data.category || "", description: data.description || "", tags: (data.tags || []).join(", "),
|
|
60
|
+
isPublished: data.isPublished, useAsReference: data.useAsReference !== false, project: data.project ? data.project.id : "", isNew: S.isNew,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export function openDetails() {
|
|
64
|
+
if (S.mode !== "edit") return; // Interact uses the page as it is: name, category and the rest are not changed there
|
|
65
|
+
if (config.hooks.details) return config.hooks.details(detailsOf()); // the app shows its own form and answers with saveDetails()
|
|
66
|
+
fillProjects();
|
|
67
|
+
$("dTitle").value = data.title || "";
|
|
68
|
+
$("dCategory").value = data.category || "";
|
|
69
|
+
$("dDesc").value = data.description || "";
|
|
70
|
+
$("dTags").value = (data.tags || []).join(", ");
|
|
71
|
+
$("dVis").value = String(Boolean(data.isPublished)); // private unless it was made public
|
|
72
|
+
$("dRef").checked = data.useAsReference !== false;
|
|
73
|
+
$("dDesc").required = false; // optional: it defaults to the name
|
|
74
|
+
$("detailsTitle").textContent = S.isNew ? "Name and save your component" : "Component details";
|
|
75
|
+
$("detailsSave").textContent = S.isNew ? "Create component" : "Save details";
|
|
76
|
+
$("detailsError").classList.add("hidden");
|
|
77
|
+
dlg.showModal();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Saves the design's details (and, the first time, the design itself). Rejects with the reason when it can't. */
|
|
81
|
+
export async function saveDetails(fields) {
|
|
82
|
+
const fromDraft = S.isNew && S.draftKey && S.draftKey === S.curKey; // a design opened from a Figma draft: its Figma data moves to the new page
|
|
83
|
+
const saved = S.isNew ? await host.createComponent({ ...fields, htmlCode: assemble(), ...(fromDraft ? { draftId: data.draftId } : {}) }) : await host.updateComponent(data.id, fields);
|
|
84
|
+
const movedProject = (data.project ? data.project.id : "") !== (fields.project || "");
|
|
85
|
+
data.project = (data.projects || []).find((p) => p.id === fields.project) || null;
|
|
86
|
+
Object.assign(data, { title: fields.title, category: fields.category, description: fields.description, tags: String(fields.tags || "").split(",").map((t) => t.trim()).filter(Boolean), isPublished: String(fields.isPublished) === "true", useAsReference: fields.useAsReference !== false && fields.useAsReference !== "false" });
|
|
87
|
+
if (S.isNew) {
|
|
88
|
+
data.id = saved._id;
|
|
89
|
+
data.version = saved.version || 1;
|
|
90
|
+
S.isNew = false;
|
|
91
|
+
S.savedMarkup = model.markup;
|
|
92
|
+
host.showDesign(data.id);
|
|
93
|
+
const d = docs.get(S.curKey);
|
|
94
|
+
if (data.project) pinBoard(S.curKey, false);
|
|
95
|
+
if (d.board && data.project) saveBoard([boardPayload([data.id, d.board])]);
|
|
96
|
+
remapKey(S.curKey, data.id);
|
|
97
|
+
if (fromDraft) { S.draftKey = null; if (saved.figmaSource) document.dispatchEvent(new CustomEvent("figma-source-ready", { detail: { info: saved.figmaSource, id: data.id } })); }
|
|
98
|
+
}
|
|
99
|
+
updateChrome();
|
|
100
|
+
// the plane belongs to a project, so a design that moved to another one reloads onto that project's plane
|
|
101
|
+
if (movedProject && !anyDirty()) host.openDesign(data.id);
|
|
102
|
+
return data;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Registers this part's listeners and does its first draw (called once, in order, by editor.js). */
|
|
106
|
+
export function init() {
|
|
107
|
+
$("addMenu").innerHTML = BLOCKS.map(([name], i) => `<button type="button" data-block="${i}" class="block w-full rounded px-3 py-1.5 text-left text-xs hover:bg-neutral-100">${name}</button>`).join("");
|
|
108
|
+
$("addBtn").addEventListener("click", (e) => { e.stopPropagation(); $("addMenu").classList.toggle("hidden"); });
|
|
109
|
+
document.addEventListener("click", () => $("addMenu").classList.add("hidden"));
|
|
110
|
+
$("addMenu").addEventListener("click", (e) => {
|
|
111
|
+
const b = e.target.closest("[data-block]");
|
|
112
|
+
if (!b) return;
|
|
113
|
+
const html = BLOCKS[Number(b.dataset.block)][1];
|
|
114
|
+
if (model.markup.trim() === "" || !S.selected) {
|
|
115
|
+
if (model.markup.trim() === "") { addToEmpty(html); return; }
|
|
116
|
+
return cmd({ cmd: "insert", path: [], html, position: "inside" });
|
|
117
|
+
}
|
|
118
|
+
cmd({ cmd: "insert", path: S.selected.path, html, position: CONTAINERS.has(S.selected.tag) ? "inside" : "after" });
|
|
119
|
+
});
|
|
120
|
+
$("dCategory").innerHTML += (data.categories || []).map((g) => `<optgroup label="${esc(g.group)}">${g.items.map((i) => `<option value="${i.slug}">${esc(i.name)}</option>`).join("")}</optgroup>`).join("");
|
|
121
|
+
$("dProject").addEventListener("change", async (e) => {
|
|
122
|
+
if (e.target.value !== "__new__") return;
|
|
123
|
+
const name = prompt("Project name");
|
|
124
|
+
if (!name || !name.trim()) { fillProjects(); return; }
|
|
125
|
+
let project;
|
|
126
|
+
try { project = await host.createProject(name); } catch (err) { alert(err.message || "Could not create the project"); return fillProjects(); }
|
|
127
|
+
(data.projects ||= []).push(project);
|
|
128
|
+
fillProjects();
|
|
129
|
+
$("dProject").value = project.id;
|
|
130
|
+
});
|
|
131
|
+
$("detailsBtn").addEventListener("click", openDetails);
|
|
132
|
+
$("detailsCancel").addEventListener("click", () => dlg.close());
|
|
133
|
+
|
|
134
|
+
$("detailsForm").addEventListener("submit", async (e) => {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
const fields = { title: $("dTitle").value.trim(), category: $("dCategory").value, description: $("dDesc").value.trim(), tags: $("dTags").value, isPublished: $("dVis").value, useAsReference: $("dRef").checked, project: $("dProject").value };
|
|
137
|
+
if (!fields.description) fields.description = fields.title;
|
|
138
|
+
if (!fields.title || !fields.category || !fields.description) return;
|
|
139
|
+
const err = $("detailsError");
|
|
140
|
+
$("detailsSave").disabled = true;
|
|
141
|
+
try {
|
|
142
|
+
await saveDetails(fields);
|
|
143
|
+
dlg.close();
|
|
144
|
+
} catch (error) {
|
|
145
|
+
err.textContent = error.message || "Could not save";
|
|
146
|
+
err.classList.remove("hidden");
|
|
147
|
+
} finally {
|
|
148
|
+
$("detailsSave").disabled = false;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// What an app can do with a mounted editor (the object mountEditor() resolves to):
|
|
2
|
+
// getHtml() the component's source as it is now
|
|
3
|
+
// setHtml(html) replaces it (one undo step); a change made this way is reported with source "host"
|
|
4
|
+
// onChange(fn) fn({ html, dirty, status, source }) after every change; source says where it came from: "host" (your setHtml), "editor" (the markup changed there) or "status" (only the save state). Returns an unsubscribe
|
|
5
|
+
// isDirty() whether there is anything unsaved
|
|
6
|
+
// save() the editor's own save (a first save that needs a name asks the app through ui.onDetails, else shows its dialog)
|
|
7
|
+
// saveDetails(f) finishes that first save with { title, category, description, tags, isPublished, useAsReference, project }; rejects with the reason
|
|
8
|
+
// details() what the details form starts from
|
|
9
|
+
// release() the app is leaving the page on purpose and has kept the work: no "Leave site?" question
|
|
10
|
+
import { config } from "../config.js";
|
|
11
|
+
import { parseComponentSource } from "../../playground/component-source.js";
|
|
12
|
+
import { S, model } from "./base.js";
|
|
13
|
+
import { assemble, commit, loadFrame } from "./docs.js";
|
|
14
|
+
import { anyDirty } from "./designs.js";
|
|
15
|
+
import { save } from "./toolbar.js";
|
|
16
|
+
import { detailsOf, saveDetails } from "./add-menu.js";
|
|
17
|
+
|
|
18
|
+
const listeners = new Set();
|
|
19
|
+
let fromHost = false, last = "", lastMarkup = model.markup;
|
|
20
|
+
const statusText = () => (document.getElementById("saveState") || {}).textContent || "";
|
|
21
|
+
|
|
22
|
+
config.hooks.chrome = () => {
|
|
23
|
+
// "host": the app's own setHtml; "editor": the markup changed in the editor (canvas, undo, code); "status": only the save state moved
|
|
24
|
+
const source = fromHost ? "host" : model.markup !== lastMarkup ? "editor" : "status";
|
|
25
|
+
lastMarkup = model.markup;
|
|
26
|
+
const state = { html: assemble(), dirty: anyDirty(), status: statusText().trim(), source };
|
|
27
|
+
const key = JSON.stringify([state.html, state.dirty, state.status, state.source]);
|
|
28
|
+
if (key === last) return;
|
|
29
|
+
last = key;
|
|
30
|
+
listeners.forEach((fn) => fn(state));
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const api = {
|
|
34
|
+
getHtml: assemble,
|
|
35
|
+
setHtml(html) {
|
|
36
|
+
if (String(html) === assemble()) return;
|
|
37
|
+
fromHost = true;
|
|
38
|
+
try { S.parsed = parseComponentSource(String(html)); commit(S.parsed.markup, "code"); loadFrame(); } finally { fromHost = false; }
|
|
39
|
+
},
|
|
40
|
+
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
|
41
|
+
isDirty: anyDirty,
|
|
42
|
+
save,
|
|
43
|
+
saveDetails,
|
|
44
|
+
details: detailsOf,
|
|
45
|
+
/** The app is about to leave the page itself (and keeps the work): the browser's "leave site?" question is skipped. */
|
|
46
|
+
release() { config.leaving = true; },
|
|
47
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { parseComponentSource } from "../../playground/component-source.js";
|
|
3
|
+
|
|
4
|
+
export const $ = (id) => document.getElementById(id);
|
|
5
|
+
export const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
6
|
+
|
|
7
|
+
export const data = config.data;
|
|
8
|
+
|
|
9
|
+
// A two-finger swipe across the editor must never be taken for the browser's Back gesture (it leaves the design behind): the page
|
|
10
|
+
// does not chain a scroll that ran out to the browser's own navigation.
|
|
11
|
+
document.documentElement.style.overscrollBehavior = "none";
|
|
12
|
+
if (document.body) document.body.style.overscrollBehavior = "none";
|
|
13
|
+
|
|
14
|
+
export const S = {};
|
|
15
|
+
S.parsed = parseComponentSource(data.htmlCode);
|
|
16
|
+
S.mode = "edit";
|
|
17
|
+
S.frameHeight = 600;
|
|
18
|
+
S.selected = null;
|
|
19
|
+
S.tree = null;
|
|
20
|
+
S.history = [];
|
|
21
|
+
S.hIndex = -1;
|
|
22
|
+
S.isNew = !data.id;
|
|
23
|
+
S.savedMarkup = S.isNew ? "" : S.parsed.markup;
|
|
24
|
+
S.lastKey = "";
|
|
25
|
+
S.lastTime = 0;
|
|
26
|
+
S.loadSeq = 0;
|
|
27
|
+
S.viewTouched = false;
|
|
28
|
+
S.sentZoom = 0;
|
|
29
|
+
S.tool = "move";
|
|
30
|
+
S.curKey = data.id || "draft-0";
|
|
31
|
+
S.draftKey = data.draftId ? S.curKey : null; // a design opened from a Figma draft: its first save sends the draft's id, so its Figma data moves to the new page
|
|
32
|
+
S.switching = false;
|
|
33
|
+
S.switchToken = 0;
|
|
34
|
+
S.frameAt = null;
|
|
35
|
+
S.handingOver = false;
|
|
36
|
+
S.lastCustom = null;
|
|
37
|
+
S.editingInfo = null;
|
|
38
|
+
S.gestureOn = false;
|
|
39
|
+
S.frameSel = null;
|
|
40
|
+
S.ftMenu = false;
|
|
41
|
+
S.ftPop = null;
|
|
42
|
+
S.ftSides = {};
|
|
43
|
+
S.selData = null;
|
|
44
|
+
S.leftOpen = false;
|
|
45
|
+
|
|
46
|
+
export const model = { markup: S.parsed.markup };
|
|
47
|
+
export const isTailwindScript = (s) => s.src && /tailwindcss/i.test(s.src);
|
|
48
|
+
|
|
49
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export const DEFAULT_W = 1280;
|
|
52
|
+
export const collapsed = new Set();
|
|
53
|
+
|
|
54
|
+
export const frame = $("frame");
|
|
55
|
+
export const send = (msg) => frame.contentWindow && frame.contentWindow.postMessage(msg, "*");
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { checkTags } from "../code-view.js";
|
|
2
|
+
import { parseComponentSource } from "../../playground/component-source.js";
|
|
3
|
+
import { createCodeEditor, tagProblems } from "../code-editor.js";
|
|
4
|
+
import { $, S, data, model } from "./base.js";
|
|
5
|
+
import { pref } from "./color-ui.js";
|
|
6
|
+
import { assemble, commit, loadFrame } from "./docs.js";
|
|
7
|
+
import { layout } from "./plane.js";
|
|
8
|
+
|
|
9
|
+
// ── Code panel (two-way with the canvas) ─────────────────────────────────────
|
|
10
|
+
// The editor itself is design/code-editor.js (CodeMirror); this part keeps it and the canvas in step.
|
|
11
|
+
let ed = null; // the code editor, once the panel has been opened
|
|
12
|
+
let starting = false;
|
|
13
|
+
const codeOpen = () => !$("codePanel").classList.contains("hidden");
|
|
14
|
+
export const codeFocused = () => Boolean(ed && ed.hasFocus());
|
|
15
|
+
const codeValue = () => (ed ? ed.getValue() : "");
|
|
16
|
+
/** Copies text and says so on the button for a moment. */
|
|
17
|
+
function copyText(text, btn) {
|
|
18
|
+
const done = (msg) => { if (!btn) return; const was = btn.textContent; btn.textContent = msg; setTimeout(() => { btn.textContent = was; }, 1200); };
|
|
19
|
+
navigator.clipboard.writeText(text).then(() => done("Copied"), () => done("Copy failed"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── The selected element, highlighted in the code ───────────────────────────
|
|
23
|
+
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
|
|
24
|
+
/** Where the element at `path` (element indices from the body down) sits in the markup text: its whole source, and just its opening tag. */
|
|
25
|
+
export function elementRange(markup, path) {
|
|
26
|
+
const re = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
|
|
27
|
+
const key = path.join(".");
|
|
28
|
+
const root = { kids: 0, path: [] }, stack = [];
|
|
29
|
+
let m;
|
|
30
|
+
while ((m = re.exec(markup))) {
|
|
31
|
+
if (m[0].startsWith("<!--")) continue;
|
|
32
|
+
const tag = m[2].toLowerCase();
|
|
33
|
+
const end = m.index + m[0].length;
|
|
34
|
+
if (m[1]) {
|
|
35
|
+
const i = stack.map((n) => n.tag).lastIndexOf(tag);
|
|
36
|
+
if (i < 0) continue;
|
|
37
|
+
const node = stack[i];
|
|
38
|
+
stack.length = i;
|
|
39
|
+
if (node.path.join(".") === key) return { start: node.start, openEnd: node.openEnd, end };
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const parent = stack[stack.length - 1] || root;
|
|
43
|
+
const node = { tag, start: m.index, openEnd: end, kids: 0, path: [...parent.path, parent.kids++] };
|
|
44
|
+
const lone = VOID_TAGS.has(tag) || m[3].trim().endsWith("/");
|
|
45
|
+
if (lone) { if (node.path.join(".") === key) return { start: node.start, openEnd: end, end }; continue; }
|
|
46
|
+
if (["script", "style", "textarea", "title"].includes(tag)) { const close = markup.toLowerCase().indexOf(`</${tag}`, end); if (close >= 0) re.lastIndex = close; }
|
|
47
|
+
stack.push(node);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/** Paints the selected element's source in the code (its opening tag darker), and when the selection just changed, scrolls to it. */
|
|
52
|
+
export function updateCodeHighlight(reveal) {
|
|
53
|
+
if (!codeOpen() || !ed) return;
|
|
54
|
+
const text = ed.getValue();
|
|
55
|
+
const base = model.markup ? text.indexOf(model.markup) : -1;
|
|
56
|
+
const r = S.selected && S.selected.path && S.selected.path.length && base >= 0 ? elementRange(model.markup, S.selected.path) : null;
|
|
57
|
+
ed.setMarks(r ? [[base + r.start, base + r.end, "sel"], [base + r.start, base + r.openEnd, "selt"]] : []);
|
|
58
|
+
const n = ed.lineCount();
|
|
59
|
+
$("codeLines").textContent = `${n} line${n === 1 ? "" : "s"}`;
|
|
60
|
+
if (reveal && r) ed.reveal(base + r.start);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function syncCode() {
|
|
64
|
+
if (!codeOpen() || !ed) return;
|
|
65
|
+
const next = model.markup.trim() ? assemble() : "";
|
|
66
|
+
const prev = ed.getValue();
|
|
67
|
+
if (prev === next) return updateCodeHighlight(false);
|
|
68
|
+
ed.setValue(next); // keeps the scroll position and the cursor
|
|
69
|
+
// show what changed: the first place the new code differs from the old
|
|
70
|
+
let i = 0;
|
|
71
|
+
while (i < prev.length && i < next.length && prev[i] === next[i]) i++;
|
|
72
|
+
if (prev) ed.reveal(i);
|
|
73
|
+
updateCodeHighlight(false);
|
|
74
|
+
}
|
|
75
|
+
async function setCodePanel(open) {
|
|
76
|
+
$("codePanel").classList.toggle("hidden", !open);
|
|
77
|
+
$("codePanel").classList.toggle("flex", open);
|
|
78
|
+
$("codeBtn").classList.toggle("on", open);
|
|
79
|
+
if (open && !ed && !starting) {
|
|
80
|
+
starting = true;
|
|
81
|
+
try {
|
|
82
|
+
ed = await createCodeEditor($("codeMount"), {
|
|
83
|
+
value: model.markup.trim() ? assemble() : "", onChange: onCodeInput, problems: tagProblems, onSave: () => $("saveBtn").click(),
|
|
84
|
+
wrap: true, // long class lists wrap instead of scrolling sideways (a narrow panel beside the design would cut off the start of the line)
|
|
85
|
+
});
|
|
86
|
+
} finally { starting = false; }
|
|
87
|
+
}
|
|
88
|
+
if (open && ed) { syncCode(); updateCodeHighlight(true); ed.focus(); }
|
|
89
|
+
layout();
|
|
90
|
+
}
|
|
91
|
+
let codeTimer = null;
|
|
92
|
+
/** Shows the first place the tags don't match up (and returns it), or hides the warning. */
|
|
93
|
+
function showCodeProblem() {
|
|
94
|
+
const bad = checkTags(codeValue());
|
|
95
|
+
const w = $("codeWarn");
|
|
96
|
+
w.classList.toggle("hidden", !bad);
|
|
97
|
+
if (bad) { w.textContent = `Line ${bad.line}: ${bad.message}. Not applied until fixed.`; w.dataset.line = String(bad.line); }
|
|
98
|
+
return bad;
|
|
99
|
+
}
|
|
100
|
+
/** Applies the code to the design now (it waits half a second after typing), unless its tags don't match up. */
|
|
101
|
+
function applyCode() {
|
|
102
|
+
clearTimeout(codeTimer);
|
|
103
|
+
codeTimer = null;
|
|
104
|
+
if (showCodeProblem()) return false;
|
|
105
|
+
S.parsed = parseComponentSource(codeValue());
|
|
106
|
+
commit(S.parsed.markup, "code");
|
|
107
|
+
loadFrame();
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
/** Typing in the code: the squiggles and the warning show at once; the canvas follows once typing pauses (broken tags are never sent to it, where the browser would "repair" them its own way). */
|
|
111
|
+
function onCodeInput() {
|
|
112
|
+
const n = ed.lineCount();
|
|
113
|
+
$("codeLines").textContent = `${n} line${n === 1 ? "" : "s"}`;
|
|
114
|
+
showCodeProblem();
|
|
115
|
+
clearTimeout(codeTimer);
|
|
116
|
+
codeTimer = setTimeout(applyCode, 500);
|
|
117
|
+
}
|
|
118
|
+
const copyFlash = (btn, msg) => { const was = btn.textContent; btn.textContent = msg; setTimeout(() => { btn.textContent = was; }, 1200); };
|
|
119
|
+
|
|
120
|
+
/** Registers this part's listeners and does its first draw (called once, in order, by editor.js). */
|
|
121
|
+
export function init() {
|
|
122
|
+
$("codeBtn").addEventListener("click", () => setCodePanel(!codeOpen()));
|
|
123
|
+
$("codeClose").addEventListener("click", () => setCodePanel(false));
|
|
124
|
+
$("codeWarn").addEventListener("click", () => {
|
|
125
|
+
if (!ed) return;
|
|
126
|
+
const [from, to] = ed.lineRange(Number($("codeWarn").dataset.line) || 1);
|
|
127
|
+
ed.select(from, to);
|
|
128
|
+
});
|
|
129
|
+
$("codeCopy").addEventListener("click", (e) => copyText(codeValue(), e.currentTarget));
|
|
130
|
+
$("codeFind").addEventListener("click", () => ed && ed.openSearch());
|
|
131
|
+
// Format: Prettier tidies the whole code (markup, <style> and <script>), as one undo step
|
|
132
|
+
$("codeFormat").addEventListener("click", async (e) => {
|
|
133
|
+
const btn = e.currentTarget;
|
|
134
|
+
if (!ed) return;
|
|
135
|
+
const r = await ed.format();
|
|
136
|
+
if (!r.ok) { copyFlash(btn, "Fix the errors first"); return; }
|
|
137
|
+
if (!r.changed) { copyFlash(btn, "Already tidy"); return; }
|
|
138
|
+
applyCode();
|
|
139
|
+
syncCode();
|
|
140
|
+
});
|
|
141
|
+
// ── Where the code sits: below the design, or to its left (side by side) ──────────────────────────────────────────────────────────────────
|
|
142
|
+
// Below the design by default. The choice is remembered; a design opened with `codeDock` starts there.
|
|
143
|
+
let dock = "bottom";
|
|
144
|
+
const sectionEl = () => $("canvas").parentElement; // the column that holds the design (and the code, when it is below)
|
|
145
|
+
const DOCK_ICON = {
|
|
146
|
+
left: '<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M10 4v16"/></svg>',
|
|
147
|
+
bottom: '<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 14h18"/></svg>',
|
|
148
|
+
};
|
|
149
|
+
function setDock(side, remember = true) {
|
|
150
|
+
const panel = $("codePanel"), handle = $("codeResize");
|
|
151
|
+
dock = side;
|
|
152
|
+
if (side === "left") {
|
|
153
|
+
sectionEl().parentElement.insertBefore(panel, sectionEl());
|
|
154
|
+
panel.classList.remove("border-t");
|
|
155
|
+
panel.classList.add("border-r");
|
|
156
|
+
panel.style.height = "";
|
|
157
|
+
panel.style.width = `${Number(pref("wc:codeW")) || 440}px`;
|
|
158
|
+
handle.className = "absolute inset-y-0 -right-1 z-10 w-2 cursor-col-resize";
|
|
159
|
+
} else {
|
|
160
|
+
sectionEl().append(panel);
|
|
161
|
+
panel.classList.add("border-t");
|
|
162
|
+
panel.classList.remove("border-r");
|
|
163
|
+
panel.style.width = "";
|
|
164
|
+
panel.style.height = `${Number(pref("wc:codeH")) || 256}px`;
|
|
165
|
+
handle.className = "absolute inset-x-0 -top-1 z-10 h-2 cursor-row-resize";
|
|
166
|
+
}
|
|
167
|
+
// the button shows where the code would go next
|
|
168
|
+
$("codeDock").innerHTML = DOCK_ICON[side === "left" ? "bottom" : "left"];
|
|
169
|
+
$("codeDock").title = side === "left" ? "Dock the code below the design" : "Dock the code beside the design (left)";
|
|
170
|
+
if (remember) pref("wc:codeDock", side);
|
|
171
|
+
layout();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Resize: drag the panel's edge (top when below, right when beside); the size is remembered. The handle keeps the pointer
|
|
175
|
+
// (setPointerCapture) so the drag follows it over the design's iframe too, which would otherwise take the pointer and leave it stuck.
|
|
176
|
+
(() => {
|
|
177
|
+
const panel = $("codePanel"), handle = $("codeResize");
|
|
178
|
+
let drag = null;
|
|
179
|
+
handle.addEventListener("pointerdown", (e) => {
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
drag = { x: e.clientX, y: e.clientY, w: panel.offsetWidth, h: panel.offsetHeight, side: dock };
|
|
182
|
+
handle.setPointerCapture(e.pointerId);
|
|
183
|
+
document.body.style.cursor = dock === "left" ? "col-resize" : "row-resize";
|
|
184
|
+
});
|
|
185
|
+
handle.addEventListener("pointermove", (e) => {
|
|
186
|
+
if (!drag) return;
|
|
187
|
+
if (drag.side === "left") panel.style.width = `${Math.round(Math.min(innerWidth * 0.7, Math.max(260, drag.w + e.clientX - drag.x)))}px`;
|
|
188
|
+
else panel.style.height = `${Math.round(Math.min(innerHeight * 0.8, Math.max(120, drag.h + drag.y - e.clientY)))}px`;
|
|
189
|
+
layout();
|
|
190
|
+
});
|
|
191
|
+
const end = (e) => {
|
|
192
|
+
if (!drag) return;
|
|
193
|
+
const side = drag.side;
|
|
194
|
+
drag = null;
|
|
195
|
+
document.body.style.cursor = "";
|
|
196
|
+
try { handle.releasePointerCapture(e.pointerId); } catch { /* already released */ }
|
|
197
|
+
pref(side === "left" ? "wc:codeW" : "wc:codeH", String(side === "left" ? panel.offsetWidth : panel.offsetHeight));
|
|
198
|
+
};
|
|
199
|
+
handle.addEventListener("pointerup", end);
|
|
200
|
+
handle.addEventListener("pointercancel", end);
|
|
201
|
+
handle.addEventListener("lostpointercapture", end);
|
|
202
|
+
$("codeDock").addEventListener("click", () => setDock(dock === "left" ? "bottom" : "left"));
|
|
203
|
+
setDock(data.codeDock || pref("wc:codeDock") || "bottom", false);
|
|
204
|
+
if (data.codeOpen) setCodePanel(true); // a new component starts with its code open
|
|
205
|
+
})();
|
|
206
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { colorPickerOpener, closeColorPicker, openColorPicker } from "../color-picker.js";
|
|
2
|
+
import { FAMILY_STACKS } from "../float-toolbar.js";
|
|
3
|
+
import { FLOAT_ACTIONS, SIDES4, applyPanel, ftb, renderFloat } from "./frame-handles.js";
|
|
4
|
+
import { $, S } from "./base.js";
|
|
5
|
+
import { insp, liveKey, pickerClosed, setProp, setProps } from "./inspector.js";
|
|
6
|
+
import { canvas, curWidth } from "./plane.js";
|
|
7
|
+
import { scheduleLayout, setFrameSize } from "./designs.js";
|
|
8
|
+
import { renderPagesList } from "./pages-panel.js";
|
|
9
|
+
import { renderLayers, revealLayer } from "./layers.js";
|
|
10
|
+
|
|
11
|
+
// The border colour follows the picker while it is dragged, not only once it closes. Everything from opening the picker to
|
|
12
|
+
// closing it is one undo step, however slowly it is dragged: a "live:" key, merged without the time limit (commit).
|
|
13
|
+
// ── Our colour picker instead of the browser's ───────────────────────────────
|
|
14
|
+
// The browser's picker opens on top of the element being coloured; ours opens beside the selection. A press on any colour
|
|
15
|
+
// field in the bar or the panel opens it and feeds that field as if it were being dragged: "input" while picking, "change" once
|
|
16
|
+
// it closes, so live updates and one undo step per pick work exactly as before. The field is found again each time, since the
|
|
17
|
+
// bar or panel may have been rebuilt meanwhile.
|
|
18
|
+
const COLOR_FIELDS = ["ftcol", "ftp", "propcolor", "ccustom"];
|
|
19
|
+
function fieldSelector(t) {
|
|
20
|
+
const k = COLOR_FIELDS.find((d) => t.dataset[d] !== undefined);
|
|
21
|
+
return k ? `${ftb.contains(t) ? "#ftb" : "#inspector"} input[type="color"][data-${k}="${CSS.escape(t.dataset[k])}"]` : null;
|
|
22
|
+
}
|
|
23
|
+
/** The selected element on screen: the outline drawn around it. */
|
|
24
|
+
function selectionRect() {
|
|
25
|
+
const rs = [...$("sel").children].map((n) => n.getBoundingClientRect()).filter((r) => r.width > 8 && r.height > 8);
|
|
26
|
+
if (!rs.length) return null;
|
|
27
|
+
return { left: Math.min(...rs.map((r) => r.left)), top: Math.min(...rs.map((r) => r.top)), right: Math.max(...rs.map((r) => r.right)), bottom: Math.max(...rs.map((r) => r.bottom)) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const FT_COLOR = { text: "color", fill: "background-color" };
|
|
31
|
+
const toggleDesignsMenu = (e) => { e.stopPropagation(); $("addMenu").classList.add("hidden"); $("designsMenu").classList.toggle("hidden"); };
|
|
32
|
+
|
|
33
|
+
// Layers live in the left panel, opened from the toggle in the tools.
|
|
34
|
+
|
|
35
|
+
export function setLeft(open) {
|
|
36
|
+
S.leftOpen = open;
|
|
37
|
+
$("leftPanel").classList.toggle("hidden", !open);
|
|
38
|
+
$("leftPanel").classList.toggle("flex", open);
|
|
39
|
+
$("leftBtn").classList.toggle("on", open);
|
|
40
|
+
// Back, the toggle and the page menu move into the panel's header while it is open, and back to the floating tools when it closes
|
|
41
|
+
if (open) $("leftHead").appendChild($("navGroup")); else $("pillL").insertBefore($("navGroup"), $("navSep"));
|
|
42
|
+
$("navSep").classList.toggle("hidden", open);
|
|
43
|
+
renderPagesList();
|
|
44
|
+
renderLayers();
|
|
45
|
+
pref("wc:left", open ? "1" : "0");
|
|
46
|
+
if (open) revealLayer(true);
|
|
47
|
+
scheduleLayout();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// side panel: closed until you want every property or the layer tree
|
|
51
|
+
let panelTab = "props";
|
|
52
|
+
export const pref = (k, v) => { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v); } catch { /* storage is optional */ } return null; };
|
|
53
|
+
export function setPanelTab(t) {
|
|
54
|
+
panelTab = t;
|
|
55
|
+
document.querySelectorAll("#panelTabs .ptab").forEach((b) => b.classList.toggle("is-on", b.dataset.ptab === t));
|
|
56
|
+
$("inspector").classList.toggle("hidden", t !== "props");
|
|
57
|
+
revealLayer(true);
|
|
58
|
+
}
|
|
59
|
+
export function setPanel(open) {
|
|
60
|
+
$("panel").classList.toggle("hidden", !open);
|
|
61
|
+
$("panel").classList.toggle("flex", open);
|
|
62
|
+
$("panelBtn").classList.toggle("on", open);
|
|
63
|
+
pref("wc:panel", open ? "1" : "0");
|
|
64
|
+
if (open) revealLayer(true);
|
|
65
|
+
scheduleLayout();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Registers this part's listeners and does its first draw (called once, in order, by editor.js). */
|
|
69
|
+
export function init() {
|
|
70
|
+
document.addEventListener("click", (e) => {
|
|
71
|
+
let t = e.target.closest && e.target.closest('input[type="color"]');
|
|
72
|
+
if (!t || !(ftb.contains(t) || insp.contains(t)) || !S.selected) return;
|
|
73
|
+
const sel = fieldSelector(t);
|
|
74
|
+
if (!sel) return;
|
|
75
|
+
e.preventDefault(); // not the browser's picker
|
|
76
|
+
let opener = t.closest("label") || t;
|
|
77
|
+
if (colorPickerOpener() === opener) return closeColorPicker(); // the same swatch again: done
|
|
78
|
+
// one panel at a time: the bar's padding / radius panel (or its menu) closes when a colour is picked
|
|
79
|
+
if (ftb.contains(t) && (S.ftPop || S.ftMenu)) {
|
|
80
|
+
S.ftPop = null; S.ftMenu = false; renderFloat(true);
|
|
81
|
+
const again = document.querySelector(sel);
|
|
82
|
+
if (!again) return;
|
|
83
|
+
t = again; opener = t.closest("label") || t;
|
|
84
|
+
}
|
|
85
|
+
const feed = (type, hex) => { const f = document.querySelector(sel); if (f) { f.value = hex; f.dispatchEvent(new Event(type, { bubbles: true })); } };
|
|
86
|
+
// from the Properties panel: just left of the panel, level with the swatch; from the floating bar: beside the selection
|
|
87
|
+
const fromPanel = insp.contains(t);
|
|
88
|
+
const p = insp.getBoundingClientRect(), o = opener.getBoundingClientRect();
|
|
89
|
+
const near = fromPanel ? { left: p.left, right: p.right, top: o.top, bottom: o.bottom } : selectionRect();
|
|
90
|
+
const bounds = fromPanel ? { left: 0, top: 0, right: innerWidth, bottom: innerHeight } : canvas.getBoundingClientRect();
|
|
91
|
+
openColorPicker({ value: t.value, near, bounds, opener, onInput: (hex) => feed("input", hex), onClose: (hex) => feed("change", hex) });
|
|
92
|
+
}, true);
|
|
93
|
+
ftb.addEventListener("input", (e) => {
|
|
94
|
+
const t = e.target;
|
|
95
|
+
if (!S.selected) return;
|
|
96
|
+
const label = t.closest(".swatch");
|
|
97
|
+
if (t.dataset.ftcol && FT_COLOR[t.dataset.ftcol]) {
|
|
98
|
+
if (label) { label.style.background = t.value; label.style.backgroundImage = "none"; }
|
|
99
|
+
return setProp(FT_COLOR[t.dataset.ftcol], t.value, undefined, liveKey(`ft-${t.dataset.ftcol}`));
|
|
100
|
+
}
|
|
101
|
+
if (t.dataset.ftp !== "border-color") return;
|
|
102
|
+
const hex = ftb.querySelector('[data-ftp="border-color-hex"]');
|
|
103
|
+
if (label) label.style.background = t.value;
|
|
104
|
+
if (hex) hex.value = t.value.toUpperCase();
|
|
105
|
+
setProps(SIDES4.map((d) => `border-${d}-color`), t.value, "borderColor", [], undefined, liveKey("ft-border"));
|
|
106
|
+
});
|
|
107
|
+
ftb.addEventListener("change", (e) => {
|
|
108
|
+
const t = e.target;
|
|
109
|
+
if (t.dataset.ftin === "fw") return setFrameSize({ w: Number(t.value) || curWidth() });
|
|
110
|
+
if (t.dataset.ftin === "fh") return setFrameSize({ h: t.value.trim() === "" ? null : Number(t.value) || null });
|
|
111
|
+
if (!S.selected) return;
|
|
112
|
+
if (t.dataset.ftsel === "family") return setProp("font-family", t.value, FAMILY_STACKS[t.value]);
|
|
113
|
+
if (t.dataset.ftin === "size") { const n = Number(t.value); return n > 0 ? setProp("font-size", `${n}px`) : renderFloat(); }
|
|
114
|
+
if (t.dataset.ftp === "border-color") { // the picker closed: its last value joins the drag's undo step, and the next pick starts a new one
|
|
115
|
+
if (S.selected) setProps(SIDES4.map((d) => `border-${d}-color`), t.value, "borderColor", [], undefined, liveKey("ft-border"));
|
|
116
|
+
return pickerClosed(t);
|
|
117
|
+
}
|
|
118
|
+
if (t.dataset.ftp) return applyPanel(t.dataset.ftp, t.value);
|
|
119
|
+
if (t.dataset.ftin === "radius") return FLOAT_ACTIONS.radius(Math.max(0, Number(t.value) || 0));
|
|
120
|
+
if (t.dataset.ftcol && FT_COLOR[t.dataset.ftcol]) { setProp(FT_COLOR[t.dataset.ftcol], t.value, undefined, liveKey(`ft-${t.dataset.ftcol}`)); return pickerClosed(t); }
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
document.addEventListener("click", (e) => {
|
|
124
|
+
$("designsMenu").classList.add("hidden");
|
|
125
|
+
if ((S.ftMenu || S.ftPop) && !e.target.closest("#ftb")) { S.ftMenu = false; S.ftPop = null; renderFloat(true); }
|
|
126
|
+
});
|
|
127
|
+
$("designsBtn").addEventListener("click", toggleDesignsMenu);
|
|
128
|
+
$("navMoreBtn").addEventListener("click", toggleDesignsMenu);
|
|
129
|
+
$("designsMenu").addEventListener("click", (e) => e.stopPropagation());
|
|
130
|
+
$("leftBtn").addEventListener("click", () => setLeft(!S.leftOpen));
|
|
131
|
+
$("panelBtn").addEventListener("click", () => setPanel($("panel").classList.contains("hidden")));
|
|
132
|
+
$("panelClose").addEventListener("click", () => setPanel(false));
|
|
133
|
+
$("panelTabs").addEventListener("click", (e) => { const b = e.target.closest("[data-ptab]"); if (b) setPanelTab(b.dataset.ptab); });
|
|
134
|
+
new ResizeObserver(() => scheduleLayout()).observe(canvas);
|
|
135
|
+
}
|