Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
545 lines
18 KiB
TypeScript
545 lines
18 KiB
TypeScript
/* =============================================================================
|
|
* M01_SL_Panel.ts
|
|
* 시트 로직 아래 시트 판 — 고른 상자의 시트를 우리 스프레드시트 편집기로 · 칸 → 입력/출력 알약 · 칸 위 알약 표시 ·
|
|
* 받는 알약 칸 잠금 · 틀 단추(진짜 엑셀 수식) · 틀린 수식/안 이어진 입력 알약 한 줄
|
|
* 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · M01_SL_Model 타입 그대로 · 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴)
|
|
* 글 [한, 영] 짝은 아래 TEXT
|
|
* ========================================================================== */
|
|
|
|
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
|
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
|
import type {
|
|
CalcValue,
|
|
CalcValues,
|
|
SpreadsheetHandle,
|
|
Workbook,
|
|
} from "../../A00_Common/spreadsheet/spreadsheet";
|
|
import { disconnect, type SheetLogic, type SlEdge, type SlNode, type SlPill } from "./M01_SL_Model";
|
|
import "./M01_SL_Panel.css";
|
|
|
|
const TEXT = {
|
|
Cell: ["고른 칸", "Selected cell"],
|
|
PillIn: ["입력 알약으로", "Make input pill"],
|
|
PillOut: ["출력 알약으로", "Make output pill"],
|
|
AskName: ["알약 이름", "Pill name"],
|
|
Ok: ["정함", "Set"],
|
|
Cancel: ["취소", "Cancel"],
|
|
Frames: ["틀", "Templates"],
|
|
FrameMore: ["할증", "Markup"],
|
|
FrameDiv: ["나누기", "Divide"],
|
|
FrameIf: ["만약", "If"],
|
|
FrameMin: ["최소", "Min"],
|
|
FrameMax: ["최대", "Max"],
|
|
FrameUp: ["올림", "Round up"],
|
|
FrameLook: ["표에서 찾기", "Look up in table"],
|
|
FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"],
|
|
In: ["입력", "In"],
|
|
Out: ["출력", "Out"],
|
|
Lock: ["선", "Wire"],
|
|
Pills: ["알약", "Pills"],
|
|
NoPills: [
|
|
"아직 알약이 없음 — 칸을 골라 알약으로 정함",
|
|
"No pills yet — pick a cell and make it a pill",
|
|
],
|
|
Value: ["값", "Value"],
|
|
Unit: ["단위", "Unit"],
|
|
From: ["어디서 오나", "Comes from"],
|
|
To: ["어디로 가나", "Goes to"],
|
|
NoFrom: ["아직 이어지지 않음", "Not connected yet"],
|
|
NoTo: ["아직 어디로도 안 감", "Goes nowhere yet"],
|
|
Close: ["닫기", "Close"],
|
|
ReadOnly: ["표준은 읽기만", "Read only"],
|
|
Loading: ["시트를 불러오는 중", "Loading sheet"],
|
|
BreakAsk: [
|
|
"이 칸은 선으로 들어오는 값입니다. 선을 끊고 직접 고칠까요?",
|
|
"This cell receives a wire. Cut the wire and edit it yourself?",
|
|
],
|
|
BreakOk: ["선을 끊고 고침", "Cut and edit"],
|
|
Cannot: ["이 고침은 지금 할 수 없음", "This change cannot be made now"],
|
|
Problem: ["⚠ ", "⚠ "],
|
|
Unwired: [" 입력 알약이 아직 이어지지 않음", " input pill is not connected yet"],
|
|
Err_DIV0: ["0으로 나눔", "divides by zero"],
|
|
Err_NAME: ["알 수 없는 이름", "unknown name"],
|
|
Err_REF: ["없는 칸을 가리킴", "points to a missing cell"],
|
|
Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"],
|
|
Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"],
|
|
Err_NA: ["찾는 값이 없음", "value not found"],
|
|
Err_OTHER: ["수식이 틀림", "formula is wrong"],
|
|
} as const;
|
|
|
|
const tp = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
|
|
|
/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */
|
|
const FRAMES: [keyof typeof TEXT, string][] = [
|
|
["FrameMore", "=B1*(1+B2/100)"],
|
|
["FrameDiv", "=B1/B2"],
|
|
["FrameIf", '=IF(B3="급",10,0)'],
|
|
["FrameMin", "=MIN(B1,B2)"],
|
|
["FrameMax", "=MAX(B1,B2)"],
|
|
["FrameUp", "=ROUNDUP(B4,3)"],
|
|
["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"],
|
|
];
|
|
|
|
const ERR_KEY: Record<string, keyof typeof TEXT> = {
|
|
"#DIV/0!": "Err_DIV0",
|
|
"#NAME?": "Err_NAME",
|
|
"#REF!": "Err_REF",
|
|
"#VALUE!": "Err_VALUE",
|
|
"#CYCLE!": "Err_CYCLE",
|
|
"#N/A": "Err_NA",
|
|
};
|
|
|
|
type Handlers = {
|
|
change(sl: SheetLogic): void;
|
|
pill(nodeId: string, cell: string, dir: "in" | "out", name: string): void;
|
|
};
|
|
type SheetModule = typeof import("../../A00_Common/spreadsheet/spreadsheet");
|
|
|
|
interface State {
|
|
nodeId: string;
|
|
sl: SheetLogic;
|
|
on: Handlers;
|
|
readOnly: boolean;
|
|
handle: SpreadsheetHandle | null;
|
|
/** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */
|
|
snap: CalcValues;
|
|
/** 편집기가 마지막으로 낸 문서(활성 빼고) — 같으면 다시 안 끼움 */
|
|
json: string;
|
|
cell: string;
|
|
info: { dir: "in" | "out"; name: string } | null;
|
|
asking: "in" | "out" | null;
|
|
busy: boolean;
|
|
parts: {
|
|
cell: HTMLElement;
|
|
bar: HTMLElement;
|
|
msg: HTMLElement;
|
|
sheet: HTMLElement;
|
|
pills: HTMLElement;
|
|
info: HTMLElement;
|
|
};
|
|
observer: MutationObserver | null;
|
|
redraw(): void;
|
|
}
|
|
|
|
const states = new WeakMap<HTMLElement, State>();
|
|
let sheetModule: Promise<SheetModule> | null = null;
|
|
/** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */
|
|
const loadSheet = (): Promise<SheetModule> =>
|
|
(sheetModule ??= import("../../A00_Common/spreadsheet/spreadsheet"));
|
|
|
|
const norm = (book: Workbook): string => JSON.stringify({ ...book, 활성: null, 계산값: null });
|
|
const nodeOf = (st: State): SlNode | undefined => st.sl.nodes.find((n) => n.id === st.nodeId);
|
|
const shown = (st: State): Workbook => ({
|
|
...st.sl.workbook,
|
|
활성: nodeOf(st)?.sheetId ?? st.sl.workbook.활성,
|
|
});
|
|
|
|
/* ------------------------------ 값 · 말 ------------------------------ */
|
|
|
|
function valueText(v: CalcValue | number | string | boolean | undefined | null): string {
|
|
if (v === undefined || v === null) return "";
|
|
if (typeof v !== "object") return String(v);
|
|
if ("수" in v) return v.수;
|
|
if ("글" in v) return v.글;
|
|
if ("참" in v) return v.참 ? "TRUE" : "FALSE";
|
|
return v.오류;
|
|
}
|
|
|
|
function valueAt(st: State, addr: string): string {
|
|
const node = nodeOf(st);
|
|
const sheet = st.sl.workbook.시트.find((s) => s.id === node?.sheetId);
|
|
const cell = sheet?.칸[addr];
|
|
if (!cell) return "";
|
|
if (cell.식 === undefined) return valueText(cell.값);
|
|
return valueText(st.snap[sheet!.id]?.[addr]);
|
|
}
|
|
|
|
const edgesIn = (st: State, pill: string): SlEdge[] =>
|
|
st.sl.edges.filter((e) => e.to.node === st.nodeId && e.to.pill === pill);
|
|
const edgesOut = (st: State, pill: string): SlEdge[] =>
|
|
st.sl.edges.filter((e) => e.from.node === st.nodeId && e.from.pill === pill);
|
|
|
|
/** 선으로 값을 받는 칸 — 직접 고치기 전에 물음 */
|
|
function lockedCells(st: State): Map<string, SlEdge> {
|
|
const out = new Map<string, SlEdge>();
|
|
for (const p of nodeOf(st)?.pillsIn ?? []) {
|
|
const e = edgesIn(st, p.name)[0];
|
|
if (e) out.set(p.cell, e);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 틀린 수식 · 이어지지 않은 입력 알약 — 칸 → 까닭 */
|
|
function problems(st: State): Map<string, string> {
|
|
const out = new Map<string, string>();
|
|
const node = nodeOf(st);
|
|
if (!node || !st.handle) return out;
|
|
const sheet = st.sl.workbook.시트.find((s) => s.id === node.sheetId);
|
|
const snap = st.snap[node.sheetId] ?? {};
|
|
for (const [addr, v] of Object.entries(snap)) {
|
|
if (typeof v === "object" && "오류" in v) {
|
|
out.set(addr, `${addr} ${tp(ERR_KEY[v.오류] ?? "Err_OTHER")}`);
|
|
}
|
|
}
|
|
for (const p of node.pillsIn) {
|
|
const filled = sheet?.칸[p.cell]?.식 !== undefined;
|
|
if (!edgesIn(st, p.name).length && !filled && node.kind !== "input" && node.kind !== "const") {
|
|
out.set(p.cell, `${p.name}(${p.cell})${tp("Unwired")}`);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */
|
|
function refreshSnap(st: State): void {
|
|
if (!st.handle) return;
|
|
st.snap = st.handle.recalc();
|
|
st.redraw();
|
|
}
|
|
|
|
/* ------------------------------ 칸 위 표시 ------------------------------ */
|
|
|
|
function applyMarks(st: State): void {
|
|
const node = nodeOf(st);
|
|
const grid = st.parts.sheet;
|
|
if (!node) return;
|
|
const pill = new Map<string, { dir: "in" | "out"; p: SlPill }>();
|
|
for (const p of node.pillsIn) pill.set(p.cell, { dir: "in", p });
|
|
for (const p of node.pillsOut) pill.set(p.cell, { dir: "out", p });
|
|
const lock = lockedCells(st);
|
|
const bad = problems(st);
|
|
for (const c of grid.querySelectorAll<HTMLElement>(".aislo-cell")) {
|
|
const addr = c.dataset.addr ?? "";
|
|
const hit = pill.get(addr);
|
|
for (const a of ["slPill", "slName", "slLock", "slErr"]) delete c.dataset[a];
|
|
if (hit) {
|
|
c.dataset.slPill = hit.dir;
|
|
c.dataset.slName = `${tp(hit.dir === "in" ? "In" : "Out")} ${hit.p.name}`;
|
|
}
|
|
if (lock.has(addr)) c.dataset.slLock = tp("Lock");
|
|
if (bad.has(addr)) c.dataset.slErr = "1";
|
|
}
|
|
const first = [...bad.values()][0];
|
|
st.parts.msg.textContent = first
|
|
? `${tp("Problem")}${first}${bad.size > 1 ? ` (+${bad.size - 1})` : ""}`
|
|
: "";
|
|
st.parts.msg.hidden = !first;
|
|
}
|
|
|
|
/* ------------------------------ 위 줄 · 알약 목록 ------------------------------ */
|
|
|
|
function nameDefault(st: State): string {
|
|
const row = st.cell.replace(/^[A-Z]+/, "");
|
|
const sheet = st.sl.workbook.시트.find((s) => s.id === nodeOf(st)?.sheetId);
|
|
const a = sheet?.칸[`A${row}`];
|
|
return a ? valueText(a.값 ?? a.식) : "";
|
|
}
|
|
|
|
function drawBar(st: State): void {
|
|
const { bar, cell } = st.parts;
|
|
cell.textContent = `${tp("Cell")} ${st.cell}`;
|
|
const frag: (HTMLElement | string)[] = [];
|
|
if (st.readOnly) {
|
|
bar.replaceChildren(el("span", { className: "m01-slpanel-note", text: tp("ReadOnly") }));
|
|
return;
|
|
}
|
|
const ask = (dir: "in" | "out"): void => {
|
|
st.asking = dir;
|
|
drawBar(st);
|
|
};
|
|
const pillRow = el("div", {
|
|
className: "m01-slpanel-row",
|
|
children: [
|
|
createButton({ label: tp("PillIn"), variant: "ghost", size: "sm", onClick: () => ask("in") }),
|
|
createButton({
|
|
label: tp("PillOut"),
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => ask("out"),
|
|
}),
|
|
],
|
|
});
|
|
frag.push(pillRow);
|
|
if (st.asking) {
|
|
const dir = st.asking;
|
|
const input = el("input", {
|
|
className: "ui-input m01-slpanel-name",
|
|
attrs: { type: "text", "aria-label": tp("AskName") },
|
|
});
|
|
input.value = nameDefault(st);
|
|
const done = (): void => {
|
|
const name = input.value.trim();
|
|
if (!name) return;
|
|
st.asking = null;
|
|
st.on.pill(st.nodeId, st.cell, dir, name);
|
|
drawBar(st);
|
|
};
|
|
input.addEventListener("keydown", (e) => e.key === "Enter" && done());
|
|
frag.push(
|
|
el("div", {
|
|
className: "m01-slpanel-row",
|
|
children: [
|
|
el("span", {
|
|
className: `m01-slpanel-dir is-${dir}`,
|
|
text: tp(dir === "in" ? "In" : "Out"),
|
|
}),
|
|
input,
|
|
createButton({ label: tp("Ok"), variant: "filled", size: "sm", onClick: done }),
|
|
createButton({
|
|
label: tp("Cancel"),
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => ((st.asking = null), drawBar(st)),
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
queueMicrotask(() => input.focus());
|
|
}
|
|
frag.push(
|
|
el("div", {
|
|
className: "m01-slpanel-row",
|
|
children: [
|
|
el("span", { className: "m01-slpanel-note", text: tp("Frames") }),
|
|
...FRAMES.map(([key, formula]) =>
|
|
createButton({
|
|
label: tp(key),
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => putFormula(st, formula),
|
|
}),
|
|
),
|
|
el("span", { className: "m01-slpanel-note", text: tp("FrameHint") }),
|
|
],
|
|
}),
|
|
);
|
|
bar.replaceChildren(...frag.filter((x): x is HTMLElement => typeof x !== "string"));
|
|
}
|
|
|
|
function drawPills(st: State): void {
|
|
const node = nodeOf(st);
|
|
const chips: HTMLElement[] = [];
|
|
const add = (dir: "in" | "out", p: SlPill): void => {
|
|
const b = el("button", {
|
|
className: `m01-slpanel-pill is-${dir}${st.info?.name === p.name && st.info.dir === dir ? " is-on" : ""}`,
|
|
text: `${tp(dir === "in" ? "In" : "Out")} · ${p.name} (${p.cell})`,
|
|
attrs: { type: "button" },
|
|
});
|
|
b.addEventListener("click", () => ((st.info = { dir, name: p.name }), drawPills(st)));
|
|
chips.push(b);
|
|
};
|
|
for (const p of node?.pillsIn ?? []) add("in", p);
|
|
for (const p of node?.pillsOut ?? []) add("out", p);
|
|
st.parts.pills.replaceChildren(
|
|
...(chips.length
|
|
? chips
|
|
: [el("span", { className: "m01-slpanel-note", text: tp("NoPills") })]),
|
|
);
|
|
drawInfo(st);
|
|
}
|
|
|
|
function drawInfo(st: State): void {
|
|
const box = st.parts.info;
|
|
const node = nodeOf(st);
|
|
const p = (st.info?.dir === "in" ? node?.pillsIn : node?.pillsOut)?.find(
|
|
(x) => x.name === st.info?.name,
|
|
);
|
|
if (!st.info || !p || !node) {
|
|
box.hidden = true;
|
|
box.replaceChildren();
|
|
return;
|
|
}
|
|
const dir = st.info.dir;
|
|
const nameOf = (id: string): string => st.sl.nodes.find((n) => n.id === id)?.name ?? "";
|
|
const links = (dir === "in" ? edgesIn(st, p.name) : edgesOut(st, p.name)).map((e) => {
|
|
const end = dir === "in" ? e.from : e.to;
|
|
return `${nameOf(end.node)} · ${end.pill}`;
|
|
});
|
|
const rows: [string, string][] = [
|
|
[tp("Value"), valueAt(st, p.cell)],
|
|
[tp("Unit"), p.unit ?? ""],
|
|
[
|
|
tp(dir === "in" ? "From" : "To"),
|
|
links.length ? links.join(", ") : tp(dir === "in" ? "NoFrom" : "NoTo"),
|
|
],
|
|
];
|
|
box.hidden = false;
|
|
box.replaceChildren(
|
|
el("strong", { text: `${tp(dir === "in" ? "In" : "Out")} · ${p.name} (${p.cell})` }),
|
|
...rows.map(([k, v]) =>
|
|
el("div", {
|
|
className: "m01-slpanel-infoline",
|
|
children: [el("span", { className: "m01-slpanel-note", text: k }), v],
|
|
}),
|
|
),
|
|
createButton({
|
|
label: tp("Close"),
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => ((st.info = null), drawPills(st)),
|
|
}),
|
|
);
|
|
}
|
|
|
|
/* ------------------------------ 편집기 ------------------------------ */
|
|
|
|
/** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */
|
|
function putFormula(st: State, formula: string): void {
|
|
const root = st.parts.sheet;
|
|
const bar = root.querySelector<HTMLTextAreaElement>(".ss-formula");
|
|
const addr = root.querySelector<HTMLInputElement>(".ss-addr");
|
|
if (!bar || !addr || st.readOnly) return;
|
|
const at = st.cell;
|
|
bar.focus();
|
|
bar.value = formula;
|
|
bar.dispatchEvent(new Event("input", { bubbles: true }));
|
|
bar.dispatchEvent(
|
|
new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }),
|
|
);
|
|
addr.value = at;
|
|
addr.dispatchEvent(
|
|
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
|
|
);
|
|
}
|
|
|
|
/** 편집기가 문서를 냄 — 선 받는 칸을 직접 고쳤으면 먼저 물음 */
|
|
async function onBook(st: State, book: Workbook): Promise<void> {
|
|
if (st.busy) return;
|
|
const prev = st.sl;
|
|
const node = nodeOf(st);
|
|
const next: SheetLogic = { ...prev, workbook: { ...book, 활성: prev.workbook.활성 } };
|
|
const before = prev.workbook.시트.find((s) => s.id === node?.sheetId);
|
|
const after = book.시트.find((s) => s.id === node?.sheetId);
|
|
const hit = [...lockedCells(st)].find(
|
|
([addr]) => JSON.stringify(before?.칸[addr]) !== JSON.stringify(after?.칸[addr]),
|
|
);
|
|
if (!hit) {
|
|
st.json = norm(book);
|
|
st.sl = next;
|
|
st.on.change(next);
|
|
return void setTimeout(() => refreshSnap(st), 0);
|
|
}
|
|
st.busy = true;
|
|
const ok = await showConfirmDialog(tp("BreakAsk"), tp("BreakOk"));
|
|
st.busy = false;
|
|
if (!ok) {
|
|
st.json = norm(shown(st));
|
|
return st.handle?.setDoc(structuredClone(shown(st)));
|
|
}
|
|
try {
|
|
const cut = disconnect(next, hit[1].id);
|
|
const sheet = cut.workbook.시트.find((s) => s.id === node?.sheetId);
|
|
const mine = after?.칸[hit[0]];
|
|
if (sheet) {
|
|
if (mine) sheet.칸[hit[0]] = mine;
|
|
else delete sheet.칸[hit[0]];
|
|
}
|
|
st.json = norm(cut.workbook);
|
|
st.sl = cut;
|
|
st.on.change(cut);
|
|
setTimeout(() => refreshSnap(st), 0);
|
|
} catch {
|
|
showToast(tp("Cannot"), "error");
|
|
st.handle?.setDoc(structuredClone(shown(st)));
|
|
}
|
|
}
|
|
|
|
async function mountSheet(st: State): Promise<void> {
|
|
const { createSpreadsheet } = await loadSheet();
|
|
if (!st.parts.sheet.isConnected || st.handle) return;
|
|
st.parts.sheet.replaceChildren();
|
|
const book = structuredClone(shown(st)); // 편집기가 제자리에서 고치므로 사본을 줌
|
|
st.json = norm(book);
|
|
st.handle = createSpreadsheet(st.parts.sheet, book, {
|
|
preview: true,
|
|
tabs: false,
|
|
readOnly: st.readOnly,
|
|
onChange: (b) => void onBook(st, b),
|
|
onSelect: (s) => {
|
|
st.cell = s.칸;
|
|
st.parts.cell.textContent = `${tp("Cell")} ${st.cell}`;
|
|
if (st.asking) drawBar(st);
|
|
},
|
|
});
|
|
st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st)));
|
|
st.observer.observe(st.parts.sheet, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true,
|
|
attributeFilter: ["data-addr"],
|
|
});
|
|
refreshSnap(st);
|
|
}
|
|
|
|
/** 아래 시트 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */
|
|
export function renderPanel(
|
|
host: HTMLElement,
|
|
sl: SheetLogic,
|
|
nodeId: string,
|
|
on: Handlers,
|
|
opts: { readOnly?: boolean } = {},
|
|
): void {
|
|
const old = states.get(host);
|
|
const readOnly = !!opts.readOnly;
|
|
if (old && old.nodeId === nodeId && old.readOnly === readOnly) {
|
|
old.sl = sl;
|
|
old.on = on;
|
|
if (old.handle && norm(shown(old)) !== old.json) {
|
|
old.json = norm(shown(old));
|
|
old.handle.setDoc(structuredClone(shown(old)));
|
|
setTimeout(() => refreshSnap(old), 0);
|
|
return;
|
|
}
|
|
return old.redraw();
|
|
}
|
|
if (old) {
|
|
old.observer?.disconnect();
|
|
old.handle?.destroy();
|
|
}
|
|
const parts: State["parts"] = {
|
|
cell: el("span", { className: "m01-slpanel-cell", text: `${tp("Cell")} A1` }),
|
|
bar: el("div", { className: "m01-slpanel-bar" }),
|
|
msg: el("p", { className: "m01-slpanel-msg", attrs: { role: "status" } }),
|
|
sheet: el("div", { className: "m01-slpanel-sheet", text: tp("Loading") }),
|
|
pills: el("div", { className: "m01-slpanel-pills" }),
|
|
info: el("div", { className: "m01-slpanel-info" }),
|
|
};
|
|
const st: State = {
|
|
nodeId,
|
|
sl,
|
|
on,
|
|
readOnly,
|
|
handle: null,
|
|
snap: {},
|
|
json: "",
|
|
cell: "A1",
|
|
info: null,
|
|
asking: null,
|
|
busy: false,
|
|
parts,
|
|
observer: null,
|
|
redraw: () => {
|
|
drawBar(st);
|
|
drawPills(st);
|
|
applyMarks(st);
|
|
},
|
|
};
|
|
states.set(host, st);
|
|
const node = nodeOf(st);
|
|
host.classList.add("m01-slpanel");
|
|
host.replaceChildren(
|
|
el("div", {
|
|
className: "m01-slpanel-head",
|
|
children: [el("strong", { text: node?.name ?? "" }), parts.cell],
|
|
}),
|
|
parts.bar,
|
|
parts.msg,
|
|
parts.sheet,
|
|
parts.pills,
|
|
parts.info,
|
|
);
|
|
parts.msg.hidden = true;
|
|
st.redraw();
|
|
void mountSheet(st);
|
|
}
|