- 로직 삭제 = 서버 정본에서 바로(로직 줄 + 수식 문서 · `/logic/remove`) → 로직 목록 · 부르는 곳(다른 수식 문서 · 일위대가 조합 · M02 단가산출 바탕)이 있으면 확인 창 전에 막고 까닭(`/logic/users`) - 머리 단추 다섯 — [신규 로직] · [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장] · [저장] 한 번 = 기본정보 + 수식 · [되돌림] = 둘 다 저장 뒤 상태 · 줄 쌓기 [고침 버림] · [수식 저장]은 M01 에서 걷음(M02 사본은 그대로) - 합계 상자 화면 아래 고정 · 원문 손잡이 브라우저 오른쪽 끝 - 왼쪽: 그룹 머리 한 줄(접어도 [+] · X 자리 그대로) · 「비우면 번호 없음」 · 가나다 체크 걷음 · 금액 줄 한 줄 배치 · 이름 단추 = 그 이름 찾은 찾기 창 · 수량 기호 = 값 + 출처 꼬리표 · [+] 메뉴(단가산출 · 율 · 식 · 근거 글 · 틀 찾기 걷음) · 식 글 눌러 그 자리 고침([식 고치기] 걷음) · 단위 = 공용 단위 고르기(친 글도 씀) · 만약 안 탄 갈래 숨김 - 오른쪽 기계 줄: 비목 수 링크 걷음 · 제목 줄 누름 = 시간당 경비 펼침 · 펼친 제목 보통 글 - 요소 키가 기호 이름으로 샌 것(`k_EQ0006`) — 변환기가 자원 이름으로(`k_하베스터`) · 수식 문서 다시 빌드(10 파일 · 78 기호 · 금액 같음) - 시험 페이지 `tools/line-stack-test` · main.py 마운트 · vite 프록시 · M01 왼쪽 「시험: 줄 쌓기」 걷음 - 단위 표준 표 읽기 = 로그인만(M02 일반 사용자 단위 목록) - 시험: test_804_7 새로 · 804_2 · 804_4 이음 글 고침(createStackScreen 줄 나뉨) · 552_3 여는 단추 기본 ⋯ 꼴로 고침(부르는 쪽이 [+] 로 바꿀 수 있음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
465 lines
18 KiB
TypeScript
465 lines
18 KiB
TypeScript
/* =============================================================================
|
||
* line_stack_edit.ts
|
||
* 줄 쌓기 왼쪽 편집(PLAN 804-2 · 요구 670~677) — 항목(띠 · 번호 · 제목 · 가나다 · [+ 줄] · 접기 · X) 안 줄 카드(⋮⋮ · X)
|
||
* 차림(804-5 · 요구 682) = 항목 머리 띠 · 줄 = 들여쓰기 + 왼쪽 세로 선 · 순서 = 손잡이 끌기(`line_stack_drag.ts`) ·
|
||
* X = 확인 없이 지우고 알림 눌러 되돌리기 한 번 · 자원 이름 = 단추(누르면 찾아 넣기 창 → 바꿈)
|
||
* 줄 = 인력 · 재료 · 기계 · 다른 단가산출 · 율 · 식(금액 줄 — 이름 · 단위 · 비목 · 수량 식 · 단가 고르기 또는 식)
|
||
* · 공식 묶음(`line_stack_rows.ts`) · 글 줄
|
||
* 고침 = 부른 쪽 `change(fn)` — fn 이 문서 · 차림을 고치면 부른 쪽이 `docOf` 로 굳히고 미리 셈 · 다시 그림(입력은 change 때)
|
||
* 자원 고르기(804-3) = 찾아 넣기 창(`line_stack_pick.ts` — M02 [항목 추가] 창 그대로) · [+ 줄] 인력 · 재료 · 기계 · 다른 단가산출 =
|
||
* 고르면 줄이 생김(닫으면 안 생김) · 있는 줄 = 자원 이름 단추
|
||
* ========================================================================== */
|
||
|
||
import { createButton, createIconButton } from "@ui/ui_template_button";
|
||
import { createRowMenu } from "@ui/ui_template_context_menu";
|
||
import { eqText } from "@ui/ui_template_equation";
|
||
import { el, showToast } from "@ui/ui_template_elements";
|
||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||
import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_util_formula_doc";
|
||
import { isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
|
||
import { lineMap, offLine, type Values } from "../common_util/common_util_formula_doc_view";
|
||
import type { DocTable } from "../common_util/common_util_formula_doc_table";
|
||
import {
|
||
dropSyms,
|
||
docOf,
|
||
freshId,
|
||
kindOf,
|
||
moveItem,
|
||
moveLine,
|
||
newVar,
|
||
ownedOf,
|
||
stackOf,
|
||
type MoneyKind,
|
||
type StackDoc,
|
||
type StackItem,
|
||
} from "../common_util/common_util_line_stack";
|
||
import { placeNames } from "../common_util/common_util_line_stack_copy";
|
||
import {
|
||
addPicked,
|
||
aliasText,
|
||
repick,
|
||
shownUnit,
|
||
tableAlias,
|
||
} from "../common_util/common_util_line_stack_pick";
|
||
import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick";
|
||
import { reach } from "./formula_doc_render";
|
||
import { attachDrag } from "./line_stack_drag";
|
||
import { openStackPick, type StackTake } from "./line_stack_pick";
|
||
import { buildBlock } from "./line_stack_rows";
|
||
import { lt, type LsKey } from "./line_stack_text";
|
||
|
||
export interface EditHooks {
|
||
doc: StackDoc;
|
||
answer: DocAnswer;
|
||
items: StackItem[];
|
||
change: (fn: (d: StackDoc, items: StackItem[]) => void) => void;
|
||
/** 품셈 표 하나(표 고르기 창) */
|
||
table: (key: string) => Promise<DocTable | null>;
|
||
subDoc: (key: string) => FormulaDoc | null;
|
||
name: (key: string) => string;
|
||
/** 공식 묶음 펼친 자리(`항목.줄`) — 화면 상태만 */
|
||
open: Set<string>;
|
||
/** 줄 · 항목 누름 — 오른쪽 짝 강조(679) */
|
||
pair: (loc: string) => void;
|
||
/** M02 단가산출(804-4) — 다른 단가산출 · 일위대가 = 복사해 펼침 · M01 = 없음(M01 전체만) */
|
||
take?: StackTake;
|
||
}
|
||
|
||
const field = (
|
||
label: string,
|
||
value: unknown,
|
||
set: (t: string) => void,
|
||
size: "s" | "m" | "l" = "m",
|
||
): HTMLElement => {
|
||
const f = createInputField({
|
||
placeholder: label,
|
||
value: value === undefined || value === null ? "" : String(value),
|
||
compact: true,
|
||
size: 24,
|
||
});
|
||
f.root.classList.add(`lstk-f-${size}`);
|
||
f.input.setAttribute("aria-label", label);
|
||
f.input.title = label;
|
||
f.input.addEventListener("change", () => set(f.input.value));
|
||
return f.root;
|
||
};
|
||
const btn = (label: string, onClick: () => void) =>
|
||
createButton({ label, variant: "ghost", size: 24, onClick });
|
||
|
||
const KIND_KEY: Record<MoneyKind, LsKey> = {
|
||
인력: "K_인력",
|
||
재료: "K_재료",
|
||
기계: "K_기계",
|
||
단가산출: "K_단가산출",
|
||
율: "K_율",
|
||
};
|
||
|
||
/** 금액 줄 몸 — 한 줄(804-7 · 요구 687-7): [종류][이름 단추 | 이름 칸 · 단가 식][수량][단위][비목] · 좁으면 접힘 */
|
||
function moneyBody(h: EditHooks, i: number): HTMLElement[] {
|
||
const d = h.doc;
|
||
const line = (d.금액 ?? [])[i];
|
||
const kind = kindOf(d, i, h.subDoc);
|
||
const own = ownedOf(d).get(i);
|
||
const v = own ? d.변수?.find((x) => varId(x) === own) : undefined;
|
||
const put = (fn: (l: MoneyLine & { 규격?: string }) => void): void =>
|
||
h.change((doc) => fn(doc.금액![i]));
|
||
// 표 값 기호 = 화면 이름(요구 683) — 칸 글은 화면 이름 ↔ 기호로 오감
|
||
const alias = tableAlias(d);
|
||
const shown = (t: unknown): string => aliasText(String(t ?? ""), alias);
|
||
const real = (t: string): string => aliasText(t.trim(), alias, true);
|
||
const head: HTMLElement[] = [];
|
||
if (own && v) {
|
||
// 이름 단추(요구 687-9) — 누르면 찾아 넣기 창이 그 이름을 찾은 채 열림 · 고르면 바꿈
|
||
const link = v.연결 ?? {};
|
||
const mat = link.재료 as Record<string, unknown> | undefined;
|
||
const size = (line as { 규격?: string }).규격;
|
||
const name = String(line.이름 ?? "") || String(mat?.검색어 ?? mat?.이름 ?? "");
|
||
const res = link.키
|
||
? link.갈래 === "단가산출"
|
||
? (h.subDoc(link.키)?.이름 ?? h.name(link.키))
|
||
: h.name(link.키)
|
||
: "";
|
||
const pick = createButton({
|
||
label: (size && !name.includes(size) ? `${name} ${size}` : name) || "—",
|
||
variant: "ghost",
|
||
tone: "link",
|
||
size: 24,
|
||
iconStart: "search",
|
||
onClick: () =>
|
||
openStackPick(
|
||
kind,
|
||
(p) => h.change((doc) => repick(doc, i, own, kind, p)),
|
||
undefined,
|
||
String(mat?.검색어 ?? line.이름 ?? ""),
|
||
),
|
||
});
|
||
pick.classList.add("lstk-res");
|
||
pick.title = `${lt("Repick")}${res ? ` — ${res}` : ""}`;
|
||
pick.dataset.repick = String(i);
|
||
head.push(pick);
|
||
} else
|
||
head.push(
|
||
field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "m"),
|
||
field(lt("Price"), shown(line.단가), (t) => put((l) => (l.단가 = real(t))), "l"),
|
||
);
|
||
const why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭;
|
||
// 율 밑 번지(복사해 온 조각) — 칸 아래 읽는 이름(804-5)
|
||
const places = /번지\./.test(`${line.단가} ${line.수량 ?? ""}`) ? placeNames(d, h.items) : null;
|
||
const read = (t: unknown): string =>
|
||
shown(t).replace(/번지\.([^.\s()]+)\./g, (all, p: string) =>
|
||
places?.[p] ? `${places[p]} ` : all,
|
||
);
|
||
const unit = el("span", { className: "lstk-upick" });
|
||
const drawUnit = (): void =>
|
||
unit.replaceChildren(
|
||
createUnitPick({
|
||
value: String(line.단위 ?? "") || NO_UNIT,
|
||
size: 24,
|
||
free: true,
|
||
ariaLabel: lt("Unit"),
|
||
extra: [...new Set((d.금액 ?? []).map((m) => String(m.단위 ?? "")).filter(Boolean))],
|
||
onPick: (u) => put((l) => (u === NO_UNIT ? delete l.단위 : (l.단위 = u))),
|
||
}),
|
||
);
|
||
drawUnit();
|
||
return [
|
||
el("span", { className: "lstk-kind", text: lt(KIND_KEY[kind]) }),
|
||
...head,
|
||
// 율 · 식 줄(자원 없음)에 수량이 없으면 칸 안 보임(한 줄 유지)
|
||
...(own || String(line.수량 ?? "") ? [qtyBox(h, i, shown, real)] : []),
|
||
unit,
|
||
...(line.비목 === "묶음"
|
||
? []
|
||
: [
|
||
createSelectField({
|
||
options: ["노무비", "재료비", "경비"].map((x) => ({ value: x, text: x })),
|
||
value: line.비목 ?? "노무비",
|
||
compact: true,
|
||
size: 24,
|
||
ariaLabel: lt("Cost"),
|
||
onChange: (x) => put((l) => (l.비목 = x)),
|
||
}).root,
|
||
]),
|
||
...(places
|
||
? [
|
||
el("div", {
|
||
className: "lstk-hint lstk-wide",
|
||
text: `= ${[line.수량, line.단가].filter(Boolean).map(read).join(" × ")}`,
|
||
}),
|
||
]
|
||
: []),
|
||
...(why ? [el("div", { className: "lstk-miss lstk-wide", text: why })] : []),
|
||
];
|
||
}
|
||
|
||
/** 수량 — 기호 하나면 값 + 출처 꼬리표(「0.39 ← 표 · 잡석 수량」 · 요구 687-12 · 기호는 마우스 글) · 누르면 그 자리에서 식 고침 */
|
||
function qtyBox(
|
||
h: EditHooks,
|
||
i: number,
|
||
shown: (t: unknown) => string,
|
||
real: (t: string) => string,
|
||
): HTMLElement {
|
||
const line = (h.doc.금액 ?? [])[i];
|
||
const text = String(line.수량 ?? "");
|
||
const v = h.doc.변수?.find((x) => varId(x) === text);
|
||
const at = `qty:${i}`;
|
||
const edit = (): HTMLElement => {
|
||
const f = field(lt("Qty"), shown(text), (t) =>
|
||
h.change((doc) => {
|
||
h.open.delete(at);
|
||
const l = doc.금액![i];
|
||
if (t.trim()) l.수량 = real(t);
|
||
else delete l.수량;
|
||
}),
|
||
);
|
||
f.classList.add("lstk-qty");
|
||
f.dataset.qty = String(i);
|
||
return f;
|
||
};
|
||
if (!v || h.open.has(at)) return edit();
|
||
const val = (h.answer.값 ?? {})[text];
|
||
const link = v.연결 ?? {};
|
||
const kind = link.갈래 ?? "식";
|
||
const from =
|
||
v.이름 || (link.키 || link.표 ? h.name(String(link.키 ?? link.표)) : "") || shown(text);
|
||
const b = createButton({
|
||
label: `${isNum(val) ? eqText(val) : "—"}${v.단위 && shownUnit(v.단위) ? ` ${shownUnit(v.단위)}` : ""} ← ${kind} · ${from}`,
|
||
variant: "ghost",
|
||
size: 24,
|
||
onClick: () => {
|
||
h.open.add(at);
|
||
h.change(() => undefined);
|
||
requestAnimationFrame(() =>
|
||
document.querySelector<HTMLInputElement>(`[data-qty="${i}"] input`)?.focus(),
|
||
);
|
||
},
|
||
});
|
||
b.classList.add("lstk-qty");
|
||
b.title = `${lt("Qty")} = ${shown(text)}`;
|
||
b.dataset.qty = String(i);
|
||
return b;
|
||
}
|
||
|
||
/** [+ 줄] 고르개 값 → 새 줄 */
|
||
function addLine(h: EditHooks, at: number, kind: string): void {
|
||
if (["인력", "재료", "기계", "단가산출"].includes(kind))
|
||
return openStackPick(
|
||
kind as MoneyKind,
|
||
(p) => h.change((d, items) => void addPicked(d, items[at], kind as MoneyKind, p)),
|
||
h.take,
|
||
);
|
||
h.change((d, items) => {
|
||
const it = items[at];
|
||
const n = (d.금액 ??= []).length;
|
||
const money = (line: MoneyLine): void => {
|
||
d.금액!.push(line);
|
||
it.줄.push({ 꼴: "금액", i: n });
|
||
};
|
||
if (kind === "율")
|
||
money({ 이름: "공구손료 및 경장비", 단위: "식", 비목: "경비", 단가: "노무비 × 3 %" });
|
||
else if (kind === "공식") {
|
||
const id = freshId(d, "x");
|
||
(d.변수 ??= []).push(newVar(id, { 갈래: "숫자", 값: 0 }));
|
||
it.줄.push({ 꼴: "공식", 기호: [id] });
|
||
h.open.add(`${at}.${it.줄.length - 1}`);
|
||
} else if (kind === "글") it.줄.push({ 꼴: "글", 글: "" });
|
||
});
|
||
}
|
||
|
||
/** 줄 지움 — 금액 줄은 그 줄이 가진 연결 기호까지 · 공식 묶음은 기호까지 */
|
||
function dropLine(d: StackDoc, items: StackItem[], i: number, j: number): void {
|
||
const line = items[i].줄[j];
|
||
if (line.꼴 === "금액") {
|
||
const own = ownedOf(d).get(line.i);
|
||
if (own) dropSyms(d, [own]);
|
||
} else if (line.꼴 === "공식") dropSyms(d, line.기호);
|
||
items[i].줄.splice(j, 1);
|
||
}
|
||
|
||
export function buildEdit(h: EditHooks): HTMLElement {
|
||
// [+] 줄 더함 메뉴(요구 687-13 · 18) — 인력 · 재료 · 기계 · 단가산출 · 율 · 식 · 근거 글
|
||
const add = (at: number): HTMLElement => {
|
||
const kinds: [string, LsKey][] = [
|
||
["인력", "K_인력"],
|
||
["재료", "K_재료"],
|
||
["기계", "K_기계"],
|
||
["단가산출", h.take ? "K_복사" : "K_단가산출"],
|
||
["율", "Add_율"],
|
||
["공식", "Add_식"],
|
||
["글", "K_글"],
|
||
];
|
||
const menu = createRowMenu(
|
||
kinds.map(([k, label]) => ({ label: lt(label), onClick: () => addLine(h, at, k) })),
|
||
{ icon: "plus", title: lt("Add_Line"), size: 24 },
|
||
);
|
||
menu.classList.add("lstk-add");
|
||
return menu;
|
||
};
|
||
// X 지움 — 확인 없이 · 알림 누르면 되돌림(그 뒤 다른 고침이 없을 때만)
|
||
const drop = (what: string, fn: (d: StackDoc, items: StackItem[]) => void): void => {
|
||
const snap = JSON.stringify(h.doc);
|
||
let after = "";
|
||
h.change((d, items) => {
|
||
fn(d, items);
|
||
after = JSON.stringify(docOf(d, items));
|
||
});
|
||
showToast(`${what || lt("K_줄")} ${lt("Dropped")}`, "info", 6000, () =>
|
||
h.change((d, items) => {
|
||
if (JSON.stringify(docOf(d, items)) !== after)
|
||
return void showToast(lt("Undo_Late"), "warning");
|
||
const back = JSON.parse(snap) as StackDoc;
|
||
for (const k of Object.keys(d)) delete (d as Record<string, unknown>)[k];
|
||
Object.assign(d, back);
|
||
items.splice(0, items.length, ...stackOf(back));
|
||
}),
|
||
);
|
||
};
|
||
const x = (title: string, onClick: () => void): HTMLElement => {
|
||
const b = createIconButton({ icon: "x", title, size: 24, onClick });
|
||
b.classList.add("lstk-x");
|
||
return b;
|
||
};
|
||
const grip = (): HTMLElement =>
|
||
el("span", { className: "ui-list-row__grip lstk-grip", attrs: { title: lt("Drag") } });
|
||
const root = el("div", { className: "lstk-edit" });
|
||
/** 항목 접힘 표시(화면 상태)를 항목 차례 바뀜에 맞춤 */
|
||
const refold = (fn: (flags: boolean[]) => void): void => {
|
||
const flags = h.items.map((_, k) => h.open.has(`fold:${k}`));
|
||
fn(flags);
|
||
h.items.forEach((_, k) => h.open.delete(`fold:${k}`));
|
||
flags.forEach((f, k) => f && h.open.add(`fold:${k}`));
|
||
};
|
||
// 항목 번호 — 산출근거와 같은 셈(제목 있는 항목 1. 2. · 옛 가나다 항목 = 가. 나. · 체크 걷음 요구 687-5)
|
||
let n = 0;
|
||
let sub = 0;
|
||
const nos = h.items.map((it) => {
|
||
if (!it.제목.trim()) return "";
|
||
const no = it.가나다 ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"}.` : `${++n}.`;
|
||
if (!it.가나다) sub = 0;
|
||
return no;
|
||
});
|
||
// 만약(IF) 안 탄 갈래(요구 687-10) — 갈래를 다 열면 닿고 지금 값으로는 안 닿는 기호 · 꺼진 금액 줄 = 숨김(산출근거와 같음)
|
||
const vals = (h.answer.값 ?? {}) as Values;
|
||
const taken = new Set(reach(h.doc, vals));
|
||
const every = new Set(reach(h.doc, {}, true));
|
||
const steps = lineMap(h.doc);
|
||
const off = (line: StackItem["줄"][number]): boolean =>
|
||
line.꼴 === "금액"
|
||
? offLine((h.doc.금액 ?? [])[line.i] ?? {}, vals, steps)
|
||
: line.꼴 === "공식" &&
|
||
line.기호.length > 0 &&
|
||
line.기호.every((id) => every.has(id) && !taken.has(id));
|
||
const items = h.items.map((it, i) => {
|
||
const folded = h.open.has(`fold:${i}`);
|
||
const itemGrip = grip();
|
||
// 그룹 머리 한 줄(요구 687-6 · 688) — 손잡이 · 접기 · 번호 · 이름 · [+] · X · 접어도 자리 그대로
|
||
const head = el("div", {
|
||
className: "lstk-ihead",
|
||
children: [
|
||
itemGrip,
|
||
createIconButton({
|
||
icon: folded ? "chevronRight" : "chevronDown",
|
||
title: folded ? lt("Unfold_Item") : lt("Fold_Item"),
|
||
size: 24,
|
||
onClick: () => {
|
||
if (folded) h.open.delete(`fold:${i}`);
|
||
else h.open.add(`fold:${i}`);
|
||
h.change(() => undefined);
|
||
},
|
||
}),
|
||
el("b", { className: "lstk-no", text: nos[i] }),
|
||
field(lt("Title"), it.제목, (t) => h.change((_, items) => (items[i].제목 = t.trim())), "l"),
|
||
el("span", {
|
||
className: "lstk-hint lstk-count",
|
||
text: folded ? `${lt("K_줄")} ${it.줄.length}` : "",
|
||
}),
|
||
add(i),
|
||
x(lt("Del_Item"), () =>
|
||
drop(it.제목.trim() || lt("Item"), (d, items) => {
|
||
refold((f) => f.splice(i, 1));
|
||
for (let j = items[i].줄.length - 1; j >= 0; j--) dropLine(d, items, i, j);
|
||
items.splice(i, 1);
|
||
}),
|
||
),
|
||
],
|
||
});
|
||
const lines = it.줄.flatMap((line, j) => {
|
||
if (off(line)) return [];
|
||
const lineGrip = grip();
|
||
const del = x(lt("Del"), () =>
|
||
drop(line.꼴 === "금액" ? String(h.doc.금액?.[line.i]?.이름 ?? "") : "", (d, items) =>
|
||
dropLine(d, items, i, j),
|
||
),
|
||
);
|
||
const body =
|
||
line.꼴 === "금액"
|
||
? moneyBody(h, line.i)
|
||
: line.꼴 === "글"
|
||
? [
|
||
el("span", { className: "lstk-kind", text: lt("K_글") }),
|
||
field(
|
||
lt("Text_Ph"),
|
||
line.글,
|
||
(t) => h.change((_, items) => ((items[i].줄[j] as { 글: string }).글 = t)),
|
||
"l",
|
||
),
|
||
]
|
||
: buildBlock(h, i, j, line, (id) => every.has(id) && !taken.has(id));
|
||
// 금액 · 글 = 한 줄(X 끝) · 공식 묶음 = 머리 줄 끝에 X
|
||
const card = el("div", {
|
||
className: `lstk-line k-${line.꼴}`,
|
||
children:
|
||
line.꼴 === "공식"
|
||
? [lineGrip, ...body.slice(0, 1).map((hd) => (hd.append(del), hd)), ...body.slice(1)]
|
||
: [lineGrip, ...body, del],
|
||
});
|
||
card.dataset.loc = `${i}.${j}`;
|
||
card.addEventListener("pointerdown", () => h.pair(`${i}.${j}`));
|
||
attachDrag(lineGrip, {
|
||
what: "line",
|
||
root,
|
||
self: card,
|
||
drop: (to) => {
|
||
const [k, p] = to.split(".").map(Number);
|
||
h.change((_, items) => moveLine(items, i, j, k, p));
|
||
},
|
||
});
|
||
return [card];
|
||
});
|
||
const box = el("div", {
|
||
className: `lstk-item${it.가나다 ? " is-sub" : ""}${folded ? " is-folded" : ""}`,
|
||
attrs: { "data-loc": String(i) },
|
||
children: [
|
||
head,
|
||
...(folded
|
||
? []
|
||
: [
|
||
el("div", {
|
||
className: "lstk-lines",
|
||
children: lines.length
|
||
? lines
|
||
: [el("div", { className: "lstk-empty", text: lt("Empty_Item") })],
|
||
}),
|
||
]),
|
||
],
|
||
});
|
||
head.addEventListener("pointerdown", () => h.pair(String(i)));
|
||
attachDrag(itemGrip, {
|
||
what: "item",
|
||
root,
|
||
self: box,
|
||
drop: (to) => {
|
||
const p = Number(to);
|
||
refold((f) => moveItem(f, i, p));
|
||
h.change((_, items) => moveItem(items, i, p));
|
||
},
|
||
});
|
||
return box;
|
||
});
|
||
root.append(
|
||
...items,
|
||
btn(lt("Add_Item"), () => h.change((_, items) => items.push({ 제목: "", 줄: [] }))),
|
||
);
|
||
return root;
|
||
}
|