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.
Files changed (60) hide show
  1. package/README.md +49 -2
  2. package/core/board-layout.js +71 -0
  3. package/core/class-groups.js +210 -0
  4. package/core/design-spec.js +303 -0
  5. package/core/exporters.js +156 -0
  6. package/core/pages.js +39 -0
  7. package/core/prop-tailwind.js +158 -0
  8. package/core/split-sections.js +99 -0
  9. package/core/theme.js +87 -0
  10. package/design/agent.js +2118 -0
  11. package/design/classes-section.js +103 -0
  12. package/design/code-editor.js +293 -0
  13. package/design/code-view.js +158 -0
  14. package/design/color-picker.js +107 -0
  15. package/design/config.js +5 -0
  16. package/design/controls.js +132 -0
  17. package/design/css-props.js +111 -0
  18. package/design/design-tab.js +120 -0
  19. package/design/device-frame.js +40 -0
  20. package/design/editor/add-menu.js +151 -0
  21. package/design/editor/api.js +47 -0
  22. package/design/editor/base.js +55 -0
  23. package/design/editor/code-panel.js +206 -0
  24. package/design/editor/color-ui.js +135 -0
  25. package/design/editor/designs.js +606 -0
  26. package/design/editor/docs.js +121 -0
  27. package/design/editor/frame-handles.js +223 -0
  28. package/design/editor/frame-messages.js +195 -0
  29. package/design/editor/inspector.js +461 -0
  30. package/design/editor/layers.js +61 -0
  31. package/design/editor/pages-panel.js +161 -0
  32. package/design/editor/panel-resize.js +47 -0
  33. package/design/editor/plane.js +132 -0
  34. package/design/editor/present.js +52 -0
  35. package/design/editor/toolbar.js +267 -0
  36. package/design/editor.js +47 -0
  37. package/design/figma-sections.js +440 -0
  38. package/design/float-toolbar.js +163 -0
  39. package/design/group-controls.js +52 -0
  40. package/design/host.js +22 -0
  41. package/design/panel-model.js +130 -0
  42. package/design/panel-state.js +40 -0
  43. package/design/panel.js +212 -0
  44. package/design/presenter.js +312 -0
  45. package/design/prop-sections.js +18 -0
  46. package/design/selection-overlay.js +93 -0
  47. package/design/store.js +29 -0
  48. package/design/style-sections.js +315 -0
  49. package/design/tailwind-classes.js +65 -0
  50. package/embed-frame.js +43 -0
  51. package/embed.html +15 -0
  52. package/embed.js +60 -0
  53. package/index.js +62 -0
  54. package/package.json +50 -5
  55. package/playground/component-source.js +124 -0
  56. package/styles.css +402 -0
  57. package/template.js +247 -0
  58. package/vendor/codemirror.js +24 -0
  59. package/vendor/preact-htm.js +1 -0
  60. package/vendor/prettier.js +171 -0
