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,103 @@
|
|
|
1
|
+
// The Code tab below the element's own code, as components: the element's classes (one box: a chip per class, click one to edit
|
|
2
|
+
// it, × to remove it, type at the end to add), its inline CSS (the style attribute, as property/value rows), and its attributes.
|
|
3
|
+
// They act through `actions`, which the editor provides:
|
|
4
|
+
// setClassesText(text) · setStyle(css | null) · setAttribute(name, value | null)
|
|
5
|
+
import { html, useState, useEffect, useRef } from "../vendor/preact-htm.js";
|
|
6
|
+
import { parseToken } from "@windcraft/core/class-groups.js";
|
|
7
|
+
import { TextInput } from "./controls.js";
|
|
8
|
+
|
|
9
|
+
/** "color: red; margin: 0" -> [["color","red"],["margin","0"]] */
|
|
10
|
+
const parseStyle = (style) => (style || "").split(";").map((d) => d.trim()).filter(Boolean).map((d) => { const i = d.indexOf(":"); return [d.slice(0, i).trim(), d.slice(i + 1).trim()]; });
|
|
11
|
+
/** The style attribute for rows: only rows with both a property and a value count. null when there are none. */
|
|
12
|
+
const cssOf = (rows) => rows.filter(([k, v]) => k.trim() && v.trim()).map(([k, v]) => `${k.trim()}: ${v.trim()}`).join("; ");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A row of two inputs (a property and its value, an attribute and its value). Each commits on Enter or leaving, with the
|
|
16
|
+
* other as last committed; `onCommit({ k, v })`. A row only half filled stays as typed.
|
|
17
|
+
*/
|
|
18
|
+
function PairRow({ k, v, keyHolder, valueHolder, list, onCommit }) {
|
|
19
|
+
const [cells, setCells] = useState({ k, v });
|
|
20
|
+
const last = useRef({ k, v }); // what the row holds right now (two commits can come before a redraw)
|
|
21
|
+
useEffect(() => { last.current = { k, v }; setCells({ k, v }); }, [k, v]); // an edit landed, or another element: show what is there
|
|
22
|
+
const commit = (patch) => {
|
|
23
|
+
const before = last.current, next = { ...before, ...patch };
|
|
24
|
+
last.current = next;
|
|
25
|
+
setCells(next);
|
|
26
|
+
onCommit(next, before);
|
|
27
|
+
};
|
|
28
|
+
return html`<div class="kv">
|
|
29
|
+
<${TextInput} list=${list} value=${cells.k} placeholder=${keyHolder} onCommit=${(text) => commit({ k: text })} />
|
|
30
|
+
<${TextInput} value=${cells.v} placeholder=${valueHolder} onCommit=${(text) => commit({ v: text })} />
|
|
31
|
+
</div>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The element's style attribute as rows, and an empty row to add one. */
|
|
35
|
+
function StyleRows({ style, actions }) {
|
|
36
|
+
const [rows, setRows] = useState(() => parseStyle(style));
|
|
37
|
+
const latest = useRef(rows); // the rows right now (two commits can come before a redraw)
|
|
38
|
+
const show = (next) => { latest.current = next; setRows(next); };
|
|
39
|
+
// the style changed from elsewhere (an undo, another element): show it; one we just saved keeps the rows as they are
|
|
40
|
+
useEffect(() => { const now = parseStyle(style); if (cssOf(latest.current) !== cssOf(now)) show(now); }, [style]);
|
|
41
|
+
const commit = (i, { k, v }) => {
|
|
42
|
+
const next = latest.current.map((row, j) => (j === i ? [k, v] : row));
|
|
43
|
+
if (i >= next.length) next.push([k, v]);
|
|
44
|
+
const kept = next.filter(([a, b]) => a || b);
|
|
45
|
+
show(kept);
|
|
46
|
+
const css = cssOf(kept);
|
|
47
|
+
actions.setStyle(css ? `${css};` : null);
|
|
48
|
+
};
|
|
49
|
+
return [...rows, ["", ""]].map(([k, v], i) => html`<${PairRow} key=${i} k=${k} v=${v} keyHolder="property" valueHolder="value" list="dl-cssprops" onCommit=${(next) => commit(i, next)} />`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The element's attributes (class and style are above), and an empty row to add one. Renaming one removes the old name. */
|
|
53
|
+
function AttrRows({ attrs, actions }) {
|
|
54
|
+
const entries = Object.entries(attrs || {});
|
|
55
|
+
return [...entries, ["", ""]].map(([k, v], i) => html`<${PairRow} key=${i} k=${k} v=${v} keyHolder="attribute" valueHolder="value"
|
|
56
|
+
onCommit=${(next, old) => {
|
|
57
|
+
if (old.k && old.k !== next.k.trim()) actions.setAttribute(old.k, null);
|
|
58
|
+
if (next.k.trim()) actions.setAttribute(next.k.trim(), next.v);
|
|
59
|
+
}} />`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The element's classes in one box, like a tag input: each class a chip (a md: / hover: one tinted), × removes it, a click
|
|
64
|
+
* turns it into text to change (Enter or leaving saves; emptied, it goes), and typing at the end adds classes (Enter, space or
|
|
65
|
+
* a paste of several). Backspace in the empty end takes the last chip back into the text to change it. Every change saves
|
|
66
|
+
* the whole list at once (setClassesText), so the classes keep their order.
|
|
67
|
+
*/
|
|
68
|
+
function ClassBox({ classes, actions }) {
|
|
69
|
+
const toks = String(classes || "").split(/\s+/).filter(Boolean);
|
|
70
|
+
const [editing, setEditing] = useState(/** @type {number | null} */ (null));
|
|
71
|
+
const [draft, setDraft] = useState("");
|
|
72
|
+
const end = useRef(/** @type {HTMLInputElement | null} */ (null));
|
|
73
|
+
const save = (list) => actions.setClassesText([...new Set(list.filter(Boolean))].join(" "));
|
|
74
|
+
const add = (text) => { const more = text.split(/[\s,]+/).filter(Boolean); if (more.length) save([...toks, ...more]); setDraft(""); };
|
|
75
|
+
const replace = (i, text) => { setEditing(null); const parts = text.split(/\s+/).filter(Boolean); if (parts.join(" ") !== toks[i]) save([...toks.slice(0, i), ...parts, ...toks.slice(i + 1)]); };
|
|
76
|
+
const keyEnd = (e) => {
|
|
77
|
+
if (e.key === "Enter" || e.key === " " || e.key === ",") { if (draft.trim()) { e.preventDefault(); add(draft); } else if (e.key !== "Enter") e.preventDefault(); }
|
|
78
|
+
else if (e.key === "Backspace" && !draft && toks.length) { e.preventDefault(); setDraft(toks[toks.length - 1]); save(toks.slice(0, -1)); }
|
|
79
|
+
else if (e.key === "Escape") { setDraft(""); e.target.blur(); }
|
|
80
|
+
};
|
|
81
|
+
return html`<div class="cbox" onMouseDown=${(e) => { if (e.target === e.currentTarget) { e.preventDefault(); end.current && end.current.focus(); } }}>
|
|
82
|
+
${toks.map((t, i) => (editing === i
|
|
83
|
+
? html`<input class="cbedit" value=${t} size=${Math.max(4, t.length)} autofocus spellcheck=${false}
|
|
84
|
+
ref=${(el) => el && document.activeElement !== el && el.focus()}
|
|
85
|
+
onKeyDown=${(e) => { if (e.key === "Enter") { e.preventDefault(); replace(i, e.target.value); } else if (e.key === "Escape") setEditing(null); }}
|
|
86
|
+
onBlur=${(e) => replace(i, e.target.value)} />`
|
|
87
|
+
: html`<span class=${`chip ${parseToken(t).variant ? "var" : ""}`} title="Click to change it" onClick=${() => setEditing(i)}>${t}
|
|
88
|
+
<button type="button" title=${`Remove ${t}`} onMouseDown=${(e) => e.preventDefault()} onClick=${(e) => { e.stopPropagation(); save(toks.filter((_, j) => j !== i)); }}>×</button></span>`))}
|
|
89
|
+
<input ref=${end} class="cbin" value=${draft} spellcheck=${false} placeholder=${toks.length ? "Add a class…" : "Add classes (hover:bg-red-500)…"}
|
|
90
|
+
onInput=${(e) => setDraft(e.target.value)} onKeyDown=${keyEnd} onBlur=${() => draft.trim() && add(draft)}
|
|
91
|
+
onPaste=${(e) => { const t = e.clipboardData.getData("text"); if (/\s/.test(t.trim())) { e.preventDefault(); add(draft + " " + t); } }} />
|
|
92
|
+
</div>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function ClassesSection({ s, actions }) {
|
|
96
|
+
const n = s.classes.split(/\s+/).filter(Boolean).length;
|
|
97
|
+
return html`
|
|
98
|
+
<div class="sec"><div class="secb nopad"><p class="sub first">Classes <span class="muted">${n}</span></p>
|
|
99
|
+
<${ClassBox} classes=${s.classes} actions=${actions} />
|
|
100
|
+
</div></div>
|
|
101
|
+
<div class="sec"><div class="secb nopad"><p class="sub first">Inline CSS <span class="muted">style attribute</span></p><${StyleRows} style=${s.style} actions=${actions} /></div></div>
|
|
102
|
+
<div class="sec"><div class="secb nopad"><p class="sub first">Attributes</p><${AttrRows} attrs=${s.attrs} actions=${actions} /></div></div>`;
|
|
103
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// The code editor: CodeMirror 6 set up for HTML with Tailwind. Line numbers, undo/redo, auto-closing tags and brackets, auto-indent,
|
|
2
|
+
// Tab / Shift+Tab to indent a block, Cmd/Ctrl+/ to comment, find and replace (Cmd/Ctrl+F), code folding, highlighting for HTML, CSS and
|
|
3
|
+
// JavaScript, tag/attribute completion, Tailwind class completion inside class="…", and squiggles under what doesn't parse.
|
|
4
|
+
// CodeMirror itself is loaded the first time an editor is made (vendor/codemirror.js).
|
|
5
|
+
//
|
|
6
|
+
// const ed = await createCodeEditor(el, { value, onChange(text), problems(text) -> [{ from, to, message }], onSave(), wrap, placeholderText });
|
|
7
|
+
// ed.getValue() ed.setValue(text) ed.focus() ed.hasFocus() ed.setMarks([[from, to, "sel"]]) ed.reveal(index) ed.select(from, to)
|
|
8
|
+
// ed.format() -> { ok, changed, message } (Prettier: HTML with its <style> and <script>; also Shift+Alt+F, and with formatOnPaste the first paste into an empty editor)
|
|
9
|
+
// ed.lineCount() ed.openSearch() ed.setProblems(list) ed.destroy()
|
|
10
|
+
import { tailwindClasses } from "./tailwind-classes.js";
|
|
11
|
+
import { checkTags } from "./code-view.js";
|
|
12
|
+
|
|
13
|
+
let cmPromise = null;
|
|
14
|
+
const load = () => (cmPromise ||= import("../vendor/codemirror.js"));
|
|
15
|
+
|
|
16
|
+
/** Completion for class names: while the cursor is inside class="…", offers Tailwind's (with variants such as md:hover:). */
|
|
17
|
+
function classCompletion(context) {
|
|
18
|
+
const { state, pos } = context;
|
|
19
|
+
const from = Math.max(0, pos - 1500);
|
|
20
|
+
const before = state.doc.sliceString(from, pos);
|
|
21
|
+
const lt = before.lastIndexOf("<");
|
|
22
|
+
const tag = before.slice(lt < 0 ? 0 : lt);
|
|
23
|
+
const m = /\sclass\s*=\s*(["'])([^"']*)$/.exec(tag);
|
|
24
|
+
if (!m) return null;
|
|
25
|
+
const token = /[^\s"']*$/.exec(m[2])[0];
|
|
26
|
+
if (!token && !context.explicit) return null;
|
|
27
|
+
const start = pos - token.length;
|
|
28
|
+
const colon = token.lastIndexOf(":");
|
|
29
|
+
const prefix = colon >= 0 ? token.slice(0, colon + 1) : "";
|
|
30
|
+
const rest = token.slice(prefix.length);
|
|
31
|
+
const all = tailwindClasses();
|
|
32
|
+
const options = prefix
|
|
33
|
+
? all.filter((c) => !c.endsWith(":")).map((c) => ({ label: prefix + c, type: "class" }))
|
|
34
|
+
: all.map((c) => ({ label: c, type: c.endsWith(":") ? "keyword" : "class", boost: c.endsWith(":") ? -1 : 0 }));
|
|
35
|
+
if (!rest && !prefix && !context.explicit) return null;
|
|
36
|
+
return { from: start, options, validFor: /^[\w:\-/.\[\]%#!]*$/ };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ICONS = {
|
|
40
|
+
down: '<path d="M8 3v9.5M4 8.5l4 4 4-4"/>', up: '<path d="M8 13V3.5M4 7.5l4-4 4 4"/>', lines: '<path d="M3 4h10M3 8h10M3 12h10"/>',
|
|
41
|
+
close: '<path d="M4 4l8 8M12 4l-8 8"/>', right: '<path d="M6 3.5l4.5 4.5L6 12.5"/>', openDown: '<path d="M3.5 6l4.5 4.5L12.5 6"/>',
|
|
42
|
+
replace: '<path d="M2.5 4.5h7M2.5 8h4M9 6.5l2.5 1.5L9 9.5M2.5 11.5h7"/>', replaceAll: '<path d="M2.5 3.5h6M2.5 6.5h4M2.5 9.5h6M2.5 12.5h4M9.5 8l2.5 1.5L9.5 11"/>',
|
|
43
|
+
};
|
|
44
|
+
const icon = (name) => `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`;
|
|
45
|
+
/** The fold gutter's markers: a chevron pointing down for an open block, right for a folded one (VS Code's). */
|
|
46
|
+
function foldMarker(open) {
|
|
47
|
+
const el = document.createElement("span");
|
|
48
|
+
el.className = `wc-fold${open ? "" : " shut"}`;
|
|
49
|
+
el.innerHTML = icon(open ? "openDown" : "right");
|
|
50
|
+
el.title = open ? "Fold" : "Unfold";
|
|
51
|
+
return el;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Find and replace as VS Code lays it out: Find with its Aa / .* / ab toggles inside the field and "n of N" beside it, previous / next /
|
|
55
|
+
* select all, close; the chevron on the left opens the Replace row. */
|
|
56
|
+
function searchPanel(view, cm) {
|
|
57
|
+
const { SearchQuery, setSearchQuery, getSearchQuery } = cm;
|
|
58
|
+
const dom = document.createElement("div");
|
|
59
|
+
dom.className = "wcs";
|
|
60
|
+
dom.innerHTML = `<button type="button" class="wcs-toggle" title="Toggle replace" aria-label="Toggle replace">${icon("right")}</button>
|
|
61
|
+
<div class="wcs-rows">
|
|
62
|
+
<div class="wcs-row">
|
|
63
|
+
<div class="wcs-field find"><input name="search" placeholder="Find" aria-label="Find" main-field="true" autocomplete="off" spellcheck="false">
|
|
64
|
+
<span class="wcs-opts"><button type="button" class="wcs-btn" data-opt="caseSensitive" title="Match case">Aa</button><button type="button" class="wcs-btn" data-opt="wholeWord" title="Match whole word"><u>ab</u></button><button type="button" class="wcs-btn" data-opt="regexp" title="Use regular expression">.*</button></span></div>
|
|
65
|
+
<span class="wcs-count"></span>
|
|
66
|
+
<button type="button" class="wcs-btn" data-act="prev" title="Previous match (Shift+Enter)">${icon("up")}</button>
|
|
67
|
+
<button type="button" class="wcs-btn" data-act="next" title="Next match (Enter)">${icon("down")}</button>
|
|
68
|
+
<button type="button" class="wcs-btn" data-act="all" title="Select all matches">${icon("lines")}</button>
|
|
69
|
+
<button type="button" class="wcs-btn" data-act="close" title="Close (Escape)">${icon("close")}</button>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="wcs-row" data-replace hidden>
|
|
72
|
+
<div class="wcs-field"><input name="replace" placeholder="Replace" aria-label="Replace" autocomplete="off" spellcheck="false"></div>
|
|
73
|
+
<button type="button" class="wcs-btn" data-act="replace" title="Replace (Enter)">${icon("replace")}</button>
|
|
74
|
+
<button type="button" class="wcs-btn" data-act="replaceAll" title="Replace all">${icon("replaceAll")}</button>
|
|
75
|
+
</div>
|
|
76
|
+
</div>`;
|
|
77
|
+
const $ = (sel) => dom.querySelector(sel);
|
|
78
|
+
const find = $("input[name=search]"), rep = $("input[name=replace]"), count = $(".wcs-count"), toggle = $(".wcs-toggle"), repRow = $("[data-replace]");
|
|
79
|
+
const opts = { caseSensitive: false, wholeWord: false, regexp: false };
|
|
80
|
+
const queryOf = () => new SearchQuery({ search: find.value, replace: rep.value, ...opts });
|
|
81
|
+
const commit = () => { const q = queryOf(); if (!q.eq(getSearchQuery(view.state))) view.dispatch({ effects: setSearchQuery.of(q) }); };
|
|
82
|
+
const paint = () => {
|
|
83
|
+
const q = getSearchQuery(view.state);
|
|
84
|
+
dom.querySelectorAll("[data-opt]").forEach((b) => b.classList.toggle("on", q[b.dataset.opt]));
|
|
85
|
+
let n = 0, at = 0;
|
|
86
|
+
if (q.search && q.valid) {
|
|
87
|
+
const sel = view.state.selection.main.from;
|
|
88
|
+
for (const cur = q.getCursor(view.state); n < 5000; ) { const m = cur.next(); if (m.done) break; n++; if (!at && m.value.from >= sel) at = n; }
|
|
89
|
+
if (!at && n) at = 1;
|
|
90
|
+
}
|
|
91
|
+
count.textContent = n ? `${at} of ${n}${n >= 5000 ? "+" : ""}` : "No results"; // (as in VS Code, also while the field is empty)
|
|
92
|
+
count.classList.toggle("bad", Boolean(q.search) && !n);
|
|
93
|
+
$(".wcs-field.find").classList.toggle("bad", Boolean(q.search) && !n);
|
|
94
|
+
};
|
|
95
|
+
const showReplace = (on) => { repRow.hidden = !on; toggle.innerHTML = icon(on ? "openDown" : "right"); };
|
|
96
|
+
find.addEventListener("input", () => { commit(); });
|
|
97
|
+
rep.addEventListener("input", commit);
|
|
98
|
+
find.addEventListener("keydown", (e) => {
|
|
99
|
+
if (e.key === "Enter") { e.preventDefault(); (e.shiftKey ? cm.findPrevious : cm.findNext)(view); }
|
|
100
|
+
else if (e.key === "Escape") { e.preventDefault(); cm.closeSearchPanel(view); view.focus(); }
|
|
101
|
+
});
|
|
102
|
+
rep.addEventListener("keydown", (e) => {
|
|
103
|
+
if (e.key === "Enter") { e.preventDefault(); (e.ctrlKey || e.metaKey ? cm.replaceAll : cm.replaceNext)(view); }
|
|
104
|
+
else if (e.key === "Escape") { e.preventDefault(); cm.closeSearchPanel(view); view.focus(); }
|
|
105
|
+
});
|
|
106
|
+
toggle.addEventListener("click", () => showReplace(repRow.hidden));
|
|
107
|
+
dom.addEventListener("click", (e) => {
|
|
108
|
+
const o = e.target.closest("[data-opt]"), a = e.target.closest("[data-act]");
|
|
109
|
+
if (o) { opts[o.dataset.opt] = !opts[o.dataset.opt]; commit(); find.focus(); return; }
|
|
110
|
+
if (!a) return;
|
|
111
|
+
const run = { next: cm.findNext, prev: cm.findPrevious, all: cm.selectMatches, replace: cm.replaceNext, replaceAll: cm.replaceAll, close: cm.closeSearchPanel }[a.dataset.act];
|
|
112
|
+
run(view);
|
|
113
|
+
if (a.dataset.act === "close") view.focus();
|
|
114
|
+
});
|
|
115
|
+
const sync = () => {
|
|
116
|
+
const q = getSearchQuery(view.state);
|
|
117
|
+
if (q.search !== find.value) find.value = q.search;
|
|
118
|
+
if (q.replace !== rep.value) rep.value = q.replace;
|
|
119
|
+
Object.assign(opts, { caseSensitive: q.caseSensitive, wholeWord: q.wholeWord, regexp: q.regexp });
|
|
120
|
+
paint();
|
|
121
|
+
};
|
|
122
|
+
sync();
|
|
123
|
+
return {
|
|
124
|
+
dom, top: true,
|
|
125
|
+
mount() { find.select(); },
|
|
126
|
+
update(u) { if (u.docChanged || u.selectionSet || u.transactions.some((t) => t.effects.some((e) => e.is(setSearchQuery)))) sync(); },
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Where the tags first stop matching up, as a range in the code (for the squiggle), or none. */
|
|
131
|
+
export function tagProblems(text) {
|
|
132
|
+
const bad = checkTags(text);
|
|
133
|
+
if (!bad) return [];
|
|
134
|
+
const lines = text.split("\n");
|
|
135
|
+
const from = lines.slice(0, bad.line - 1).reduce((s, l) => s + l.length + 1, 0);
|
|
136
|
+
const line = lines[bad.line - 1] || "";
|
|
137
|
+
const lead = line.length - line.trimStart().length;
|
|
138
|
+
return [{ from: from + lead, to: from + Math.max(line.length, lead + 1), message: bad.message }];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function createCodeEditor(parent, { value = "", onChange, problems, onSave, placeholderText, wrap = false, formatOnPaste = false } = {}) {
|
|
142
|
+
const cm = await load();
|
|
143
|
+
const { EditorState, EditorView, Decoration, StateEffect, StateField, keymap } = cm;
|
|
144
|
+
let quiet = 0;
|
|
145
|
+
|
|
146
|
+
const setMarksEffect = StateEffect.define();
|
|
147
|
+
// the selected element's source, shaded (its opening tag darker): set from outside with setMarks()
|
|
148
|
+
const marksField = StateField.define({
|
|
149
|
+
create: () => Decoration.none,
|
|
150
|
+
update(deco, tr) {
|
|
151
|
+
deco = deco.map(tr.changes);
|
|
152
|
+
for (const e of tr.effects) if (e.is(setMarksEffect)) {
|
|
153
|
+
const len = tr.newDoc.length;
|
|
154
|
+
deco = Decoration.set(e.value.filter(([a, b]) => a < b && b <= len).map(([a, b, cls]) => Decoration.mark({ class: `cm-${cls}` }).range(a, b)), true);
|
|
155
|
+
}
|
|
156
|
+
return deco;
|
|
157
|
+
},
|
|
158
|
+
provide: (f) => EditorView.decorations.from(f),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const { tags: t } = cm;
|
|
162
|
+
const style = cm.HighlightStyle.define([
|
|
163
|
+
{ tag: t.tagName, color: "#116329" }, { tag: t.angleBracket, color: "#6e7781" }, { tag: t.attributeName, color: "#8250df" },
|
|
164
|
+
{ tag: [t.attributeValue, t.string], color: "#0a3069" }, { tag: [t.comment, t.blockComment, t.lineComment], color: "#6e7781", fontStyle: "italic" },
|
|
165
|
+
{ tag: [t.keyword, t.controlKeyword, t.definitionKeyword, t.moduleKeyword], color: "#cf222e" }, { tag: [t.number, t.bool, t.null], color: "#0550ae" },
|
|
166
|
+
{ tag: [t.propertyName, t.className], color: "#0550ae" }, { tag: [t.function(t.variableName), t.function(t.propertyName)], color: "#8250df" },
|
|
167
|
+
{ tag: t.regexp, color: "#116329" }, { tag: t.invalid, color: "#cf222e" },
|
|
168
|
+
]);
|
|
169
|
+
const theme = EditorView.theme({
|
|
170
|
+
"&": { height: "100%", fontSize: "12px", backgroundColor: "#fff", color: "#24292f" },
|
|
171
|
+
"&.cm-focused": { outline: "none" },
|
|
172
|
+
".cm-scroller": { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", lineHeight: "20px", overflow: "auto" },
|
|
173
|
+
".cm-content": { padding: "12px 0", caretColor: "#111" },
|
|
174
|
+
".cm-gutters": { backgroundColor: "#fff", color: "#9a9a9a", border: "none" },
|
|
175
|
+
".cm-activeLineGutter": { backgroundColor: "transparent", color: "#222" },
|
|
176
|
+
".cm-activeLine": { backgroundColor: "rgba(0,0,0,.025)" },
|
|
177
|
+
".cm-lineNumbers .cm-gutterElement": { padding: "0 6px 0 14px", minWidth: "34px", fontSize: "12.5px" },
|
|
178
|
+
".cm-foldGutter .cm-gutterElement": { cursor: "pointer", padding: "0 2px", display: "flex", alignItems: "center" },
|
|
179
|
+
".wc-fold": { display: "flex", alignItems: "center", justifyContent: "center", width: "16px", height: "20px", color: "#8a8a8a", opacity: 0, transition: "opacity .12s" },
|
|
180
|
+
".wc-fold.shut": { opacity: 1 },
|
|
181
|
+
".wc-fold svg": { width: "15px", height: "15px" },
|
|
182
|
+
".cm-gutters:hover .wc-fold": { opacity: 1 },
|
|
183
|
+
".cm-foldGutter .cm-gutterElement:hover .wc-fold": { color: "#111" },
|
|
184
|
+
".cm-foldPlaceholder": { backgroundColor: "#eee", border: "1px solid #ddd", color: "#666", borderRadius: "3px", padding: "0 5px", margin: "0 2px" },
|
|
185
|
+
".cm-selectionBackground, &.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": { backgroundColor: "rgba(10,132,224,.22) !important" },
|
|
186
|
+
".cm-sel": { backgroundColor: "rgba(10,132,224,.07)" },
|
|
187
|
+
".cm-selt": { backgroundColor: "rgba(10,132,224,.16)" },
|
|
188
|
+
".cm-matchingBracket": { backgroundColor: "rgba(10,132,224,.18)", outline: "none" },
|
|
189
|
+
".cm-tooltip": { border: "1px solid #e5e5e5", borderRadius: "8px", backgroundColor: "#fff", boxShadow: "0 6px 20px rgba(0,0,0,.12)", overflow: "hidden" },
|
|
190
|
+
".cm-tooltip-autocomplete > ul": { fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", fontSize: "12px", maxHeight: "220px" },
|
|
191
|
+
".cm-tooltip-autocomplete ul li[aria-selected]": { backgroundColor: "#111", color: "#fff" },
|
|
192
|
+
// find and replace, laid out like VS Code's: a small widget in the corner of the editor
|
|
193
|
+
".cm-panels": { position: "absolute", top: 0, right: 0, left: "auto", background: "transparent", border: "none", zIndex: 10, pointerEvents: "none", fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif" },
|
|
194
|
+
".cm-panels-top": { borderBottom: "none" },
|
|
195
|
+
".wcs": { pointerEvents: "auto", display: "flex", alignItems: "flex-start", gap: "2px", margin: "0 14px 0 0", padding: "4px 6px 4px 2px", background: "#fff", border: "1px solid #e6e6e6", borderTop: "none", borderRadius: "0 0 8px 8px", boxShadow: "0 4px 14px rgba(0,0,0,.08)", fontSize: "13px", color: "#3b3b3b" },
|
|
196
|
+
".wcs-toggle": { alignSelf: "stretch", width: "16px", display: "flex", alignItems: "center", justifyContent: "center", border: "none", background: "transparent", color: "#9a9a9a", borderRadius: "3px", cursor: "pointer", padding: 0, outline: "none" },
|
|
197
|
+
".wcs-toggle svg": { width: "12px", height: "12px" },
|
|
198
|
+
".wcs-toggle:focus-visible": { boxShadow: "inset 0 0 0 1px #0090f1" },
|
|
199
|
+
".wcs-toggle:hover": { background: "#f0f0f0", color: "#555" },
|
|
200
|
+
".wcs-row[hidden]": { display: "none" },
|
|
201
|
+
".wcs-rows": { display: "flex", flexDirection: "column", gap: "4px" },
|
|
202
|
+
".wcs-row": { display: "flex", alignItems: "center", gap: "2px", height: "24px" },
|
|
203
|
+
".wcs-field": { position: "relative", width: "216px", height: "24px", display: "flex", alignItems: "center" },
|
|
204
|
+
".wcs-field input": { width: "100%", height: "24px", boxSizing: "border-box", border: "1px solid transparent", borderRadius: "2px", background: "#fff", color: "#222", fontSize: "13px", padding: "0 6px", outline: "1px solid #dcdcdc", outlineOffset: "-1px", fontFamily: "inherit" },
|
|
205
|
+
".wcs-field.find input": { paddingRight: "76px" },
|
|
206
|
+
".wcs-field input:focus": { outline: "1px solid #0090f1" },
|
|
207
|
+
".wcs-field.bad input": { outline: "1px solid #d1242f" },
|
|
208
|
+
".wcs-opts": { position: "absolute", right: "2px", top: "1px", display: "flex", gap: "1px" },
|
|
209
|
+
".wcs-btn": { width: "22px", height: "22px", display: "inline-flex", alignItems: "center", justifyContent: "center", border: "none", borderRadius: "3px", background: "transparent", color: "#8a8a8a", cursor: "pointer", padding: 0, fontSize: "11.5px", fontWeight: 600, outline: "none", fontFamily: "inherit" },
|
|
210
|
+
".wcs-btn:hover": { background: "#f0f0f0", color: "#444" },
|
|
211
|
+
".wcs-btn:focus-visible": { boxShadow: "inset 0 0 0 1px #0090f1" },
|
|
212
|
+
".wcs-btn.on": { background: "#bfdcf7", boxShadow: "inset 0 0 0 1px #0090f1", color: "#0b4f8a" },
|
|
213
|
+
".wcs-btn svg": { width: "14px", height: "14px" },
|
|
214
|
+
".wcs-count": { minWidth: "70px", padding: "0 6px", textAlign: "center", fontSize: "12px", color: "#616161", whiteSpace: "nowrap" },
|
|
215
|
+
".wcs-count.bad": { color: "#d1242f" },
|
|
216
|
+
".wcs-sep": { width: "1px", height: "16px", background: "#d4d4d4", margin: "0 3px" },
|
|
217
|
+
".cm-diagnostic": { fontFamily: "system-ui, sans-serif", fontSize: "12px", padding: "4px 8px" },
|
|
218
|
+
".cm-lintRange-error": { backgroundImage: "none", textDecoration: "underline wavy #d1242f", textUnderlineOffset: "3px" },
|
|
219
|
+
}, { dark: false });
|
|
220
|
+
|
|
221
|
+
const htmlSupport = cm.html({ matchClosingTags: true, autoCloseTags: true, selfClosingTags: true });
|
|
222
|
+
const extensions = [
|
|
223
|
+
cm.lineNumbers(), cm.highlightActiveLineGutter(), cm.foldGutter({ markerDOM: foldMarker }), cm.history(), cm.drawSelection(), cm.dropCursor(),
|
|
224
|
+
EditorState.allowMultipleSelections.of(true), cm.indentOnInput(), cm.indentUnit.of(" "), EditorState.tabSize.of(2),
|
|
225
|
+
cm.bracketMatching(), cm.closeBrackets(), cm.rectangularSelection(), cm.crosshairCursor(), cm.highlightActiveLine(), cm.highlightSelectionMatches(),
|
|
226
|
+
cm.search({ top: true, createPanel: (view) => searchPanel(view, cm) }), cm.syntaxHighlighting(style), htmlSupport, htmlSupport.language.data.of({ autocomplete: classCompletion }),
|
|
227
|
+
cm.autocompletion({ activateOnTyping: true, icons: false, maxRenderedOptions: 60 }),
|
|
228
|
+
keymap.of([
|
|
229
|
+
...(onSave ? [{ key: "Mod-s", preventDefault: true, run: () => { onSave(); return true; } }] : []),
|
|
230
|
+
{ key: "Mod-/", run: cm.toggleComment }, ...cm.closeBracketsKeymap, ...cm.defaultKeymap, ...cm.searchKeymap, ...cm.historyKeymap,
|
|
231
|
+
...cm.foldKeymap, ...cm.completionKeymap, ...cm.lintKeymap, cm.indentWithTab,
|
|
232
|
+
]),
|
|
233
|
+
...(problems ? [cm.linter((view) => problems(view.state.doc.toString()).map((p) => ({ from: p.from, to: p.to, severity: "error", message: p.message })), { delay: 350 })] : []),
|
|
234
|
+
...(wrap ? [EditorView.lineWrapping] : []),
|
|
235
|
+
...(placeholderText ? [cm.placeholder(placeholderText)] : []),
|
|
236
|
+
marksField, theme,
|
|
237
|
+
EditorView.updateListener.of((u) => { if (u.docChanged && !quiet && onChange) onChange(u.state.doc.toString()); }),
|
|
238
|
+
];
|
|
239
|
+
let view = null;
|
|
240
|
+
/** Tidies the whole text with Prettier, as one undo step; the cursor stays about where it was. */
|
|
241
|
+
async function format() {
|
|
242
|
+
const text = view.state.doc.toString();
|
|
243
|
+
if (!text.trim()) return { ok: true, changed: false };
|
|
244
|
+
let out;
|
|
245
|
+
try {
|
|
246
|
+
const { formatSource } = await import("../vendor/prettier.js");
|
|
247
|
+
out = await formatSource(text);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
return { ok: false, changed: false, message: String(err && err.message ? err.message : err).split("\n")[0] };
|
|
250
|
+
}
|
|
251
|
+
if (view.state.doc.toString() !== text) return { ok: false, changed: false, message: "The code changed while formatting" };
|
|
252
|
+
if (out === text) return { ok: true, changed: false };
|
|
253
|
+
let a = 0;
|
|
254
|
+
while (a < text.length && a < out.length && text[a] === out[a]) a++;
|
|
255
|
+
let b = 0;
|
|
256
|
+
while (b < text.length - a && b < out.length - a && text[text.length - 1 - b] === out[out.length - 1 - b]) b++;
|
|
257
|
+
view.dispatch({ changes: { from: a, to: text.length - b, insert: out.slice(a, out.length - b) }, userEvent: "input.format" });
|
|
258
|
+
return { ok: true, changed: true };
|
|
259
|
+
}
|
|
260
|
+
extensions.push(
|
|
261
|
+
keymap.of([{ key: "Shift-Alt-f", preventDefault: true, run: () => { format(); return true; } }]),
|
|
262
|
+
...(formatOnPaste ? [EditorView.domEventHandlers({ paste(_e, v) { if (v.state.doc.length === 0) setTimeout(() => format(), 0); return false; } })] : []),
|
|
263
|
+
);
|
|
264
|
+
view = new EditorView({ state: EditorState.create({ doc: value, extensions }), parent });
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
view,
|
|
268
|
+
getValue: () => view.state.doc.toString(),
|
|
269
|
+
/** Replaces the text (not reported through onChange), keeping the scroll position and the cursor where it can. */
|
|
270
|
+
setValue(text) {
|
|
271
|
+
const cur = view.state.doc.toString();
|
|
272
|
+
if (text === cur) return;
|
|
273
|
+
let a = 0;
|
|
274
|
+
while (a < cur.length && a < text.length && cur[a] === text[a]) a++;
|
|
275
|
+
let b = 0;
|
|
276
|
+
while (b < cur.length - a && b < text.length - a && cur[cur.length - 1 - b] === text[text.length - 1 - b]) b++;
|
|
277
|
+
quiet++;
|
|
278
|
+
try { view.dispatch({ changes: { from: a, to: cur.length - b, insert: text.slice(a, text.length - b) } }); } finally { quiet--; }
|
|
279
|
+
},
|
|
280
|
+
format,
|
|
281
|
+
focus: () => view.focus(),
|
|
282
|
+
hasFocus: () => view.hasFocus,
|
|
283
|
+
lineCount: () => view.state.doc.lines,
|
|
284
|
+
setMarks: (marks) => view.dispatch({ effects: setMarksEffect.of(marks) }),
|
|
285
|
+
/** Scrolls so the character at `index` is in view (centred when it was out of sight). */
|
|
286
|
+
reveal: (index) => view.dispatch({ effects: EditorView.scrollIntoView(Math.min(index, view.state.doc.length), { y: "center" }) }),
|
|
287
|
+
select(from, to) { view.dispatch({ selection: { anchor: from, head: to }, effects: EditorView.scrollIntoView(from, { y: "center" }) }); view.focus(); },
|
|
288
|
+
lineRange(line) { const l = view.state.doc.line(Math.min(Math.max(1, line), view.state.doc.lines)); return [l.from, l.to]; },
|
|
289
|
+
openSearch: () => { view.focus(); cm.openSearchPanel(view); },
|
|
290
|
+
setProblems: (list) => view.dispatch(cm.setDiagnostics(view.state, list.map((p) => ({ from: p.from, to: p.to, severity: "error", message: p.message })))),
|
|
291
|
+
destroy: () => view.destroy(),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// What the editor's code boxes need beyond a textarea: syntax colours (painted on a layer behind the see-through
|
|
3
|
+
// textarea), a check for tags that don't match up, and tidy indentation on request. Used by the code panel and by the
|
|
4
|
+
// Code tab's element code (editor.js).
|
|
5
|
+
|
|
6
|
+
const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
|
|
7
|
+
/** Tags whose closing tag HTML lets you leave out (the browser closes them itself). */
|
|
8
|
+
const OPTIONAL_CLOSE = new Set(["p", "li", "dt", "dd", "option", "optgroup", "tr", "td", "th", "thead", "tbody", "tfoot", "colgroup", "rt", "rp", "html", "head", "body"]);
|
|
9
|
+
/** Tags whose content is text, not markup. */
|
|
10
|
+
const RAW = new Set(["script", "style", "textarea", "title"]);
|
|
11
|
+
|
|
12
|
+
const esc = (t) => t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
13
|
+
const TAG_RE = /<(\/?)([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)(>?)/y;
|
|
14
|
+
const ATTR_RE = /([^\s=/>]+)(\s*=\s*)?("[^"]*"|'[^']*'|[^\s>]+)?/g;
|
|
15
|
+
|
|
16
|
+
/** The text cut into coloured pieces: [start, end, class] for every non-plain run (tags, attributes, strings, comments). */
|
|
17
|
+
function tokens(text) {
|
|
18
|
+
const out = [];
|
|
19
|
+
let i = 0;
|
|
20
|
+
while (i < text.length) {
|
|
21
|
+
const lt = text.indexOf("<", i);
|
|
22
|
+
if (lt === -1) break;
|
|
23
|
+
if (text.startsWith("<!--", lt)) {
|
|
24
|
+
const end = text.indexOf("-->", lt + 4);
|
|
25
|
+
const e = end === -1 ? text.length : end + 3;
|
|
26
|
+
out.push([lt, e, "ck-com"]);
|
|
27
|
+
i = e;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (text[lt + 1] === "!") { // <!doctype html>
|
|
31
|
+
const e = text.indexOf(">", lt);
|
|
32
|
+
out.push([lt, e === -1 ? text.length : e + 1, "ck-doc"]);
|
|
33
|
+
i = e === -1 ? text.length : e + 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
TAG_RE.lastIndex = lt;
|
|
37
|
+
const m = TAG_RE.exec(text);
|
|
38
|
+
if (!m) { i = lt + 1; continue; }
|
|
39
|
+
const nameEnd = lt + 1 + m[1].length + m[2].length;
|
|
40
|
+
out.push([lt, nameEnd, "ck-tag"]);
|
|
41
|
+
const attrs = m[3], base = nameEnd;
|
|
42
|
+
let a;
|
|
43
|
+
ATTR_RE.lastIndex = 0;
|
|
44
|
+
while ((a = ATTR_RE.exec(attrs))) {
|
|
45
|
+
if (!a[0]) { ATTR_RE.lastIndex++; continue; }
|
|
46
|
+
const s = base + a.index;
|
|
47
|
+
out.push([s, s + a[1].length, "ck-attr"]);
|
|
48
|
+
if (a[3]) { const vs = s + a[1].length + (a[2] || "").length; out.push([vs, vs + a[3].length, "ck-str"]); }
|
|
49
|
+
}
|
|
50
|
+
const end = lt + m[0].length;
|
|
51
|
+
if (m[4]) out.push([end - 1 - (attrs.endsWith("/") ? 1 : 0), end, "ck-tag"]);
|
|
52
|
+
i = end;
|
|
53
|
+
// the inside of <style> and <script> is not markup: skip to its closing tag
|
|
54
|
+
const tag = m[2].toLowerCase();
|
|
55
|
+
if (!m[1] && RAW.has(tag)) {
|
|
56
|
+
const close = text.toLowerCase().indexOf(`</${tag}`, i);
|
|
57
|
+
i = close === -1 ? text.length : close;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The text as coloured HTML for the layer behind a code textarea. `marks` ([start, end, class]) shade ranges on top of
|
|
65
|
+
* the colours, such as the selected element's source.
|
|
66
|
+
*/
|
|
67
|
+
export function highlight(text, marks = []) {
|
|
68
|
+
const toks = tokens(text);
|
|
69
|
+
const cuts = new Set([0, text.length]);
|
|
70
|
+
for (const [s, e] of [...toks, ...marks]) { cuts.add(s); cuts.add(e); }
|
|
71
|
+
const points = [...cuts].filter((p) => p >= 0 && p <= text.length).sort((a, b) => a - b);
|
|
72
|
+
let out = "", ti = 0, cur = null, buf = "";
|
|
73
|
+
const flush = () => { if (buf) out += cur ? `<span class="${cur}">${esc(buf)}</span>` : esc(buf); buf = ""; };
|
|
74
|
+
for (let k = 0; k < points.length - 1; k++) {
|
|
75
|
+
const s = points[k], e = points[k + 1];
|
|
76
|
+
while (ti < toks.length && toks[ti][1] <= s) ti++;
|
|
77
|
+
const tk = toks[ti] && toks[ti][0] <= s && toks[ti][1] >= e ? toks[ti][2] : "";
|
|
78
|
+
const cls = [tk, ...marks.filter((m) => m[0] <= s && m[1] >= e).map((m) => m[2])].filter(Boolean).join(" ") || null;
|
|
79
|
+
if (cls !== cur) { flush(); cur = cls; }
|
|
80
|
+
buf += text.slice(s, e);
|
|
81
|
+
}
|
|
82
|
+
flush();
|
|
83
|
+
return out + "\n";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const lineAt = (text, index) => text.slice(0, index).split("\n").length;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The first place the tags don't match up, or null: { line, message }. A tag left open, a closing tag with nothing to
|
|
90
|
+
* close, or a tag that never ends. Closing tags HTML lets you leave out (</p>, </li>, </td>...) are not required.
|
|
91
|
+
*/
|
|
92
|
+
export function checkTags(text) {
|
|
93
|
+
const stack = [];
|
|
94
|
+
let i = 0;
|
|
95
|
+
while (i < text.length) {
|
|
96
|
+
const lt = text.indexOf("<", i);
|
|
97
|
+
if (lt === -1) break;
|
|
98
|
+
if (text.startsWith("<!--", lt)) { const e = text.indexOf("-->", lt + 4); if (e === -1) return { line: lineAt(text, lt), message: "A comment (<!--) is never closed" }; i = e + 3; continue; }
|
|
99
|
+
if (text[lt + 1] === "!") { const e = text.indexOf(">", lt); i = e === -1 ? text.length : e + 1; continue; }
|
|
100
|
+
TAG_RE.lastIndex = lt;
|
|
101
|
+
const m = TAG_RE.exec(text);
|
|
102
|
+
if (!m) { i = lt + 1; continue; }
|
|
103
|
+
const tag = m[2].toLowerCase();
|
|
104
|
+
if (!m[4]) return { line: lineAt(text, lt), message: `<${m[1]}${tag}> never ends: it is missing its ">"` };
|
|
105
|
+
i = lt + m[0].length;
|
|
106
|
+
if (m[1]) {
|
|
107
|
+
const at = stack.map((s) => s.tag).lastIndexOf(tag);
|
|
108
|
+
if (at === -1) { if (!VOID.has(tag)) return { line: lineAt(text, lt), message: `</${tag}> closes nothing: there is no open <${tag}> here` }; continue; }
|
|
109
|
+
const open = stack.slice(at + 1).find((s) => !OPTIONAL_CLOSE.has(s.tag));
|
|
110
|
+
if (open) return { line: open.line, message: `<${open.tag}> is never closed (</${tag}> on line ${lineAt(text, lt)} comes first)` };
|
|
111
|
+
stack.length = at;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (VOID.has(tag) || m[3].trim().endsWith("/")) continue;
|
|
115
|
+
stack.push({ tag, line: lineAt(text, lt) });
|
|
116
|
+
if (RAW.has(tag)) {
|
|
117
|
+
const close = text.toLowerCase().indexOf(`</${tag}`, i);
|
|
118
|
+
if (close === -1) return { line: lineAt(text, lt), message: `<${tag}> is never closed` };
|
|
119
|
+
i = close;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const open = [...stack].reverse().find((s) => !OPTIONAL_CLOSE.has(s.tag)); // the innermost: usually the one just typed
|
|
123
|
+
return open ? { line: open.line, message: `<${open.tag}> is never closed` } : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Format ───────────────────────────────────────────────────────────────────
|
|
127
|
+
const INLINE = new Set(["a", "abbr", "b", "bdi", "bdo", "br", "button", "cite", "code", "data", "dfn", "em", "i", "img", "input", "kbd", "label", "mark", "q", "s", "samp", "select", "small", "span", "strong", "sub", "sup", "svg", "time", "u", "var", "wbr", "textarea"]);
|
|
128
|
+
const KEEP = new Set(["pre", "textarea", "script", "style", "svg"]); // written exactly as they are
|
|
129
|
+
const openTag = (el) => { const c = el.cloneNode(false); c.innerHTML = ""; const h = c.outerHTML; return VOID.has(el.localName) ? h : h.slice(0, h.lastIndexOf("</")); };
|
|
130
|
+
const isInlineOnly = (el) => Array.from(el.childNodes).every((n) => n.nodeType === 3 || n.nodeType === 8 || (n.nodeType === 1 && INLINE.has(n.localName)));
|
|
131
|
+
|
|
132
|
+
/** Markup (a fragment, no <html>) with tidy indentation: block elements on their own lines, text and inline elements kept on theirs. */
|
|
133
|
+
export function formatHtml(markup, indent = " ") {
|
|
134
|
+
const tpl = document.createElement("template");
|
|
135
|
+
tpl.innerHTML = markup;
|
|
136
|
+
const lines = [];
|
|
137
|
+
const walk = (node, depth) => {
|
|
138
|
+
const pad = indent.repeat(depth);
|
|
139
|
+
if (node.nodeType === 3) { const t = node.textContent.replace(/\s+/g, " ").trim(); if (t) lines.push(pad + esc(t)); return; }
|
|
140
|
+
if (node.nodeType === 8) { lines.push(`${pad}<!--${node.data}-->`); return; }
|
|
141
|
+
if (node.nodeType !== 1) return;
|
|
142
|
+
const name = node.localName;
|
|
143
|
+
if (KEEP.has(name) || VOID.has(name) || isInlineOnly(node)) {
|
|
144
|
+
// short enough, or not to be touched: one line (inline content keeps its own spacing, squeezed)
|
|
145
|
+
const html = KEEP.has(name) ? node.outerHTML : node.outerHTML.replace(/\s*\n\s*/g, " ");
|
|
146
|
+
lines.push(pad + html);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
lines.push(pad + openTag(node));
|
|
150
|
+
Array.from(node.childNodes).forEach((k) => walk(k, depth + 1));
|
|
151
|
+
lines.push(`${pad}</${name}>`);
|
|
152
|
+
};
|
|
153
|
+
Array.from(tpl.content.childNodes).forEach((n) => walk(n, 0));
|
|
154
|
+
return lines.join("\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Line numbers for a gutter: "1\n2\n3..." */
|
|
158
|
+
export const lineNumbers = (text) => Array.from({ length: text.split("\n").length }, (_, i) => i + 1).join("\n");
|