From 61d9c0de5805bf98352ec6e113dcaecb7acdf038 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:13:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=20D=20=EC=9E=87=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A9=94=EB=89=B4(?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=EB=B6=80=20=EC=84=9C=EC=8B=9D=20=C2=B7=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=EC=84=B1=20=C2=B7=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=C2=B7=20=EB=88=88=EA=B8=88=EC=84=A0)=20=C2=B7=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20=C2=B7=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=83=81=EC=9E=90=20=C2=B7=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spreadsheet_data_menu.ts(새) — [↑] [↓] [필터] [데이터 ▾] · 패널 창(sub4 조건부 서식 · 유효성 · 이름 관리) · 눈금선 · 유효성 목록 ▼ · Alt+↓ - spreadsheet_editor.ts — 확정 때 유효성 검사(막음 = 편집 유지 · 경고 = 알림) · 주소 상자에 이름 = 그 범위로 · 새 이름 = 지금 고름에 정의 - spreadsheet_mouse.ts · spreadsheet_selection.ts — Ctrl+누르기 여러 범위 · Shift 는 마지막 범위 - spreadsheet_keys.ts · spreadsheet.ts — 유효성에 막히면 이동 · 시트 바꿈 멈춤 - spreadsheet_extras.ts — 정렬 · 필터를 데이터 메뉴로 옮김 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY --- A00_Common/spreadsheet/spreadsheet.css | 49 ++++ A00_Common/spreadsheet/spreadsheet.ts | 3 +- .../spreadsheet/spreadsheet_data_menu.ts | 213 ++++++++++++++++++ A00_Common/spreadsheet/spreadsheet_editor.ts | 37 ++- A00_Common/spreadsheet/spreadsheet_extras.ts | 53 +---- A00_Common/spreadsheet/spreadsheet_keys.ts | 4 +- A00_Common/spreadsheet/spreadsheet_mouse.ts | 16 +- .../spreadsheet/spreadsheet_selection.ts | 4 +- 8 files changed, 320 insertions(+), 59 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_data_menu.ts diff --git a/A00_Common/spreadsheet/spreadsheet.css b/A00_Common/spreadsheet/spreadsheet.css index 9f068cd6..067f5f1a 100644 --- a/A00_Common/spreadsheet/spreadsheet.css +++ b/A00_Common/spreadsheet/spreadsheet.css @@ -257,3 +257,52 @@ .ss-hint__item.is-on { background: #ddf4ff; } + +/* 데이터 메뉴 — 패널 창 · 유효성 목록 ▼ */ +.ss-dialog { + position: absolute; + top: 4px; + right: 16px; + z-index: 21; + max-height: calc(100% - 8px); + overflow: auto; + border: 1px solid #d0d7de; + border-radius: 6px; + background: #fff; + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); +} + +.ss-dialog[hidden], +.ss-list-arrow[hidden] { + display: none; +} + +.ss-dialog__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + border-bottom: 1px solid #d0d7de; + font-weight: bold; +} + +.ss-dialog__body { + padding: 8px; +} + +.ss-list-arrow { + position: absolute; + z-index: 8; + width: 16px; + padding: 0; + border: 1px solid #8c959f; + background: #f6f8fa; + font-size: 9px; + cursor: pointer; +} + +.ss-validation__dropdown { + position: absolute; + z-index: 20; +} diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index f53be2df..ba56a878 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -74,7 +74,7 @@ export interface SpreadsheetHandle { /** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */ function farCorner(sel: Selection): { r: number; c: number } { - const g = sel.범위[0]; + const g = sel.범위[sel.범위.length - 1]; const a = sel.기준; const whole = (lo: number, hi: number, last: number): boolean => lo === 0 && hi === last; return { @@ -222,6 +222,7 @@ export function createSpreadsheet( const sheet = ctx.book.시트.find((s) => s.id === sheetId); if (!sheet || sheetId === ctx.selection.시트) return; ed.commit(); + if (ed.editing()) return; // 유효성에 막힘 ctx.book.활성 = sheetId; ctx.selection = memory.get(sheetId) ?? selectCell(sheet, 0, 0); grid.render(); diff --git a/A00_Common/spreadsheet/spreadsheet_data_menu.ts b/A00_Common/spreadsheet/spreadsheet_data_menu.ts new file mode 100644 index 00000000..4b023d90 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_data_menu.ts @@ -0,0 +1,213 @@ +/* ============================================================================= + * spreadsheet_data_menu.ts (주인 D) + * 데이터 단추 · 메뉴 잇기 — 수식 입력줄 앞 [↑] [↓] [필터] [데이터 ▾]. + * 정렬 · 자동 필터(sub3 `spreadsheet_sort_dialog` · `spreadsheet_filter`) · + * 조건부 서식 · 데이터 유효성 · 이름 관리(sub4 패널 — 부품 오른위 창에 띄움) · 눈금선 보이기/숨기기. + * 유효성 목록 칸은 고르면 칸 옆 ▼ · Alt+↓ 로 값 목록(필터 머리 칸이면 필터 목록이 먼저). + * ========================================================================== */ + +import "./spreadsheet_stage3.css"; // sub4 패널 스타일(잇는 쪽이 붙임) +import { createMapContextMenu } from "@ui/ui_template_context_menu"; +import { el } from "@ui/ui_template_elements"; +import { parseRange, toA1 } from "./spreadsheet_address"; +import { createCondFmtPanel } from "./spreadsheet_condfmt"; +import { attachFilter, toggleFilter } from "./spreadsheet_filter"; +import { createNameManagerPanel } from "./spreadsheet_names"; +import { openSortDialog, sortSelection } from "./spreadsheet_sort_dialog"; +import { + createValidationDropdown, + createValidationPanel, + findValidation, +} from "./spreadsheet_validation"; +import type { KeyHooks } from "./spreadsheet_keys"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; + +const TEXT = { + asc: "오름차순 정렬(활성 칸 열 기준)", + desc: "내림차순 정렬(활성 칸 열 기준)", + filter: "자동 필터 켜기 · 끄기(Ctrl+Shift+L · 머리 칸에서 Alt+↓ 값 목록)", + menu: "데이터 ▾", + sort: "정렬…", + filterItem: "자동 필터 켜기 · 끄기", + condfmt: "조건부 서식…", + validation: "데이터 유효성…", + names: "이름 관리…", + grid: "눈금선 보이기 · 숨기기", + close: "닫기", + list: "값 목록(Alt+↓)", +}; + +export interface DataMenu { + /** 수식 입력줄 앞에 둘 단추들 */ + buttons: HTMLElement[]; + keys: Pick; + part: PartHandle; +} + +export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMenu { + const filter = attachFilter(ctx); + const menu = createMapContextMenu("ss"); + ctx.root.append(menu.element); + let closeSort: (() => void) | null = null; + + const button = (text: string, title: string, run: (b: HTMLElement) => void, keep = false) => { + const b = el("button", { className: "ss-brush", text, attrs: { type: "button", title } }); + b.addEventListener("click", () => { + run(b); + if (!keep) back(); + }); + return b; + }; + + // ── 패널 창(조건부 서식 · 유효성 · 이름) — 한 번에 하나 ───────────────────── + const title = el("span", { className: "ss-dialog__title" }); + const body = el("div", { className: "ss-dialog__body" }); + const shut = el("button", { + className: "ss-find__btn", + text: "×", + attrs: { type: "button", title: TEXT.close }, + }); + const dialog = el("div", { + className: "ss-dialog", + attrs: { hidden: "" }, + children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body], + }); + ctx.root.append(dialog); + let panel: PartHandle | null = null; + const closePanel = (): void => { + panel?.destroy(); + panel = null; + body.replaceChildren(); + dialog.setAttribute("hidden", ""); + }; + shut.addEventListener("click", () => (closePanel(), back())); + dialog.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + e.stopPropagation(); + closePanel(); + back(); + }); + function openPanel(name: string, make: (c: SpreadsheetContext) => PartHandle): void { + closePanel(); + panel = make(ctx); + title.textContent = name.replace("…", ""); + if (panel.root) body.append(panel.root); + dialog.removeAttribute("hidden"); + } + + function openMenu(anchor: HTMLElement): void { + const r = anchor.getBoundingClientRect(); + const items: [string, () => void][] = [ + [TEXT.sort, () => ((closeSort = openSortDialog(ctx)), undefined)], + [TEXT.filterItem, () => (toggleFilter(ctx), back())], + [TEXT.condfmt, () => openPanel(TEXT.condfmt, createCondFmtPanel)], + [TEXT.validation, () => openPanel(TEXT.validation, createValidationPanel)], + [TEXT.names, () => openPanel(TEXT.names, createNameManagerPanel)], + [TEXT.grid, () => (toggleGridlines(), back())], + ]; + if (!ctx.readOnly) menu.open(r.left, r.bottom, items); + } + + function toggleGridlines(): void { + const s = ctx.sheet(); + ctx.dispatch({ 종류: "눈금선", 시트: s.id, 보임: s.보기?.눈금선 === false }); + } + + // ── 유효성 목록 — ▼ 단추 · Alt+↓ ───────────────────────────────────────── + let drop: HTMLElement | null = null; + const closeDrop = (): void => { + drop?.remove(); + drop = null; + }; + const arrow = el("button", { + className: "ss-list-arrow", + text: "▼", + attrs: { type: "button", hidden: "", title: TEXT.list }, + }); + arrow.addEventListener("mousedown", (e) => e.preventDefault()); + arrow.addEventListener("click", () => openList()); + ctx.root.append(arrow); + /** 칸 자리(부품 뿌리 기준) */ + const at = (r: number, c: number) => { + const { box } = ctx.grid.editorSlot(r, c); + const g = ctx.grid.root.getBoundingClientRect(); + const o = ctx.root.getBoundingClientRect(); + return { x: box.x + g.left - o.left, y: box.y + g.top - o.top, w: box.w, h: box.h }; + }; + function openList(): boolean { + const { r, c } = ctx.selection.활성; + const rule = findValidation(ctx.sheet(), r, c); + if (ctx.readOnly || !rule) return false; + closeDrop(); + drop = createValidationDropdown(ctx, r, c, rule, (value) => { + closeDrop(); + const key = toA1(r, c); + const n = Number(value); + const 값 = value !== "" && Number.isFinite(n) ? n : value; + const 서식 = ctx.sheet().칸[key]?.서식; + const cell = 서식 === undefined ? { 값 } : { 값, 서식 }; + ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: { [key]: cell } }); + back(); + }); + if (!drop) return false; + const p = at(r, c); + drop.style.left = `${p.x}px`; // cellBox(스크롤 전 내용 좌표) 자리를 화면 자리로 고침 + drop.style.top = `${p.y + p.h}px`; + ctx.root.append(drop); + return true; + } + const onOutside = (e: MouseEvent): void => { + if (drop && !drop.contains(e.target as Node) && e.target !== arrow) closeDrop(); + }; + document.addEventListener("mousedown", onOutside, true); + + const buttons = ctx.readOnly + ? [] + : [ + button("↑", TEXT.asc, () => sortSelection(ctx, false)), + button("↓", TEXT.desc, () => sortSelection(ctx, true)), + button("필터", TEXT.filter, () => toggleFilter(ctx)), + button(TEXT.menu, TEXT.menu, openMenu, true), + ]; + + return { + buttons, + keys: { + filter: () => toggleFilter(ctx), + dropdown() { + const f = ctx.sheet().필터; + const g = f && parseRange(f.범위); + const { r, c } = ctx.selection.활성; + if (g && r === g.r0 && c >= g.c0 && c <= g.c1) return (filter.open(c), true); + return openList(); + }, + }, + part: { + root: null, + refresh() { + filter.refresh(); + panel?.refresh(); + closeDrop(); + const { r, c } = ctx.selection.활성; + const list = !ctx.readOnly && findValidation(ctx.sheet(), r, c)?.규칙.종류 === "목록"; + arrow.hidden = !list || ctx.editor.editing(); + if (!list) return; + const p = at(r, c); + arrow.style.left = `${p.x + p.w + 1}px`; + arrow.style.top = `${p.y}px`; + arrow.style.height = `${p.h}px`; + }, + destroy() { + document.removeEventListener("mousedown", onOutside, true); + closeSort?.(); + closePanel(); + closeDrop(); + filter.destroy(); + menu.element.remove(); + dialog.remove(); + arrow.remove(); + buttons.forEach((b) => b.remove()); + }, + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_editor.ts b/A00_Common/spreadsheet/spreadsheet_editor.ts index ed3b9e90..5d5736b8 100644 --- a/A00_Common/spreadsheet/spreadsheet_editor.ts +++ b/A00_Common/spreadsheet/spreadsheet_editor.ts @@ -8,12 +8,14 @@ * 수식 입력줄(방향키가 글자 사이를 옮김). F2 가 둘을 바꿈. * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; +import { el, showToast } from "@ui/ui_template_elements"; import { moveFormula } from "./spreadsheet_refshift"; import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address"; import { funcHelp, suggestFunctions, type FuncHelp } from "./spreadsheet_func_help"; +import { defineName, resolveName } from "./spreadsheet_names"; import { tokenize } from "./spreadsheet_parser"; import { cellsOf, move, selectCell } from "./spreadsheet_selection"; +import { checkValue, findValidation } from "./spreadsheet_validation"; import type { Cell, CellAddress, CellStyle, Sheet } from "./spreadsheet_types"; import type { CellBox, @@ -329,10 +331,12 @@ export function createEditor(ctx: SpreadsheetContext): Editor { focus(); } - function write(cells: CellAddress[], value: string, from: CellAddress): void { - if (!st) return; + /** 칸에 적음 — 유효성(sub4) 「막음」 을 어기면 안 적고 false(편집 그대로 · 엑셀처럼 다시 치게) */ + function write(cells: CellAddress[], value: string, from: CellAddress): boolean { + if (!st) return true; const sheet = sheetOf(st.sheet); const out: Record = {}; + const warn = new Set(); for (const a of cells) { let v = value; if (v.length > 1 && v[0] === "=" && (a.r !== from.r || a.c !== from.c)) @@ -340,21 +344,29 @@ export function createEditor(ctx: SpreadsheetContext): Editor { const key = toA1(a.r, a.c); const next = textToCell(v, sheet.칸[key]); if (next === null && !sheet.칸[key]) continue; + const rule = next?.값 !== undefined ? findValidation(sheet, a.r, a.c) : undefined; + const check = rule && checkValue(rule, next!.값!); + if (check && !check.ok) { + if (check.어기면 === "막음") return (showToast(check.메시지, "error"), false); + warn.add(check.메시지); + } out[key] = next; } + for (const m of warn) showToast(m, "warning"); if (Object.keys(out).length) ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: out }); + return true; } function commit(): void { if (!st) return; const value = text(); - if (value !== st.original) write([st.at], value, st.at); + if (value !== st.original && !write([st.at], value, st.at)) return; end(); } function commitAll(): void { if (!st) return; - write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at); + if (!write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at)) return; end(); } @@ -441,6 +453,19 @@ export function createEditor(ctx: SpreadsheetContext): Editor { focus(); }); + /** 이름 상자(sub4) — 있는 이름이면 그 범위로 · 없으면 지금 고름에 이름을 붙임(엑셀) */ + function nameBox(name: string): void { + const hit = resolveName(ctx.book, ctx.selection.시트, name); + if (hit) { + if (hit.시트 !== ctx.selection.시트) ctx.showSheet(hit.시트); + const at = { r: hit.범위.r0, c: hit.범위.c0 }; + return ctx.select({ 시트: hit.시트, 범위: [hit.범위], 활성: at, 기준: { ...at } }, true); + } + const why = ctx.readOnly ? null : defineName(ctx, name, ctx.selection.범위[0]); + if (why) showToast(why, "error"); + sync(); + } + function goTo(value: string): void { const bang = value.lastIndexOf("!"); let sheet = ctx.sheet(); @@ -454,7 +479,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor { const body = value.slice(bang + 1).replace(/\$/g, ""); const one = parseA1(body); const g = parseRange(body); - if (!g) return sync(); + if (!g) return nameBox(value.trim()); if (one) return ctx.select(selectCell(sheet, one.r, one.c), true); const at = { r: g.r0, c: g.c0 }; ctx.select({ 시트: sheet.id, 범위: [g], 활성: at, 기준: { ...at } }, true); diff --git a/A00_Common/spreadsheet/spreadsheet_extras.ts b/A00_Common/spreadsheet/spreadsheet_extras.ts index 47bc79f1..668b2f4d 100644 --- a/A00_Common/spreadsheet/spreadsheet_extras.ts +++ b/A00_Common/spreadsheet/spreadsheet_extras.ts @@ -2,22 +2,20 @@ * spreadsheet_extras.ts (주인 D) * 2단계 부품 잇기(sub1 파일들의 「잇는 법」) — 찾기 패널 · 메모 풍선(마우스 올림) · 메모 편집(Shift+F2) · * 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) · - * 도구 모음을 누른 뒤 초점을 격자로 되돌림 · 정렬(↑ ↓ 정렬…) · 자동 필터(단추 · Ctrl+Shift+L · Alt+↓). + * 도구 모음을 누른 뒤 초점을 격자로 되돌림 · 데이터 단추 · 메뉴는 `spreadsheet_data_menu.ts`. * 빨간 세모는 C 격자 · 우클릭 [메모 …] 는 E 메뉴 몫(브레인 배정). * ========================================================================== */ import { el } from "@ui/ui_template_elements"; -import { parseRange } from "./spreadsheet_address"; import { parseClipboard } from "./spreadsheet_clipboard"; import { attachComments, getComment, setComment } from "./spreadsheet_comments"; +import { mountDataMenu } from "./spreadsheet_data_menu"; import type { Editor } from "./spreadsheet_editor"; -import { attachFilter, toggleFilter } from "./spreadsheet_filter"; import { mountFindPanel } from "./spreadsheet_find_panel"; import { createFormatPainter } from "./spreadsheet_format_painter"; import type { KeyHooks } from "./spreadsheet_keys"; import type { MouseHooks } from "./spreadsheet_mouse"; import { pasteSpecial, type PasteSpecialMode } from "./spreadsheet_paste_special"; -import { openSortDialog, sortSelection } from "./spreadsheet_sort_dialog"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; const TEXT = { @@ -25,10 +23,6 @@ const TEXT = { pasteTitle: "골라 붙여넣기", modes: { 값: "값만", 서식: "서식만", 수식: "수식만" } as Record, memo: "메모 — Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소", - sortAsc: "오름차순 정렬(활성 칸 열 기준)", - sortDesc: "내림차순 정렬(활성 칸 열 기준)", - sortDialog: "정렬 — 기준 여럿 · 머리 행", - filter: "자동 필터 켜기 · 끄기(Ctrl+Shift+L · 머리 칸에서 Alt+↓ 값 목록)", }; export interface Extras { @@ -115,32 +109,9 @@ export function attachExtras( }); if (!ctx.readOnly) ed.bar.prepend(brush); - // ── 정렬 · 필터(sub3) — 수식 입력줄 앞 데이터 단추 ─────────────────────── - const filter = attachFilter(ctx); - let closeSort: (() => void) | null = null; - const dataButton = (text: string, title: string, run: () => void, dialog = false) => { - const b = el("button", { className: "ss-brush", text, attrs: { type: "button", title } }); - b.addEventListener("click", () => { - run(); - if (!dialog) back(); - }); - return b; - }; - const data = [ - dataButton("↑", TEXT.sortAsc, () => sortSelection(ctx, false)), - dataButton("↓", TEXT.sortDesc, () => sortSelection(ctx, true)), - dataButton( - "정렬…", - TEXT.sortDialog, - () => { - closeSort?.(); - closeSort = openSortDialog(ctx); - }, - true, - ), - dataButton("필터", TEXT.filter, () => toggleFilter(ctx)), - ]; - if (!ctx.readOnly) brush.after(...data); + // ── 데이터 단추 · 메뉴(정렬 · 필터 · 조건부 서식 · 유효성 · 이름 · 눈금선) ───────── + const data = mountDataMenu(ctx, back); + brush.after(...data.buttons); // ── 골라 붙여넣기 ───────────────────────────────────────────────────────── let armed = false; @@ -201,8 +172,6 @@ export function attachExtras( root: null, refresh: () => showBrush(), destroy() { - closeSort?.(); - data.forEach((b) => b.remove()); ctx.root.removeEventListener("paste", onPaste, true); toolbarRoot?.removeEventListener("click", onToolbarClick); toolbarRoot?.removeEventListener("change", back); @@ -217,15 +186,7 @@ export function attachExtras( find: (replace) => find.open(replace), pasteSpecial: () => (armed = true), comment: editMemo, - filter: () => toggleFilter(ctx), - dropdown() { - const f = ctx.sheet().필터; - const g = f && parseRange(f.범위); - const { r, c } = ctx.selection.활성; - if (!g || r !== g.r0 || c < g.c0 || c > g.c1) return false; - filter.open(c); - return true; - }, + ...data.keys, escape() { painter.clear(); sticky = false; @@ -248,6 +209,6 @@ export function attachExtras( put(comments.root!, hit.r, hit.c); // showBalloon 의 cellBox 자리 → 화면 자리로 고침 }, }, - parts: [find, comments, filter, self], + parts: [find, comments, data.part, self], }; } diff --git a/A00_Common/spreadsheet/spreadsheet_keys.ts b/A00_Common/spreadsheet/spreadsheet_keys.ts index a23180e1..105e5725 100644 --- a/A00_Common/spreadsheet/spreadsheet_keys.ts +++ b/A00_Common/spreadsheet/spreadsheet_keys.ts @@ -68,12 +68,14 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks } if (ctrl) return (ed.commitAll(), true); ed.commit(); + if (ed.editing()) return true; // 유효성에 막힘 next(e.shiftKey ? -1 : 1, 0); return true; } case "Tab": if (!e.shiftKey && ed.accept()) return true; // 함수 자동 완성 ed.commit(); + if (ed.editing()) return true; next(0, e.shiftKey ? -1 : 1); return true; case "Escape": @@ -92,7 +94,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks } if (e.shiftKey || ctrl) return false; // 글자 고르기 · 낱말 건너기는 textarea 에 맡김 ed.commit(); - go(move(ctx.sheet(), ctx.selection, arrow[0], arrow[1])); + if (!ed.editing()) go(move(ctx.sheet(), ctx.selection, arrow[0], arrow[1])); return true; } diff --git a/A00_Common/spreadsheet/spreadsheet_mouse.ts b/A00_Common/spreadsheet/spreadsheet_mouse.ts index 9100497f..4d5c03b8 100644 --- a/A00_Common/spreadsheet/spreadsheet_mouse.ts +++ b/A00_Common/spreadsheet/spreadsheet_mouse.ts @@ -106,7 +106,7 @@ export function attachMouse( function fillDrag(): void { const sheet = ctx.sheet(); - const src = ctx.selection.범위[0]; + const src = ctx.selection.범위[ctx.selection.범위.length - 1]; let result = { target: src, clear: null as CellRange | null }; drag( (p) => { @@ -186,6 +186,7 @@ export function attachMouse( return; } ed.commit(); + if (ed.editing()) return; // 유효성에 막힘 } ed.focus(); if (ctx.readOnly && hit.kind === "fillHandle") hit.kind = "cell"; @@ -205,9 +206,17 @@ export function attachMouse( return drag((p) => ctx.select(selectCols(sheet, a, p.c, a))); } case "cell": { - const first = e.shiftKey + // Ctrl = 범위 더함(여러 범위) · Shift = 마지막 범위를 늘림 + const keep = + e.ctrlKey || e.metaKey + ? ctx.selection.범위 + : e.shiftKey + ? ctx.selection.범위.slice(0, -1) + : []; + const one = e.shiftKey ? selectSpan(sheet, ctx.selection.기준, hit) : selectCell(sheet, hit.r, hit.c); + const first = { ...one, 범위: [...keep, ...one.범위] }; ctx.select(first); const anchor = first.기준; let last = `${hit.r},${hit.c}`; @@ -215,7 +224,8 @@ export function attachMouse( (p) => { if (`${p.r},${p.c}` === last) return; last = `${p.r},${p.c}`; - ctx.select(selectSpan(sheet, anchor, p)); + const span = selectSpan(sheet, anchor, p); + ctx.select({ ...span, 범위: [...keep, ...span.범위] }); }, () => hooks.selected?.(), ); diff --git a/A00_Common/spreadsheet/spreadsheet_selection.ts b/A00_Common/spreadsheet/spreadsheet_selection.ts index 779324dc..310846e5 100644 --- a/A00_Common/spreadsheet/spreadsheet_selection.ts +++ b/A00_Common/spreadsheet/spreadsheet_selection.ts @@ -202,7 +202,7 @@ export function move( if (to.r === edge.r && to.c === edge.c) return selectCell(sheet, from.r, from.c); return selectCell(sheet, to.r, to.c); } - const g = sel.범위[0] ?? span(sel.활성, sel.활성); + const g = sel.범위[sel.범위.length - 1] ?? span(sel.활성, sel.활성); // 여러 범위면 마지막(활성) 범위 const anchor = sel.기준; // 움직이는 끝 = 닻에서 먼 쪽 const far = { @@ -218,7 +218,7 @@ export function move( } return { 시트: sheet.id, - 범위: [expand(span(anchor, to), list)], + 범위: [...sel.범위.slice(0, -1), expand(span(anchor, to), list)], 활성: { ...sel.활성 }, 기준: { ...anchor }, };