Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_6' into sub_laptop_4

This commit is contained in:
2026-11-01 21:29:42 +09:00
21 changed files with 489 additions and 69 deletions
@@ -36,7 +36,7 @@ import {
type TreeRef,
type TreeView,
} from "./M02_MasterTemplete_Api_Fetch";
import { askCopyFolder, type CopyTarget } from "./M02_MasterTemplete_TreePanel_CopyTo";
import { copyToLayer, type CopyTarget } from "./M02_MasterTemplete_TreePanel_CopyTo";
import { askFolder, askNewItem, askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag";
import {
@@ -196,7 +196,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
{ label: L("M02_TreeCopy").replace("{n}", n), run: () => copy(list) },
...(opt.copyTo ?? []).map((to) => ({
label: to.label.replace("{n}", n),
run: () => void copyTo(list, to),
run: () => copyTo(list, to),
})),
];
};
@@ -243,6 +243,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
...(isTop
? []
: [
{ label: L("M02_TreeAddTopFolder"), run: () => void addFolder([]) }, // 720-2 = 「+」 길
{ label: L("M02_TreeRename"), run: () => void rename(target) },
{ label: L("M02_TreeMove"), run: () => void move(target) },
{ label: L("M02_Delete"), run: () => void remove(target) },
@@ -554,25 +555,12 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success");
}
/** 「〇〇로 복사…」 — 그 층 폴더를 고름(`askCopyFolder`) → 새 키 · 같은 이름 「(2)」 · 이 나무는 그대로 */
async function copyTo(list: Target[], to: CopyTarget): Promise<void> {
if (!api.paste || !list.length) return;
/** 「〇〇로 복사…」(39-6) — 몸은 `TreePanel_CopyTo` */
function copyTo(list: Target[], to: CopyTarget): void {
const paste_ = api.paste;
if (!paste_ || !list.length) return;
const clip = { kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) };
try {
const dest = await askCopyFolder(api.fetch, to, list.length);
if (!dest) return;
await api.paste(to.layer, to.owner, clip, dest);
const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사…?$/, "");
showToast(
L("M02_TreeCopiedTo")
.replace("{n}", String(list.length))
.replace("{layer}", where)
.replace("{folder}", dest.join(" / ") || L("M02_TreeTop")),
"success",
);
} catch (error) {
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
}
void copyToLayer({ fetch: api.fetch, paste: paste_ }, clip, to);
}
async function paste(dest: string[]): Promise<void> {
@@ -2,10 +2,12 @@
* M02_MasterTemplete_TreePanel_CopyTo.ts
* 「〇〇로 복사…」(39-6) — 붙일 층의 나무를 읽어 대상 폴더를 고름(맨 위 · 폴더) → 경로 · 취소 = null.
* 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움.
* `copyToLayer` = 고름 → 붙여넣기 → 알림(구조물 조합 나무 `TreePanel` 에서 뗌 · 720-2).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch";
import type { Layer, TreeClip, TreeView } from "./M02_MasterTemplete_Api_Fetch";
import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs";
import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model";
@@ -26,3 +28,38 @@ export async function askCopyFolder(
const title = to.label.replace("{n}", String(n));
return askFolder(title, folders, () => false, [], L("M02_TreeCopyFolder"));
}
/** 「〇〇로 복사…」 — 그 층 폴더를 고름(`askCopyFolder`) → 새 키 · 같은 이름 「(2)」 · 부른 나무는 그대로 */
export async function copyToLayer(
api: {
fetch: (layer: Layer, owner: string | null) => Promise<TreeView>;
paste: (
layer: Layer,
owner: string | null,
clip: TreeClip,
dest: string[],
) => Promise<TreeView>;
},
clip: TreeClip,
to: CopyTarget,
): Promise<void> {
const n = String(clip.refs.length);
try {
const dest = await askCopyFolder(api.fetch, to, clip.refs.length);
if (!dest) return;
await api.paste(to.layer, to.owner, clip, dest);
const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사…?$/, "");
showToast(
L("M02_TreeCopiedTo")
.replace("{n}", n)
.replace("{layer}", where)
.replace("{folder}", dest.join(" / ") || L("M02_TreeTop")),
"success",
);
} catch (error) {
showToast(
L("M02_ActionFailed").replace("{value}", error instanceof Error ? error.message : ""),
"error",
);
}
}
@@ -595,6 +595,10 @@ export function createMain(
sheet.recalc();
panel.refresh(sheet.getDoc()?.열 ?? []);
}
// 720-3 — 서고 문서(단가산출 · 일위대가) 이름을 고쳐 저장하면 머리 제목도 새 이름(「키 이름」)
const named = (doc as { 이름?: unknown } | null)?.이름;
if ((at.kind === "sangeun" || isPrecombo(at.kind)) && typeof named === "string" && sel === at)
title.textContent = at.label = named ? `${at.name} ${named}` : at.name;
showNotice([]);
showToast(L("M02_Saved"), "success");
onSaved?.(); // 좌측 목록의 판을 새 판으로
@@ -239,11 +239,13 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
history.replaceState(null, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`);
void side.refreshPrecombo();
};
const preBar = (): HTMLElement =>
// 층 단추 옆 「+」(720-2) = 서고 칸이 만든 맨 위 메뉴
const preBar = (add: HTMLElement): HTMLElement =>
createLayerBar({
user,
active: { root: pre.root, owner: pre.owner },
company,
add,
// 다른 층 = 저장 안 한 고침 물음 → 그 층 나무 + 목록표(720-1) · 켜진 단추 다시 누름 = 목록표
go: (next) =>
void main
@@ -254,11 +256,12 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
});
// 단가산출 층(717-2a) — 일위대가 층과 따로 · 기본 개인(못 가면 마스터) · 주소에 안 실음
let sk = preScope("personal");
const skBar = (): HTMLElement =>
const skBar = (add: HTMLElement): HTMLElement =>
createLayerBar({
user,
active: { root: sk.root, owner: sk.owner },
company,
add,
go: (next) =>
void main
.confirmLeave()
@@ -8,10 +8,12 @@
* 여럿(구조물 조합 나무 꼴 · `TreePanel_Pick`) — Ctrl · Shift 클릭 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V ·
* Delete 가 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(새 키).
* 문서 누름 = 편집 칸 · 켜진 층 단추 다시 누름 = 목록표(페이지 몫).
* 최상위 폴더(720-2) — 층 단추 옆 「+」(맨 위 메뉴) · 폴더 · 문서 줄 [최상위 폴더 만들기] = 같은 `addFolder([])`.
* 문서 줄 [이름 바꾸기](720-3) — 읽고 문서 `이름` 만 갈아 저장(서버 새 길 없음) · 열린 문서면 새 이름으로 다시 엶.
* ========================================================================== */
import { isCtrlKey } from "../A00_Common/key_shortcut";
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { t as L } from "@ui/ui_template_locale";
import {
@@ -28,6 +30,8 @@ import {
listTemplates,
newLibrary,
pasteLibraryTree,
readTemplate,
saveTemplate,
type Layer,
type LibraryKind,
type PrecomboTree,
@@ -58,8 +62,8 @@ export interface LibraryScope {
export interface LibraryOptions {
kind: LibraryKind;
scope: () => LibraryScope;
/** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */
bar: () => HTMLElement;
/** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) · `add` = 단추 옆 「+」(720-2) */
bar: (add: HTMLElement) => HTMLElement;
confirmLeave: () => Promise<boolean>;
onOpen: (sel: Selection | null) => void;
getOpen: () => Selection | null;
@@ -130,6 +134,15 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
className: "ui-tree m02-tree__body",
attrs: { "data-library-tree": kind, tabindex: "0" },
});
// 층 단추 옆 「+」(720-2 · 구조물 조합 꼴) — 맨 위 메뉴 · 읽기만 층 = 숨김(그릴 때)
const add = createButton({
label: "+",
variant: "ghost",
onClick: (event) => openTreeMenu(event, menuAt([])),
});
add.classList.add("m02-tree__add");
add.title = L("M02_TreeAddTop");
add.setAttribute("aria-label", L("M02_TreeAddTop"));
const leaves = new Map<string, HTMLElement>();
const toggled = new Map<string, boolean>(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로
const targets = new Map<string, Target>();
@@ -209,6 +222,34 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name });
};
/** 문서 이름 바꾸기(720-3) — 열린 문서면 저장 안 한 고침 물음 → 읽고 `이름` 만 갈아 저장 → 나무 · 상세 새 이름 */
const renameDoc = async (row: LibraryRow): Promise<void> => {
const at = opt.scope();
const was = row.제목 || row.이름;
const got = await askText(L("M02_TreeRename"), [
{ key: "name", label: L("M02_TreeItemName"), value: was },
]);
const name = got?.name.trim();
if (!name || name === was) return;
const cur = opt.getOpen();
const shown =
cur?.kind === kind &&
cur.name === row.이름 &&
cur.layer === at.layer &&
(cur.owner ?? null) === at.owner;
if (shown && !(await opt.confirmLeave())) return;
try {
const doc = await readTemplate(at.layer, kind, row.이름, null, at.owner);
const body = { ...(doc.문서 as Record<string, unknown>), 이름: name };
await saveTemplate(at.layer, kind, row.이름, null, doc.판 ?? "", body, at.owner);
showToast(L("M02_TreeDone"), "success");
await refresh(true);
if (shown) await open({ ...row, 제목: name });
} catch (error) {
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
}
};
/* --- 지우기 · 여럿 · 복사 붙여넣기(구조물 조합 나무 꼴) --- */
const pickedTargets = (): Target[] =>
[...picked].map((id) => targets.get(id)).filter((t): t is Target => !!t);
@@ -255,6 +296,7 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
item(opt.text.newHere, () => make(path)),
...(path.length
? [
item(L("M02_TreeAddTopFolder"), () => void addFolder([])),
item(L("M02_TreeRename"), () => void rename(path)),
item(L("M02_Delete"), () => void remove([target])),
{ label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) },
@@ -288,12 +330,15 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label };
const target: Target = { kind: "item", item };
targets.set(id, target);
const off = (): string | undefined => (opt.scope().canWrite ? undefined : tc("ReadOnly"));
const single = (): TreeMenuItem[] => [
{ label: L("M02_TreeAddTopFolder"), run: () => void addFolder([]), disabledReason: off() },
{ label: L("M02_TreeRename"), run: () => void renameDoc(row), disabledReason: off() },
{ label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) },
{
label: L("M02_Delete"),
run: () => void remove([target]),
disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly"),
disabledReason: off(),
},
...(opt.more?.(row.이름, label) ?? []),
];
@@ -424,7 +469,8 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
const refresh = async (keep = false): Promise<void> => {
const mine = ++seq;
const at = opt.scope();
barHost.replaceChildren(opt.bar());
add.hidden = !at.canWrite;
barHost.replaceChildren(opt.bar(add));
if (!keep) treeHost.replaceChildren(createEmpty({ text: tc("Loading"), small: true }));
const [got, tree] = await Promise.all([
// 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리
@@ -15,6 +15,7 @@ import type { Selection } from "./M02_MasterTemplete_UI_Main";
import {
buildLibrarySection,
type LibraryHandle,
type LibraryOptions,
type LibraryScope,
} from "./M02_MasterTemplete_UI_Side_Library";
@@ -53,8 +54,8 @@ export const precomboList = (scope: PrecomboScope): Selection => ({
export interface PrecomboOptions {
scope: () => PrecomboScope;
/** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */
bar: () => HTMLElement;
/** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) · `add` = 「+」(720-2) */
bar: LibraryOptions["bar"];
confirmLeave: () => Promise<boolean>;
onOpen: (sel: Selection | null) => void;
getOpen: () => Selection | null;