feat(720-2): 최상위 폴더 만들기 — 폴더 · 문서 줄 메뉴 + 단가산출 · 일위대가 층 줄 「+」(구조물 조합과 같은 addFolder([]))
- 구조물 조합 나무 「〇〇로 복사…」 몸을 TreePanel_CopyTo 로 뗌(TreePanel 700 → 688 줄 · 동작 같음) - 낡은 시험 test_m02_layer_screen(복사 대상 글 자리 = CopyTo) · test_m02_precombo_scope(층 줄 add 인자) 새 꼴로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,11 @@
|
||||
* 여럿(구조물 조합 나무 꼴 · `TreePanel_Pick`) — Ctrl · Shift 클릭 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V ·
|
||||
* Delete 가 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(새 키).
|
||||
* 문서 누름 = 편집 칸 · 켜진 층 단추 다시 누름 = 목록표(페이지 몫).
|
||||
* 최상위 폴더(720-2) — 층 단추 옆 「+」(맨 위 메뉴) · 폴더 · 문서 줄 [최상위 폴더 만들기] = 같은 `addFolder([])`.
|
||||
* ========================================================================== */
|
||||
|
||||
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 {
|
||||
@@ -58,8 +59,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 +131,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>();
|
||||
@@ -255,6 +265,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 +299,14 @@ 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_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 +437,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;
|
||||
|
||||
@@ -296,9 +296,10 @@ def test_켜진_층_단추는_목록으로_복사_끌기():
|
||||
assert "(to === layer && toOwner === here)) return [];" in page
|
||||
copy_to = _text(M02 / "M02_MasterTemplete_TreePanel_CopyTo.ts")
|
||||
assert "listFolders(buildFolders(view))" in copy_to and ".catch(() => null)" in copy_to
|
||||
assert "const dest = await askCopyFolder(api.fetch, to, list.length);" in _text(
|
||||
assert "const dest = await askCopyFolder(api.fetch, to, clip.refs.length);" in copy_to
|
||||
assert "void copyToLayer({ fetch: api.fetch, paste: paste_ }, clip, to);" in _text(
|
||||
M02 / "M02_MasterTemplete_TreePanel.ts"
|
||||
)
|
||||
) # 720-2 — 몸은 CopyTo 로 뗌(TreePanel 700 줄)
|
||||
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert "if (opt.canEdit) drag.arm(button, one.id);" in panel
|
||||
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
|
||||
|
||||
@@ -72,12 +72,12 @@ def test_사전조합_층_단추는_페이지를_다시_안_세움():
|
||||
assert "history.replaceState(" in pre and "side.refreshPrecombo()" in pre
|
||||
assert "navigateTo" not in pre and "setActiveLayer" not in pre # 페이지 층 · 해시 이동 없음
|
||||
assert "entry.pre = pre.root;" in pre # 구조물 조합 층 이동(go) 때도 주소에 남음
|
||||
bar = page.split("const preBar = (): HTMLElement =>")[1].split("const side = buildSide(")[0]
|
||||
bar = page.split("const preBar = (add: HTMLElement): HTMLElement =>")[1].split("const side = buildSide(")[0]
|
||||
# 720-1 — 다른 층 = 저장 안 한 고침 물음 → 그 층 나무 + 목록표
|
||||
go = bar.split("go: (next) =>")[1].split("reselect:")[0]
|
||||
assert "main.confirmLeave()" in go and "setPre(next), void main.open(precomboList(pre))" in go
|
||||
assert ".confirmLeave()" in go and "setPre(next), void main.open(precomboList(pre))" in go
|
||||
sk = page.split("const skBar = (")[1].split("const side = buildSide(")[0]
|
||||
assert "main.confirmLeave()" in sk and "void main.open(sangeunList(sk))" in sk.split("reselect:")[0]
|
||||
assert ".confirmLeave()" in sk and "void main.open(sangeunList(sk))" in sk.split("reselect:")[0]
|
||||
# 구조물 조합 층 단추는 그대로 해시 이동 한 벌
|
||||
assert page.count('layerBar("') == 1
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
|
||||
@@ -107,6 +107,7 @@ export const ui_locales_m2 = {
|
||||
M02_TreeAddTop: ["맨 위에 폴더 · 항목 추가", "Add folder or item at top"],
|
||||
M02_TreeEmpty: ["항목이 없음", "No items"],
|
||||
M02_TreeAddFolder: ["폴더 추가", "Add folder"],
|
||||
M02_TreeAddTopFolder: ["최상위 폴더 만들기", "New top-level folder"],
|
||||
M02_TreeAddItem: ["항목 추가", "Add item"],
|
||||
M02_TreeRename: ["이름 바꾸기", "Rename"],
|
||||
M02_TreeMove: ["옮기기", "Move"],
|
||||
|
||||
Reference in New Issue
Block a user