From 3f5dfd47b1f4f9a6fe0ea7a73c2ae595ffef9b46 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:54:24 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(spreadsheet):=20=EC=9A=B0=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EB=A9=94=EB=89=B4=EC=97=90=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=ED=95=AD=EB=AA=A9=20=EB=8D=A7=EB=B6=99?= =?UTF-8?q?=EC=9E=84(81-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA --- .../spreadsheet/spreadsheet_clipboard.ts | 3 +- A00_Common/spreadsheet/spreadsheet_menu.ts | 152 +++++++++++- .../spreadsheet/spreadsheet_menu_items.ts | 218 ++++++++++++++++++ A00_Common/spreadsheet/spreadsheet_text.ts | 26 +++ ui_template/ui_template_context_menu.css | 6 + ui_template/ui_template_context_menu.ts | 12 +- 6 files changed, 407 insertions(+), 10 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_menu_items.ts diff --git a/A00_Common/spreadsheet/spreadsheet_clipboard.ts b/A00_Common/spreadsheet/spreadsheet_clipboard.ts index 46a6b556e..7a653cb1b 100644 --- a/A00_Common/spreadsheet/spreadsheet_clipboard.ts +++ b/A00_Common/spreadsheet/spreadsheet_clipboard.ts @@ -460,7 +460,8 @@ function findBlockingMerge(sheet: Sheet, target: CellRange): boolean { return false; } -function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress): void { +/** 블록을 `at` 에 붙임 — 우클릭 메뉴의 보안 문맥 붙여넣기(81-3)도 이 함수를 그대로 씀. */ +export function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress): void { const target: CellRange = { r0: at.r, c0: at.c, diff --git a/A00_Common/spreadsheet/spreadsheet_menu.ts b/A00_Common/spreadsheet/spreadsheet_menu.ts index 603fa99e7..651a2ae93 100644 --- a/A00_Common/spreadsheet/spreadsheet_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_menu.ts @@ -28,10 +28,35 @@ function within(range: CellRange, r: number, c: number): boolean { /** `ctx.root` 의 contextmenu 를 받음(root null) */ export function attachMenu(ctx: SpreadsheetContext): PartHandle { - if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} }; - const menu = createMapContextMenu("ss"); ctx.root.append(menu.element); + + // 읽기 전용 · 미리보기 시트 — 메뉴 없이 무반응 대신 [복사] 한 줄만(81-3) + if (ctx.readOnly) { + const onReadOnlyMenu = (ev: MouseEvent): void => { + if (!ctx.grid.hitTest(ev.clientX, ev.clientY)) return; + ev.preventDefault(); + const box = ctx.root.getBoundingClientRect(); + menu.open(ev.clientX - box.left, ev.clientY - box.top, [ + [st("MenuCopyOnly"), () => document.execCommand("copy")], + ]); + }; + ctx.root.addEventListener("contextmenu", onReadOnlyMenu); + return { + root: menu.element, + refresh() {}, + destroy() { + ctx.root.removeEventListener("contextmenu", onReadOnlyMenu); + menu.close(); + menu.element.remove(); + }, + }; + } + + const loadMenuItems = () => import("./spreadsheet_menu_items"); + const fallbackPasteHint = (ok: boolean): void => { + if (!ok) showToast(st("MenuPasteHint"), "info"); + }; ctx.cellMenuExtra ??= () => []; // 칸 메뉴 있음 — 조건 만들기가 이 끝에 붙음 function isMerged(range: CellRange): boolean { @@ -79,7 +104,40 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], - [st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")], + [ + st("MenuPaste"), + () => void loadMenuItems().then((m) => m.pasteFromMenu(ctx).then(fallbackPasteHint)), + ], + { + label: st("MenuPasteSpecial"), + children: [ + [ + st("MenuPasteValues"), + () => + void loadMenuItems().then((m) => + m.pasteSpecialFromMenu(ctx, "값").then(fallbackPasteHint), + ), + ], + [ + st("MenuPasteFormat"), + () => + void loadMenuItems().then((m) => + m.pasteSpecialFromMenu(ctx, "서식").then(fallbackPasteHint), + ), + ], + ], + }, + [st("MenuInsertCells"), () => void loadMenuItems().then((m) => m.insertCells(ctx))], + [st("MenuDeleteCells"), () => void loadMenuItems().then((m) => m.deleteCells(ctx))], + [ + st("MenuDeleteRow"), + () => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at: r, 수: 1 }), + ], + [ + st("MenuDeleteCol"), + () => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at: c, 수: 1 }), + ], + { divider: true }, [ st("MenuClearContent"), () => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }), @@ -107,6 +165,15 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { ...(note ? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[]) : []), + { divider: true }, + [st("MenuAutofit"), () => void loadMenuItems().then((m) => m.autofitCols(ctx, [c]))], + { divider: true }, + [st("MenuCondFmt"), () => void loadMenuItems().then((m) => m.openCondFmt(ctx))], + [st("MenuValidation"), () => void loadMenuItems().then((m) => m.openValidation(ctx))], + [st("MenuNames"), () => void loadMenuItems().then((m) => m.openNames(ctx))], + { divider: true }, + [st("MenuFreeze"), () => void loadMenuItems().then((m) => m.freezeAtActiveCell(ctx))], + [st("MenuUnfreeze"), () => void loadMenuItems().then((m) => m.unfreezePanes(ctx))], ...(ctx.cellMenuExtra?.(r, c) ?? []), ]; } @@ -117,11 +184,24 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], - [st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")], [ - st("MenuInsertRow"), + st("MenuPaste"), + () => void loadMenuItems().then((m) => m.pasteFromMenu(ctx).then(fallbackPasteHint)), + ], + [ + st("MenuInsertRowAbove"), () => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }), ], + [ + st("MenuInsertRowBelow"), + () => + ctx.dispatch({ + 종류: "행넣기", + 시트: ctx.selection.시트, + at: rows[rows.length - 1] + 1, + 수, + }), + ], [ st("MenuDeleteRow"), () => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }), @@ -148,6 +228,25 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { 숨김: false, }), ], + { divider: true }, + [st("MenuRowSize"), () => void loadMenuItems().then((m) => m.openRowSizeDialog(ctx, rows))], + { divider: true }, + [st("MenuFilterToggle"), () => void loadMenuItems().then((m) => m.filterToggle(ctx))], + [st("MenuFreeze"), () => void loadMenuItems().then((m) => m.freezeFirstRow(ctx))], + [st("MenuUnfreeze"), () => void loadMenuItems().then((m) => m.unfreezePanes(ctx))], + { + label: st("MenuGroup"), + children: [ + [ + st("MenuGroupOn"), + () => void loadMenuItems().then((m) => m.groupAxis(ctx, "행", rows, true)), + ], + [ + st("MenuGroupOff"), + () => void loadMenuItems().then((m) => m.groupAxis(ctx, "행", rows, false)), + ], + ], + }, ]; } @@ -157,11 +256,24 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], - [st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")], [ - st("MenuInsertCol"), + st("MenuPaste"), + () => void loadMenuItems().then((m) => m.pasteFromMenu(ctx).then(fallbackPasteHint)), + ], + [ + st("MenuInsertColLeft"), () => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }), ], + [ + st("MenuInsertColRight"), + () => + ctx.dispatch({ + 종류: "열넣기", + 시트: ctx.selection.시트, + at: cols[cols.length - 1] + 1, + 수, + }), + ], [ st("MenuDeleteCol"), () => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }), @@ -188,6 +300,32 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { 숨김: false, }), ], + { divider: true }, + [st("MenuColSize"), () => void loadMenuItems().then((m) => m.openColSizeDialog(ctx, cols))], + [st("MenuAutofit"), () => void loadMenuItems().then((m) => m.autofitCols(ctx, cols))], + { divider: true }, + [st("MenuSortAsc"), () => void loadMenuItems().then((m) => m.sortAsc(ctx))], + [st("MenuSortDesc"), () => void loadMenuItems().then((m) => m.sortDesc(ctx))], + [st("MenuFilterToggle"), () => void loadMenuItems().then((m) => m.filterToggle(ctx))], + [st("MenuFreeze"), () => void loadMenuItems().then((m) => m.freezeFirstColumn(ctx))], + [st("MenuUnfreeze"), () => void loadMenuItems().then((m) => m.unfreezePanes(ctx))], + { + label: st("MenuGroup"), + children: [ + [ + st("MenuGroupOn"), + () => void loadMenuItems().then((m) => m.groupAxis(ctx, "열", cols, true)), + ], + [ + st("MenuGroupOff"), + () => void loadMenuItems().then((m) => m.groupAxis(ctx, "열", cols, false)), + ], + ], + }, + { divider: true }, + [st("MenuCondFmt"), () => void loadMenuItems().then((m) => m.openCondFmt(ctx))], + [st("MenuValidation"), () => void loadMenuItems().then((m) => m.openValidation(ctx))], + [st("MenuNames"), () => void loadMenuItems().then((m) => m.openNames(ctx))], ]; } diff --git a/A00_Common/spreadsheet/spreadsheet_menu_items.ts b/A00_Common/spreadsheet/spreadsheet_menu_items.ts new file mode 100644 index 000000000..a11d2c216 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_menu_items.ts @@ -0,0 +1,218 @@ +/* ============================================================================= + * spreadsheet_menu_items.ts (주인 E · 81-3) + * 우클릭 메뉴에 덧붙이는 구글 시트 항목 — 기능은 전부 이미 있는 것만(분석_정함.md 81-3 · 조사_A 3 표(a)). + * `spreadsheet_menu.ts` 가 메뉴를 열 때 한 번만 `import()`(크기 예산 — 안 열면 안 받음). + * 안 만듦: 하이퍼링크 · 댓글 · 범위/시트 보호 · 다른 파일로 복사. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { dialogKeys } from "./spreadsheet_actions"; +import { pasteBlock, parseClipboard } from "./spreadsheet_clipboard"; +import { pasteSpecial } from "./spreadsheet_paste_special"; +import { openInsertCellsDialog, openDeleteCellsDialog } from "./spreadsheet_insert_dialog"; +import { autofitColumn } from "./spreadsheet_autofit"; +import { sortSelection } from "./spreadsheet_sort_dialog"; +import { toggleFilter } from "./spreadsheet_filter"; +import { + freezeFirstRow, + freezeFirstColumn, + freezeAtActiveCell, + unfreezePanes, +} from "./spreadsheet_view_menu"; +import { createCondFmtPanel } from "./spreadsheet_condfmt"; +import { createValidationPanel } from "./spreadsheet_validation"; +import { st } from "./spreadsheet_text"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; + +/** 보안 문맥(localhost · https)에서만 시스템 클립보드를 읽음 — http(Tailscale 주소)는 null + * (부른 쪽이 Ctrl+V 안내로 되돌림, 지금처럼). */ +async function readSystemClipboard(): Promise<{ html: string; text: string } | null> { + if (!window.isSecureContext || !navigator.clipboard?.read) return null; + try { + const items = await navigator.clipboard.read(); + let html = ""; + let text = ""; + for (const item of items) { + if (item.types.includes("text/html")) html = await (await item.getType("text/html")).text(); + if (item.types.includes("text/plain")) text = await (await item.getType("text/plain")).text(); + } + return { html, text }; + } catch { + return null; // 권한 거부 · 빈 클립보드 등 + } +} + +/** 메뉴 [붙여넣기] — 됐으면 true(부른 쪽은 더 안 함) · 안 됐으면 false(Ctrl+V 안내로 되돌림). */ +export async function pasteFromMenu(ctx: SpreadsheetContext): Promise { + const data = await readSystemClipboard(); + if (!data) return false; + const at = { ...ctx.selection.활성 }; + const block = parseClipboard(data.html, data.text, at); + if (!block) { + showToast(st("ClipboardPasteFailed"), "error"); + return true; + } + pasteBlock(ctx, block, at); + return true; +} + +/** 메뉴 [선택하여 붙여넣기 — 값만 · 서식만] — 결과는 위와 같은 뜻. */ +export async function pasteSpecialFromMenu( + ctx: SpreadsheetContext, + mode: "값" | "서식", +): Promise { + const data = await readSystemClipboard(); + if (!data) return false; + const at = { ...ctx.selection.활성 }; + const block = parseClipboard(data.html, data.text, at); + if (!block) { + showToast(st("ClipboardPasteFailed"), "error"); + return true; + } + const source = + block.출처 === "aislo" && block.원점 ? { 시트: ctx.selection.시트, ...block.원점 } : undefined; + pasteSpecial(ctx, block, at, mode, source); + return true; +} + +// ── 행/열 크기(숫자 창) ────────────────────────────────────────────────────── +function sizeDialog(title: string, initial: number, unit: string, onOk: (n: number) => void): void { + const input = el("input", { + attrs: { type: "number", min: "4", step: "1", value: String(Math.round(initial)) }, + }) as HTMLInputElement; + input.style.cssText = "width:80px;margin-right:6px;"; + const overlay = el("div", { className: "ui-confirm" }); + const panel = el("div", { className: "ui-confirm__panel" }); + const close = (): void => overlay.remove(); + overlay.addEventListener("click", (ev) => { + if (ev.target === overlay) close(); + }); + const commit = (): void => { + const n = Number(input.value); + if (Number.isFinite(n) && n > 0) onOk(n); + close(); + }; + panel.append( + el("h3", { text: title }), + el("div", { children: [input, el("span", { text: unit })] }), + el("div", { + className: "ui-confirm__actions", + children: [ + createButton({ label: "취소", variant: "ghost", onClick: close }), + createButton({ label: "확인", variant: "filled", onClick: commit }), + ], + }), + ); + overlay.append(panel); + document.body.append(overlay); + dialogKeys(overlay, close, commit); + input.focus(); + input.select(); +} + +export function openRowSizeDialog(ctx: SpreadsheetContext, rows: number[]): void { + if (ctx.readOnly) return; + sizeDialog(st("MenuRowSize"), 15, "pt", (pt) => + ctx.dispatch({ 종류: "행높이", 시트: ctx.sheet().id, 행: rows, 높이: pt }), + ); +} + +export function openColSizeDialog(ctx: SpreadsheetContext, cols: number[]): void { + if (ctx.readOnly) return; + sizeDialog( + st("MenuColSize"), + 64, + "px", + (px) => ctx.dispatch({ 종류: "열폭", 시트: ctx.sheet().id, 열: cols, 폭: px / 7 }), // 대략 7px/문자(헤더 쪽과 같은 어림) + ); +} + +export function autofitCols(ctx: SpreadsheetContext, cols: number[]): void { + for (const c of cols) autofitColumn(ctx, c); +} + +export function sortAsc(ctx: SpreadsheetContext): void { + sortSelection(ctx, false); +} + +export function sortDesc(ctx: SpreadsheetContext): void { + sortSelection(ctx, true); +} + +export function filterToggle(ctx: SpreadsheetContext): void { + toggleFilter(ctx); +} + +export { freezeFirstRow, freezeFirstColumn, freezeAtActiveCell, unfreezePanes }; + +export function groupAxis( + ctx: SpreadsheetContext, + axis: "행" | "열", + 번호: number[], + on: boolean, +): void { + ctx.dispatch({ + 종류: "묶음설정", + 시트: ctx.sheet().id, + 축: axis, + 번호, + 묶음: on ? { 층: 1 } : null, + }); +} + +// ── 조건부 서식 · 데이터 확인 · 범위 이름 — 작은 창에 패널을 그대로 띄움 ──────────── +let panel: PartHandle | null = null; +let host: HTMLElement | null = null; +function closePanelHost(): void { + panel?.destroy(); + panel = null; + host?.remove(); + host = null; +} +function openPanelHost( + ctx: SpreadsheetContext, + title: string, + make: (c: SpreadsheetContext) => PartHandle, +): void { + closePanelHost(); + const shut = el("button", { text: "×", attrs: { type: "button", title: "닫기" } }); + shut.style.cssText = "margin-left:auto;"; + const headRow = el("div", { + children: [el("strong", { text: title }), shut], + }); + headRow.style.cssText = "display:flex;align-items:center;gap:8px;margin-bottom:8px;"; + const overlay = el("div", { className: "ui-confirm" }); + const panelEl = el("div", { className: "ui-confirm__panel" }); + overlay.addEventListener("click", (ev) => { + if (ev.target === overlay) closePanelHost(); + }); + shut.addEventListener("click", () => closePanelHost()); + panelEl.append(headRow); + panel = make(ctx); + if (panel.root) panelEl.append(panel.root); + overlay.append(panelEl); + document.body.append(overlay); + host = overlay; + dialogKeys(overlay, closePanelHost); +} + +export function openCondFmt(ctx: SpreadsheetContext): void { + openPanelHost(ctx, st("MenuCondFmt"), createCondFmtPanel); +} + +export function openValidation(ctx: SpreadsheetContext): void { + openPanelHost(ctx, st("MenuValidation"), createValidationPanel); +} + +export async function openNames(ctx: SpreadsheetContext): Promise { + const m = await import("./spreadsheet_names"); + openPanelHost(ctx, st("MenuNames"), m.createNameManagerPanel); +} + +export function insertCells(ctx: SpreadsheetContext): void { + openInsertCellsDialog(ctx); +} + +export function deleteCells(ctx: SpreadsheetContext): void { + openDeleteCellsDialog(ctx); +} diff --git a/A00_Common/spreadsheet/spreadsheet_text.ts b/A00_Common/spreadsheet/spreadsheet_text.ts index 0b097515f..9291c67a6 100644 --- a/A00_Common/spreadsheet/spreadsheet_text.ts +++ b/A00_Common/spreadsheet/spreadsheet_text.ts @@ -95,6 +95,32 @@ const TEXT = { MenuCommentInsert: ["메모 삽입", "Insert comment"], MenuCommentEdit: ["메모 편집", "Edit comment"], MenuCommentDelete: ["메모 삭제", "Delete comment"], + // 81-3 — 구글 시트에 있고 이미 기능이 있는 것만 메뉴에 덧붙임 + MenuPaste: ["붙여넣기", "Paste"], + MenuPasteSpecial: ["선택하여 붙여넣기", "Paste special"], + MenuPasteValues: ["값만 붙여넣기", "Paste values only"], + MenuPasteFormat: ["서식만 붙여넣기", "Paste format only"], + MenuInsertRowAbove: ["위에 행 삽입", "Insert row above"], + MenuInsertRowBelow: ["아래에 행 삽입", "Insert row below"], + MenuInsertColLeft: ["왼쪽에 열 삽입", "Insert column left"], + MenuInsertColRight: ["오른쪽에 열 삽입", "Insert column right"], + MenuInsertCells: ["셀 삽입…", "Insert cells…"], + MenuDeleteCells: ["셀 삭제…", "Delete cells…"], + MenuAutofit: ["데이터에 맞추기", "Fit to data"], + MenuRowSize: ["행 높이…", "Row height…"], + MenuColSize: ["열 너비…", "Column width…"], + MenuSortAsc: ["정렬 A→Z", "Sort A→Z"], + MenuSortDesc: ["정렬 Z→A", "Sort Z→A"], + MenuFilterToggle: ["필터 켜기 · 끄기", "Toggle filter"], + MenuFreeze: ["틀 고정", "Freeze"], + MenuUnfreeze: ["틀 고정 풀기", "Unfreeze"], + MenuGroup: ["그룹", "Group"], + MenuGroupOn: ["묶기", "Group"], + MenuGroupOff: ["묶음 풀기", "Ungroup"], + MenuCondFmt: ["조건부 서식…", "Conditional formatting…"], + MenuValidation: ["데이터 확인…", "Data validation…"], + MenuNames: ["범위 이름 지정…", "Named range…"], + MenuCopyOnly: ["복사", "Copy"], ClipboardPasteFailed: ["붙여넣기를 읽지 못함", "Could not read paste content"], ClipboardMergeBlocked: [ "병합된 셀의 일부를 바꿀 수 없습니다", diff --git a/ui_template/ui_template_context_menu.css b/ui_template/ui_template_context_menu.css index 2c0bd6eef..4ab592270 100644 --- a/ui_template/ui_template_context_menu.css +++ b/ui_template/ui_template_context_menu.css @@ -64,3 +64,9 @@ .ui-ctx-menu__item.is-sub { padding-left: var(--spacing-24); } + +/* 구분선(81-3) — 블록별 CSS 가 없으면(`.ss__context-menu-sep` 같은) 이 기본으로 */ +.ui-ctx-menu__sep { + margin: var(--spacing-4); + border-top: 1px solid var(--color-border); +} diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index 48874fbb6..334c2b02b 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -14,10 +14,12 @@ import "./ui_template_context_menu.css"; /** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 누르면 바로 아래로 - * 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화). */ + * 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화). + * 구분선 = {divider: true}(81-3 — 덧붙이는 꼴 · B04 · B05 · 나무 메뉴는 안 써서 그대로). */ export type MapContextMenuItem = | [label: string, action: () => void] - | { label: string; children: Array<[label: string, action: () => void]> }; + | { label: string; children: Array<[label: string, action: () => void]> } + | { divider: true }; export interface MapContextMenu { /** 뷰포트에 append할 요소. */ @@ -122,6 +124,12 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { element.append(buildLeaf(item[0], item[1])); return; } + if ("divider" in item) { + const sep = document.createElement("div"); + sep.className = `${blockClass}__context-menu-sep ui-ctx-menu__sep`; + element.append(sep); + return; + } // 하위 메뉴(아코디언) — 머리글을 누르면 종류 목록이 바로 아래로 펼쳐진다. // 한 번에 한 군만 연다(다른 군을 열면 앞의 군은 접힘). const head = document.createElement("button"); From e0cbb1f17d308a9899af43e5f05ac74dfb10a8a3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:04:09 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(spreadsheet):=20=EC=95=8C=EC=95=BD=20?= =?UTF-8?q?=EC=A4=84=20=EB=81=9D=20=EC=97=B0=EA=B2=B0=20=EB=AA=A8=EB=91=90?= =?UTF-8?q?=20=ED=95=B4=EC=A0=9C(81-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA --- .../spreadsheet/spreadsheet_link_set.ts | 18 +++++++ A00_Common/spreadsheet/spreadsheet_links.css | 7 +++ A00_Common/spreadsheet/spreadsheet_links.ts | 47 ++++++++++++++++--- .../spreadsheet/spreadsheet_link_set_entry.ts | 17 +++++++ resources/tester/test_spreadsheet_set_link.py | 2 +- 5 files changed, 83 insertions(+), 8 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_link_set.ts b/A00_Common/spreadsheet/spreadsheet_link_set.ts index 0a2db64b1..4a8a2de5c 100644 --- a/A00_Common/spreadsheet/spreadsheet_link_set.ts +++ b/A00_Common/spreadsheet/spreadsheet_link_set.ts @@ -70,6 +70,24 @@ export function linkCommand( return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }; } +/** 알약 여러 키의 연결을 모든 시트에서 한꺼번에 뺌(81-4 [연결 모두 해제]) — + * 바뀐 시트가 둘 이상이면 「묶음」(되돌리기 한 번) · 바뀐 것 없으면 null. */ +export function unlinkAllCommand(book: Workbook, keys: string[]): Command | null { + const keySet = new Set(keys); + const commands: Command[] = []; + for (const s of book.시트) { + const was = s.연결 ?? []; + const next = was.filter((l) => !keySet.has(l.키)); + if (next.length === was.length) continue; + const copy = structuredClone(s); + if (next.length) copy.연결 = next; + else delete copy.연결; + commands.push({ 종류: "시트통째", 시트: copy }); + } + if (!commands.length) return null; + return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }; +} + /** 면적 문서에 알약 키를 잇기 · 풀기(`칸` null) — 활성 시트에 · 새 문서를 돌려줌(받은 문서는 그대로 · * 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null · 같은 칸 다른 알약은 함께 둠(`replace` 면 뺌). 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */ export function linkAreaCell( diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index 30694cd8e..6b3e5768c 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -89,6 +89,13 @@ color: var(--color-text-secondary); } +/* [연결 모두 해제](81-4) — 자재 추가와 같은 결이나 덜 강조 */ +.ss-link--clear { + border-style: dashed; + color: var(--color-text-secondary); + margin-left: auto; +} + /* 연결 칸 왼아래 세모(초록 오류 세모는 왼위 · 빨강 메모는 오른위) */ .ss-link-dot { position: absolute; diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index e2d31e23a..378402ef9 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -14,12 +14,12 @@ import "./spreadsheet_links.css"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; -import { el, showToast } from "@ui/ui_template_elements"; +import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { inRange, parseA1, parseRange, quoteSheet, toA1 } from "./spreadsheet_address"; import { applyCommand } from "./spreadsheet_commands"; import { askShare } from "./spreadsheet_link_ask"; import { linkHues } from "./spreadsheet_link_color"; -import { linkCommand, linksAt } from "./spreadsheet_link_set"; +import { linkCommand, linksAt, unlinkAllCommand } from "./spreadsheet_link_set"; import type { slotOf } from "./spreadsheet_editor"; import { shiftSpan } from "./spreadsheet_refshift"; import { selectCell } from "./spreadsheet_selection"; @@ -81,6 +81,9 @@ const TEXT = { dup: "이미 목록에 있는 자재", orphan: "일위대가에 없는 알약 — 연결을 풀면 [저장] 때 남음", orphanX: "연결 풀기", + unlinkAll: "연결 모두 해제", + unlinkAllConfirm: "이 줄의 알약 연결을 모두 풀까요? 되돌리기로 되돌릴 수 있습니다.", + unlinkAllDone: "연결을 모두 풀었습니다", }; type RowCol = Extract; @@ -313,10 +316,8 @@ export function mountLinks( } } - function renderBar(): void { - if (!opts) return; - align(); - const map = found(); + /** 알약 줄에 보일 전부(목록 항목 + 문서에만 있는 연결, 풀 수 있게) — [연결 모두 해제] 도 같은 목록을 씀. */ + function shownItems(map: Map): LinkItem[] { const shown: LinkItem[] = []; const seen = new Set(); const add = (item: LinkItem) => { @@ -325,13 +326,35 @@ export function mountLinks( shown.push(item); }; for (const item of items) add(item); - // 문서에 있는 연결(목록에서 빠진 것) — 풀 수 있게 늘 보임 for (const { link } of map.values()) add( link.자재 ? ofMaterial(link.자재) : { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 }, ); + return shown; + } + + /** [연결 모두 해제] — 편집 가능 + 이 줄에 연결 1개 이상일 때만 · 물음 한 번 · 되돌리기 한 번. */ + async function unlinkAll(): Promise { + if (ctx.readOnly) return; + const map = found(); + const keys = shownItems(map) + .map((i) => i.키) + .filter((k) => map.has(k)); + if (!keys.length) return; + if (!(await showConfirmDialog(TEXT.unlinkAllConfirm, TEXT.unlinkAll))) return; + const command = unlinkAllCommand(ctx.book, keys); + if (!command) return; + ctx.dispatch(command); + showToast(TEXT.unlinkAllDone, "info"); + } + + function renderBar(): void { + if (!opts) return; + align(); + const map = found(); + const shown = shownItems(map); const hues = linkHues(map.keys()); const listed = new Set(items.map((i) => i.키)); const sure = opts.모드 === "입력" && opts.확정 === true; @@ -353,6 +376,16 @@ export function mountLinks( more.addEventListener("click", () => openPick(more)); pills.push(more); } + // [연결 모두 해제] — 편집 가능 + 이 줄에 연결 1개 이상일 때만(81-4) + if (!ctx.readOnly && shown.some((item) => map.has(item.키))) { + const clear = el("button", { + className: "ss-link ss-link--clear", + text: TEXT.unlinkAll, + attrs: { type: "button" }, + }); + clear.addEventListener("click", () => void unlinkAll()); + pills.push(clear); + } bar.replaceChildren(...pills); if (fresh) bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts index 45acbf3be..73e4bc101 100644 --- a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -6,6 +6,7 @@ import { linkAreaCell, linkCommand, linksAt, + unlinkAllCommand, } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; @@ -152,4 +153,20 @@ eq( linkHues(["철근|ton"]).get("철근|ton"), ); +// unlinkAllCommand(81-4) — 여러 시트에 걸친 키를 한꺼번에 뺌 · 되돌리기 한 번 +b = book(); +undo = run(b, unlinkAllCommand(b, ["깔기|㎡", "치기|㎥"])); +eq("모두 해제 — 두 시트 다 빠짐(연결 칸 없어짐)", links(b), [null, null]); +run(b, undo); +eq("모두 해제 되돌림", links(b), links(book())); + +b = book(); +run(b, unlinkAllCommand(b, ["깔기|㎡"])); +eq("모두 해제 — 한 시트만 바뀌면 시트통째 하나", links(b), [ + null, + [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }], +]); + +eq("모두 해제 — 바뀔 것 없음 = null", unlinkAllCommand(book(), ["없음"]), null); + console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/test_spreadsheet_set_link.py b/resources/tester/test_spreadsheet_set_link.py index fbb65186c..02f70679b 100644 --- a/resources/tester/test_spreadsheet_set_link.py +++ b/resources/tester/test_spreadsheet_set_link.py @@ -55,7 +55,7 @@ def test_setLink_순수_길(): assert run.stdout.strip(), run.stderr result = json.loads(run.stdout) assert result["fail"] == 0, result["failures"] - assert result["pass"] == 29 + assert result["pass"] == 33 def test_손잡이_setLink_몸(): From 4d1535526fa779408d7971cad36c2c6943a6c594 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:14:35 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(spreadsheet):=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EB=AC=B6=EC=9D=8C=EC=97=90=20=EC=A7=80=EB=A6=84=20=CE=A6=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(81-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA --- A00_Common/spreadsheet/spreadsheet_text.ts | 1 + A00_Common/spreadsheet/spreadsheet_toolbar.ts | 30 ++++++++-- .../spreadsheet_toolbar_unit_entry.ts | 36 ++++++++++++ .../tester/test_spreadsheet_toolbar_unit.py | 57 +++++++++++++++++++ 4 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 resources/tester/spreadsheet/spreadsheet_toolbar_unit_entry.ts create mode 100644 resources/tester/test_spreadsheet_toolbar_unit.py diff --git a/A00_Common/spreadsheet/spreadsheet_text.ts b/A00_Common/spreadsheet/spreadsheet_text.ts index 9291c67a6..a5798a4ad 100644 --- a/A00_Common/spreadsheet/spreadsheet_text.ts +++ b/A00_Common/spreadsheet/spreadsheet_text.ts @@ -134,6 +134,7 @@ const TEXT = { UnitGroup_op: ["연산·비교", "Operators"], UnitRecent: ["최근 씀", "Recently used"], UnitAsFormat: ["숫자 형식으로 붙이기", "Attach as number format"], + UnitPhiTitle: ["지름(Φ) — 숫자 앞에 붙음", "Diameter (Φ) — attached before the number"], FormulaHint_label: [ "엑셀 수식까지: 엑셀에서 Ctrl+` → 복사 → 여기 Ctrl+V · 끝나면 엑셀 Ctrl+` 다시", "Formulas too: in Excel Ctrl+` → copy → paste here (Ctrl+V) · then Ctrl+` again in Excel", diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.ts b/A00_Common/spreadsheet/spreadsheet_toolbar.ts index 5ff071829..a6e38dd9a 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.ts @@ -62,8 +62,11 @@ const BORDER_LINES: BorderLine[] = [ type TextKey = Parameters[0]; -const UNIT_GROUPS: [label: TextKey, items: string[]][] = [ - ["UnitGroup_area", ["㎡", "㎥", "m", "㎝", "㎜", "㎞"]], +/** 지름 기호 — 숫자 앞에 붙임(81-5 · 자재 규격에서 압도적 · Φ300). */ +export const PHI_UNIT = "Φ"; + +export const UNIT_GROUPS: [label: TextKey, items: string[]][] = [ + ["UnitGroup_area", ["㎡", "㎥", "m", "㎝", "㎜", "㎞", PHI_UNIT]], ["UnitGroup_weight", ["kg", "t", "ℓ", "㎖"]], ["UnitGroup_count", ["개", "본", "식", "EA", "매"]], ["UnitGroup_rate", ["m/s", "°", "%", "‰"]], @@ -92,11 +95,17 @@ function pushRecentUnit(unit: string): string[] { } /** 숫자 형식 코드 끝 따옴표 글자(전에 붙인 단위)를 떼고 새 단위를 붙임 — 없으면 "0" 에서 시작. */ -function attachUnitToFormat(code: string, unit: string): string { +export function attachUnitToFormat(code: string, unit: string): string { const base = (code || "0").replace(/"[^"]*"\s*$/, ""); return base + `"${unit}"`; } +/** Φ(지름 기호) 는 숫자 앞 — 서식 코드 머리의 글자를 바꿔 끼움(81-5). */ +export function prependUnitToFormat(code: string, unit: string): string { + const base = (code || "0").replace(/^\s*"[^"]*"/, ""); + return `"${unit}"` + base; +} + /** 활성 칸에 지금 걸린 서식 — 칸 자기 것 없으면 열 · 행 · 시트 기본(`Sheet.기본.서식`) · 그다음 0. */ function styleAt(ctx: SpreadsheetContext, r: number, c: number): CellStyle { const sheet = ctx.sheet(); @@ -407,12 +416,22 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { } } else if (unitAsFormat.checked) { const { r, c } = ctx.selection.활성; - patch({ 형식: attachUnitToFormat(styleAt(ctx, r, c).형식 ?? "", unit) }); + const 형식 = styleAt(ctx, r, c).형식 ?? ""; + patch({ + 형식: unit === PHI_UNIT ? prependUnitToFormat(형식, unit) : attachUnitToFormat(형식, unit), + }); } else { const { r, c } = ctx.selection.활성; const sheet = ctx.sheet(); const key = toA1(r, c); - const text = cellToText(sheet.칸[key]) + unit; + const before = cellToText(sheet.칸[key]); + // Φ 는 글 앞 — 강제 텍스트 홑낫표(')가 있으면 그 뒤에(숫자 앞) 끼워 넣음(81-5) + const text = + unit === PHI_UNIT + ? before[0] === "'" + ? "'" + unit + before.slice(1) + : unit + before + : before + unit; ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: { [key]: textToCell(text, sheet.칸[key]) } }); } renderRecent(pushRecentUnit(unit)); @@ -436,6 +455,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { for (const u of items) { const b = createButton({ label: u, variant: "ghost", onClick: () => insertUnit(u) }); b.className += " ss-toolbar__unit-btn"; + if (u === PHI_UNIT) b.title = st("UnitPhiTitle"); grid.append(b); } unitPop.append( diff --git a/resources/tester/spreadsheet/spreadsheet_toolbar_unit_entry.ts b/resources/tester/spreadsheet/spreadsheet_toolbar_unit_entry.ts new file mode 100644 index 000000000..18e2f66c1 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_toolbar_unit_entry.ts @@ -0,0 +1,36 @@ +/* 단위 Φ(81-5) 순수 길 시험 진입 — vite --ssr 로 번들해 Node 로 돌림. + * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_toolbar_unit.py` 가 읽음) */ +import { + attachUnitToFormat, + PHI_UNIT, + prependUnitToFormat, + UNIT_GROUPS, +} from "../../../A00_Common/spreadsheet/spreadsheet_toolbar"; + +let pass = 0; +const failures: string[] = []; +const eq = (name: string, got: unknown, want: unknown): void => { + if (JSON.stringify(got) === JSON.stringify(want)) pass += 1; + else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`); +}; + +// Φ 는 길이 묶음(UnitGroup_area) 끝 — 다른 묶음엔 안 들어감 +const lengthGroup = UNIT_GROUPS.find(([label]) => label === "UnitGroup_area")!; +eq("Φ = 길이 묶음 끝", lengthGroup[1][lengthGroup[1].length - 1], PHI_UNIT); +eq( + "Φ 는 다른 묶음에 없음", + UNIT_GROUPS.filter(([label]) => label !== "UnitGroup_area").some((g) => g[1].includes(PHI_UNIT)), + false, +); + +// 보통 단위(뒤에 붙음) — 지금처럼 +eq("보통 단위 서식 — 뒤", attachUnitToFormat("0", "㎡"), '0"㎡"'); +eq("보통 단위 서식 — 옛 단위 바꿈", attachUnitToFormat('0"㎡"', "㎝"), '0"㎝"'); + +// Φ(지름) — 숫자 앞 +eq("Φ 서식 — 앞", prependUnitToFormat("0", PHI_UNIT), '"Φ"0'); +eq("Φ 서식 — 빈 코드도 0 기준", prependUnitToFormat("", PHI_UNIT), '"Φ"0'); +eq("Φ 서식 — 옛 Φ 바꿈(겹쳐 안 붙음)", prependUnitToFormat('"Φ"0', PHI_UNIT), '"Φ"0'); +eq("Φ 서식 — 뒤 단위와 공존", prependUnitToFormat('0"㎡"', PHI_UNIT), '"Φ"0"㎡"'); + +console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/test_spreadsheet_toolbar_unit.py b/resources/tester/test_spreadsheet_toolbar_unit.py new file mode 100644 index 000000000..ca727888f --- /dev/null +++ b/resources/tester/test_spreadsheet_toolbar_unit.py @@ -0,0 +1,57 @@ +"""단위 Φ(81-5) — `spreadsheet_toolbar.ts` UNIT_GROUPS 길이 묶음 끝에 Φ · 숫자 앞에 붙임. + +순수 길(`attachUnitToFormat` · `prependUnitToFormat` · `UNIT_GROUPS`)을 vite `--ssr` 로 +번들해 Node 로 돌림. 화면(단위 ▾ 팝오버 · 누르면 붙음)은 ORCA 로 확인. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_toolbar_unit_entry.ts" +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_단위_파이_순수_길(): + with tempfile.TemporaryDirectory(prefix="spreadsheet_toolbar_unit_") as outdir: + entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/") + build = subprocess.run( + [ + "node", + str(VITE), + "build", + "--configLoader", + "runner", + "--ssr", + entry_rel, + "--outDir", + outdir, + ], + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + ) + assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}" + run = subprocess.run( + ["node", str(Path(outdir) / "spreadsheet_toolbar_unit_entry.js")], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + assert run.stdout.strip(), run.stderr + result = json.loads(run.stdout) + assert result["fail"] == 0, result["failures"] + assert result["pass"] == 8