package/embed.js ADDED
@@ -0,0 +1,60 @@
1
+ // embedEditor(container, options): the editor inside a container of your page, isolated in an iframe (its own keys, zoom and styles), with
2
+ // the same object mountEditor() gives back. Several can be on one page. The page that embeds it keeps the host (saving) and, if it likes,
3
+ // the code and the details form.
4
+ //
5
+ // const ed = await embedEditor(el, { host, data: { htmlCode }, ui: { panels: false, nav: false }, onDetails: (fields) => showMyForm(fields) });
6
+ // ed.onChange(({ html, dirty, status, source }) => ...); ed.setHtml(html); ed.save(); await ed.saveDetails(fields); ed.destroy();
7
+ //
8
+ // options.src where the editor's embed.html is served (default: next to this file); it needs the same import map as any editor page
9
+ // (see embed.html). options.tailwind false when the iframe page already has Tailwind.
10
+ const here = (path) => new URL(path, import.meta.url).href;
11
+
12
+ /**
13
+ * @param {HTMLElement} container
14
+ * @param {{ host: import("./design/host.js").Host, data?: object, ui?: object, src?: string, tailwind?: boolean, onDetails?: (fields: object) => void }} options
15
+ */
16
+ export function embedEditor(container, { host, data = {}, ui = {}, src = here("./embed.html"), tailwind = true, onDetails }) {
17
+ const iframe = Object.assign(document.createElement("iframe"), { src, title: "Design editor" });
18
+ iframe.style.cssText = "width:100%;height:100%;border:0;display:block";
19
+ container.append(iframe);
20
+ const listeners = new Set();
21
+ const pending = new Map();
22
+ let seq = 0, html = String(data.htmlCode || ""), dirty = false, status = "", details = null;
23
+ const tell = (msg) => iframe.contentWindow && iframe.contentWindow.postMessage({ wc: "host", ...msg }, "*");
24
+
25
+ let mountedOk;
26
+ const mounted = new Promise((resolve) => { mountedOk = resolve; });
27
+ const onMessage = async (e) => {
28
+ const m = e.data;
29
+ if (e.source !== iframe.contentWindow || !m || m.wc !== "editor") return;
30
+ if (m.t === "ready") tell({ t: "init", data, ui, tailwind, hostMethods: Object.keys(host).filter((k) => typeof host[k] === "function") });
31
+ else if (m.t === "mounted") { html = m.html; details = m.details; }
32
+ else if (m.t === "painted") mountedOk();
33
+ else if (m.t === "change") { ({ html, dirty, status } = m.state); listeners.forEach((fn) => fn(m.state)); }
34
+ else if (m.t === "details") onDetails && onDetails(m.fields);
35
+ else if (m.t === "rpc") {
36
+ try { tell({ t: "rpcResult", id: m.id, ok: true, value: await host[m.method](...m.args) }); }
37
+ catch (err) { tell({ t: "rpcResult", id: m.id, ok: false, error: (err && err.message) || String(err) }); }
38
+ } else if (m.t === "detailsResult") {
39
+ const p = pending.get(m.id);
40
+ pending.delete(m.id);
41
+ if (p) m.ok ? p.resolve() : p.reject(new Error(m.error));
42
+ }
43
+ };
44
+ addEventListener("message", onMessage);
45
+
46
+ return {
47
+ /** Resolves when the editor has opened the design and painted it (show your own loading message until then). */
48
+ ready: mounted,
49
+ getHtml: () => html,
50
+ setHtml: (value) => { html = String(value); tell({ t: "setHtml", html }); },
51
+ onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
52
+ isDirty: () => dirty,
53
+ status: () => status,
54
+ details: () => details,
55
+ save: () => tell({ t: "save" }),
56
+ release: () => tell({ t: "release" }),
57
+ saveDetails: (fields) => new Promise((resolve, reject) => { const id = ++seq; pending.set(id, { resolve, reject }); tell({ t: "saveDetails", id, fields }); }),
58
+ destroy() { removeEventListener("message", onMessage); iframe.remove(); listeners.clear(); },
59
+ };
60
+ }
package/index.js ADDED
@@ -0,0 +1,62 @@
1
+ // @windcraft/editor: the visual editor as a package.
2
+ //
3
+ // import { mountEditor } from "@windcraft/editor";
4
+ // await mountEditor(document.getElementById("root"), { host, data });
5
+ //
6
+ // `host` is what the editor needs from the app around it (design/host.js lists it): saving, projects, the AI and the page address.
7
+ // `data` is the design to open (below). The page must map "@windcraft/core/" and "@windcraft/editor/" to where those packages are
8
+ // served (an import map, or a bundler does it). One editor per page: it takes over the page's size and keyboard.
9
+ import { setHost } from "./design/host.js";
10
+ import { config } from "./design/config.js";
11
+ import { editorHtml } from "./template.js";
12
+
13
+ const here = (path) => new URL(path, import.meta.url).href;
14
+ const TAILWIND = "https://cdn.tailwindcss.com";
15
+ let mounted = false;
16
+
17
+ /** The page itself never zooms or scales: only the canvas does. A pinch, Ctrl/Cmd + scroll and Ctrl/Cmd + = / - / 0 stay out of the browser's zoom. */
18
+ function keepPageUnzoomed() {
19
+ addEventListener("wheel", (e) => { if (e.ctrlKey || e.metaKey) e.preventDefault(); }, { passive: false });
20
+ ["gesturestart", "gesturechange", "gestureend"].forEach((t) => addEventListener(t, (e) => e.preventDefault(), { passive: false }));
21
+ addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && ["=", "+", "-", "_", "0"].includes(e.key)) e.preventDefault(); }, true);
22
+ }
23
+
24
+ const load = (el) => new Promise((resolve, reject) => { el.onload = resolve; el.onerror = () => reject(new Error(`Could not load ${el.src || el.href}`)); document.head.append(el); });
25
+
26
+ /**
27
+ * Opens the editor in `root`.
28
+ * @param {HTMLElement} root
29
+ * @param {object} options
30
+ * @param {import("./design/host.js").Host} options.host
31
+ * @param {object} [options.data] the design to open: { id, title, htmlCode, version, category, description, tags, isPublished, useAsReference, group,
32
+ * project: {id,name}, projects: [{id,name}], categories: [{ group, items: [{ slug, name }] }], plane: [the project's other designs], board }
33
+ * @param {{ href: string, title?: string, label?: string } | null} [options.back] the Back link's target; none hides it
34
+ * @param {{ panels?: boolean, nav?: boolean, tools?: string[] | null, onDetails?: (fields: object) => void }} [options.ui] what of the editor shows:
35
+ * panels: false hides Layers, Properties and Code; nav: false hides Back, the page list and the project; tools: the ids to keep
36
+ * ("move","hand","interact","frame","shape","text","add"; null keeps all); onDetails: the app shows its own details form when a first save needs a name
37
+ * @param {boolean} [options.tailwind] false when the page already loads Tailwind's play CDN (v3); true (default) loads it
38
+ */
39
+ export async function mountEditor(root, { host, data = {}, back = null, tailwind = true, ui = {} }) {
40
+ if (mounted) throw new Error("The editor is already mounted on this page");
41
+ mounted = true;
42
+ setHost(host);
43
+ config.data = { id: null, title: "", version: 0, htmlCode: "", category: "", description: "", tags: [], isPublished: false, useAsReference: true, project: null, projects: [], categories: [], ...data };
44
+ config.ui = { panels: true, nav: true, tools: null, ...ui };
45
+ if (ui.onDetails) config.hooks.details = ui.onDetails;
46
+ keepPageUnzoomed();
47
+ const link = Object.assign(document.createElement("link"), { rel: "stylesheet", href: here("./styles.css") });
48
+ const jobs = [load(link)];
49
+ if (tailwind && !window.tailwind) jobs.push(load(Object.assign(document.createElement("script"), { src: TAILWIND })));
50
+ await Promise.all(jobs);
51
+ if (!root.style.height && root.parentElement === document.body) root.style.height = "100vh"; // an editor that is the page; one in a container takes the container's height
52
+ root.innerHTML = editorHtml;
53
+ const a = root.querySelector("#backLink"), label = root.querySelector("#backLabel");
54
+ if (back && a) { a.href = back.href; a.title = back.title || "Back"; if (label) label.textContent = back.label || "Back"; }
55
+ else if (a) { a.remove(); if (label) label.remove(); }
56
+ root.classList.toggle("wc-no-panels", config.ui.panels === false);
57
+ root.classList.toggle("wc-no-nav", config.ui.nav === false);
58
+ root.classList.toggle("wc-no-add", Boolean(config.ui.tools) && !config.ui.tools.includes("add"));
59
+ await import("./design/editor.js");
60
+ return (await import("./design/editor/api.js")).api;
61
+ }
62
+ export { embedEditor } from "./embed.js";
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
- "name": "windcraft-editor",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "windcraft-editor",
3
+ "version": "0.1.0",
4
+ "description": "Windcraft's visual HTML + Tailwind editor. Mount it in a page and give it a host that loads and saves designs. Includes @windcraft/core.",
5
+ "keywords": [
6
+ "tailwind",
7
+ "tailwindcss",
8
+ "visual-editor",
9
+ "design-tool",
10
+ "html",
11
+ "wysiwyg",
12
+ "windcraft"
13
+ ],
14
+ "type": "module",
15
+ "license": "MIT",
16
+ "author": "Swapnil Jadhav",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/Tolaj/windcraft.git",
20
+ "directory": "packages/editor"
21
+ },
22
+ "homepage": "https://github.com/Tolaj/windcraft#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/Tolaj/windcraft/issues"
25
+ },
26
+ "main": "index.js",
27
+ "exports": {
28
+ ".": "./index.js",
29
+ "./*": "./*"
30
+ },
31
+ "files": [
32
+ "index.js",
33
+ "embed.js",
34
+ "embed.html",
35
+ "embed-frame.js",
36
+ "template.js",
37
+ "styles.css",
38
+ "design",
39
+ "playground",
40
+ "vendor",
41
+ "core",
42
+ "README.md"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "scripts": {
48
+ "prepack": "node scripts/bundle-core.mjs",
49
+ "postpack": "node scripts/bundle-core.mjs clean"
50
+ }
51
+ }
@@ -0,0 +1,124 @@
1
+ import { themeHead } from "@windcraft/core/theme.js";
2
+ const TAILWIND_RE = /tailwindcss|@tailwindcss\/browser/i;
3
+
4
+ const isTailwindScript = (s) => s.src && TAILWIND_RE.test(s.src);
5
+
6
+ /**
7
+ * Splits whatever the user pasted (fragment, full document, with <style>,
8
+ * <link>, <script>, body attributes) into separately usable parts.
9
+ */
10
+ export function parseComponentSource(code) {
11
+ const dom = new DOMParser().parseFromString(code || "", "text/html");
12
+ const hasBodyTag = /<body[\s>]/i.test(code || "");
13
+
14
+ const styles = [];
15
+ const links = [];
16
+ const scripts = [];
17
+
18
+ dom.querySelectorAll("style").forEach((el) => {
19
+ styles.push({ type: el.getAttribute("type") || "", text: el.textContent });
20
+ el.remove();
21
+ });
22
+
23
+ dom.querySelectorAll("link").forEach((el) => {
24
+ const rel = (el.getAttribute("rel") || "").toLowerCase();
25
+ if (rel === "stylesheet" || rel === "preconnect" || rel === "preload") {
26
+ links.push(el.outerHTML);
27
+ }
28
+ el.remove();
29
+ });
30
+
31
+ dom.querySelectorAll("script").forEach((el) => {
32
+ scripts.push({
33
+ src: el.getAttribute("src") || "",
34
+ type: el.getAttribute("type") || "",
35
+ defer: el.hasAttribute("defer"),
36
+ text: el.textContent,
37
+ });
38
+ el.remove();
39
+ });
40
+
41
+ const bodyAttrs = {};
42
+ if (hasBodyTag) {
43
+ Array.from(dom.body.attributes).forEach((a) => (bodyAttrs[a.name] = a.value));
44
+ }
45
+
46
+ return {
47
+ markup: dom.body.innerHTML.trim(),
48
+ styles,
49
+ links,
50
+ scripts,
51
+ bodyAttrs,
52
+ hasTailwindScript: scripts.some(isTailwindScript),
53
+ hasCss: styles.length > 0 || links.length > 0,
54
+ hasJs: scripts.some((s) => !isTailwindScript(s)),
55
+ };
56
+ }
57
+
58
+ const escapeAttr = (v) => String(v).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
59
+ const escapeScript = (t) => t.replace(/<\/script/gi, "<\\/script");
60
+
61
+ const HEIGHT_REPORTER = `
62
+ (function () {
63
+ var last = 0;
64
+ function report() {
65
+ var h = Math.ceil(document.documentElement.getBoundingClientRect().height);
66
+ if (h && h !== last) { last = h; parent.postMessage({ type: "preview-height", height: h }, "*"); }
67
+ }
68
+ new ResizeObserver(report).observe(document.documentElement);
69
+ window.addEventListener("load", report);
70
+ // Esc in the live page goes back to editing (the editor's Interact tool listens for it)
71
+ document.addEventListener("keydown", function (e) { if (e.key === "Escape") parent.postMessage({ type: "key", key: "escape" }, "*"); });
72
+ })();`;
73
+
74
+ // The project's theme for every document built here (set by the page that shows designs; see core/theme.js).
75
+ // The theme's Tailwind config is added only where this adds Tailwind itself: a design that brings its own script keeps its own config.
76
+ let previewTheme = null;
77
+ export const setPreviewTheme = (theme) => { previewTheme = theme || null; };
78
+ /** The head markup for the theme (the font and the config), or nothing without one. */
79
+ export const themeMarkup = () => (previewTheme ? themeHead(previewTheme) : "");
80
+
81
+ /** Builds a complete, standalone document for an iframe's srcdoc. */
82
+ export function buildPreviewDocument(parsed, { width } = {}) {
83
+ const head = [
84
+ '<meta charset="utf-8">',
85
+ `<meta name="viewport" content="width=${width || "device-width"}, initial-scale=1">`,
86
+ ];
87
+
88
+ if (!parsed.hasTailwindScript) {
89
+ head.push('<script src="https://cdn.tailwindcss.com"><\/script>');
90
+ if (previewTheme) head.push(themeMarkup());
91
+ }
92
+ head.push(...parsed.links);
93
+ parsed.styles.forEach((s) => {
94
+ const type = s.type ? ` type="${escapeAttr(s.type)}"` : "";
95
+ head.push(`<style${type}>${s.text.replace(/<\/style/gi, "<\\/style")}</style>`);
96
+ });
97
+
98
+ const bodyAttrs = Object.entries(parsed.bodyAttrs)
99
+ .map(([k, v]) => ` ${k}="${escapeAttr(v)}"`)
100
+ .join("");
101
+
102
+ const scripts = parsed.scripts
103
+ .map((s) => {
104
+ const attrs =
105
+ (s.src ? ` src="${escapeAttr(s.src)}"` : "") +
106
+ (s.type ? ` type="${escapeAttr(s.type)}"` : "") +
107
+ (s.defer ? " defer" : "");
108
+ return `<script${attrs}>${s.src ? "" : escapeScript(s.text)}<\/script>`;
109
+ })
110
+ .join("\n");
111
+
112
+ return `<!DOCTYPE html>
113
+ <html>
114
+ <head>
115
+ ${head.join("\n")}
116
+ <style>html,body{margin:0}</style>
117
+ </head>
118
+ <body${bodyAttrs}>
119
+ ${parsed.markup}
120
+ ${scripts}
121
+ <script>${HEIGHT_REPORTER}<\/script>
122
+ </body>
123
+ </html>`;
124
+ }