feat(M01-시트로직): 아래 시트 판 Panel 추가 - 시트 편집기 · 입력/출력 알약 · 받는 칸 잠금 · 틀 단추 · 한 줄 오류
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
.m01-slpanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-slpanel-head,
|
||||
.m01-slpanel-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-slpanel-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-slpanel-cell {
|
||||
padding: 2px var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m01-slpanel-note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-slpanel .ui-btn,
|
||||
.m01-slpanel .ui-input {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-slpanel-name {
|
||||
flex: none;
|
||||
width: 12em;
|
||||
}
|
||||
|
||||
.m01-slpanel-msg {
|
||||
margin: 0;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
background: var(--color-surface-raised);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.m01-slpanel-sheet {
|
||||
position: relative;
|
||||
min-height: 260px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m01-slpanel-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-slpanel-pill,
|
||||
.m01-slpanel-dir {
|
||||
min-height: 44px;
|
||||
padding: 0 var(--spacing-12);
|
||||
border: 2px solid;
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-surface);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-slpanel-dir {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.m01-slpanel-pill:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.m01-slpanel-pill.is-on {
|
||||
background: var(--color-surface-raised);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.is-in {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.is-out {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.m01-slpanel-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m01-slpanel-infoline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-slpanel-infoline > .m01-slpanel-note {
|
||||
min-width: 6em;
|
||||
}
|
||||
|
||||
/* 칸 위 알약 표시 — 색 + 글자(입력 · 출력) 둘 다 */
|
||||
.m01-slpanel .aislo-cell[data-sl-pill="in"] {
|
||||
box-shadow: inset 0 0 0 2px #3b82f6;
|
||||
}
|
||||
|
||||
.m01-slpanel .aislo-cell[data-sl-pill="out"] {
|
||||
box-shadow: inset 0 0 0 2px #f59e0b;
|
||||
}
|
||||
|
||||
.m01-slpanel .aislo-cell[data-sl-pill]::after {
|
||||
content: attr(data-sl-name);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 0 4px;
|
||||
border-radius: 0 0 0 6px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m01-slpanel .aislo-cell[data-sl-pill="in"]::after {
|
||||
border: 1px solid #3b82f6;
|
||||
}
|
||||
|
||||
.m01-slpanel .aislo-cell[data-sl-pill="out"]::after {
|
||||
border: 1px solid #f59e0b;
|
||||
}
|
||||
|
||||
.m01-slpanel .aislo-cell[data-sl-lock]::before {
|
||||
content: "🔒 " attr(data-sl-lock);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 0 4px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m01-slpanel .aislo-cell[data-sl-err] {
|
||||
outline: 2px dashed var(--color-danger);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
/* =============================================================================
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user