Compare commits
313
Commits
c0eb280c32
...
CODEX
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a3645bcaa | ||
|
|
9dfb9d61e6 | ||
|
|
2700746110 | ||
|
|
3a508ac1e1 | ||
|
|
ee0475d0a4 | ||
|
|
96b7409411 | ||
|
|
cd2690450e | ||
|
|
db8d3d02d4 | ||
|
|
60246df689 | ||
|
|
990e8e6733 | ||
|
|
5f500ab8b9 | ||
|
|
e5118855e2 | ||
|
|
ff1436c85b | ||
|
|
59c394efaf | ||
|
|
fe8136fb3f | ||
|
|
4abfc5b265 | ||
|
|
5932f5d260 | ||
|
|
e4e2dc2aac | ||
|
|
513cebe023 | ||
|
|
52d441e3a9 | ||
|
|
26772b87bd | ||
|
|
de7032bc3b | ||
|
|
214c55a389 | ||
|
|
a7ffde0247 | ||
|
|
841af0a033 | ||
|
|
db52cdabae | ||
|
|
ae47bcd921 | ||
|
|
f8dda99fc8 | ||
|
|
16de9eed8b | ||
|
|
6606ef167a | ||
|
|
527e68db85 | ||
|
|
fb5aee7ea2 | ||
|
|
453095f6db | ||
|
|
8851668024 | ||
|
|
f528378f2c | ||
|
|
2d70be32ea | ||
|
|
1c371fca85 | ||
|
|
e06d43a6bc | ||
|
|
4c8ee6828f | ||
|
|
76bb9aec55 | ||
|
|
d35269e32a | ||
|
|
625c1012c8 | ||
|
|
1904cd5072 | ||
|
|
ea636fb6fc | ||
|
|
e76e638013 | ||
|
|
0fb69c053b | ||
|
|
0c3b089719 | ||
|
|
820a44f79d | ||
|
|
9d24cb04b9 | ||
|
|
b6e71c729d | ||
|
|
c32e2cbd5d | ||
|
|
ff0d9775ec | ||
|
|
3f39b5b6c3 | ||
|
|
00b08bf152 | ||
|
|
6198fad176 | ||
|
|
73829e7bde | ||
|
|
4ea695907e | ||
|
|
c4735e4669 | ||
|
|
f85aa9136a | ||
|
|
f1e0809d6c | ||
|
|
2a0f359c7c | ||
|
|
32c3081b68 | ||
|
|
a9ad227068 | ||
|
|
c8c0698c91 | ||
|
|
14c4a507d6 | ||
|
|
44a6ec51db | ||
|
|
6c0014a430 | ||
|
|
165901d58d | ||
|
|
eed7ffaf42 | ||
|
|
15715c2bda | ||
|
|
fa572cd0b7 | ||
|
|
ac83e1b550 | ||
|
|
ec41d50d92 | ||
|
|
fedc860365 | ||
|
|
93c817658a | ||
|
|
73040246ae | ||
|
|
c608b9554f | ||
|
|
ec8812e829 | ||
|
|
7b62305e12 | ||
|
|
2388d02da9 | ||
|
|
3660924f80 | ||
|
|
789b62be9d | ||
|
|
ccd9bc89c7 | ||
|
|
3869852d3d | ||
|
|
1d35254c90 | ||
|
|
17e0e92604 | ||
|
|
f33fa9dac0 | ||
|
|
a927ae64bc | ||
|
|
8ebb553259 | ||
|
|
1b229fa4a7 | ||
|
|
c5ad429dc3 | ||
|
|
5fdd5f8a4e | ||
|
|
e3a9cb65d0 | ||
|
|
be4f3d33ac | ||
|
|
289daf899a | ||
|
|
fa01245b8b | ||
|
|
8739027872 | ||
|
|
41a717f62b | ||
|
|
137796158b | ||
|
|
e0f9df9ba9 | ||
|
|
b98fbccdd0 | ||
|
|
91cc795102 | ||
|
|
7eacf332ee | ||
|
|
c4edc751be | ||
|
|
a268859540 | ||
|
|
bb0bd97c84 | ||
|
|
d1f627ea31 | ||
|
|
b813de897f | ||
|
|
023c5838c4 | ||
|
|
0ca69fbc47 | ||
|
|
b60c10e73e | ||
|
|
9ae91ea5da | ||
|
|
ba91e9e63d | ||
|
|
32d73aa2c9 | ||
|
|
14092e715b | ||
|
|
017d559401 | ||
|
|
5de2f75828 | ||
|
|
854531cfd8 | ||
|
|
d3c93b4520 | ||
|
|
79a0994ffb | ||
|
|
0e5a19eab8 | ||
|
|
89fc686962 | ||
|
|
74b6937bc0 | ||
|
|
326d5ec61a | ||
|
|
5bdedb5da9 | ||
|
|
e010f72505 | ||
|
|
dbda85e8ee | ||
|
|
a0845bb978 | ||
|
|
b9820cff1a | ||
|
|
25f1276d7b | ||
|
|
403e3acfda | ||
|
|
59b83efe0c | ||
|
|
24b63ca1ea | ||
|
|
cb5858bb64 | ||
|
|
22835ca9e2 | ||
|
|
c25db2636e | ||
|
|
9b9787f60a | ||
|
|
86ae6db5a0 | ||
|
|
b79793ea31 | ||
|
|
31bcad6454 | ||
|
|
4b6602484d | ||
|
|
3990ca1c28 | ||
|
|
ba47d33877 | ||
|
|
04f2476741 | ||
|
|
2623633b86 | ||
|
|
fc249c00f6 | ||
|
|
3a4b306636 | ||
|
|
ee64781a7c | ||
|
|
4bbef4ac54 | ||
|
|
db899349a4 | ||
|
|
4f7470bbbd | ||
|
|
dde6f0c2f8 | ||
|
|
119a390bd7 | ||
|
|
b2e2fcff91 | ||
|
|
e4eef2a47b | ||
|
|
87417b3a31 | ||
|
|
0f7617f99d | ||
|
|
69f9c64c98 | ||
|
|
e31e1b0016 | ||
|
|
7a8d37d544 | ||
|
|
444aed129f | ||
|
|
66812026c1 | ||
|
|
860e251425 | ||
|
|
cd7a81a4af | ||
|
|
ede12fbc0e | ||
|
|
7b294adca2 | ||
|
|
5bea31ee5c | ||
|
|
a9a9b73961 | ||
|
|
35d0ce3853 | ||
|
|
b59de25664 | ||
|
|
76f917813b | ||
|
|
7c1e95482d | ||
|
|
a3434e3c74 | ||
|
|
a144d4019e | ||
|
|
a3fa9f152a | ||
|
|
fca4463768 | ||
|
|
e48870765e | ||
|
|
117ea76ec2 | ||
|
|
ebdee04faa | ||
|
|
8d49a38df2 | ||
|
|
4f1c048bb2 | ||
|
|
ddfff14fb6 | ||
|
|
610ac8564f | ||
|
|
4e7f2e2c2a | ||
|
|
36e33a0e3b | ||
|
|
de94486af7 | ||
|
|
c6e826fe1f | ||
|
|
e222ddc172 | ||
|
|
16c1443b93 | ||
|
|
7aa9f2229d | ||
|
|
18117e16b4 | ||
|
|
4804caa9bc | ||
|
|
d887a8cec3 | ||
|
|
73ad5c7070 | ||
|
|
6acc8d0c52 | ||
|
|
4aca316ace | ||
|
|
6275e48396 | ||
|
|
647ae91469 | ||
|
|
0c3e4028e5 | ||
|
|
61d9c0de58 | ||
|
|
327226e252 | ||
|
|
69694a3f6b | ||
|
|
e1fc529f45 | ||
|
|
015c35ad0e | ||
|
|
500424d7ae | ||
|
|
acdac34572 | ||
|
|
85ada11e9d | ||
|
|
e02a3c9a26 | ||
|
|
fc20316c0c | ||
|
|
851fdb63e5 | ||
|
|
1a4e24b8a9 | ||
|
|
3656f02704 | ||
|
|
d299ca3d3c | ||
|
|
8606272f5c | ||
|
|
a939d0bf55 | ||
|
|
5718aa7f9d | ||
|
|
6dad7497dc | ||
|
|
c7f18d533b | ||
|
|
57ddb3866d | ||
|
|
d60b1ccc7e | ||
|
|
6b08058a58 | ||
|
|
ce3df39206 | ||
|
|
77c132adb5 | ||
|
|
a4ad54571c | ||
|
|
a52f8fcd87 | ||
|
|
fddaa4edfb | ||
|
|
a644b2617c | ||
|
|
83a3a57568 | ||
|
|
7d9fc6eea5 | ||
|
|
d1164e29e4 | ||
|
|
5c570e5a95 | ||
|
|
4f1510bb48 | ||
|
|
0f970c8c20 | ||
|
|
c8bb862a5c | ||
|
|
0415177f44 | ||
|
|
e214350683 | ||
|
|
85fa62ae4d | ||
|
|
b93e03e505 | ||
|
|
88866fb4a5 | ||
|
|
8ad456d1e1 | ||
|
|
6f8ab6e067 | ||
|
|
3dc523ef41 | ||
|
|
48a2135132 | ||
|
|
bfd7123829 | ||
|
|
b8312343eb | ||
|
|
d5c5090b79 | ||
|
|
b184d18a50 | ||
|
|
095eca0ddf | ||
|
|
f77b14b51c | ||
|
|
9f240931f5 | ||
|
|
d8db2683f9 | ||
|
|
1bdbba72f9 | ||
|
|
f27c83cd93 | ||
|
|
82910bec0a | ||
|
|
b58073730f | ||
|
|
0d2d5614aa | ||
|
|
7725b39ecb | ||
|
|
279ef5fb4f | ||
|
|
bc5b4217c1 | ||
|
|
a2011c8035 | ||
|
|
5807313d21 | ||
|
|
d38ef573e0 | ||
|
|
cd05bfef3b | ||
|
|
0750b2f0d3 | ||
|
|
dd7300aa02 | ||
|
|
736dac4eb5 | ||
|
|
cd824aca25 | ||
|
|
d53ef8d3bf | ||
|
|
14bf7b1def | ||
|
|
2dc0a71ba7 | ||
|
|
4a6e0ffb3a | ||
|
|
1c5a803e1b | ||
|
|
26fd4fb72e | ||
|
|
65f43ca083 | ||
|
|
ef5a7e9771 | ||
|
|
243d00c218 | ||
|
|
7e694a8825 | ||
|
|
65a6b6e942 | ||
|
|
9d1a4f9bdb | ||
|
|
a7f2383d90 | ||
|
|
8ae1111f16 | ||
|
|
38a13bcb0b | ||
|
|
1ae10e64e2 | ||
|
|
83e08c2c7f | ||
|
|
fabc033c53 | ||
|
|
db46810f7b | ||
|
|
ddfe562c2e | ||
|
|
c6af3b9b2a | ||
|
|
4f71b4a5ce | ||
|
|
db489c7c3a | ||
|
|
ef63904866 | ||
|
|
39c79f05d0 | ||
|
|
5b6cf0103e | ||
|
|
847f0e44de | ||
|
|
7b2c22d920 | ||
|
|
8f55821a6a | ||
|
|
b6b4d7772d | ||
|
|
d3ca77fcb5 | ||
|
|
6555313a22 | ||
|
|
1cd52cfdfb | ||
|
|
c411d866bb | ||
|
|
1e29f5e0b2 | ||
|
|
576b012eae | ||
|
|
2aa4c80577 | ||
|
|
acca7d1fb6 | ||
|
|
4679261634 | ||
|
|
28b7e70f28 | ||
|
|
9bf3258509 | ||
|
|
dd60b6a1cb | ||
|
|
07953fa523 | ||
|
|
6cc98d68f1 | ||
|
|
abd0d4b126 | ||
|
|
e8615db238 |
@@ -67,6 +67,8 @@ config/corridor_node/
|
||||
config/server_calc_node/
|
||||
# 구조물도 식 풀이 번들 — `npm run build:formula` 산출물(2026-09-13).
|
||||
config/formula_node/
|
||||
# 산출근거 스프레드시트 재계산 번들 — `npm run build:spreadsheet` 산출물.
|
||||
config/spreadsheet_node/
|
||||
|
||||
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
|
||||
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
|
||||
@@ -77,3 +79,4 @@ docs/wiki/graphify-out/.graphify_incremental.json
|
||||
# 까닭 — 코드 창에서 graphify 가 돌 때마다 이 폴더가 바뀌어, 위키 브랜치를 받을 때
|
||||
# 「덮으면 네 것이 날아간다」로 **병합이 막혔다**. 4.3MB·443파일도 함께 가벼워진다.
|
||||
docs/wiki/graphify-out/cache/
|
||||
~$*
|
||||
|
||||
+22
-2
@@ -8,11 +8,18 @@
|
||||
* - hashchange 1회 구독: 셸 chrome 갱신 + 현재 라우트 렌더
|
||||
* ========================================================================== */
|
||||
|
||||
import "./random_uuid_polyfill";
|
||||
import "@ui/ui_template_theme.css";
|
||||
import { injectBaseStyles } from "@ui/ui_template_elements";
|
||||
import { ROUTES, type RoutePath } from "@config/config_frontend";
|
||||
import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
|
||||
import { currentRoute, renderCurrentRoute, ensureInitialHash } from "./router";
|
||||
import {
|
||||
currentRoute,
|
||||
renderCurrentRoute,
|
||||
ensureInitialHash,
|
||||
setLeaveGuard,
|
||||
takeLeaveGuard,
|
||||
} from "./router";
|
||||
|
||||
const FOOTERLESS_ROUTES: readonly RoutePath[] = [
|
||||
ROUTES.B01_ACCOUNT,
|
||||
@@ -44,14 +51,27 @@ function bootstrap(): void {
|
||||
initThemeAndLang();
|
||||
|
||||
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
|
||||
let shown = "";
|
||||
const renderAll = (): void => {
|
||||
shown = currentRoute();
|
||||
const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute());
|
||||
const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신
|
||||
void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더
|
||||
};
|
||||
|
||||
// 4. hashchange 1회 구독 (리스너 누적 방지)
|
||||
window.addEventListener("hashchange", renderAll);
|
||||
window.addEventListener("hashchange", () => {
|
||||
const guard = takeLeaveGuard();
|
||||
if (!guard || currentRoute() === shown) return renderAll();
|
||||
void guard().then((ok) => {
|
||||
if (ok) {
|
||||
setLeaveGuard(null);
|
||||
renderAll();
|
||||
} else {
|
||||
history.replaceState(null, "", `#/${shown}`); // 취소 — 머문 채 주소만 되돌림
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 5. 최초 진입
|
||||
ensureInitialHash();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* http + IP 주소(예: Tailscale)는 보안 문맥이 아니라 `crypto.randomUUID` 가 없음.
|
||||
* getRandomValues(보안 문맥 아니어도 됨)로 v4 UUID 를 채움 · 이미 있으면 그대로.
|
||||
* `main.ts` 가 맨 먼저 부름. CAD 짝 = `B07_DesignDetail/openwebcad/src/random-uuid-polyfill.ts`.
|
||||
*/
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID !== "function") {
|
||||
crypto.randomUUID = () => {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` as `${string}-${string}-${string}-${string}-${string}`;
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -65,6 +65,13 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
|
||||
(await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate,
|
||||
};
|
||||
|
||||
/** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */
|
||||
let leaveGuard: (() => Promise<boolean>) | null = null;
|
||||
export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
|
||||
leaveGuard = guard;
|
||||
};
|
||||
export const takeLeaveGuard = (): (() => Promise<boolean>) | null => leaveGuard;
|
||||
|
||||
/** 로그인 여부 (토큰 존재 확인) */
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
const { fetchSession } = await import("../A06_Login/A06_Login_Api_Fetch");
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/* spreadsheet.css (주인 D) — 부품 틀 · 주소 상자 · 수식 입력줄 · 칸 편집기 · 채우기 미리보기 · 알림 */
|
||||
|
||||
.ss {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-body);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.ss-grid-host {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ss-bar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: stretch;
|
||||
height: 26px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ss-addr {
|
||||
width: 96px;
|
||||
padding: 0 6px;
|
||||
border: none;
|
||||
border-right: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ss-addr:focus {
|
||||
box-shadow: inset 0 0 0 2px var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.ss-fx {
|
||||
align-self: center;
|
||||
padding: 0 8px;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ss-formula {
|
||||
flex: 1;
|
||||
padding: 4px 6px;
|
||||
border: none;
|
||||
border-left: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: 18px;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ss--readonly .ss-formula {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 칸 편집기 = 숨은 textarea — 쉴 때는 활성 칸 자리에 투명하게(한글 조합 창 자리) */
|
||||
.ss-input {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 1px 3px;
|
||||
border: 2px solid var(--color-focus-ring);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
line-height: 1.25;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ss-input--idle {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ss-fill-preview {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed var(--color-text-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ss-toast {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 40px;
|
||||
z-index: 20;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
/* 2단계 잇기 — 서식 붓 · 찾기 패널 · 메모 · 골라 붙여넣기 */
|
||||
.ss-brush {
|
||||
flex: none;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-right: 1px solid var(--color-border);
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-brush.is-on {
|
||||
background: color-mix(in srgb, var(--color-focus-ring) 20%, var(--color-surface-raised));
|
||||
box-shadow: inset 0 0 0 2px var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.ss--painting .ss-grid-host {
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.ss-find {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 16px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss-find[hidden],
|
||||
.ss-find__row[hidden],
|
||||
.ss-memo[hidden],
|
||||
.ss-pick[hidden],
|
||||
.spreadsheet-comment-balloon[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ss-find__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ss-find__input {
|
||||
width: 180px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.ss-find__btn {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-find__opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ss-find__status {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ss-memo,
|
||||
.spreadsheet-comment-balloon {
|
||||
position: absolute;
|
||||
z-index: 15;
|
||||
min-width: 140px;
|
||||
max-width: 260px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 15%, var(--color-surface-raised));
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
white-space: pre-wrap;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.ss-memo {
|
||||
min-height: 60px;
|
||||
resize: both;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ss-pick {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss-pick > button {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 함수 자동 완성 · 인자 도움말 */
|
||||
.ss-hint {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
min-width: 200px;
|
||||
max-width: 360px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
font-size: 12px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss-hint[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ss-hint__item,
|
||||
.ss-hint__help {
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ss-hint__item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-hint__item.is-on {
|
||||
background: color-mix(in srgb, var(--color-accent) 18%, var(--color-surface-raised));
|
||||
}
|
||||
|
||||
/* 데이터 메뉴 — 패널 창 · 유효성 목록 ▼ */
|
||||
.ss-dialog {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 16px;
|
||||
z-index: 21;
|
||||
max-height: calc(100% - 8px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.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 var(--color-border);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ss-dialog__body {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.ss-list-arrow {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
width: 16px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-validation__dropdown {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet.ts (주인 D)
|
||||
* 산출근거 스프레드시트 진입 — `createSpreadsheet(칸, 통합문서, {readOnly, onChange, onSelect})` →
|
||||
* `{getDoc, setDoc, recalc, destroy}`(표 부품 `createSheet` 와 같은 모양 · M02 는 부르는 줄만 바꿈).
|
||||
* 문맥(`SpreadsheetContext`)을 만들어 C 격자 · E 도구 · 탭 · 메뉴 · 클립보드를 붙이고 D 입력(고름 · 편집기 ·
|
||||
* 키 · 마우스)을 잇음. 한글 조합 = 숨은 textarea 에 늘 초점 · compositionstart 로 편집 시작(keydown 글자 방식 금지).
|
||||
* ⚠ M02 만 `import()` 로 불러옴 — 정적 import 금지(다른 페이지 번들에 안 섞이게).
|
||||
* 저장은 부른 쪽 몫(`onChange` 로 문서 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
|
||||
* 화면 차례: 도구 모음(E) · 주소 상자 + 수식 입력줄(D) · 격자(C) · 시트 탭(E) · 상태 표시줄(sub4).
|
||||
* 2단계 부품(찾기 · 메모 · 서식 붓 · 골라 붙여넣기)은 `spreadsheet_extras.ts` 로 이음.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./spreadsheet.css";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import { attachClipboard } from "./spreadsheet_clipboard";
|
||||
import { applyCommand, CommandError } from "./spreadsheet_commands";
|
||||
import { createEditor, slotOf } from "./spreadsheet_editor";
|
||||
import { toCalcValue } from "./spreadsheet_eval";
|
||||
import { attachExtras } from "./spreadsheet_extras";
|
||||
import { createCalcEngine } from "./spreadsheet_graph";
|
||||
import { createGrid } from "./spreadsheet_grid";
|
||||
import { createHistory } from "./spreadsheet_history";
|
||||
import { attachKeys } from "./spreadsheet_keys";
|
||||
import type { LinkOptions, Links } from "./spreadsheet_links";
|
||||
import { attachMenu } from "./spreadsheet_menu";
|
||||
import { attachMouse } from "./spreadsheet_mouse";
|
||||
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
|
||||
import { createStatusBar } from "./spreadsheet_statusbar";
|
||||
import { mountTabs } from "./spreadsheet_tabs";
|
||||
import { mountToolbar } from "./spreadsheet_toolbar";
|
||||
import type {
|
||||
CalcValue,
|
||||
CalcValues,
|
||||
Command,
|
||||
CommandEffect,
|
||||
SheetCellAddress,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
import type {
|
||||
EditorHandle,
|
||||
GridHandle,
|
||||
PartHandle,
|
||||
Selection,
|
||||
SpreadsheetContext,
|
||||
} from "./spreadsheet_view_types";
|
||||
|
||||
export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types";
|
||||
export type { LinkItem, LinkOptions } from "./spreadsheet_links";
|
||||
|
||||
export interface SpreadsheetOptions {
|
||||
/** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */
|
||||
readOnly?: boolean;
|
||||
/** 명령마다 문서 사본 — 페이지가 캐시(sessionStorage)에 쌓고 [저장] 단추를 켬 */
|
||||
onChange?: (book: Workbook) => void;
|
||||
/** 고름이 바뀜 — 시트 id · 활성 칸 A1 · 범위 A1 */
|
||||
onSelect?: (selection: { 시트: string; 칸: string; 범위: string }) => void;
|
||||
/** 아래 시트 탭 줄 — false 면 안 그림(산출근거 = 시트 하나) · 시트 오가기 · 새 시트 단축키 · 시트 메뉴도 끔 */
|
||||
tabs?: boolean;
|
||||
/** 위 알약 막대 — 입력값 · 자재를 칸에 연결(`spreadsheet_links.ts` · 이때만 불러옴) */
|
||||
links?: LinkOptions;
|
||||
}
|
||||
|
||||
export interface SpreadsheetHandle {
|
||||
/** 문서 사본 */
|
||||
getDoc(): Workbook;
|
||||
/** 문서 바꿔 끼움 — 되돌리기 이력 비움 · 고름 A1 */
|
||||
setDoc(book: Workbook): void;
|
||||
/** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */
|
||||
recalc(): CalcValues;
|
||||
/** 알약 키 → 연결 칸 값(빈 칸 0) */
|
||||
getLinkedValues(): Record<string, CalcValue>;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */
|
||||
function farCorner(sel: Selection): { r: number; c: number } {
|
||||
const g = sel.범위[sel.범위.length - 1];
|
||||
const a = sel.기준;
|
||||
const whole = (lo: number, hi: number, last: number): boolean => lo === 0 && hi === last;
|
||||
return {
|
||||
r: whole(g.r0, g.r1, LAST_R)
|
||||
? sel.활성.r
|
||||
: Math.abs(g.r1 - a.r) >= Math.abs(a.r - g.r0)
|
||||
? g.r1
|
||||
: g.r0,
|
||||
c: whole(g.c0, g.c1, LAST_C)
|
||||
? sel.활성.c
|
||||
: Math.abs(g.c1 - a.c) >= Math.abs(a.c - g.c0)
|
||||
? g.c1
|
||||
: g.c0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSpreadsheet(
|
||||
host: HTMLElement,
|
||||
book: Workbook,
|
||||
opts: SpreadsheetOptions = {},
|
||||
): SpreadsheetHandle {
|
||||
const readOnly = !!opts.readOnly;
|
||||
const noTabs = opts.tabs === false;
|
||||
const root = el("div", {
|
||||
className: `ss${readOnly ? " ss--readonly" : ""}${noTabs ? " ss--notabs" : ""}`,
|
||||
});
|
||||
host.append(root);
|
||||
const firstSheet = (b: Workbook) => b.시트.find((s) => s.id === b.활성) ?? b.시트[0];
|
||||
/** 시트마다 마지막 고름(탭을 오가도 그대로) */
|
||||
const memory = new Map<string, Selection>();
|
||||
|
||||
const ctx: SpreadsheetContext = {
|
||||
root,
|
||||
book,
|
||||
readOnly,
|
||||
engine: createCalcEngine(book),
|
||||
history: createHistory(),
|
||||
selection: selectCell(firstSheet(book), 0, 0),
|
||||
sheet: () => ctx.book.시트.find((s) => s.id === ctx.selection.시트) ?? ctx.book.시트[0],
|
||||
dispatch,
|
||||
undo: () => replay(ctx.history.undo()),
|
||||
redo: () => replay(ctx.history.redo()),
|
||||
select,
|
||||
showSheet,
|
||||
grid: null as unknown as GridHandle,
|
||||
editor: null as unknown as EditorHandle,
|
||||
};
|
||||
|
||||
const grid = createGrid(ctx);
|
||||
ctx.grid = grid;
|
||||
const ed = createEditor(ctx);
|
||||
ctx.editor = ed;
|
||||
const toolbar: PartHandle | null = readOnly ? null : mountToolbar(ctx);
|
||||
const tabs: PartHandle | null = noTabs ? null : mountTabs(ctx);
|
||||
const menu: PartHandle | null = readOnly ? null : attachMenu(ctx);
|
||||
const clip = attachClipboard(ctx);
|
||||
const status = createStatusBar(ctx);
|
||||
const others = [toolbar, tabs, menu, clip, status].filter((p): p is PartHandle => !!p);
|
||||
|
||||
grid.root.classList.add("ss-grid-host");
|
||||
for (const node of [toolbar?.root, ed.bar, grid.root, tabs?.root, status.root])
|
||||
if (node) root.append(node);
|
||||
const extras = attachExtras(ctx, ed, toolbar?.root ?? null);
|
||||
others.push(...extras.parts);
|
||||
const detachKeys = attachKeys(ctx, ed, extras.keys);
|
||||
const detachMouse = attachMouse(ctx, ed, extras.mouse);
|
||||
const toast = el("div", { className: "ss-toast" });
|
||||
let toastTimer = 0;
|
||||
/** 알약 막대 · 연결 따라가기 — 선택이 있거나 문서에 연결이 있을 때만 불러옴(크기 예산) */
|
||||
let links: Links | null = null;
|
||||
let linking = false;
|
||||
function loadLinks(): void {
|
||||
if (linking || !(opts.links || ctx.book.시트.some((s) => s.연결?.length))) return;
|
||||
linking = true;
|
||||
void import("./spreadsheet_links").then((m) => {
|
||||
if (!root.isConnected) return; // 받기 전에 닫힘
|
||||
links = m.mountLinks(ctx, opts.links, () => ed.focus(), slotOf);
|
||||
others.push(links);
|
||||
links.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
grid.render();
|
||||
select(ctx.selection);
|
||||
loadLinks();
|
||||
|
||||
function notify(message: string): void {
|
||||
toast.textContent = message;
|
||||
root.append(toast);
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
function apply(command: Command): CommandEffect | null {
|
||||
try {
|
||||
return (links?.apply ?? applyCommand)(ctx.book, command);
|
||||
} catch (e) {
|
||||
if (e instanceof CommandError) return (notify(e.message), null);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function dispatch(command: Command): void {
|
||||
if (readOnly) return;
|
||||
const eff = apply(command);
|
||||
if (!eff) return;
|
||||
ctx.history.push(command, eff.undo);
|
||||
after(command, eff);
|
||||
}
|
||||
|
||||
function replay(command: Command | null): void {
|
||||
if (readOnly || !command || ed.editing()) return;
|
||||
const eff = apply(command);
|
||||
if (eff) after(command, eff);
|
||||
}
|
||||
|
||||
function after(command: Command, eff: CommandEffect): void {
|
||||
if (eff.rebuild) {
|
||||
ctx.engine.rebuild(ctx.book);
|
||||
if (!ctx.book.시트.some((s) => s.id === ctx.selection.시트)) {
|
||||
const s = firstSheet(ctx.book);
|
||||
ctx.selection = memory.get(s.id) ?? selectCell(s, 0, 0);
|
||||
}
|
||||
grid.render();
|
||||
} else {
|
||||
const again = ctx.engine.update(eff.cells);
|
||||
if (command.종류 === "칸") grid.invalidate([...again, ...touched(command.시트, command.칸)]);
|
||||
else grid.render();
|
||||
}
|
||||
select(ctx.selection);
|
||||
opts.onChange?.(structuredClone(ctx.book));
|
||||
}
|
||||
|
||||
/** 칸 명령이 건드린 칸(서식만 바뀐 칸도 다시 그리게) */
|
||||
function touched(sheet: string, cells: Record<string, unknown>): SheetCellAddress[] {
|
||||
const out: SheetCellAddress[] = [];
|
||||
for (const key of Object.keys(cells)) {
|
||||
const a = parseA1(key);
|
||||
if (a) out.push({ 시트: sheet, ...a });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function select(selection: Selection, reveal = false): void {
|
||||
ctx.selection = selection;
|
||||
memory.set(selection.시트, selection);
|
||||
grid.renderSelection();
|
||||
if (reveal) {
|
||||
const far = farCorner(selection);
|
||||
grid.reveal(far.r, far.c);
|
||||
}
|
||||
ed.sync();
|
||||
for (const p of others) p.refresh();
|
||||
const g = selection.범위[0];
|
||||
opts.onSelect?.({
|
||||
시트: selection.시트,
|
||||
칸: toA1(selection.활성.r, selection.활성.c),
|
||||
범위: g ? rangeToA1(g) : toA1(selection.활성.r, selection.활성.c),
|
||||
});
|
||||
}
|
||||
|
||||
function showSheet(sheetId: string): void {
|
||||
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();
|
||||
select(ctx.selection, true);
|
||||
ed.focus();
|
||||
}
|
||||
|
||||
return {
|
||||
getDoc: () => structuredClone(ctx.book),
|
||||
setDoc(next: Workbook) {
|
||||
ed.cancel();
|
||||
ctx.book = next;
|
||||
ctx.engine.rebuild(next);
|
||||
ctx.history.clear();
|
||||
memory.clear();
|
||||
ctx.selection = selectCell(firstSheet(next), 0, 0);
|
||||
grid.render();
|
||||
select(ctx.selection);
|
||||
loadLinks();
|
||||
},
|
||||
recalc() {
|
||||
ctx.engine.rebuild(ctx.book);
|
||||
grid.render();
|
||||
for (const p of others) p.refresh();
|
||||
return ctx.engine.snapshot();
|
||||
},
|
||||
getLinkedValues() {
|
||||
const out: Record<string, CalcValue> = {};
|
||||
for (const s of ctx.book.시트)
|
||||
for (const l of s.연결 ?? []) {
|
||||
const p = parseA1(l.칸);
|
||||
if (p) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
destroy() {
|
||||
clearTimeout(toastTimer);
|
||||
detachKeys();
|
||||
detachMouse();
|
||||
for (const p of others) p.destroy();
|
||||
ed.destroy();
|
||||
grid.destroy();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_actions.ts (주인 D)
|
||||
* 단축키 표(sub1 `spreadsheet_shortcuts.ts`) 「안됨」 잇기 + 3차 부품 메뉴 자리([데이터 ▾] 에 붙임).
|
||||
* Ctrl+Shift++ · Ctrl+- 칸 넣기 · 지우기(행 · 열 통째면 바로 · 아니면 sub1 창) · F5 · Ctrl+G 이동 ·
|
||||
* Ctrl+9/0 숨김 · Ctrl+Shift+9/0 풀기 · Ctrl+B/I/U/5 글꼴 뒤집기 · Ctrl+Shift+& 바깥 테두리 ·
|
||||
* Ctrl+Shift+_ 테두리 없앰 · Ctrl+; 오늘 날짜 · Ctrl+PageDown/PageUp 시트 · Shift+F11 새 시트 ·
|
||||
* Shift+Alt+→/← 행 · 열 묶기 · 풀기(C 윤곽 +/- 가 접음).
|
||||
* 여기는 키 · 메뉴 → 이름만 · 몸통은 `spreadsheet_actions_run.ts`(늦게 — 크기 예산).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const TEXT = {
|
||||
goTo: "이동…(F5)",
|
||||
freeze: "틀 고정",
|
||||
freezeRow: "첫 행 고정",
|
||||
freezeCol: "첫 열 고정",
|
||||
freezeAt: "지금 칸 기준 고정",
|
||||
unfreeze: "틀 고정 풀기",
|
||||
insert: "칸 넣기…(Ctrl+Shift++)",
|
||||
remove: "칸 지우기…(Ctrl+-)",
|
||||
split: "텍스트 나누기…",
|
||||
dedupe: "중복된 항목 제거…",
|
||||
series: "연속 데이터 채우기…",
|
||||
group: "묶기(Shift+Alt+→)",
|
||||
ungroup: "묶음 풀기(Shift+Alt+←)",
|
||||
outline: "윤곽",
|
||||
};
|
||||
|
||||
export type Act =
|
||||
| "goTo"
|
||||
| "insert"
|
||||
| "remove"
|
||||
| "hideRows"
|
||||
| "hideCols"
|
||||
| "showRows"
|
||||
| "showCols"
|
||||
| "group"
|
||||
| "ungroup"
|
||||
| "outline"
|
||||
| "clearBorders"
|
||||
| "today"
|
||||
| "next"
|
||||
| "prev"
|
||||
| "newSheet"
|
||||
| "freezeRow"
|
||||
| "freezeCol"
|
||||
| "freezeAt"
|
||||
| "unfreeze"
|
||||
| "split"
|
||||
| "dedupe"
|
||||
| "series"
|
||||
| `flip:${"굵게" | "기울임" | "밑줄" | "취소선"}`;
|
||||
|
||||
/** Ctrl+글자(e.code) → 이름 */
|
||||
const CTRL: Record<string, Act> = {
|
||||
KeyB: "flip:굵게",
|
||||
KeyI: "flip:기울임",
|
||||
KeyU: "flip:밑줄",
|
||||
Digit5: "flip:취소선",
|
||||
KeyG: "goTo",
|
||||
Minus: "remove",
|
||||
NumpadSubtract: "remove",
|
||||
NumpadAdd: "insert",
|
||||
Digit9: "hideRows",
|
||||
Digit0: "hideCols",
|
||||
Semicolon: "today", // ponytail: 시각(Ctrl+Shift+;)은 numfmt 시간 형식이 오면
|
||||
PageDown: "next",
|
||||
PageUp: "prev",
|
||||
};
|
||||
/** Ctrl+Shift+글자 → 이름 */
|
||||
const CTRL_SHIFT: Record<string, Act> = {
|
||||
Equal: "insert",
|
||||
NumpadAdd: "insert",
|
||||
Minus: "clearBorders",
|
||||
Digit9: "showRows",
|
||||
Digit0: "showCols",
|
||||
Digit7: "outline",
|
||||
PageDown: "next",
|
||||
PageUp: "prev",
|
||||
};
|
||||
|
||||
const SHEET_ACTS = new Set<Act>(["next", "prev", "newSheet"]);
|
||||
|
||||
/** body 에 뜬 창(.ui-confirm)이 다 닫히면 초점을 격자로 — 창을 연 바로 뒤에 부름 */
|
||||
export function watchClose(back: () => void): void {
|
||||
const w = new MutationObserver(() => {
|
||||
if (document.querySelector("body > .ui-confirm, body > .ui-confirm__panel")) return;
|
||||
w.disconnect();
|
||||
back();
|
||||
});
|
||||
w.observe(document.body, { childList: true });
|
||||
}
|
||||
|
||||
function pick(e: KeyboardEvent): Act | undefined {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
if (e.altKey)
|
||||
return e.shiftKey && !ctrl
|
||||
? ({ ArrowRight: "group", ArrowLeft: "ungroup" } as Record<string, Act>)[e.key]
|
||||
: undefined;
|
||||
if (ctrl) return (e.shiftKey ? CTRL_SHIFT : CTRL)[e.code];
|
||||
if (e.key === "F5" && !e.shiftKey) return "goTo";
|
||||
if (e.key === "F11" && e.shiftKey) return "newSheet";
|
||||
}
|
||||
|
||||
export interface Actions {
|
||||
key(e: KeyboardEvent): boolean;
|
||||
items: MapContextMenuItem[];
|
||||
}
|
||||
|
||||
export function mountActions(ctx: SpreadsheetContext, back: () => void): Actions {
|
||||
const go = (act: Act): void =>
|
||||
void import("./spreadsheet_actions_run").then((m) => m.run(ctx, back, act));
|
||||
const item = (label: string, act: Act): [string, () => void] => [label, () => go(act)];
|
||||
return {
|
||||
key(e) {
|
||||
const act = pick(e);
|
||||
// 시트 탭이 없으면(산출근거) 시트 오가기 · 새 시트를 받지 않음 — 브라우저 몫으로 둠
|
||||
if (!act || (SHEET_ACTS.has(act) && ctx.root.classList.contains("ss--notabs"))) return false;
|
||||
go(act);
|
||||
return true;
|
||||
},
|
||||
items: [
|
||||
item(TEXT.goTo, "goTo"),
|
||||
{
|
||||
label: TEXT.freeze,
|
||||
children: [
|
||||
item(TEXT.freezeRow, "freezeRow"),
|
||||
item(TEXT.freezeCol, "freezeCol"),
|
||||
item(TEXT.freezeAt, "freezeAt"),
|
||||
item(TEXT.unfreeze, "unfreeze"),
|
||||
],
|
||||
},
|
||||
item(TEXT.insert, "insert"),
|
||||
item(TEXT.remove, "remove"),
|
||||
item(TEXT.split, "split"),
|
||||
item(TEXT.dedupe, "dedupe"),
|
||||
item(TEXT.series, "series"),
|
||||
{ label: TEXT.outline, children: [item(TEXT.group, "group"), item(TEXT.ungroup, "ungroup")] },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_actions_run.ts (주인 D)
|
||||
* `spreadsheet_actions.ts` 단축키 · 메뉴가 부르는 몸통 — 처음 누를 때 늦게 불러옴(크기 예산).
|
||||
* ========================================================================== */
|
||||
|
||||
import { toA1 } from "./spreadsheet_address";
|
||||
import { watchClose, type Act } from "./spreadsheet_actions";
|
||||
import { cellStyleAt } from "./spreadsheet_cellstyle";
|
||||
import { LAST_C, LAST_R } from "./spreadsheet_selection";
|
||||
import type { CellRange, CellStyle, Command } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
/** 엑셀 날짜 일련번호 0 = 1899-12-30 */
|
||||
const EPOCH = Date.UTC(1899, 11, 30);
|
||||
|
||||
export function run(ctx: SpreadsheetContext, back: () => void, act: Act): void {
|
||||
const insert = () => import("./spreadsheet_insert_dialog");
|
||||
const view = () => import("./spreadsheet_view_menu");
|
||||
const tools = () => import("./spreadsheet_data_tools");
|
||||
/** 늦게 불러 창을 열고 닫히면 격자로 */
|
||||
const dialog = <M>(load: () => Promise<M>, open: (m: M) => void) =>
|
||||
void load().then((m) => (open(m), watchClose(back)));
|
||||
const freezeBy = (pick: (m: Awaited<ReturnType<typeof view>>) => void) =>
|
||||
void view().then((m) => (pick(m), back()));
|
||||
|
||||
const range = (): CellRange => ctx.selection.범위[0];
|
||||
const fullRows = (g: CellRange) => g.c0 === 0 && g.c1 === LAST_C;
|
||||
const fullCols = (g: CellRange) => g.r0 === 0 && g.r1 === LAST_R;
|
||||
const span = (a: number, b: number) => Array.from({ length: b - a + 1 }, (_, i) => a + i);
|
||||
const id = () => ctx.selection.시트;
|
||||
|
||||
/** 칸 넣기 · 지우기 — 행 · 열 통째 고름이면 엑셀처럼 창 없이 바로 */
|
||||
function cells(add: boolean): void {
|
||||
const g = range();
|
||||
if (fullRows(g) || fullCols(g)) {
|
||||
const rows = fullRows(g);
|
||||
ctx.dispatch({
|
||||
종류: rows ? (add ? "행넣기" : "행지우기") : add ? "열넣기" : "열지우기",
|
||||
시트: id(),
|
||||
at: rows ? g.r0 : g.c0,
|
||||
수: rows ? g.r1 - g.r0 + 1 : g.c1 - g.c0 + 1,
|
||||
});
|
||||
} else
|
||||
dialog(insert, (m) => (add ? m.openInsertCellsDialog(ctx) : m.openDeleteCellsDialog(ctx)));
|
||||
}
|
||||
|
||||
function hide(rows: boolean, 숨김: boolean): void {
|
||||
const g = range();
|
||||
const 번호 = rows ? span(g.r0, g.r1) : span(g.c0, g.c1);
|
||||
ctx.dispatch({ 종류: "숨김", 시트: id(), 축: rows ? "행" : "열", 번호, 숨김 });
|
||||
}
|
||||
|
||||
function group(on: boolean): void {
|
||||
const g = range();
|
||||
const cols = fullCols(g) && !fullRows(g);
|
||||
const 번호 = cols ? span(g.c0, g.c1) : span(g.r0, g.r1);
|
||||
const 묶음 = on ? { 층: 1 } : null;
|
||||
ctx.dispatch({ 종류: "묶음설정", 시트: id(), 축: cols ? "열" : "행", 번호, 묶음 });
|
||||
}
|
||||
|
||||
function flip(key: keyof CellStyle): void {
|
||||
const { r, c } = ctx.selection.활성;
|
||||
const on = !!cellStyleAt(ctx.book, ctx.sheet(), r, c)[key];
|
||||
ctx.dispatch({ 종류: "서식", 시트: id(), 범위: ctx.selection.범위, 바꿀: { [key]: !on } });
|
||||
}
|
||||
|
||||
/** 바깥 테두리(가는 선) — 가장자리 칸마다 지금 테두리에 변을 더함 */
|
||||
function outline(): void {
|
||||
const g = range();
|
||||
const n = (g.r1 - g.r0 + 1) * (g.c1 - g.c0 + 1);
|
||||
// ponytail: 가장자리 칸만 셈 · 행 · 열 통째(백만 칸)는 안 함 — 쓴 범위로 자르면 풀림
|
||||
if (fullRows(g) || fullCols(g) || n > 1e5) return;
|
||||
const cmds: Command[] = [];
|
||||
for (let r = g.r0; r <= g.r1; r++)
|
||||
for (let c = g.c0; c <= g.c1; c++) {
|
||||
const sides = [
|
||||
r === g.r0 && "위",
|
||||
r === g.r1 && "아래",
|
||||
c === g.c0 && "왼",
|
||||
c === g.c1 && "오른",
|
||||
].filter(Boolean) as ("위" | "아래" | "왼" | "오른")[];
|
||||
if (!sides.length) continue;
|
||||
const 테두리 = { ...cellStyleAt(ctx.book, ctx.sheet(), r, c).테두리 };
|
||||
for (const s of sides) 테두리[s] = { 선: "thin" };
|
||||
cmds.push({
|
||||
종류: "서식",
|
||||
시트: id(),
|
||||
범위: [{ r0: r, c0: c, r1: r, c1: c }],
|
||||
바꿀: { 테두리 },
|
||||
});
|
||||
}
|
||||
ctx.dispatch({ 종류: "묶음", 명령: cmds });
|
||||
}
|
||||
|
||||
function today(): void {
|
||||
const d = new Date();
|
||||
const 값 = Math.round((Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) - EPOCH) / 864e5);
|
||||
const { r, c } = ctx.selection.활성;
|
||||
const key = toA1(r, c);
|
||||
const 서식 = ctx.sheet().칸[key]?.서식;
|
||||
ctx.dispatch({
|
||||
종류: "묶음",
|
||||
명령: [
|
||||
{ 종류: "칸", 시트: id(), 칸: { [key]: 서식 === undefined ? { 값 } : { 값, 서식 } } },
|
||||
{
|
||||
종류: "서식",
|
||||
시트: id(),
|
||||
범위: [{ r0: r, c0: c, r1: r, c1: c }],
|
||||
바꿀: { 형식: "yyyy-mm-dd" },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 다음 · 이전 보이는 시트 */
|
||||
function sheetStep(d: number): void {
|
||||
const list = ctx.book.시트.filter((s) => !s.숨김);
|
||||
const i = list.findIndex((s) => s.id === id());
|
||||
const to = list[i + d];
|
||||
if (to) ctx.showSheet(to.id);
|
||||
}
|
||||
|
||||
const goTo = () => dialog(view, (m) => m.openGoToDialog(ctx));
|
||||
|
||||
function clearBorders(): void {
|
||||
ctx.dispatch({ 종류: "서식", 시트: id(), 범위: ctx.selection.범위, 바꿀: { 테두리: null } });
|
||||
}
|
||||
|
||||
if (act.startsWith("flip:")) return flip(act.slice(5) as keyof CellStyle);
|
||||
switch (act) {
|
||||
case "goTo":
|
||||
return goTo();
|
||||
case "insert":
|
||||
case "remove":
|
||||
return cells(act === "insert");
|
||||
case "hideRows":
|
||||
case "hideCols":
|
||||
case "showRows":
|
||||
case "showCols":
|
||||
return hide(act.endsWith("Rows"), act.startsWith("hide"));
|
||||
case "group":
|
||||
case "ungroup":
|
||||
return group(act === "group");
|
||||
case "outline":
|
||||
return outline();
|
||||
case "clearBorders":
|
||||
return clearBorders();
|
||||
case "today":
|
||||
return today();
|
||||
case "next":
|
||||
case "prev":
|
||||
return sheetStep(act === "next" ? 1 : -1);
|
||||
case "newSheet":
|
||||
return ctx.root.querySelector<HTMLElement>(".ss-tabs__add")?.click();
|
||||
case "freezeRow":
|
||||
return freezeBy((m) => m.freezeFirstRow(ctx));
|
||||
case "freezeCol":
|
||||
return freezeBy((m) => m.freezeFirstColumn(ctx));
|
||||
case "freezeAt":
|
||||
return freezeBy((m) => m.freezeAtActiveCell(ctx));
|
||||
case "unfreeze":
|
||||
return freezeBy((m) => m.unfreezePanes(ctx));
|
||||
case "split":
|
||||
return dialog(tools, (m) => m.openTextToColumnsDialog(ctx));
|
||||
case "dedupe":
|
||||
return dialog(tools, (m) => m.openRemoveDuplicatesDialog(ctx));
|
||||
case "series":
|
||||
return dialog(tools, (m) => m.openFillSeriesDialog(ctx));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_address.ts (주인 A)
|
||||
* 주소 — A1 ↔ 행열(0 부터) · 범위 글 · 열 글자 · 시트 이름 따옴표 · Map 열쇠.
|
||||
* 계약은 `spreadsheet_types.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type { CellAddress, CellRange } from "./spreadsheet_types";
|
||||
|
||||
/** 0 → `A` · 27 → `AB` */
|
||||
export function colName(c: number): string {
|
||||
let s = "";
|
||||
for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26)) {
|
||||
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** `AB` → 27 · 못 읽으면 -1 (대소문자 가리지 않음) */
|
||||
export function colIndex(letters: string): number {
|
||||
if (!/^[A-Za-z]{1,3}$/.test(letters)) return -1;
|
||||
let n = 0;
|
||||
for (const ch of letters.toUpperCase()) n = n * 26 + ch.charCodeAt(0) - 64;
|
||||
return n <= MAX_COLS ? n - 1 : -1;
|
||||
}
|
||||
|
||||
/** 행 글(1 부터) → 0 부터 · 못 읽으면 -1 */
|
||||
export function rowIndex(digits: string): number {
|
||||
const n = /^\d{1,7}$/.test(digits) ? Number(digits) : 0;
|
||||
return n >= 1 && n <= MAX_ROWS ? n - 1 : -1;
|
||||
}
|
||||
|
||||
/** (30, 4) → `E31` */
|
||||
export function toA1(r: number, c: number): string {
|
||||
return colName(c) + (r + 1);
|
||||
}
|
||||
|
||||
/** `E31` · `$E$31` → {r:30, c:4} · 못 읽으면 null */
|
||||
export function parseA1(text: string): CellAddress | null {
|
||||
const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(text.trim());
|
||||
if (!m) return null;
|
||||
const c = colIndex(m[1]);
|
||||
const r = rowIndex(m[2]);
|
||||
return c < 0 || r < 0 ? null : { r, c };
|
||||
}
|
||||
|
||||
/** 칸 하나면 `E31` · 아니면 `C3:AD3` · 행 전체 `3:3` · 열 전체 `A:A` */
|
||||
export function rangeToA1(range: CellRange): string {
|
||||
const { r0, c0, r1, c1 } = range;
|
||||
if (c0 === 0 && c1 === MAX_COLS - 1) return `${r0 + 1}:${r1 + 1}`;
|
||||
if (r0 === 0 && r1 === MAX_ROWS - 1) return `${colName(c0)}:${colName(c1)}`;
|
||||
const a = toA1(r0, c0);
|
||||
return r0 === r1 && c0 === c1 ? a : `${a}:${toA1(r1, c1)}`;
|
||||
}
|
||||
|
||||
/** 닫힌 범위로 세움(뒤집힌 것 바로) */
|
||||
export function normRange(r0: number, c0: number, r1: number, c1: number): CellRange {
|
||||
return {
|
||||
r0: Math.min(r0, r1),
|
||||
c0: Math.min(c0, c1),
|
||||
r1: Math.max(r0, r1),
|
||||
c1: Math.max(c0, c1),
|
||||
};
|
||||
}
|
||||
|
||||
/** `C3:AD3` · `E31` · `A:A` · `3:5` → 범위(뒤집힌 글은 바로 세움) · 못 읽으면 null */
|
||||
export function parseRange(text: string): CellRange | null {
|
||||
const parts = text.trim().split(":");
|
||||
if (parts.length > 2) return null;
|
||||
const [a, b = a] = parts;
|
||||
const p = parseA1(a);
|
||||
const q = parseA1(b);
|
||||
if (p && q) return normRange(p.r, p.c, q.r, q.c);
|
||||
if (parts.length !== 2) return null;
|
||||
const strip = (s: string) => s.replace(/^\$/, "");
|
||||
const ca = colIndex(strip(a));
|
||||
const cb = colIndex(strip(b));
|
||||
if (ca >= 0 && cb >= 0) return normRange(0, ca, MAX_ROWS - 1, cb);
|
||||
const ra = rowIndex(strip(a));
|
||||
const rb = rowIndex(strip(b));
|
||||
if (ra >= 0 && rb >= 0) return normRange(ra, 0, rb, MAX_COLS - 1);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function inRange(range: CellRange, r: number, c: number): boolean {
|
||||
return r >= range.r0 && r <= range.r1 && c >= range.c0 && c <= range.c1;
|
||||
}
|
||||
|
||||
export function rangesOverlap(a: CellRange, b: CellRange): boolean {
|
||||
return a.r0 <= b.r1 && b.r0 <= a.r1 && a.c0 <= b.c1 && b.c0 <= a.c1;
|
||||
}
|
||||
|
||||
/** `a` 가 `b` 안에 통째로 듦 */
|
||||
export function rangeInside(a: CellRange, b: CellRange): boolean {
|
||||
return a.r0 >= b.r0 && a.r1 <= b.r1 && a.c0 >= b.c0 && a.c1 <= b.c1;
|
||||
}
|
||||
|
||||
/** 식에 넣을 시트 이름 — 글자 · 숫자 · _ 밖이 있으면 `'…'`(안의 `'` 는 `''`) */
|
||||
export function quoteSheet(name: string): string {
|
||||
const plain =
|
||||
/^[\p{L}_][\p{L}\p{N}_]*$/u.test(name) &&
|
||||
!/^[A-Za-z]{1,3}\d+$/.test(name) && // 칸 주소처럼 보이는 이름
|
||||
!/^(TRUE|FALSE)$/i.test(name) &&
|
||||
!/^R\d*C\d*$/i.test(name); // R1C1 처럼 보이는 이름
|
||||
return plain ? name : `'${name.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
/** Map 열쇠 — r × MAX_COLS + c */
|
||||
export function cellId(r: number, c: number): number {
|
||||
return r * MAX_COLS + c;
|
||||
}
|
||||
|
||||
export function fromCellId(id: number): CellAddress {
|
||||
return { r: Math.floor(id / MAX_COLS), c: id % MAX_COLS };
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/* spreadsheet_analysis.css (주인 A) — 추적 화살표 겹판 · 수식 계산 창 · 오류 ⚠ 단추 */
|
||||
|
||||
/* 추적 화살표 — 크기 0 겹판(격자 조작을 막지 않음) */
|
||||
.ss-trace {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ss-trace__line {
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.ss-trace__line.is-plain,
|
||||
.ss-trace__dot.is-plain,
|
||||
.ss-trace__head.is-plain {
|
||||
stroke: var(--color-info);
|
||||
fill: var(--color-info);
|
||||
}
|
||||
|
||||
.ss-trace__line.is-error,
|
||||
.ss-trace__dot.is-error,
|
||||
.ss-trace__head.is-error {
|
||||
stroke: var(--color-danger);
|
||||
fill: var(--color-danger);
|
||||
}
|
||||
|
||||
.ss-trace__line.is-sheet,
|
||||
.ss-trace__dot.is-sheet,
|
||||
.ss-trace__head.is-sheet {
|
||||
stroke: var(--color-text-body);
|
||||
fill: var(--color-text-body);
|
||||
}
|
||||
|
||||
.ss-trace__line.is-sheet {
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.ss-trace__box {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke: var(--color-info);
|
||||
}
|
||||
|
||||
.ss-trace__box.is-error {
|
||||
stroke: var(--color-danger);
|
||||
}
|
||||
|
||||
.ss-trace__sheet {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-text-body);
|
||||
}
|
||||
|
||||
/* 수식 계산 창 */
|
||||
.ss-steps {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: var(--z-dropdown);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.ss-steps__box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 420px;
|
||||
max-width: 90%;
|
||||
padding: var(--spacing-12);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-body);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
box-shadow: var(--shadow-lg);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ss-steps__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ss-steps__meta {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ss-steps__text {
|
||||
min-height: 60px;
|
||||
padding: var(--spacing-8);
|
||||
font-family: var(--font-mono, monospace);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: var(--color-canvas);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.ss-steps__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 오류 ⚠ 단추 · 풍선 · 메뉴 */
|
||||
.ss-errtip {
|
||||
position: absolute;
|
||||
z-index: var(--z-dropdown);
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ss-errtip__btn {
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
color: var(--color-warning);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.ss-errtip__balloon {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 22px;
|
||||
z-index: var(--z-dropdown);
|
||||
width: max-content;
|
||||
max-width: 320px;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-body);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss-errtip__btn:hover + .ss-errtip__balloon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ss-errtip__menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 22px;
|
||||
z-index: var(--z-dropdown);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
padding: var(--spacing-4);
|
||||
font-size: 13px;
|
||||
color: var(--color-text-body);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss-errtip__head {
|
||||
padding: var(--spacing-4);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ss-errtip__item {
|
||||
padding: var(--spacing-4);
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-errtip__item:hover {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_autofit.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 열 머리 경계를 두 번 눌러 폭 자동 맞춤 — 그 열 안 글자 수로 어림(엑셀 「글자 단위」).
|
||||
* 진짜 픽셀 값은 C(격자)가 쓰는 글꼴로 재야 더 정확 — `measure` 자리에 C 의 실제 재기 함수를
|
||||
* 나중에 꽂을 수 있게 열어 둠(안 주면 아래 어림 규칙을 씀).
|
||||
*
|
||||
* 잇는 법(D · sub7 · C 격자 머리) — 열 머리 경계에서 두 번 누르면(dblclick)
|
||||
* `autofitColumn(ctx, col)` 을 부름. C 가 완성되면 `measure` 자리에 캔버스 `measureText` 를 넘겨 더 정밀하게.
|
||||
*
|
||||
* ── 이미 계약에 있어 새 파일이 필요 없는 것(붙일 자리만 확인) ──────────────────────
|
||||
* · 여러 범위(Ctrl+누르기) — `spreadsheet_view_types.ts` `Selection.범위` 가 이미 배열(D `spreadsheet_selection.ts` 가 씀).
|
||||
* · 눈금선 끄기 — `Command`(`종류:"눈금선"`) · `Sheet.보기?.눈금선` 이미 있음(A 가 처리).
|
||||
* · 확대 · 축소 — 값 자체를 저장하지 않는 화면 전용 CSS 배율(C 격자 뿌리에 transform) · 계약 밖.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Workbook } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
export type TextMeasurer = (text: string) => number;
|
||||
|
||||
const PADDING = 2;
|
||||
const MIN_WIDTH = 4;
|
||||
|
||||
/** 글꼴을 모를 때 어림 — 한글 · 한자 · 전각은 1.9 배 폭 · 나머지는 1 배. */
|
||||
const defaultMeasure: TextMeasurer = (text) => {
|
||||
let width = 0;
|
||||
for (const ch of text) width += ch.codePointAt(0)! >= 0x1100 ? 1.9 : 1;
|
||||
return width;
|
||||
};
|
||||
|
||||
const colFromKey = (key: string): number => {
|
||||
const m = /^([A-Z]+)\d+$/.exec(key);
|
||||
if (!m) return -1;
|
||||
let col = 0;
|
||||
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
return col - 1;
|
||||
};
|
||||
|
||||
/** 그 열에 든 칸 글자(식 칸은 식 글로 어림 — 계산값은 여기서 모름) 중 가장 넓은 것 + 여백. */
|
||||
export function estimateColumnWidth(
|
||||
book: Workbook,
|
||||
sheetId: string,
|
||||
col: number,
|
||||
measure: TextMeasurer = defaultMeasure,
|
||||
): number {
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
if (!sheet) return MIN_WIDTH;
|
||||
let widest = 0;
|
||||
for (const [key, cell] of Object.entries(sheet.칸)) {
|
||||
if (colFromKey(key) !== col) continue;
|
||||
const text = cell.식 !== undefined ? cell.식 : cell.값 !== undefined ? String(cell.값) : "";
|
||||
if (text) widest = Math.max(widest, measure(text));
|
||||
}
|
||||
return Math.max(MIN_WIDTH, Math.ceil(widest + PADDING));
|
||||
}
|
||||
|
||||
/** 폭을 자동 맞춤 값으로 바꿈(열넓이 명령 하나). */
|
||||
export function autofitColumn(ctx: SpreadsheetContext, col: number, measure?: TextMeasurer): void {
|
||||
const width = estimateColumnWidth(ctx.book, ctx.selection.시트, col, measure);
|
||||
ctx.dispatch({ 종류: "열폭", 시트: ctx.selection.시트, 열: [col], 폭: width });
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_autosum.ts (3단계 · 주인 sub_laptop_4)
|
||||
* 자동 합계(Alt+= · 도구 단추 Σ) — 엑셀 규칙: 활성 칸 바로 위가 숫자면 그 위 연속 숫자 칸을
|
||||
* 위로 죽 잡고, 아니면 바로 왼쪽이 숫자면 왼쪽으로 죽 잡음. 여러 칸을 고른 채면 그 고름
|
||||
* 안에서(활성 칸 위 또는 왼쪽만) 잡음. 아무것도 못 찾으면 빈 `=SUM()`.
|
||||
*
|
||||
* 잇는 법(D · E · sub7 — 지금은 하나도 안 이어짐):
|
||||
* 1) `spreadsheet_keys.ts` 에 Alt+= 를 매핑해 `applyAutoSum(ctx)` 를 부름.
|
||||
* 2) `spreadsheet_toolbar.ts` 에 Σ 단추를 두고 눌리면 `applyAutoSum(ctx)` 를 부름.
|
||||
* 3) 지금은 칸에 식 글만 바로 넣음(편집기를 안 거침) — 사용자가 범위를 눈으로 고쳐 잡게 하려면
|
||||
* `ctx.editor.begin("=SUM(...)")` 로 편집 상태로 열고 커밋은 사용자가 Enter.
|
||||
* ========================================================================== */
|
||||
|
||||
import { rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import { isFrac } from "./spreadsheet_functions_math";
|
||||
import type { CellRange } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
function isNumericCell(ctx: SpreadsheetContext, sheet: string, r: number, c: number): boolean {
|
||||
if (r < 0 || c < 0) return false;
|
||||
return isFrac(ctx.engine.value(sheet, r, c));
|
||||
}
|
||||
|
||||
function verticalRun(
|
||||
ctx: SpreadsheetContext,
|
||||
sheet: string,
|
||||
r: number,
|
||||
c: number,
|
||||
top: number,
|
||||
): CellRange | null {
|
||||
if (r - 1 < top || !isNumericCell(ctx, sheet, r - 1, c)) return null;
|
||||
let start = r - 1;
|
||||
while (start - 1 >= top && isNumericCell(ctx, sheet, start - 1, c)) start--;
|
||||
return { r0: start, c0: c, r1: r - 1, c1: c };
|
||||
}
|
||||
|
||||
function horizontalRun(
|
||||
ctx: SpreadsheetContext,
|
||||
sheet: string,
|
||||
r: number,
|
||||
c: number,
|
||||
left: number,
|
||||
): CellRange | null {
|
||||
if (c - 1 < left || !isNumericCell(ctx, sheet, r, c - 1)) return null;
|
||||
let start = c - 1;
|
||||
while (start - 1 >= left && isNumericCell(ctx, sheet, r, start - 1)) start--;
|
||||
return { r0: r, c0: start, r1: r, c1: c - 1 };
|
||||
}
|
||||
|
||||
/** 활성 칸 기준 합산 범위를 찾음 — 없으면 null(빈 `=SUM()`). */
|
||||
export function autoSumRange(ctx: SpreadsheetContext): CellRange | null {
|
||||
const sheet = ctx.selection.시트;
|
||||
const { r, c } = ctx.selection.활성;
|
||||
const sel = ctx.selection.범위[0];
|
||||
|
||||
if (sel && (sel.r1 > sel.r0 || sel.c1 > sel.c0)) {
|
||||
if (r > sel.r0) return { r0: sel.r0, c0: c, r1: r - 1, c1: c };
|
||||
if (c > sel.c0) return { r0: r, c0: sel.c0, r1: r, c1: c - 1 };
|
||||
}
|
||||
|
||||
return verticalRun(ctx, sheet, r, c, 0) ?? horizontalRun(ctx, sheet, r, c, 0);
|
||||
}
|
||||
|
||||
/** 활성 칸에 `=SUM(...)` 을 바로 넣음(칸 명령 — 편집기를 안 거침). */
|
||||
export function applyAutoSum(ctx: SpreadsheetContext): void {
|
||||
const range = autoSumRange(ctx);
|
||||
const { r, c } = ctx.selection.활성;
|
||||
const 식 = range ? `SUM(${rangeToA1(range)})` : "SUM()";
|
||||
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: { [toA1(r, c)]: { 식 } } });
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_cellstyle.ts (주인 C)
|
||||
* 서식 → CSS — 글꼴 · 정렬(균등 분할 · 선택 영역 가운데 · 세로 · 줄 바꿈) · 채움색 ·
|
||||
* 테두리(hair · thin · medium · thick · double · dotted · dashed · 색) · 겹친 테두리 고르기.
|
||||
* 칸 하나가 위 · 왼 이웃과 테두리를 나눠 가짐(표 `border-collapse` 방식) — 겹쳐 그리지 않게
|
||||
* 위 · 왼만 두 이웃 중 굵은 쪽으로 그리고 오른 · 아래는 시트 끝 칸만 그림(`resolveEdgeBorders`).
|
||||
* 0 계약 머리 — 몸은 C 가 채움. 계약은 `spreadsheet_types.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { colName, toA1 } from "./spreadsheet_address";
|
||||
import type {
|
||||
BorderLine,
|
||||
BorderSide,
|
||||
CellStyle,
|
||||
HAlign,
|
||||
Sheet,
|
||||
VAlign,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
|
||||
/** 선 굵기 차례 — 겹치면 굵은 쪽이 이김(엑셀과 완전히 같지 않음 · 근사). */
|
||||
const BORDER_WEIGHT: Record<BorderLine, number> = {
|
||||
hair: 0,
|
||||
dotted: 1,
|
||||
dashed: 2,
|
||||
thin: 3,
|
||||
double: 4,
|
||||
medium: 5,
|
||||
thick: 6,
|
||||
};
|
||||
|
||||
/** 두 이웃 칸이 한 자리에 그리려는 테두리 중 굵은 쪽. 없으면 있는 쪽 · 둘 다 없으면 undefined. */
|
||||
export function strongerBorder(a?: BorderSide, b?: BorderSide): BorderSide | undefined {
|
||||
if (!a) return b;
|
||||
if (!b) return a;
|
||||
return BORDER_WEIGHT[b.선] > BORDER_WEIGHT[a.선] ? b : a;
|
||||
}
|
||||
|
||||
/** 칸 하나가 실제로 그릴 네 변 — 위 · 왼은 이웃과 겹쳐 고름 · 오른 · 아래는 시트 끝(그 다음 칸이 없음)일 때만. */
|
||||
export interface ResolvedBorders {
|
||||
위?: BorderSide;
|
||||
아래?: BorderSide;
|
||||
왼?: BorderSide;
|
||||
오른?: BorderSide;
|
||||
}
|
||||
|
||||
/** `서식` 표 번호 — 칸 → 행 → 열 → 시트 `기본.서식` → 0(기본) 차례(칸 안의 계약 주석과 같음). */
|
||||
export function styleIndexAt(sheet: Sheet, r: number, c: number, key: string): number {
|
||||
const cell = sheet.칸[key];
|
||||
if (cell?.서식 !== undefined) return cell.서식;
|
||||
const rowFmt = sheet.행?.[String(r + 1)]?.서식;
|
||||
if (rowFmt !== undefined) return rowFmt;
|
||||
const colFmt = sheet.열?.[colName(c)]?.서식;
|
||||
if (colFmt !== undefined) return colFmt;
|
||||
if (sheet.기본?.서식 !== undefined) return sheet.기본.서식;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** (r, c) 칸이 쓸 서식 — 서식 표 밖 번호는 기본(빈 `{}`)으로. */
|
||||
export function cellStyleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle {
|
||||
const key = toA1(r, c);
|
||||
const idx = styleIndexAt(sheet, r, c, key);
|
||||
return book.서식[idx] ?? {};
|
||||
}
|
||||
|
||||
/** 칸 하나 · 병합 칸 한 덩이(box)의 테두리 — 네 변마다 안쪽 이웃과 겹쳐 굵은 쪽으로 고름.
|
||||
* 병합이면 왼위(r0,c0) · 오른아래(r1,c1) 이 다른 칸이라 네 변을 따로 봄(테두리는 칸마다 있음). */
|
||||
export function resolveBoxBorders(
|
||||
book: Workbook,
|
||||
sheet: Sheet,
|
||||
r0: number,
|
||||
c0: number,
|
||||
r1: number,
|
||||
c1: number,
|
||||
lastRow: number,
|
||||
lastCol: number,
|
||||
): ResolvedBorders {
|
||||
const top = cellStyleAt(book, sheet, r0, c0).테두리?.위;
|
||||
const aboveOf = r0 > 0 ? cellStyleAt(book, sheet, r0 - 1, c0).테두리?.아래 : undefined;
|
||||
const left = cellStyleAt(book, sheet, r0, c0).테두리?.왼;
|
||||
const leftOf = c0 > 0 ? cellStyleAt(book, sheet, r0, c0 - 1).테두리?.오른 : undefined;
|
||||
const bottomOwn = cellStyleAt(book, sheet, r1, c0).테두리?.아래;
|
||||
const belowOf = r1 < lastRow ? cellStyleAt(book, sheet, r1 + 1, c0).테두리?.위 : undefined;
|
||||
const rightOwn = cellStyleAt(book, sheet, r0, c1).테두리?.오른;
|
||||
const rightOf = c1 < lastCol ? cellStyleAt(book, sheet, r0, c1 + 1).테두리?.왼 : undefined;
|
||||
return {
|
||||
위: strongerBorder(top, aboveOf),
|
||||
왼: strongerBorder(left, leftOf),
|
||||
아래: r1 >= lastRow ? bottomOwn : strongerBorder(bottomOwn, belowOf),
|
||||
오른: c1 >= lastCol ? rightOwn : strongerBorder(rightOwn, rightOf),
|
||||
};
|
||||
}
|
||||
|
||||
/** 색 글을 비교용으로 정리 — 공백 · 대소문자 · `#` 3자리 · ARGB(`#FF000000`) · `rgb(0,0,0)` 를 `#rrggbb` 로. */
|
||||
function normalizeColor(c: string): string {
|
||||
const t = c.trim().toLowerCase().replace(/\s+/g, "");
|
||||
const rgb = /^rgba?\((\d+),(\d+),(\d+)(?:,1(?:\.0+)?)?\)$/.exec(t);
|
||||
if (rgb)
|
||||
return `#${[rgb[1], rgb[2], rgb[3]].map((n) => Number(n).toString(16).padStart(2, "0")).join("")}`;
|
||||
if (/^#[0-9a-f]{3}$/.test(t)) return `#${t[1]}${t[1]}${t[2]}${t[2]}${t[3]}${t[3]}`;
|
||||
if (/^#ff[0-9a-f]{6}$/.test(t)) return `#${t.slice(3)}`;
|
||||
return t;
|
||||
}
|
||||
|
||||
/** 엑셀 「자동」 글자색 · 테두리색 — 검정 · windowtext · auto 는 테마 글자색을 따름(저장 값은 그대로 · 그릴 때만). */
|
||||
export function isAutoInk(c: string | undefined): boolean {
|
||||
if (!c) return true;
|
||||
const n = normalizeColor(c);
|
||||
return n === "#000000" || n === "black" || n === "windowtext" || n === "auto";
|
||||
}
|
||||
|
||||
/** 엑셀 「자동」 채움 — 흰색 · window 는 채움 없음처럼(테마 바탕이 비침). */
|
||||
export function isAutoPaper(c: string | undefined): boolean {
|
||||
if (!c) return true;
|
||||
const n = normalizeColor(c);
|
||||
return n === "#ffffff" || n === "white" || n === "window";
|
||||
}
|
||||
|
||||
/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). `scale` = 확대 · 축소 배율(선 굵기도 같이). */
|
||||
function borderCss(side: BorderSide | undefined, scale: number): string {
|
||||
if (!side) return "";
|
||||
const color = isAutoInk(side.색) ? "var(--aislo-grid-fg)" : (side.색 as string);
|
||||
const w = (px: number): number => Math.max(1, Math.round(px * scale));
|
||||
switch (side.선) {
|
||||
case "hair":
|
||||
return `${w(1)}px dotted ${color}`;
|
||||
case "thin":
|
||||
return `${w(1)}px solid ${color}`;
|
||||
case "medium":
|
||||
return `${w(2)}px solid ${color}`;
|
||||
case "thick":
|
||||
return `${w(3)}px solid ${color}`;
|
||||
case "double":
|
||||
return `${w(3)}px double ${color}`;
|
||||
case "dotted":
|
||||
return `${w(1)}px dotted ${color}`;
|
||||
case "dashed":
|
||||
return `${w(1)}px dashed ${color}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). */
|
||||
const HALIGN_CSS: Record<HAlign, string> = {
|
||||
general: "left",
|
||||
left: "left",
|
||||
right: "right",
|
||||
center: "center",
|
||||
distributed: "justify",
|
||||
centerContinuous: "center",
|
||||
};
|
||||
|
||||
const VALIGN_CSS: Record<VAlign, string> = {
|
||||
top: "flex-start",
|
||||
center: "center",
|
||||
bottom: "flex-end",
|
||||
};
|
||||
|
||||
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`).
|
||||
* `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */
|
||||
export function applyCellStyle(
|
||||
cell: HTMLElement,
|
||||
style: CellStyle,
|
||||
borders: ResolvedBorders,
|
||||
hAlign: HAlign,
|
||||
scale = 1,
|
||||
): void {
|
||||
const s = cell.style;
|
||||
s.fontFamily = style.글꼴 ?? "";
|
||||
s.fontSize = style.크기 ? `${style.크기 * scale}pt` : "";
|
||||
s.fontWeight = style.굵게 ? "700" : "400";
|
||||
s.fontStyle = style.기울임 ? "italic" : "normal";
|
||||
s.textDecorationLine = [style.밑줄 && "underline", style.취소선 && "line-through"]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
s.color = isAutoInk(style.글자색) ? "" : (style.글자색 as string);
|
||||
s.backgroundColor = isAutoPaper(style.채움) ? "" : (style.채움 as string);
|
||||
s.borderTop = borderCss(borders.위, scale);
|
||||
s.borderLeft = borderCss(borders.왼, scale);
|
||||
s.borderBottom = borderCss(borders.아래, scale);
|
||||
s.borderRight = borderCss(borders.오른, scale);
|
||||
s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"];
|
||||
s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
|
||||
|
||||
let text = cell.firstElementChild as HTMLElement | null;
|
||||
if (!text || !text.classList.contains("aislo-cell__text")) {
|
||||
text = document.createElement("div");
|
||||
text.className = "aislo-cell__text";
|
||||
cell.replaceChildren(text);
|
||||
}
|
||||
text.style.textAlign = HALIGN_CSS[hAlign];
|
||||
text.style.textAlignLast = hAlign === "distributed" ? "justify" : "";
|
||||
}
|
||||
|
||||
/** 칸 글 — 서식 CSS 와 따로 두어 `invalidate()` 가 값만 바꿀 때 서식을 다시 잴 필요가 없게. */
|
||||
export function setCellText(cell: HTMLElement, text: string, title?: string): void {
|
||||
const node = cell.firstElementChild as HTMLElement | null;
|
||||
if (node && node.classList.contains("aislo-cell__text")) {
|
||||
node.textContent = text;
|
||||
} else {
|
||||
cell.textContent = text;
|
||||
}
|
||||
if (title) cell.title = title;
|
||||
else cell.removeAttribute("title");
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_clipboard.ts (주인 E)
|
||||
* 복사 · 잘라내기 · 붙여넣기 — `copy` · `cut` · `paste` 사건만(허락 창 없음 · navigator.clipboard 안 씀).
|
||||
* 형 · 수식 살리기 규칙은 `spreadsheet_types.ts` `ClipBlock` 머리. 잘라내기 = 복사 + `내용지움`(누른 순간
|
||||
* 바로 지움) · 붙여넣기 = `칸` 명령(식은 A `moveFormula` · 구글 R1C1 은 `r1c1ToA1`) + 서식 · 병합 명령
|
||||
* 묶음(칸 하나하나). 잘라낸 것을 같은 문서에 처음 붙이면(클립보드 HTML 의 `aislo-cut` 표 · 그 사이 잘라낸
|
||||
* 자리에 새로 안 씀) 지운 칸을 되살려 `옮기기` 명령으로 — 그 칸을 가리키던 다른 식도 따라감(엑셀).
|
||||
* 다른 앱 · 두 번째 붙여넣기는 보통 붙여넣기. 편집 중(`ctx.editor.editing()`)이면 사건을 편집기에 맡김.
|
||||
*
|
||||
* 이음(D `spreadsheet.ts` 잇는 자리) — `attachClipboard(ctx)` 한 번 부름(반환 `root` 없음 · 안 붙여도 됨).
|
||||
* `ctx.root` 의 copy/cut/paste 사건을 직접 받으므로 그 요소가 포커스를 받을 수 있어야 함
|
||||
* (D 의 숨은 textarea 가 늘 그 안에 있어야 함).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
|
||||
import { formatValue } from "./spreadsheet_numfmt";
|
||||
import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
|
||||
import { st } from "./spreadsheet_text";
|
||||
import type {
|
||||
BorderLine,
|
||||
BorderSide,
|
||||
Cell,
|
||||
CellAddress,
|
||||
CellInput,
|
||||
CellRange,
|
||||
CellStyle,
|
||||
ClipBlock,
|
||||
ClipCell,
|
||||
Command,
|
||||
Scalar,
|
||||
Sheet,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
/* ── 안 → 밖 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
const BORDER_CSS: Record<BorderLine, string> = {
|
||||
hair: "dotted",
|
||||
thin: "solid",
|
||||
medium: "solid",
|
||||
thick: "solid",
|
||||
double: "double",
|
||||
dotted: "dotted",
|
||||
dashed: "dashed",
|
||||
};
|
||||
|
||||
function styleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle {
|
||||
const cell = sheet.칸[toA1(r, c)];
|
||||
const idx =
|
||||
cell?.서식 ??
|
||||
sheet.열?.[colName(c)]?.서식 ??
|
||||
sheet.행?.[String(r + 1)]?.서식 ??
|
||||
sheet.기본?.서식 ??
|
||||
0;
|
||||
return book.서식[idx] ?? {};
|
||||
}
|
||||
|
||||
function styleToCss(style: CellStyle): string {
|
||||
const parts: string[] = [];
|
||||
if (style.글꼴) parts.push(`font-family:${style.글꼴}`);
|
||||
if (style.크기) parts.push(`font-size:${style.크기}pt`);
|
||||
if (style.굵게) parts.push("font-weight:bold");
|
||||
if (style.기울임) parts.push("font-style:italic");
|
||||
const deco = [style.밑줄 && "underline", style.취소선 && "line-through"]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (deco) parts.push(`text-decoration:${deco}`);
|
||||
if (style.글자색) parts.push(`color:${style.글자색}`);
|
||||
if (style.채움) parts.push(`background-color:${style.채움}`);
|
||||
if (style.가로 && style.가로 !== "general") {
|
||||
parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`);
|
||||
}
|
||||
if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`);
|
||||
if (style.줄바꿈) parts.push("white-space:pre-wrap");
|
||||
if (style.테두리) {
|
||||
(["위", "아래", "왼", "오른"] as const).forEach((side) => {
|
||||
const s = style.테두리?.[side];
|
||||
if (!s) return;
|
||||
const prop = {
|
||||
위: "border-top",
|
||||
아래: "border-bottom",
|
||||
왼: "border-left",
|
||||
오른: "border-right",
|
||||
}[side];
|
||||
parts.push(`${prop}:1px ${BORDER_CSS[s.선]} ${s.색 ?? "#000"}`);
|
||||
});
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** 안 → 안 왕복용 — 칸 원래 값 · 식 · 서식(인라인, 표 번호 아님) 그대로. */
|
||||
function buildAisloBlock(book: Workbook, sheet: Sheet, range: CellRange): ClipBlock {
|
||||
const { r0, c0, r1, c1 } = range;
|
||||
const 칸: Record<string, ClipCell> = {};
|
||||
for (let r = r0; r <= r1; r++) {
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
const cell = sheet.칸[toA1(r, c)];
|
||||
if (!cell) continue;
|
||||
const clip: ClipCell = {};
|
||||
if (cell.값 !== undefined) clip.값 = cell.값;
|
||||
if (cell.식 !== undefined) clip.식 = cell.식;
|
||||
if (cell.서식 !== undefined) clip.서식 = book.서식[cell.서식];
|
||||
if (Object.keys(clip).length) 칸[`${r - r0},${c - c0}`] = clip;
|
||||
}
|
||||
}
|
||||
const 병합 = (sheet.병합 ?? [])
|
||||
.map((m) => parseRange(m))
|
||||
.filter((m): m is CellRange => !!m && m.r0 >= r0 && m.c0 >= c0 && m.r1 <= r1 && m.c1 <= c1)
|
||||
.map((m) => ({ r0: m.r0 - r0, c0: m.c0 - c0, r1: m.r1 - r0, c1: m.c1 - c0 }));
|
||||
return { rows: r1 - r0 + 1, cols: c1 - c0 + 1, 칸, 병합, 원점: { r: r0, c: c0 }, 출처: "aislo" };
|
||||
}
|
||||
|
||||
/** 범위 → 클립보드 글 둘(TSV = 보이는 값 · HTML = 인라인 서식 표 + 안쪽 뭉치 JSON). */
|
||||
export function blockToClipboard(
|
||||
book: Workbook,
|
||||
sheetId: string,
|
||||
range: CellRange,
|
||||
valueAt: (r: number, c: number) => Scalar,
|
||||
): { html: string; text: string } {
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
if (!sheet) return { html: "", text: "" };
|
||||
const { r0, c0, r1, c1 } = range;
|
||||
const aisloBlock = buildAisloBlock(book, sheet, range);
|
||||
const rowsHtml: string[] = [];
|
||||
const tsvRows: string[] = [];
|
||||
const skip = new Set<string>();
|
||||
for (let r = r0; r <= r1; r++) {
|
||||
const cellsHtml: string[] = [];
|
||||
const tsvCells: string[] = [];
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
const key = `${r},${c}`;
|
||||
if (skip.has(key)) continue;
|
||||
const style = styleAt(book, sheet, r, c);
|
||||
const text = formatValue(valueAt(r, c), style.형식).글;
|
||||
tsvCells.push(text.replace(/\t/g, " "));
|
||||
let span = "";
|
||||
const merge = (sheet.병합 ?? [])
|
||||
.map((m) => parseRange(m))
|
||||
.find((m): m is CellRange => !!m && m.r0 === r && m.c0 === c && m.r1 <= r1 && m.c1 <= c1);
|
||||
if (merge) {
|
||||
const rs = merge.r1 - merge.r0 + 1;
|
||||
const cs = merge.c1 - merge.c0 + 1;
|
||||
if (rs > 1) span += ` rowspan="${rs}"`;
|
||||
if (cs > 1) span += ` colspan="${cs}"`;
|
||||
for (let dr = 0; dr < rs; dr++)
|
||||
for (let dc = 0; dc < cs; dc++) skip.add(`${r + dr},${c + dc}`);
|
||||
}
|
||||
cellsHtml.push(
|
||||
`<td${span} style="${escapeHtml(styleToCss(style))}">${escapeHtml(text)}</td>`,
|
||||
);
|
||||
}
|
||||
rowsHtml.push(`<tr>${cellsHtml.join("")}</tr>`);
|
||||
tsvRows.push(tsvCells.join("\t"));
|
||||
}
|
||||
const table = `<table>${rowsHtml.join("")}</table>`;
|
||||
const html = `<!--aislo:${encodeURIComponent(JSON.stringify(aisloBlock))}-->${table}`;
|
||||
return { html, text: tsvRows.join("\r\n") };
|
||||
}
|
||||
|
||||
/* ── 밖 → 안 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 「1,234.56」 · 「TRUE」 는 값으로 · 나머지는 글로. */
|
||||
function coerceValue(text: string): CellInput {
|
||||
const t = text.trim();
|
||||
if (/^[+-]?\d[\d,]*(\.\d+)?$/.test(t)) {
|
||||
const n = Number(t.replace(/,/g, ""));
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
if (/^(TRUE|FALSE)$/i.test(t)) return t.toUpperCase() === "TRUE";
|
||||
return text;
|
||||
}
|
||||
|
||||
/** 「=」 뒤에 글이 있고 식으로 보이면 식 — 엑셀 「수식 표시」 복사 대응(「=」 한 글자는 글). */
|
||||
function cellFromText(text: string): ClipCell {
|
||||
if (text.startsWith("=") && text.length > 1) return { 식: text.slice(1) };
|
||||
return { 값: coerceValue(text) };
|
||||
}
|
||||
|
||||
interface TableWalk {
|
||||
rows: number;
|
||||
cols: number;
|
||||
owner: Map<string, HTMLTableCellElement>;
|
||||
merges: CellRange[];
|
||||
}
|
||||
|
||||
function walkTable(table: HTMLTableElement): TableWalk {
|
||||
const occupied = new Set<string>();
|
||||
const owner = new Map<string, HTMLTableCellElement>();
|
||||
const merges: CellRange[] = [];
|
||||
const rows = Array.from(table.rows);
|
||||
rows.forEach((row, r) => {
|
||||
let c = 0;
|
||||
Array.from(row.cells).forEach((cellEl) => {
|
||||
while (occupied.has(`${r},${c}`)) c++;
|
||||
const rowspan = cellEl.rowSpan || 1;
|
||||
const colspan = cellEl.colSpan || 1;
|
||||
owner.set(`${r},${c}`, cellEl);
|
||||
for (let dr = 0; dr < rowspan; dr++) {
|
||||
for (let dc = 0; dc < colspan; dc++) occupied.add(`${r + dr},${c + dc}`);
|
||||
}
|
||||
if (rowspan > 1 || colspan > 1) {
|
||||
merges.push({ r0: r, c0: c, r1: r + rowspan - 1, c1: c + colspan - 1 });
|
||||
}
|
||||
c += colspan;
|
||||
});
|
||||
});
|
||||
let cols = 0;
|
||||
for (const key of occupied) {
|
||||
const c = Number(key.split(",")[1]);
|
||||
if (c + 1 > cols) cols = c + 1;
|
||||
}
|
||||
return { rows: rows.length, cols, owner, merges };
|
||||
}
|
||||
|
||||
/** 선언 나눔 — `mso-number-format:"...\;..."` 안 이스케이프된 `\;`(구역 나눔 자리표시)는 진짜
|
||||
* 선언 끝이 아님(엑셀 회계 형식처럼 구역이 여럿인 값을 여기서 잘라 버리면 값 끝에 떨어진 `\` 만
|
||||
* 남아 형식이 깨짐) — `\` 뒤 글자는 통째로 건너뛰어 그 안 `;` 는 안 자름. */
|
||||
function splitDecls(s: string): string[] {
|
||||
return s.split(/(?<!\\);/);
|
||||
}
|
||||
|
||||
/** 엑셀 · 구글이 `td style="..."` 에 싣는 인라인 서식 → `CellStyle`(읽을 것 없으면 undefined). */
|
||||
function parseStyleAttr(styleAttr: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const decl of splitDecls(styleAttr)) {
|
||||
const i = decl.indexOf(":");
|
||||
if (i < 0) continue;
|
||||
const key = decl.slice(0, i).trim().toLowerCase();
|
||||
const value = decl.slice(i + 1).trim();
|
||||
if (key) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function cssColorToHex(v: string | undefined): string | undefined {
|
||||
if (!v) return undefined;
|
||||
const s = v.trim().toLowerCase();
|
||||
if (!s || s === "windowtext" || s === "transparent" || s === "none" || s === "auto")
|
||||
return undefined;
|
||||
if (/^#[0-9a-f]{3}$/.test(s)) return `#${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}`;
|
||||
if (/^#[0-9a-f]{6}$/.test(s)) return s;
|
||||
const rgb = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
|
||||
if (rgb) {
|
||||
const hex = (n: string) => Number(n).toString(16).padStart(2, "0");
|
||||
return `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 엑셀 `mso-number-format:"\#\,\#\#0\.00"` — 따옴표 떼고 `\x` 이스케이프 풀기(`\0022` 처럼 진짜
|
||||
* CSS 유니코드 이스케이프(`\` + 16진 1~6 자리, 회계 형식 글자 구역 따옴표 `"` 를 이렇게 실음)도
|
||||
* 먼저 풀지 않으면 따옴표 대신 글자 "0022" 가 그대로 남아 형식이 깨짐 — sub5 numfmt 가 받는
|
||||
* 형식 코드에는 이 CSS 껍데기가 없어야 함). */
|
||||
function unescapeMsoNumFmt(v: string): string {
|
||||
return v
|
||||
.replace(/^"|"$/g, "")
|
||||
.replace(/\\([0-9a-fA-F]{1,6}) ?|\\(.)/g, (_, hex, ch) =>
|
||||
hex !== undefined ? String.fromCodePoint(parseInt(hex, 16)) : ch,
|
||||
);
|
||||
}
|
||||
|
||||
/** pt 굵기 → 우리 선 종류(엑셀 내보내기 흔한 값 기준 — hairline ≈ .5pt 이하 · thin ≈ 1pt · medium ≈
|
||||
* 1.5~2pt · thick ≈ 2pt 넘음). `solid` 아닌 것은 굵기와 상관없이 그 모양. */
|
||||
function ptToBorderLine(pt: number, cssStyle: string): BorderLine {
|
||||
if (cssStyle === "dashed") return "dashed";
|
||||
if (cssStyle === "dotted") return "dotted";
|
||||
if (cssStyle === "double") return "double";
|
||||
if (pt <= 0.5) return "hair";
|
||||
if (pt <= 1.25) return "thin";
|
||||
if (pt <= 2) return "medium";
|
||||
return "thick";
|
||||
}
|
||||
|
||||
function parseBorderShorthand(v: string): BorderSide | undefined {
|
||||
const m = /([\d.]+)\s*(?:pt|px)\s+(solid|dashed|dotted|double)\s*([^;]*)/i.exec(v);
|
||||
if (!m) return undefined;
|
||||
const 선 = ptToBorderLine(Number(m[1]), m[2].toLowerCase());
|
||||
const 색 = cssColorToHex(m[3]);
|
||||
return 색 ? { 선, 색 } : { 선 };
|
||||
}
|
||||
|
||||
/** `<style>...</style>` 안 `.xl65 {...}` · `.xl65, .xl70 {...}`(묶음 선택자) 짜임(엑셀이 인라인 대신
|
||||
* 클래스로 서식을 실을 때) → 클래스별 선언(같은 클래스가 여러 규칙에 나오면 뒤엣것이 이김). */
|
||||
function parseClassRules(doc: Document): Record<string, Record<string, string>> {
|
||||
const css = Array.from(doc.querySelectorAll("style"))
|
||||
.map((s) => s.textContent ?? "")
|
||||
.join("\n");
|
||||
const rules: Record<string, Record<string, string>> = {};
|
||||
for (const m of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
|
||||
const decls = parseStyleAttr(m[2]);
|
||||
for (const selectorPart of m[1].split(",")) {
|
||||
const cls = /\.([a-zA-Z0-9_-]+)\s*$/.exec(selectorPart.trim());
|
||||
if (cls) Object.assign((rules[cls[1]] ??= {}), decls);
|
||||
}
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
function styleFromTd(
|
||||
cellEl: HTMLTableCellElement,
|
||||
classRules: Record<string, Record<string, string>>,
|
||||
): CellStyle | undefined {
|
||||
const decls: Record<string, string> = {};
|
||||
for (const cls of cellEl.classList) Object.assign(decls, classRules[cls]);
|
||||
const raw = cellEl.getAttribute("style");
|
||||
if (raw) Object.assign(decls, parseStyleAttr(raw));
|
||||
if (!Object.keys(decls).length) return undefined;
|
||||
const style: CellStyle = {};
|
||||
const weight = decls["font-weight"];
|
||||
if (weight === "bold" || Number(weight) >= 700) style.굵게 = true;
|
||||
if (decls["font-style"] === "italic") style.기울임 = true;
|
||||
const color = cssColorToHex(decls["color"]);
|
||||
if (color) style.글자색 = color;
|
||||
const bg = cssColorToHex(decls["background-color"] ?? decls["background"]);
|
||||
if (bg) style.채움 = bg;
|
||||
const align = decls["text-align"];
|
||||
if (align === "left" || align === "center" || align === "right") style.가로 = align;
|
||||
if (decls["mso-number-format"]) style.형식 = unescapeMsoNumFmt(decls["mso-number-format"]);
|
||||
const 테두리: NonNullable<CellStyle["테두리"]> = {};
|
||||
const sideMap: [string, "위" | "아래" | "왼" | "오른"][] = [
|
||||
["border-top", "위"],
|
||||
["border-bottom", "아래"],
|
||||
["border-left", "왼"],
|
||||
["border-right", "오른"],
|
||||
];
|
||||
for (const [prop, side] of sideMap) {
|
||||
const b = parseBorderShorthand(decls[prop] ?? decls["border"] ?? "");
|
||||
if (b) 테두리[side] = b;
|
||||
}
|
||||
if (Object.keys(테두리).length) style.테두리 = 테두리;
|
||||
return Object.keys(style).length ? style : undefined;
|
||||
}
|
||||
|
||||
function parseHtmlTable(html: string, at: CellAddress): ClipBlock | null {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const table = doc.querySelector("table");
|
||||
if (!table) return null;
|
||||
const walk = walkTable(table);
|
||||
const sheetsMode = /data-sheets-formula/.test(html);
|
||||
const classRules = parseClassRules(doc);
|
||||
const 칸: Record<string, ClipCell> = {};
|
||||
for (const [key, cellEl] of walk.owner) {
|
||||
const [r, c] = key.split(",").map(Number);
|
||||
const text = cellEl.textContent ?? "";
|
||||
const formula = sheetsMode ? cellEl.getAttribute("data-sheets-formula") : null;
|
||||
if (formula) {
|
||||
const target = { r: at.r + r, c: at.c + c };
|
||||
칸[key] = { 식: r1c1ToA1(formula.replace(/^=/, ""), target) };
|
||||
continue;
|
||||
}
|
||||
const style = styleFromTd(cellEl, classRules);
|
||||
if (!text) {
|
||||
if (style) 칸[key] = { 서식: style };
|
||||
continue;
|
||||
}
|
||||
const clip = cellFromText(text);
|
||||
if (style) clip.서식 = style;
|
||||
칸[key] = clip;
|
||||
}
|
||||
return {
|
||||
rows: walk.rows,
|
||||
cols: walk.cols,
|
||||
칸,
|
||||
병합: walk.merges,
|
||||
원점: null,
|
||||
출처: sheetsMode ? "google" : "excel",
|
||||
};
|
||||
}
|
||||
|
||||
function parseTsv(text: string): ClipBlock {
|
||||
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
||||
while (lines.length && lines[lines.length - 1] === "") lines.pop();
|
||||
const grid = lines.map((line) => line.split("\t"));
|
||||
let cols = 0;
|
||||
for (const row of grid) if (row.length > cols) cols = row.length;
|
||||
const 칸: Record<string, ClipCell> = {};
|
||||
grid.forEach((row, r) => {
|
||||
row.forEach((text, c) => {
|
||||
if (text === "") return;
|
||||
칸[`${r},${c}`] = cellFromText(text);
|
||||
});
|
||||
});
|
||||
return { rows: grid.length, cols, 칸, 병합: [], 원점: null, 출처: "text" };
|
||||
}
|
||||
|
||||
/** 클립보드 글 → 뭉치(HTML 먼저 · 없으면 TSV) · 읽을 것 없으면 null · `at` = 붙일 왼위(구글 R1C1 풀이). */
|
||||
export function parseClipboard(html: string, text: string, at: CellAddress): ClipBlock | null {
|
||||
const aislo = /<!--aislo:([^>]*)-->/.exec(html);
|
||||
if (aislo) {
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(aislo[1])) as ClipBlock;
|
||||
} catch {
|
||||
// 손상됐으면 아래 일반 경로로 폴백.
|
||||
}
|
||||
}
|
||||
if (/<table/i.test(html)) return parseHtmlTable(html, at);
|
||||
if (text) return parseTsv(text);
|
||||
return null;
|
||||
}
|
||||
|
||||
const NO_FORMULA_NOTIFIED_KEY = "aislo-ss-notified-no-formula";
|
||||
|
||||
/** 붙인 뭉치 어디에도 수식(`식`)이 없음 — 엑셀 HTML 클립보드는 「수식 표시」(Ctrl+`) 없이 복사하면
|
||||
* 계산값만 실림(수식 자체는 절대 안 실림). */
|
||||
function hasNoFormula(block: ClipBlock): boolean {
|
||||
return !Object.values(block.칸).some((cell) => cell.식 !== undefined);
|
||||
}
|
||||
|
||||
/** 세션에 한 번만 — 엑셀에서 값만 온 붙여넣기마다 매번 뜨면 성가심. */
|
||||
function notifyNoFormulaOnce(): void {
|
||||
try {
|
||||
if (sessionStorage.getItem(NO_FORMULA_NOTIFIED_KEY)) return;
|
||||
sessionStorage.setItem(NO_FORMULA_NOTIFIED_KEY, "1");
|
||||
} catch {
|
||||
// 세션 저장 안 되면 매번 뜰 수 있으나 치명적이지 않음.
|
||||
}
|
||||
showToast(st("ClipboardExcelNoFormula"));
|
||||
}
|
||||
|
||||
/* ── 화면 붙임 — copy · cut · paste 사건 ────────────────────────────────── */
|
||||
|
||||
function activeRange(ctx: SpreadsheetContext): CellRange {
|
||||
return (
|
||||
ctx.selection.범위[0] ?? {
|
||||
r0: ctx.selection.활성.r,
|
||||
c0: ctx.selection.활성.c,
|
||||
r1: ctx.selection.활성.r,
|
||||
c1: ctx.selection.활성.c,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function rangesOverlap(a: CellRange, b: CellRange): boolean {
|
||||
return a.r0 <= b.r1 && a.r1 >= b.r0 && a.c0 <= b.c1 && a.c1 >= b.c0;
|
||||
}
|
||||
|
||||
function rangeContains(outer: CellRange, inner: CellRange): boolean {
|
||||
return (
|
||||
outer.r0 <= inner.r0 && outer.r1 >= inner.r1 && outer.c0 <= inner.c0 && outer.c1 >= inner.c1
|
||||
);
|
||||
}
|
||||
|
||||
/** 붙일 자리가 기존 병합의 일부만 걸치면 막음(엑셀 「병합된 셀의 일부를 바꿀 수 없습니다」) — 병합을
|
||||
* 통째로 덮거나(허용) · 병합 한 칸에 값 하나만 얹는 것(허용)은 됨. */
|
||||
function findBlockingMerge(sheet: Sheet, target: CellRange): boolean {
|
||||
const isSingleCell = target.r0 === target.r1 && target.c0 === target.c1;
|
||||
for (const m of sheet.병합 ?? []) {
|
||||
const mr = parseRange(m);
|
||||
if (!mr || !rangesOverlap(target, mr)) continue;
|
||||
if (rangeContains(target, mr)) continue;
|
||||
if (isSingleCell && rangeContains(mr, target)) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress): void {
|
||||
const target: CellRange = {
|
||||
r0: at.r,
|
||||
c0: at.c,
|
||||
r1: at.r + block.rows - 1,
|
||||
c1: at.c + block.cols - 1,
|
||||
};
|
||||
const sheet = ctx.sheet();
|
||||
if (findBlockingMerge(sheet, target)) {
|
||||
showToast(st("ClipboardMergeBlocked"), "error");
|
||||
return;
|
||||
}
|
||||
// 붙일 자리가 기존 병합을 통째로 덮으면 먼저 풂(엑셀도 큰 덩어리를 얹으면 병합이 풀리고 칸마다 값이 옴) —
|
||||
// 안 풀면 A `applyCommand` 가 병합 속 왼위 아닌 칸에 값을 넣으려 할 때 막음.
|
||||
const unmergeCmds: Command[] = (sheet.병합 ?? [])
|
||||
.map((m) => parseRange(m))
|
||||
.filter((m): m is CellRange => !!m && rangeContains(target, m))
|
||||
.map((범위) => ({ 종류: "병합풀기", 시트: ctx.selection.시트, 범위 }));
|
||||
const shift = block.원점 ? { dr: at.r - block.원점.r, dc: at.c - block.원점.c } : null;
|
||||
const 칸: Record<string, Cell | null> = {};
|
||||
const styleCmds: Command[] = [];
|
||||
for (const [key, clip] of Object.entries(block.칸)) {
|
||||
const [lr, lc] = key.split(",").map(Number);
|
||||
const r = at.r + lr;
|
||||
const c = at.c + lc;
|
||||
const cell: Cell = {};
|
||||
if (clip.값 !== undefined) cell.값 = clip.값;
|
||||
if (clip.식 !== undefined) cell.식 = shift ? moveFormula(clip.식, shift.dr, shift.dc) : clip.식;
|
||||
칸[toA1(r, c)] = Object.keys(cell).length ? cell : null;
|
||||
if (clip.서식) {
|
||||
styleCmds.push({
|
||||
종류: "서식",
|
||||
시트: ctx.selection.시트,
|
||||
범위: [{ r0: r, c0: c, r1: r, c1: c }],
|
||||
바꿀: clip.서식,
|
||||
});
|
||||
}
|
||||
}
|
||||
const commands: Command[] = [
|
||||
...unmergeCmds,
|
||||
{ 종류: "칸", 시트: ctx.selection.시트, 칸 },
|
||||
...styleCmds,
|
||||
];
|
||||
for (const m of block.병합) {
|
||||
commands.push({
|
||||
종류: "병합",
|
||||
시트: ctx.selection.시트,
|
||||
범위: { r0: at.r + m.r0, c0: at.c + m.c0, r1: at.r + m.r1, c1: at.c + m.c1 },
|
||||
});
|
||||
}
|
||||
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
|
||||
}
|
||||
|
||||
/** A1 칸이 범위 안인가 */
|
||||
function inside(rg: CellRange, a1: string): boolean {
|
||||
const p = parseA1(a1);
|
||||
return !!p && p.r >= rg.r0 && p.r <= rg.r1 && p.c >= rg.c0 && p.c <= rg.c1;
|
||||
}
|
||||
|
||||
interface PendingCut {
|
||||
token: string;
|
||||
sheet: string;
|
||||
range: CellRange;
|
||||
/** 지우기 전 칸 — 같은 문서에 붙일 때 되살려 `옮기기` 로 넘김 */
|
||||
cells: Record<string, Cell>;
|
||||
}
|
||||
|
||||
/** 병합을 가르는지(통째로 든 병합은 됨) — `옮기기` 명령과 같은 규칙 */
|
||||
function cutsMerge(sheet: Sheet, rg: CellRange): boolean {
|
||||
return (sheet.병합 ?? [])
|
||||
.map((m) => parseRange(m))
|
||||
.some((m) => !!m && rangesOverlap(m, rg) && !rangeContains(rg, m));
|
||||
}
|
||||
|
||||
/** `ctx.root` 의 copy · cut · paste 를 받음(root null) */
|
||||
export function attachClipboard(ctx: SpreadsheetContext): PartHandle {
|
||||
/** 마지막 잘라내기 — 같은 문서에 붙이면 칸을 옮김(그 칸을 가리키던 식도 따라감 · 엑셀) */
|
||||
let cut: PendingCut | null = null;
|
||||
|
||||
function write(ev: ClipboardEvent, prefix = ""): void {
|
||||
const range = activeRange(ctx);
|
||||
const { html, text } = blockToClipboard(ctx.book, ctx.selection.시트, range, (r, c) =>
|
||||
ctx.engine.value(ctx.selection.시트, r, c),
|
||||
);
|
||||
ev.clipboardData?.setData("text/html", prefix + html);
|
||||
ev.clipboardData?.setData("text/plain", text);
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
function onCopy(ev: ClipboardEvent): void {
|
||||
if (ctx.editor.editing()) return;
|
||||
cut = null;
|
||||
write(ev);
|
||||
}
|
||||
|
||||
function onCut(ev: ClipboardEvent): void {
|
||||
if (ctx.editor.editing() || ctx.readOnly) return;
|
||||
const range = activeRange(ctx);
|
||||
const sheet = ctx.sheet();
|
||||
const cells: Record<string, Cell> = {};
|
||||
for (const [a1, cell] of Object.entries(sheet.칸))
|
||||
if (inside(range, a1)) cells[a1] = structuredClone(cell);
|
||||
const token = Math.random().toString(36).slice(2);
|
||||
write(ev, `<!--aislo-cut:${token}-->`);
|
||||
cut = { token, sheet: sheet.id, range, cells };
|
||||
ctx.dispatch({ 종류: "내용지움", 시트: sheet.id, 범위: [range] });
|
||||
}
|
||||
|
||||
/** 잘라낸 칸을 같은 문서 안으로 — 되살린 뒤 `옮기기`(한 번에 되돌림) · 못 하면 false(보통 붙여넣기) */
|
||||
function pasteCut(pending: PendingCut, at: CellAddress): boolean {
|
||||
const src = ctx.book.시트.find((s) => s.id === pending.sheet);
|
||||
if (!src) return false;
|
||||
const { r0, c0, r1, c1 } = pending.range;
|
||||
for (const [a1, cell] of Object.entries(src.칸))
|
||||
if (inside(pending.range, a1) && (cell.값 !== undefined || cell.식 !== undefined))
|
||||
return false; // 잘라낸 뒤 그 자리에 새로 씀
|
||||
const dst = { r0: at.r, c0: at.c, r1: at.r + r1 - r0, c1: at.c + c1 - c0 };
|
||||
if (cutsMerge(src, pending.range) || cutsMerge(ctx.sheet(), dst)) return false;
|
||||
ctx.dispatch({
|
||||
종류: "묶음",
|
||||
명령: [
|
||||
{ 종류: "칸", 시트: src.id, 칸: pending.cells },
|
||||
{ 종류: "옮기기", 시트: src.id, 범위: pending.range, 대상시트: ctx.selection.시트, ...at },
|
||||
],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function onPaste(ev: ClipboardEvent): void {
|
||||
if (ctx.editor.editing() || ctx.readOnly) return;
|
||||
const html = ev.clipboardData?.getData("text/html") ?? "";
|
||||
const text = ev.clipboardData?.getData("text/plain") ?? "";
|
||||
const at = ctx.selection.활성;
|
||||
const pending = cut;
|
||||
if (pending && html.includes(`<!--aislo-cut:${pending.token}-->`)) {
|
||||
cut = null; // 엑셀처럼 한 번만 옮김 — 다음부터는 보통 붙여넣기
|
||||
if (pasteCut(pending, at)) return void ev.preventDefault();
|
||||
}
|
||||
const block = parseClipboard(html, text, at);
|
||||
if (!block) {
|
||||
showToast(st("ClipboardPasteFailed"), "error");
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
if (block.출처 === "excel" && hasNoFormula(block)) notifyNoFormulaOnce();
|
||||
pasteBlock(ctx, block, at);
|
||||
}
|
||||
|
||||
ctx.root.addEventListener("copy", onCopy);
|
||||
ctx.root.addEventListener("cut", onCut);
|
||||
ctx.root.addEventListener("paste", onPaste);
|
||||
|
||||
return {
|
||||
root: null,
|
||||
refresh() {},
|
||||
destroy() {
|
||||
ctx.root.removeEventListener("copy", onCopy);
|
||||
ctx.root.removeEventListener("cut", onCut);
|
||||
ctx.root.removeEventListener("paste", onPaste);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_commands.ts (주인 A)
|
||||
* 명령 적용 — 문서를 그 자리에서 바꾸고 되돌림 짝 · 바뀐 칸 · 통째 다시 여부를 돌려줌.
|
||||
* 병합 규칙(엑셀): 병합 일부를 가르는 칸 쓰기 · 옮기기는 막음(오류 던짐 → 화면이 알림) ·
|
||||
* 병합 안에 행열을 넣으면 병합이 늘고 지우면 줆 · 병합하면 왼위 칸 값만 남김.
|
||||
* 서식 표는 같은 서식을 한 번만 둠(칸은 번호) · 안 쓰는 서식 정리는 저장 전에(`compactStyles`).
|
||||
* 행열 · 옮기기 · 시트 지우기는 바뀐 시트를 통째로 되돌림(`시트통째`) — 다른 시트 식 글까지 한 번에.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
colName,
|
||||
inRange,
|
||||
normRange,
|
||||
parseA1,
|
||||
parseRange,
|
||||
rangeInside,
|
||||
rangesOverlap,
|
||||
rangeToA1,
|
||||
toA1,
|
||||
} from "./spreadsheet_address";
|
||||
import {
|
||||
addSheet,
|
||||
moveSheet,
|
||||
removeSheet,
|
||||
renameSheet,
|
||||
replaceSheet,
|
||||
} from "./spreadsheet_commands_sheet";
|
||||
import { shiftForCommand, shiftSpan } from "./spreadsheet_refshift";
|
||||
import { applyFilter, applySort, shiftFilter } from "./spreadsheet_sortfilter";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type {
|
||||
Cell,
|
||||
CellRange,
|
||||
CellStyle,
|
||||
Command,
|
||||
CommandEffect,
|
||||
Sheet,
|
||||
SheetCellAddress,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
|
||||
/** 막힌 명령(병합 가름 · 마지막 시트 지우기 · 같은 시트 이름 …) — 화면이 `message` 를 알림 */
|
||||
export class CommandError extends Error {}
|
||||
|
||||
export type Of<K extends Command["종류"]> = Extract<Command, { 종류: K }>;
|
||||
|
||||
export const clone = <T>(x: T): T => structuredClone(x);
|
||||
|
||||
export function sheetOf(book: Workbook, id: string): Sheet {
|
||||
const s = book.시트.find((x) => x.id === id);
|
||||
if (!s) throw new CommandError(`없는 시트: ${id}`);
|
||||
return s;
|
||||
}
|
||||
|
||||
const mergesOf = (s: Sheet): CellRange[] =>
|
||||
(s.병합 ?? []).map((t) => parseRange(t)).filter((r): r is CellRange => r !== null);
|
||||
|
||||
function setMerges(s: Sheet, list: CellRange[]) {
|
||||
if (list.length) s.병합 = list.map(rangeToA1);
|
||||
else delete s.병합;
|
||||
}
|
||||
|
||||
/** 칸 열쇠 중 범위 안 것 */
|
||||
function cellsIn(s: Sheet, ranges: CellRange[]): [string, number, number][] {
|
||||
const out: [string, number, number][] = [];
|
||||
for (const a1 of Object.keys(s.칸)) {
|
||||
const at = parseA1(a1);
|
||||
if (at && ranges.some((rg) => inRange(rg, at.r, at.c))) out.push([a1, at.r, at.c]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const isFull = (rg: CellRange, rows: boolean) =>
|
||||
rows ? rg.c0 === 0 && rg.c1 === MAX_COLS - 1 : rg.r0 === 0 && rg.r1 === MAX_ROWS - 1;
|
||||
|
||||
function sortKeys(v: unknown): unknown {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return v;
|
||||
const o = v as Record<string, unknown>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(o)
|
||||
.sort()
|
||||
.map((k) => [k, sortKeys(o[k])]),
|
||||
);
|
||||
}
|
||||
const styleKey = (s: CellStyle) => JSON.stringify(sortKeys(s));
|
||||
|
||||
function internStyle(book: Workbook, style: CellStyle): number {
|
||||
const key = styleKey(style);
|
||||
const i = book.서식.findIndex((s) => styleKey(s) === key);
|
||||
if (i >= 0) return i;
|
||||
book.서식.push(style);
|
||||
return book.서식.length - 1;
|
||||
}
|
||||
|
||||
function setComments(s: Sheet, notes: Record<string, string>) {
|
||||
if (Object.keys(notes).length) s.comments = notes;
|
||||
else delete s.comments;
|
||||
}
|
||||
|
||||
/** 시트 통째 서식 */
|
||||
const sheetStyle = (s: Sheet) => s.기본?.서식 ?? 0;
|
||||
|
||||
/** 칸 자리에서 물려받는 서식(칸 서식 없을 때) — 행 · 열 · 시트 · 0 */
|
||||
const inherited = (s: Sheet, r: number, c: number) =>
|
||||
s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? sheetStyle(s);
|
||||
|
||||
/** 시트 `기본` 한 칸 — undefined 면 지움 · 빈 `기본` 도 지움 */
|
||||
function setSheetDefault(s: Sheet, key: "서식" | "열폭" | "행높이", value: number | undefined) {
|
||||
const d = (s.기본 ??= {});
|
||||
if (value === undefined) delete d[key];
|
||||
else d[key] = value;
|
||||
if (!Object.keys(d).length) delete s.기본;
|
||||
}
|
||||
|
||||
const addr = (시트: string, r: number, c: number): SheetCellAddress => ({ 시트, r, c });
|
||||
|
||||
/** 바뀐 시트만 통째 되돌림 짝 */
|
||||
export function restoreChanged(book: Workbook, old: Sheet[]): Command[] {
|
||||
const out: Command[] = [];
|
||||
for (const o of old) {
|
||||
const now = book.시트.find((x) => x.id === o.id);
|
||||
if (now && JSON.stringify(now) !== JSON.stringify(o)) out.push({ 종류: "시트통째", 시트: o });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 모든 식 글을 명령에 맞춰 고침 — 시트 이름을 바꾸기 전에 부름 */
|
||||
export function rewriteAll(book: Workbook, command: Command) {
|
||||
for (const s of book.시트)
|
||||
for (const cell of Object.values(s.칸))
|
||||
if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, command, book);
|
||||
}
|
||||
|
||||
// ── 칸 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function setCells(book: Workbook, cmd: Of<"칸">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const merges = mergesOf(s);
|
||||
const plan: [string, number, number, Cell | null][] = [];
|
||||
for (const [key, cell] of Object.entries(cmd.칸)) {
|
||||
const at = parseA1(key);
|
||||
if (!at) throw new CommandError(`칸 주소 틀림: ${key}`);
|
||||
if (cell && (cell.값 !== undefined || cell.식 !== undefined))
|
||||
if (merges.some((m) => inRange(m, at.r, at.c) && (m.r0 !== at.r || m.c0 !== at.c)))
|
||||
throw new CommandError("병합한 칸은 왼위 칸에만 값을 넣음");
|
||||
plan.push([toA1(at.r, at.c), at.r, at.c, cell]);
|
||||
}
|
||||
const prev: Record<string, Cell | null> = {};
|
||||
for (const [a1, , , cell] of plan) {
|
||||
prev[a1] = s.칸[a1] ? clone(s.칸[a1]) : null;
|
||||
if (cell) s.칸[a1] = clone(cell);
|
||||
else delete s.칸[a1];
|
||||
}
|
||||
return {
|
||||
undo: { 종류: "칸", 시트: s.id, 칸: prev },
|
||||
cells: plan.map(([, r, c]) => addr(s.id, r, c)),
|
||||
rebuild: false,
|
||||
};
|
||||
}
|
||||
|
||||
function clearContents(book: Workbook, cmd: Of<"내용지움">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const prev: Record<string, Cell | null> = {};
|
||||
const cells: SheetCellAddress[] = [];
|
||||
for (const [a1, r, c] of cellsIn(s, cmd.범위)) {
|
||||
const cell = s.칸[a1];
|
||||
if (cell.값 === undefined && cell.식 === undefined) continue;
|
||||
prev[a1] = clone(cell);
|
||||
if (cell.서식 === undefined) delete s.칸[a1];
|
||||
else s.칸[a1] = { 서식: cell.서식 };
|
||||
cells.push(addr(s.id, r, c));
|
||||
}
|
||||
return { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false };
|
||||
}
|
||||
|
||||
// ── 서식 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 범위 서식 바꿈 — `change(지금 번호) → 새 번호`. 행 · 열 전체는 행 · 열 서식 + 든 칸. */
|
||||
function restyle(
|
||||
book: Workbook,
|
||||
sheet: string,
|
||||
ranges: CellRange[],
|
||||
change: (i: number) => number,
|
||||
): CommandEffect {
|
||||
const s = sheetOf(book, sheet);
|
||||
const before = clone(s);
|
||||
const memo = new Map<number, number>();
|
||||
const f = (i: number) => {
|
||||
if (!memo.has(i)) memo.set(i, change(i));
|
||||
return memo.get(i)!;
|
||||
};
|
||||
const prev: Record<string, Cell | null> = {};
|
||||
const cells: SheetCellAddress[] = [];
|
||||
let whole = false;
|
||||
const setInfo = (axis: "행" | "열", key: string) => {
|
||||
const table = (s[axis] ??= {});
|
||||
const info = (table[key] ??= {});
|
||||
const idx = f(info.서식 ?? sheetStyle(s));
|
||||
if (idx !== sheetStyle(s)) info.서식 = idx;
|
||||
else delete info.서식;
|
||||
if (!Object.keys(info).length) delete table[key];
|
||||
};
|
||||
const touch = (r: number, c: number) => {
|
||||
const a1 = toA1(r, c);
|
||||
if (a1 in prev) return;
|
||||
const cell = s.칸[a1];
|
||||
prev[a1] = cell ? clone(cell) : null;
|
||||
const next: Cell = { ...cell };
|
||||
const idx = f(cell?.서식 ?? inherited(s, r, c));
|
||||
if (idx === inherited(s, r, c)) delete next.서식;
|
||||
else next.서식 = idx;
|
||||
if (Object.keys(next).length) s.칸[a1] = next;
|
||||
else delete s.칸[a1];
|
||||
cells.push(addr(s.id, r, c));
|
||||
};
|
||||
for (const rg of ranges) {
|
||||
const fullRows = isFull(rg, true);
|
||||
const fullCols = isFull(rg, false);
|
||||
if (fullRows && fullCols) {
|
||||
whole = true;
|
||||
const idx = f(sheetStyle(s));
|
||||
setSheetDefault(s, "서식", idx || undefined);
|
||||
for (const k of Object.keys(s.행 ?? {})) setInfo("행", k);
|
||||
for (const k of Object.keys(s.열 ?? {})) setInfo("열", k);
|
||||
} else if (fullRows || fullCols) {
|
||||
whole = true;
|
||||
for (let i = fullRows ? rg.r0 : rg.c0; i <= (fullRows ? rg.r1 : rg.c1); i++)
|
||||
setInfo(fullRows ? "행" : "열", fullRows ? String(i + 1) : colName(i));
|
||||
} else {
|
||||
if ((rg.r1 - rg.r0 + 1) * (rg.c1 - rg.c0 + 1) > 1_000_000)
|
||||
throw new CommandError("범위가 너무 큼");
|
||||
for (let r = rg.r0; r <= rg.r1; r++) for (let c = rg.c0; c <= rg.c1; c++) touch(r, c);
|
||||
continue;
|
||||
}
|
||||
for (const [, r, c] of cellsIn(s, [rg])) touch(r, c);
|
||||
}
|
||||
if (s.행 && !Object.keys(s.행).length) delete s.행;
|
||||
if (s.열 && !Object.keys(s.열).length) delete s.열;
|
||||
return whole
|
||||
? { undo: { 종류: "시트통째", 시트: before }, cells, rebuild: true }
|
||||
: { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false };
|
||||
}
|
||||
|
||||
function patchStyle(book: Workbook, cmd: Of<"서식">): CommandEffect {
|
||||
return restyle(book, cmd.시트, cmd.범위, (i) => {
|
||||
const next: Record<string, unknown> = { ...book.서식[i] };
|
||||
for (const [k, v] of Object.entries(cmd.바꿀)) {
|
||||
if (v === null) delete next[k];
|
||||
else if (v !== undefined) next[k] = clone(v);
|
||||
}
|
||||
return internStyle(book, next as CellStyle);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 행열 넣기 · 지우기 ──────────────────────────────────────────────────────
|
||||
|
||||
function insertDelete(
|
||||
book: Workbook,
|
||||
cmd: Of<"행넣기" | "행지우기" | "열넣기" | "열지우기">,
|
||||
): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const rows = cmd.종류.startsWith("행");
|
||||
const insert = cmd.종류.endsWith("넣기");
|
||||
const max = rows ? MAX_ROWS : MAX_COLS;
|
||||
const { at, 수: n } = cmd;
|
||||
if (n < 1 || at < 0 || at >= max || (!insert && at + n > max))
|
||||
throw new CommandError("넣기 · 지우기 자리가 틀림");
|
||||
const pos = (a1: string) => {
|
||||
const p = parseA1(a1)!;
|
||||
return rows ? p.r : p.c;
|
||||
};
|
||||
if (insert && Object.keys(s.칸).some((a1) => pos(a1) + n >= max))
|
||||
throw new CommandError("격자 끝 칸이 밀려 나감 — 끝 쪽 칸을 먼저 지움");
|
||||
const old = book.시트.map(clone);
|
||||
rewriteAll(book, cmd);
|
||||
const span = (i: number) => shiftSpan(i, i, insert, at, n, max)?.[0];
|
||||
|
||||
const cells: Record<string, Cell> = {};
|
||||
for (const [a1, cell] of Object.entries(s.칸)) {
|
||||
const p = parseA1(a1)!;
|
||||
const i = span(rows ? p.r : p.c);
|
||||
if (i !== undefined) cells[rows ? toA1(i, p.c) : toA1(p.r, i)] = cell;
|
||||
}
|
||||
s.칸 = cells;
|
||||
if (s.comments) {
|
||||
const notes: Record<string, string> = {};
|
||||
for (const [a1, text] of Object.entries(s.comments)) {
|
||||
const p = parseA1(a1);
|
||||
const i = p && span(rows ? p.r : p.c);
|
||||
if (p && i !== undefined && i !== null) notes[rows ? toA1(i, p.c) : toA1(p.r, i)] = text;
|
||||
}
|
||||
setComments(s, notes);
|
||||
}
|
||||
|
||||
const shiftRange = (m: CellRange) => {
|
||||
const sp = rows
|
||||
? shiftSpan(m.r0, m.r1, insert, at, n, max)
|
||||
: shiftSpan(m.c0, m.c1, insert, at, n, max);
|
||||
return sp && (rows ? { ...m, r0: sp[0], r1: sp[1] } : { ...m, c0: sp[0], c1: sp[1] });
|
||||
};
|
||||
const merges: CellRange[] = [];
|
||||
for (const m of mergesOf(s)) {
|
||||
const next = shiftRange(m);
|
||||
if (next && (next.r0 !== next.r1 || next.c0 !== next.c1)) merges.push(next);
|
||||
}
|
||||
setMerges(s, merges);
|
||||
// 이름 범위도 따라감 · 범위가 통째로 지워진 이름은 뺌(엑셀은 `#REF!` 이름으로 남김 — 여기선 식이 `#NAME?`)
|
||||
if (s.이름정의)
|
||||
s.이름정의 = s.이름정의.flatMap((d) => {
|
||||
const 범위 = shiftRange(d.범위);
|
||||
return 범위 ? [{ ...d, 범위 }] : [];
|
||||
});
|
||||
|
||||
const axis = rows ? "행" : "열";
|
||||
const table = s[axis];
|
||||
if (table) {
|
||||
const next: Record<string, (typeof table)[string]> = {};
|
||||
for (const [k, v] of Object.entries(table)) {
|
||||
const i = span(rows ? Number(k) - 1 : parseA1(`${k}1`)!.c);
|
||||
if (i !== undefined) next[rows ? String(i + 1) : colName(i)] = v;
|
||||
}
|
||||
if (Object.keys(next).length) s[axis] = next;
|
||||
else delete s[axis];
|
||||
}
|
||||
|
||||
// 넣은 줄은 위(왼) 줄 서식 · 폭 · 높이를 따라감(엑셀 「위와 같은 서식」) — 첫 줄 앞 넣기는 안 따라감
|
||||
if (insert && at > 0) {
|
||||
for (const [a1, cell] of Object.entries(s.칸)) {
|
||||
const p = parseA1(a1)!;
|
||||
if ((rows ? p.r : p.c) !== at - 1 || cell.서식 === undefined) continue;
|
||||
for (let k = 0; k < n; k++)
|
||||
s.칸[rows ? toA1(at + k, p.c) : toA1(p.r, at + k)] = { 서식: cell.서식 };
|
||||
}
|
||||
const info = s[axis]?.[rows ? String(at) : colName(at - 1)];
|
||||
if (info) {
|
||||
const { 숨김: _hidden, ...look } = info;
|
||||
if (Object.keys(look).length)
|
||||
for (let k = 0; k < n; k++)
|
||||
s[axis]![rows ? String(at + k + 1) : colName(at + k)] = { ...look };
|
||||
}
|
||||
}
|
||||
|
||||
shiftFilter(s, rows, insert, at, n);
|
||||
const key = rows ? "행" : "열";
|
||||
const frozen = s.틀고정?.[key];
|
||||
if (frozen && at < frozen)
|
||||
s.틀고정![key] = insert ? frozen + n : frozen - (Math.min(frozen, at + n) - at);
|
||||
|
||||
return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
// ── 옮기기 (잘라 붙이기) ─────────────────────────────────────────────────────
|
||||
|
||||
function move(book: Workbook, cmd: Of<"옮기기">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const t = sheetOf(book, cmd.대상시트);
|
||||
const src = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1);
|
||||
const dr = cmd.r - src.r0;
|
||||
const dc = cmd.c - src.c0;
|
||||
const dst: CellRange = { r0: cmd.r, c0: cmd.c, r1: src.r1 + dr, c1: src.c1 + dc };
|
||||
if (dst.r0 < 0 || dst.c0 < 0 || dst.r1 >= MAX_ROWS || dst.c1 >= MAX_COLS)
|
||||
throw new CommandError("옮길 자리가 격자 밖");
|
||||
const cuts = (list: CellRange[], rg: CellRange) =>
|
||||
list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg));
|
||||
if (cuts(mergesOf(s), src) || cuts(mergesOf(t), dst))
|
||||
throw new CommandError("병합 일부를 가르는 옮기기는 못 함");
|
||||
if (s === t && dr === 0 && dc === 0)
|
||||
return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false };
|
||||
|
||||
const old = book.시트.map(clone);
|
||||
const moved: [number, number, Cell][] = [];
|
||||
for (const [a1, r, c] of cellsIn(s, [src])) {
|
||||
const cell = s.칸[a1];
|
||||
if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, cmd, book, t.id);
|
||||
moved.push([r + dr, c + dc, cell]);
|
||||
delete s.칸[a1];
|
||||
}
|
||||
rewriteAll(book, cmd);
|
||||
for (const [a1] of cellsIn(t, [dst])) delete t.칸[a1];
|
||||
for (const [r, c, cell] of moved) t.칸[toA1(r, c)] = cell;
|
||||
// 메모도 칸과 같이 — 덮은 자리 메모는 지움
|
||||
const carriedNotes: [string, string][] = [];
|
||||
const srcNotes = { ...s.comments };
|
||||
for (const [a1, text] of Object.entries(srcNotes)) {
|
||||
const p = parseA1(a1);
|
||||
if (p && inRange(src, p.r, p.c)) {
|
||||
carriedNotes.push([toA1(p.r + dr, p.c + dc), text]);
|
||||
delete srcNotes[a1];
|
||||
}
|
||||
}
|
||||
setComments(s, srcNotes);
|
||||
const dstNotes = { ...t.comments };
|
||||
for (const a1 of Object.keys(dstNotes)) {
|
||||
const p = parseA1(a1);
|
||||
if (p && inRange(dst, p.r, p.c)) delete dstNotes[a1];
|
||||
}
|
||||
setComments(t, { ...dstNotes, ...Object.fromEntries(carriedNotes) });
|
||||
|
||||
const sm = mergesOf(s);
|
||||
const carried = sm.filter((m) => rangeInside(m, src));
|
||||
setMerges(
|
||||
s,
|
||||
sm.filter((m) => !rangeInside(m, src)),
|
||||
);
|
||||
setMerges(t, [
|
||||
...mergesOf(t).filter((m) => !rangeInside(m, dst)),
|
||||
...carried.map((m) => ({ r0: m.r0 + dr, c0: m.c0 + dc, r1: m.r1 + dr, c1: m.c1 + dc })),
|
||||
]);
|
||||
return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
// ── 병합 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function merge(book: Workbook, cmd: Of<"병합">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const rg = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1);
|
||||
if (rg.r0 === rg.r1 && rg.c0 === rg.c1)
|
||||
return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false };
|
||||
const list = mergesOf(s);
|
||||
if (list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg)))
|
||||
throw new CommandError("다른 병합과 걸침");
|
||||
const absorbed = list.filter((m) => rangeInside(m, rg));
|
||||
const prev: Record<string, Cell | null> = {};
|
||||
const cells: SheetCellAddress[] = [];
|
||||
for (const [a1, r, c] of cellsIn(s, [rg])) {
|
||||
const cell = s.칸[a1];
|
||||
if ((r === rg.r0 && c === rg.c0) || (cell.값 === undefined && cell.식 === undefined)) continue;
|
||||
prev[a1] = clone(cell);
|
||||
if (cell.서식 === undefined) delete s.칸[a1];
|
||||
else s.칸[a1] = { 서식: cell.서식 };
|
||||
cells.push(addr(s.id, r, c));
|
||||
}
|
||||
setMerges(s, [...list.filter((m) => !rangeInside(m, rg)), rg]);
|
||||
const undo: Command[] = [
|
||||
{ 종류: "병합풀기", 시트: s.id, 범위: rg },
|
||||
...absorbed.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m })),
|
||||
{ 종류: "칸", 시트: s.id, 칸: prev },
|
||||
];
|
||||
return { undo: { 종류: "묶음", 명령: undo }, cells, rebuild: false };
|
||||
}
|
||||
|
||||
function unmerge(book: Workbook, cmd: Of<"병합풀기">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const list = mergesOf(s);
|
||||
const gone = list.filter((m) => rangesOverlap(m, cmd.범위));
|
||||
setMerges(
|
||||
s,
|
||||
list.filter((m) => !rangesOverlap(m, cmd.범위)),
|
||||
);
|
||||
const undo = gone.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m }));
|
||||
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: false };
|
||||
}
|
||||
|
||||
// ── 행 · 열 모양 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** 행 · 열 정보 한 속성 바꿈 → 원래 값끼리 묶은 되돌림 짝 */
|
||||
function setAxis<V>(
|
||||
s: Sheet,
|
||||
axis: "행" | "열",
|
||||
idx: number[],
|
||||
prop: "폭" | "높이" | "숨김" | "묶음",
|
||||
value: V | null,
|
||||
undoOf: (idx: number[], prev: V | null) => Command,
|
||||
): Command {
|
||||
const table = ((s as unknown as Record<string, Record<string, Record<string, unknown>>>)[axis] ??=
|
||||
{});
|
||||
const groups = new Map<string, number[]>();
|
||||
for (const i of idx) {
|
||||
const k = axis === "열" ? colName(i) : String(i + 1);
|
||||
const prev = table[k]?.[prop];
|
||||
const g = JSON.stringify(prev ?? null);
|
||||
groups.set(g, [...(groups.get(g) ?? []), i]);
|
||||
const info = (table[k] ??= {});
|
||||
if (value === null || value === false) delete info[prop];
|
||||
else info[prop] = value;
|
||||
if (!Object.keys(info).length) delete table[k];
|
||||
}
|
||||
if (!Object.keys(table).length) delete s[axis];
|
||||
return { 종류: "묶음", 명령: [...groups].map(([g, list]) => undoOf(list, JSON.parse(g))) };
|
||||
}
|
||||
|
||||
/** 모두 고르기 폭 · 높이 — 시트 `기본` 에 두고 줄마다 값은 지움(엑셀 기본 폭 · 높이) */
|
||||
function sheetSize(s: Sheet, axis: "행" | "열", value: number | null): CommandEffect {
|
||||
const before = clone(s);
|
||||
const prop = axis === "열" ? "폭" : "높이";
|
||||
setSheetDefault(s, axis === "열" ? "열폭" : "행높이", value ?? undefined);
|
||||
const table = s[axis] as Record<string, Record<string, unknown>> | undefined;
|
||||
for (const [k, info] of Object.entries(table ?? {})) {
|
||||
delete info[prop];
|
||||
if (!Object.keys(info).length) delete table![k];
|
||||
}
|
||||
if (table && !Object.keys(table).length) delete s[axis];
|
||||
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false };
|
||||
}
|
||||
|
||||
function batch(book: Workbook, cmd: Of<"묶음">): CommandEffect {
|
||||
const done: CommandEffect[] = [];
|
||||
try {
|
||||
for (const c of cmd.명령) done.push(applyCommand(book, c));
|
||||
} catch (e) {
|
||||
for (const d of done.reverse()) applyCommand(book, d.undo);
|
||||
throw e;
|
||||
}
|
||||
return {
|
||||
undo: { 종류: "묶음", 명령: done.map((d) => d.undo).reverse() },
|
||||
cells: done.flatMap((d) => d.cells),
|
||||
rebuild: done.some((d) => d.rebuild),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 들머리 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function applyCommand(book: Workbook, command: Command): CommandEffect {
|
||||
const plain = (undo: Command): CommandEffect => ({ undo, cells: [], rebuild: false });
|
||||
switch (command.종류) {
|
||||
case "칸":
|
||||
return setCells(book, command);
|
||||
case "내용지움":
|
||||
return clearContents(book, command);
|
||||
case "서식":
|
||||
return patchStyle(book, command);
|
||||
case "서식지움":
|
||||
return restyle(book, command.시트, command.범위, () => 0);
|
||||
case "행넣기":
|
||||
case "행지우기":
|
||||
case "열넣기":
|
||||
case "열지우기":
|
||||
return insertDelete(book, command);
|
||||
case "옮기기":
|
||||
return move(book, command);
|
||||
case "병합":
|
||||
return merge(book, command);
|
||||
case "병합풀기":
|
||||
return unmerge(book, command);
|
||||
case "열폭": {
|
||||
const s = sheetOf(book, command.시트);
|
||||
if (new Set(command.열).size >= MAX_COLS) return sheetSize(s, "열", command.폭);
|
||||
return plain(
|
||||
setAxis(s, "열", command.열, "폭", command.폭, (열, 폭) => ({
|
||||
종류: "열폭",
|
||||
시트: s.id,
|
||||
열,
|
||||
폭,
|
||||
})),
|
||||
);
|
||||
}
|
||||
case "행높이": {
|
||||
const s = sheetOf(book, command.시트);
|
||||
if (new Set(command.행).size >= MAX_ROWS) return sheetSize(s, "행", command.높이);
|
||||
return plain(
|
||||
setAxis(s, "행", command.행, "높이", command.높이, (행, 높이) => ({
|
||||
종류: "행높이",
|
||||
시트: s.id,
|
||||
행,
|
||||
높이,
|
||||
})),
|
||||
);
|
||||
}
|
||||
case "숨김": {
|
||||
const s = sheetOf(book, command.시트);
|
||||
const { 축 } = command;
|
||||
return plain(
|
||||
setAxis(s, 축, command.번호, "숨김", command.숨김, (번호, prev) => ({
|
||||
종류: "숨김",
|
||||
시트: s.id,
|
||||
축,
|
||||
번호,
|
||||
숨김: prev ?? false,
|
||||
})),
|
||||
);
|
||||
}
|
||||
case "묶음설정": {
|
||||
const s = sheetOf(book, command.시트);
|
||||
const { 축 } = command;
|
||||
return plain(
|
||||
setAxis(s, 축, command.번호, "묶음", command.묶음, (번호, prev) => ({
|
||||
종류: "묶음설정",
|
||||
시트: s.id,
|
||||
축,
|
||||
번호,
|
||||
묶음: prev,
|
||||
})),
|
||||
);
|
||||
}
|
||||
case "틀고정": {
|
||||
const s = sheetOf(book, command.시트);
|
||||
const prev = { 행: s.틀고정?.행 ?? 0, 열: s.틀고정?.열 ?? 0 };
|
||||
const next: Sheet["틀고정"] = {};
|
||||
if (command.행 > 0) next.행 = command.행;
|
||||
if (command.열 > 0) next.열 = command.열;
|
||||
if (Object.keys(next).length) s.틀고정 = next;
|
||||
else delete s.틀고정;
|
||||
return plain({ 종류: "틀고정", 시트: s.id, ...prev });
|
||||
}
|
||||
case "눈금선": {
|
||||
const s = sheetOf(book, command.시트);
|
||||
const prev = s.보기?.눈금선 ?? true;
|
||||
if (command.보임) {
|
||||
delete s.보기?.눈금선;
|
||||
if (s.보기 && !Object.keys(s.보기).length) delete s.보기;
|
||||
} else (s.보기 ??= {}).눈금선 = false;
|
||||
return plain({ 종류: "눈금선", 시트: s.id, 보임: prev });
|
||||
}
|
||||
case "시트더하기":
|
||||
return addSheet(book, command);
|
||||
case "시트지우기":
|
||||
return removeSheet(book, command);
|
||||
case "시트이름":
|
||||
return renameSheet(book, command);
|
||||
case "시트옮기기":
|
||||
return moveSheet(book, command);
|
||||
case "시트통째":
|
||||
return replaceSheet(book, command);
|
||||
case "정렬":
|
||||
return applySort(book, command);
|
||||
case "필터":
|
||||
return applyFilter(book, command);
|
||||
case "묶음":
|
||||
return batch(book, command);
|
||||
}
|
||||
}
|
||||
|
||||
/** 빈 통합문서 — 시트 하나(`s1` · 이름은 부른 쪽) · 서식 [{}] */
|
||||
export function emptyWorkbook(column: string, sheetName: string): Workbook {
|
||||
return {
|
||||
종류: "통합문서",
|
||||
판: 1,
|
||||
열: column,
|
||||
서식: [{}],
|
||||
시트: [emptySheet("s1", sheetName)],
|
||||
활성: "s1",
|
||||
};
|
||||
}
|
||||
|
||||
export function emptySheet(id: string, name: string): Sheet {
|
||||
return { id, 이름: name, 칸: {} };
|
||||
}
|
||||
|
||||
/** 안 쓰는 서식을 빼고 번호를 다시 매김 — 저장 직전(되돌리기 짝은 안 만듦) */
|
||||
export function compactStyles(book: Workbook): void {
|
||||
const next: CellStyle[] = [];
|
||||
const byKey = new Map<string, number>();
|
||||
const remap = new Map<number, number>();
|
||||
const map = (i: number) => {
|
||||
let j = remap.get(i);
|
||||
if (j === undefined) {
|
||||
const style = book.서식[i] ?? {};
|
||||
const k = styleKey(style);
|
||||
j = byKey.get(k);
|
||||
if (j === undefined) {
|
||||
j = next.push(style) - 1;
|
||||
byKey.set(k, j);
|
||||
}
|
||||
remap.set(i, j);
|
||||
}
|
||||
return j;
|
||||
};
|
||||
map(0);
|
||||
for (const s of book.시트) {
|
||||
for (const cell of Object.values(s.칸)) if (cell.서식 !== undefined) cell.서식 = map(cell.서식);
|
||||
for (const info of [...Object.values(s.행 ?? {}), ...Object.values(s.열 ?? {}), s.기본 ?? {}])
|
||||
if (info.서식 !== undefined) info.서식 = map(info.서식);
|
||||
}
|
||||
book.서식 = next;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_commands_sheet.ts (주인 A)
|
||||
* 시트 명령 — 더하기 · 지우기 · 이름 · 옮기기 · 통째 바꿔 넣기. 들머리는 `applyCommand`(commands).
|
||||
* ========================================================================== */
|
||||
|
||||
import { clone, CommandError, restoreChanged, rewriteAll, sheetOf } from "./spreadsheet_commands";
|
||||
import type { Of } from "./spreadsheet_commands";
|
||||
import type { Command, CommandEffect, Workbook } from "./spreadsheet_types";
|
||||
|
||||
function checkSheetName(book: Workbook, name: string, self?: string) {
|
||||
if (!name.trim() || name.length > 31) throw new CommandError("시트 이름은 1~31 글자");
|
||||
if (/[[\]:*?/\\]/.test(name) || name.startsWith("'") || name.endsWith("'"))
|
||||
throw new CommandError("시트 이름에 못 쓰는 글자: [ ] : * ? / \\ 와 앞뒤 '");
|
||||
if (book.시트.some((s) => s.id !== self && s.이름.toLowerCase() === name.toLowerCase()))
|
||||
throw new CommandError(`같은 이름 시트가 있음: ${name}`);
|
||||
}
|
||||
|
||||
export function addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect {
|
||||
if (book.시트.some((s) => s.id === cmd.시트.id))
|
||||
throw new CommandError(`같은 id 시트가 있음: ${cmd.시트.id}`);
|
||||
checkSheetName(book, cmd.시트.이름);
|
||||
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, clone(cmd.시트));
|
||||
return { undo: { 종류: "시트지우기", 시트: cmd.시트.id }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
export function removeSheet(book: Workbook, cmd: Of<"시트지우기">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
if (book.시트.length === 1) throw new CommandError("마지막 시트는 못 지움");
|
||||
const at = book.시트.indexOf(s);
|
||||
const old = book.시트.map(clone);
|
||||
rewriteAll(book, cmd);
|
||||
book.시트.splice(at, 1);
|
||||
if (book.활성 === s.id) book.활성 = book.시트[Math.min(at, book.시트.length - 1)].id;
|
||||
const undo: Command[] = [
|
||||
{ 종류: "시트더하기", 시트: old[at], 자리: at },
|
||||
...restoreChanged(book, old),
|
||||
];
|
||||
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
export function renameSheet(book: Workbook, cmd: Of<"시트이름">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
checkSheetName(book, cmd.이름, s.id);
|
||||
const prev = s.이름;
|
||||
rewriteAll(book, cmd);
|
||||
s.이름 = cmd.이름;
|
||||
return { undo: { 종류: "시트이름", 시트: s.id, 이름: prev }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
export function moveSheet(book: Workbook, cmd: Of<"시트옮기기">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const from = book.시트.indexOf(s);
|
||||
book.시트.splice(from, 1);
|
||||
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, s);
|
||||
return { undo: { 종류: "시트옮기기", 시트: s.id, 자리: from }, cells: [], rebuild: false };
|
||||
}
|
||||
|
||||
export function replaceSheet(book: Workbook, cmd: Of<"시트통째">): CommandEffect {
|
||||
const at = book.시트.findIndex((x) => x.id === cmd.시트.id);
|
||||
if (at < 0) throw new CommandError(`없는 시트: ${cmd.시트.id}`);
|
||||
const prev = book.시트[at];
|
||||
book.시트[at] = clone(cmd.시트);
|
||||
const undo: Of<"시트통째"> = { 종류: "시트통째", 시트: prev };
|
||||
if (cmd.서식) {
|
||||
undo.서식 = book.서식;
|
||||
book.서식 = clone(cmd.서식);
|
||||
}
|
||||
return { undo, cells: [], rebuild: true };
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_comments.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 메모(풍선 설명) · 칸 위쪽 빨간 세모 표시. 저장 자리 — 브레인 답(2026-09-27):
|
||||
* `Sheet.comments?: Record<string, string>` (열쇠 = A1 주소 · 값 = 메모 글 · 빈 글이면 지움).
|
||||
* 이 칸은 sub3 가 0 계약(spreadsheet_types.ts)에 보태는 중 — 여기서는 고치지 않고 모양만 가정
|
||||
* (`SheetWithComments` 로 좁혀 씀 · sub3 커밋이 오면 구조가 그대로 맞음).
|
||||
*
|
||||
* 문서를 바꾸는 길은 명령뿐(view_types.ts 머리) — 「메모」 전용 명령이 아직 없어 `시트통째`
|
||||
* (있는 시트를 통째로 바꿔 넣는 명령)로 얹음. 메모 전용 명령이 생기면 이 파일의 `applyComments`
|
||||
* 한 곳만 고치면 됨.
|
||||
*
|
||||
* 행 · 열 넣기 · 지우기 때 메모 주소 옮김 — 지금은 안 함(사각지대로 남김). A 가
|
||||
* `spreadsheet_refshift.ts` 를 완성하면 그 파일의 참조 옮김 함수(예: `shiftAddress` 류)를
|
||||
* 이 파일 `remapComments` 자리에 불러 A1 주소들을 옮기게 잇기(지우는 행 · 열에 있던 메모는
|
||||
* 참조 지움과 같은 규칙으로 버림). 지금은 `remapComments` 를 D · A 가 채울 자리로 비워 둠.
|
||||
*
|
||||
* 잇는 법(D · sub7 · C 격자) — `attachComments(ctx)` 를 한 번 붙이고:
|
||||
* 1) C 가 칸을 그릴 때 `hasComment(sheet, r, c)` 가 참이면 칸 위 오른쪽에 빨간 세모(4~6px)를 그림.
|
||||
* 2) 그 칸에 마우스가 올라가면(hover) `showBalloon(r, c)` · 벗어나면 `hideBalloon()`.
|
||||
* 3) 우클릭 메뉴 [메모 삽입 · 메모 편집 · 메모 삭제] 는 `setComment` · `deleteComment` 를 부름.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Sheet, Workbook } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
|
||||
/** 0 계약이 아직 안 실은 칸 — sub3 커밋 뒤엔 진짜 `Sheet` 가 이 모양을 그대로 가짐. */
|
||||
export interface SheetWithComments extends Sheet {
|
||||
comments?: Record<string, string>;
|
||||
}
|
||||
|
||||
const a1 = (r: number, c: number): string => {
|
||||
let col = c + 1;
|
||||
let letters = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
letters = String.fromCharCode(65 + rem) + letters;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return `${letters}${r + 1}`;
|
||||
};
|
||||
|
||||
/** 자리 이동(행열 넣기 · 지우기) 뒤 메모 주소를 옮김 — A `spreadsheet_refshift` 완성 뒤 채울 자리.
|
||||
* `shift` 는 옛 주소 → 새 주소(지워진 자리면 null). 지금은 호출부가 없어 안 씀(사각지대 기록용). */
|
||||
export function remapComments(
|
||||
comments: Record<string, string>,
|
||||
shift: (old: { r: number; c: number }) => { r: number; c: number } | null,
|
||||
): Record<string, string> {
|
||||
const next: Record<string, string> = {};
|
||||
for (const [key, text] of Object.entries(comments)) {
|
||||
const at = parseA1(key);
|
||||
const moved = shift(at);
|
||||
if (moved) next[a1(moved.r, moved.c)] = text;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function parseA1(key: string): { r: number; c: number } {
|
||||
const m = /^([A-Z]+)(\d+)$/.exec(key);
|
||||
if (!m) return { r: 0, c: 0 };
|
||||
let col = 0;
|
||||
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
return { r: Number(m[2]) - 1, c: col - 1 };
|
||||
}
|
||||
|
||||
export function getComment(sheet: Sheet, r: number, c: number): string | null {
|
||||
const comments = (sheet as SheetWithComments).comments;
|
||||
return comments?.[a1(r, c)] ?? null;
|
||||
}
|
||||
|
||||
export function hasComment(sheet: Sheet, r: number, c: number): boolean {
|
||||
return getComment(sheet, r, c) !== null;
|
||||
}
|
||||
|
||||
export function listComments(sheet: Sheet): { r: number; c: number; 글: string }[] {
|
||||
const comments = (sheet as SheetWithComments).comments ?? {};
|
||||
return Object.entries(comments).map(([key, 글]) => ({ ...parseA1(key), 글 }));
|
||||
}
|
||||
|
||||
/** `시트통째` 명령으로 메모 칸만 바꿔 넣음 — 그 밖 시트 내용은 그대로. */
|
||||
function applyComments(ctx: SpreadsheetContext, next: Record<string, string>): void {
|
||||
const sheet = ctx.sheet() as SheetWithComments;
|
||||
const cleaned: Record<string, string> = {};
|
||||
for (const [key, text] of Object.entries(next)) if (text) cleaned[key] = text;
|
||||
const nextSheet: SheetWithComments = { ...sheet, comments: cleaned };
|
||||
ctx.dispatch({ 종류: "시트통째", 시트: nextSheet });
|
||||
}
|
||||
|
||||
/** 글이 비면 메모를 지움(값과 같은 규칙 — `spreadsheet_types.ts` 「빈 칸 안 적음」). */
|
||||
export function setComment(ctx: SpreadsheetContext, r: number, c: number, text: string): void {
|
||||
const sheet = ctx.sheet() as SheetWithComments;
|
||||
const next = { ...(sheet.comments ?? {}) };
|
||||
if (text) next[a1(r, c)] = text;
|
||||
else delete next[a1(r, c)];
|
||||
applyComments(ctx, next);
|
||||
}
|
||||
|
||||
export function deleteComment(ctx: SpreadsheetContext, r: number, c: number): void {
|
||||
setComment(ctx, r, c, "");
|
||||
}
|
||||
|
||||
export interface CommentsHandle extends PartHandle {
|
||||
showBalloon(r: number, c: number): void;
|
||||
hideBalloon(): void;
|
||||
}
|
||||
|
||||
/** 풍선 뿌리 하나를 만들어 `ctx.root` 에 붙임 — 세모 그리기 자체는 C(격자)가 `hasComment` 로 판단해서 함. */
|
||||
export function attachComments(ctx: SpreadsheetContext): CommentsHandle {
|
||||
const balloon = el("div", { className: "spreadsheet-comment-balloon", attrs: { hidden: "" } });
|
||||
ctx.root.append(balloon);
|
||||
|
||||
return {
|
||||
root: balloon,
|
||||
showBalloon(r: number, c: number): void {
|
||||
const text = getComment(ctx.sheet(), r, c);
|
||||
if (!text) return void this.hideBalloon();
|
||||
balloon.textContent = text;
|
||||
balloon.removeAttribute("hidden");
|
||||
const box = ctx.grid.cellBox(r, c);
|
||||
balloon.style.left = `${box.x + box.w}px`;
|
||||
balloon.style.top = `${box.y}px`;
|
||||
},
|
||||
hideBalloon(): void {
|
||||
balloon.setAttribute("hidden", "");
|
||||
},
|
||||
refresh(): void {
|
||||
/* 문서가 바뀌면 지금 보이는 풍선 글도 새로 — 열린 풍선이 없으면 할 일 없음 */
|
||||
},
|
||||
destroy(): void {
|
||||
balloon.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 시트 전체 메모 개수(시험 · 실무 대조용) */
|
||||
export function countComments(book: Workbook, sheetId: string): number {
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
return sheet ? Object.keys((sheet as SheetWithComments).comments ?? {}).length : 0;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_condfmt.ts (3단계 · 주인 sub_laptop_4)
|
||||
* 조건부 서식 — 칸 값이 규칙에 맞으면 채움색 · 글자색을 덮어씀. 저장 자리 `Sheet.조건부서식`
|
||||
* (0 계약 `spreadsheet_types.ts` 에 방금 더함 · 계약 더함).
|
||||
*
|
||||
* 규칙 차례 = 우선순위(배열 앞쪽이 먼저 이김 · 엑셀 「규칙 관리」 위쪽과 같음). 스타일은 `서식` 표
|
||||
* 번호를 안 씀 — 규칙마다 채움 · 글자색을 직접 가져 표 번호 조율(A 몫)이 필요 없음. 「수식이 참」
|
||||
* 조건은 임의 수식 글을 파싱·계산해야 해서(엔진 A) 이 파일은 `evalFormula` 콜백만 받고 엔진을 모름.
|
||||
*
|
||||
* 잇는 법(A · C · D · sub7 — 지금은 하나도 안 이어짐):
|
||||
* 1) 격자(C)가 칸을 그릴 때 `resolveCondFmtStyle(sheet, r, c, env)` 로 덮어쓸 스타일을 얻어
|
||||
* 지금 셀 스타일 위에 얹음(env.value 는 `ctx.engine.value` 를 수 · 글 · 참거짓 · null 로 바꿔 줌).
|
||||
* 2) `env.evalFormula(식, r, c)` 구현 — `spreadsheet_parser.parse(식)` 뒤 `spreadsheet_eval` 로
|
||||
* 풀어 참거짓으로(A 가 만들 자리 · 지금 없음).
|
||||
* 3) 행 · 열 넣기 · 지우기 뒤 `remapCondFmtRanges` 를 그 시트 `조건부서식` 에 적용해 범위를 옮김
|
||||
* (`spreadsheet_commands.insertDelete` 가 지금 이 규칙을 모름 — refshift 참조 옮김과 같은 자리에
|
||||
* 걸어야 함 · A 가 채울 자리).
|
||||
* 4) 도구 모음 · 우클릭 메뉴에 [조건부 서식] 항목을 두고 `createCondFmtPanel(ctx)` 를 띄움.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { inRange, rangeToA1 } from "./spreadsheet_address";
|
||||
import type {
|
||||
CellRange,
|
||||
CondFmtCompareOp,
|
||||
CondFmtCondition,
|
||||
CondFmtRule,
|
||||
CondFmtStyle,
|
||||
Sheet,
|
||||
} from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
|
||||
|
||||
export type { CondFmtCompareOp, CondFmtCondition, CondFmtRule, CondFmtStyle };
|
||||
|
||||
/** 칸 값을 이미 수 · 글 · 참거짓 · 빈칸으로 바꿔 넘김(Frac → 수는 호출부 몫 — 이 파일은 엔진을 모름). */
|
||||
export type CondFmtValue = number | string | boolean | null;
|
||||
|
||||
function testCondition(
|
||||
condition: CondFmtCondition,
|
||||
value: CondFmtValue,
|
||||
evalFormula: (식: string) => boolean,
|
||||
): boolean {
|
||||
switch (condition.종류) {
|
||||
case "비교": {
|
||||
if (typeof value !== "number") return false;
|
||||
switch (condition.연산) {
|
||||
case "보다큼":
|
||||
return value > condition.값;
|
||||
case "보다작음":
|
||||
return value < condition.값;
|
||||
case "크거나같음":
|
||||
return value >= condition.값;
|
||||
case "작거나같음":
|
||||
return value <= condition.값;
|
||||
case "같음":
|
||||
return value === condition.값;
|
||||
case "다름":
|
||||
return value !== condition.값;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "사이":
|
||||
return typeof value === "number" && value >= condition.아래 && value <= condition.위;
|
||||
case "글포함":
|
||||
return typeof value === "string" && value.includes(condition.글);
|
||||
case "수식":
|
||||
return evalFormula(condition.식);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface CondFmtEnv {
|
||||
value(r: number, c: number): CondFmtValue;
|
||||
/** 없으면 「수식이 참」 조건은 늘 거짓 */
|
||||
evalFormula?(formula: string, r: number, c: number): boolean;
|
||||
}
|
||||
|
||||
/** 칸 하나에 먹일 조건부 서식 — 처음 맞는 규칙(우선순위 위) · 없으면 null. */
|
||||
export function resolveCondFmtStyle(
|
||||
sheet: Sheet,
|
||||
r: number,
|
||||
c: number,
|
||||
env: CondFmtEnv,
|
||||
): CondFmtStyle | null {
|
||||
for (const rule of sheet.조건부서식 ?? []) {
|
||||
if (!rule.범위.some((range) => inRange(range, r, c))) continue;
|
||||
const value = env.value(r, c);
|
||||
const hit = testCondition(rule.조건, value, (식) => env.evalFormula?.(식, r, c) ?? false);
|
||||
if (hit) return rule.스타일;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 행 · 열 넣기 · 지우기 뒤 범위 옮김 — `shift` 가 null 이면 그 범위는 버림(규칙이 범위를 다 잃으면 규칙째 버림). */
|
||||
export function remapCondFmtRanges(
|
||||
rules: CondFmtRule[],
|
||||
shift: (range: CellRange) => CellRange | null,
|
||||
): CondFmtRule[] {
|
||||
const out: CondFmtRule[] = [];
|
||||
for (const rule of rules) {
|
||||
const moved = rule.범위.map(shift).filter((r): r is CellRange => r !== null);
|
||||
if (moved.length) out.push({ ...rule, 범위: moved });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function applySheet(ctx: SpreadsheetContext, next: Sheet): void {
|
||||
ctx.dispatch({ 종류: "시트통째", 시트: next });
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
const newId = (): string => `cf${Date.now().toString(36)}${seq++}`;
|
||||
|
||||
export function addCondFmtRule(ctx: SpreadsheetContext, rule: Omit<CondFmtRule, "id">): void {
|
||||
const sheet = ctx.sheet();
|
||||
applySheet(ctx, {
|
||||
...sheet,
|
||||
조건부서식: [...(sheet.조건부서식 ?? []), { ...rule, id: newId() }],
|
||||
});
|
||||
}
|
||||
|
||||
export function removeCondFmtRule(ctx: SpreadsheetContext, id: string): void {
|
||||
const sheet = ctx.sheet();
|
||||
applySheet(ctx, { ...sheet, 조건부서식: (sheet.조건부서식 ?? []).filter((r) => r.id !== id) });
|
||||
}
|
||||
|
||||
/** 우선순위 한 칸 옮김(엑셀 「규칙 관리」 위 · 아래 화살표와 같음). */
|
||||
export function moveCondFmtRule(ctx: SpreadsheetContext, id: string, dir: -1 | 1): void {
|
||||
const sheet = ctx.sheet();
|
||||
const list = [...(sheet.조건부서식 ?? [])];
|
||||
const idx = list.findIndex((r) => r.id === id);
|
||||
const swapWith = idx + dir;
|
||||
if (idx < 0 || swapWith < 0 || swapWith >= list.length) return;
|
||||
[list[idx], list[swapWith]] = [list[swapWith], list[idx]];
|
||||
applySheet(ctx, { ...sheet, 조건부서식: list });
|
||||
}
|
||||
|
||||
const conditionText = (c: CondFmtCondition): string => {
|
||||
switch (c.종류) {
|
||||
case "비교":
|
||||
return `${c.연산} ${c.값}`;
|
||||
case "사이":
|
||||
return `${c.아래} ~ ${c.위} 사이`;
|
||||
case "글포함":
|
||||
return `「${c.글}」 포함`;
|
||||
case "수식":
|
||||
return `수식 =${c.식}`;
|
||||
}
|
||||
};
|
||||
|
||||
/** 규칙 관리 창 — 지금 시트 규칙 목록 + 고른 범위에 규칙 더하기. 잇는 법 머리 주석 참고. */
|
||||
export function createCondFmtPanel(ctx: SpreadsheetContext): PartHandle {
|
||||
const root = el("div", { className: "ss-condfmt" });
|
||||
const list = el("ul", { className: "ss-condfmt__list" });
|
||||
|
||||
const compareOps: CondFmtCompareOp[] = [
|
||||
"보다큼",
|
||||
"보다작음",
|
||||
"크거나같음",
|
||||
"작거나같음",
|
||||
"같음",
|
||||
"다름",
|
||||
];
|
||||
const kindSelect = createSelectField({
|
||||
label: "조건",
|
||||
options: [
|
||||
{ value: "비교", text: "값 비교" },
|
||||
{ value: "사이", text: "사이" },
|
||||
{ value: "글포함", text: "글 포함" },
|
||||
{ value: "수식", text: "수식이 참" },
|
||||
],
|
||||
});
|
||||
const opSelect = createSelectField({
|
||||
label: "연산",
|
||||
options: compareOps.map((op) => ({ value: op, text: op })),
|
||||
});
|
||||
const val1 = createInputField({ label: "값(또는 아래 · 글 · 수식)" });
|
||||
const val2 = createInputField({ label: "위(사이일 때만)" });
|
||||
const fill = createInputField({ label: "채움색", type: "text", placeholder: "#RRGGBB" });
|
||||
const fontColor = createInputField({ label: "글자색", type: "text", placeholder: "#RRGGBB" });
|
||||
const addBtn = createButton({ label: "규칙 더하기" });
|
||||
|
||||
addBtn.addEventListener("click", () => {
|
||||
const 범위 = ctx.selection.범위;
|
||||
if (!범위.length) return;
|
||||
const 조건 = buildCondition(kindSelect.select.value, opSelect.select.value, val1, val2);
|
||||
if (!조건) return;
|
||||
const 스타일: CondFmtStyle = {};
|
||||
if (fill.input.value) 스타일.채움 = fill.input.value;
|
||||
if (fontColor.input.value) 스타일.글자색 = fontColor.input.value;
|
||||
addCondFmtRule(ctx, { 범위: [...범위], 조건, 스타일 });
|
||||
render();
|
||||
});
|
||||
|
||||
function buildCondition(
|
||||
kind: string,
|
||||
op: string,
|
||||
a: ReturnType<typeof createInputField>,
|
||||
b: ReturnType<typeof createInputField>,
|
||||
): CondFmtCondition | null {
|
||||
if (kind === "비교") {
|
||||
const n = Number(a.input.value);
|
||||
return Number.isFinite(n) ? { 종류: "비교", 연산: op as CondFmtCompareOp, 값: n } : null;
|
||||
}
|
||||
if (kind === "사이") {
|
||||
const 아래 = Number(a.input.value);
|
||||
const 위 = Number(b.input.value);
|
||||
return Number.isFinite(아래) && Number.isFinite(위) ? { 종류: "사이", 아래, 위 } : null;
|
||||
}
|
||||
if (kind === "글포함") return a.input.value ? { 종류: "글포함", 글: a.input.value } : null;
|
||||
if (kind === "수식") return a.input.value ? { 종류: "수식", 식: a.input.value } : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const rules = ctx.sheet().조건부서식 ?? [];
|
||||
list.replaceChildren(
|
||||
...rules.map((rule, idx) => {
|
||||
const li = el("li", { className: "ss-condfmt__row" });
|
||||
const swatch = el("span", { className: "ss-condfmt__swatch" });
|
||||
if (rule.스타일.채움) swatch.style.background = rule.스타일.채움;
|
||||
const label = el("span", {
|
||||
text: `${rule.범위.map(rangeToA1).join(", ")} — ${conditionText(rule.조건)}`,
|
||||
});
|
||||
const up = createButton({ label: "↑", variant: "ghost" });
|
||||
up.disabled = idx === 0;
|
||||
up.addEventListener("click", () => {
|
||||
moveCondFmtRule(ctx, rule.id, -1);
|
||||
render();
|
||||
});
|
||||
const down = createButton({ label: "↓", variant: "ghost" });
|
||||
down.disabled = idx === rules.length - 1;
|
||||
down.addEventListener("click", () => {
|
||||
moveCondFmtRule(ctx, rule.id, 1);
|
||||
render();
|
||||
});
|
||||
const del = createButton({ label: "지움", variant: "danger" });
|
||||
del.addEventListener("click", () => {
|
||||
removeCondFmtRule(ctx, rule.id);
|
||||
render();
|
||||
});
|
||||
li.append(swatch, label, up, down, del);
|
||||
return li;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const form = el("div", {
|
||||
className: "ss-condfmt__form",
|
||||
children: [
|
||||
kindSelect.root,
|
||||
opSelect.root,
|
||||
val1.root,
|
||||
val2.root,
|
||||
fill.root,
|
||||
fontColor.root,
|
||||
addBtn,
|
||||
],
|
||||
});
|
||||
root.append(el("h4", { text: "조건부 서식 규칙" }), list, form);
|
||||
render();
|
||||
|
||||
return {
|
||||
root,
|
||||
refresh: render,
|
||||
destroy(): void {
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_data_menu.ts (주인 D)
|
||||
* 데이터 단추 · 메뉴 잇기 — 수식 입력줄 앞 [↑] [↓] [필터] [데이터 ▾].
|
||||
* 정렬 · 자동 필터(sub3 `spreadsheet_sort_dialog` · `spreadsheet_filter`) ·
|
||||
* 조건부 서식 · 데이터 유효성 · 이름 관리 · 시트 숨김/탭 색(sub4 패널 — 부품 오른위 창에 띄움) · 눈금선 ·
|
||||
* 수식 분석(sub3 — 선행 · 종속 추적 화살표 · 화살표 지우기 · 수식 계산 · 오류 ⚠ 풍선) · 자동 합계(Σ · Alt+=) ·
|
||||
* 이동 · 틀 고정 · 칸 넣기/지우기 · 데이터 도구 · 윤곽(`spreadsheet_actions.ts`).
|
||||
* 유효성 목록 칸은 고르면 칸 옆 ▼ · Alt+↓ 로 값 목록(필터 머리 칸이면 필터 목록이 먼저).
|
||||
* 누를 때만 쓰는 창(정렬 · 조건부 서식 · 시트)은 `import()` 로 늦게 — 크기 예산(열 때 받는 조각).
|
||||
* ========================================================================== */
|
||||
|
||||
import "./spreadsheet_stage3.css"; // sub4 패널 스타일(잇는 쪽이 붙임)
|
||||
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import { autoSumRange } from "./spreadsheet_autosum";
|
||||
import { isError } from "./spreadsheet_eval";
|
||||
import type { FilterHandle } from "./spreadsheet_filter";
|
||||
import { createCondFmtPanel } from "./spreadsheet_condfmt";
|
||||
import { createSheetExtraPanel } from "./spreadsheet_sheet_extra";
|
||||
import { mountActions } from "./spreadsheet_actions";
|
||||
import type { ErrorTipHandle } from "./spreadsheet_errcheck";
|
||||
import type { TraceHandle } from "./spreadsheet_trace";
|
||||
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+↓)",
|
||||
sheets: "시트 숨기기 · 탭 색…",
|
||||
audit: "수식 분석",
|
||||
prec: "선행 참조 추적",
|
||||
dep: "종속 참조 추적",
|
||||
clear: "화살표 지우기",
|
||||
steps: "수식 계산…",
|
||||
noMore: "더 추적할 칸 없음",
|
||||
sum: "자동 합계(Alt+=)",
|
||||
};
|
||||
|
||||
export interface DataMenu {
|
||||
/** 수식 입력줄 앞에 둘 단추들 */
|
||||
buttons: HTMLElement[];
|
||||
keys: Pick<KeyHooks, "filter" | "dropdown" | "autosum" | "more">;
|
||||
part: PartHandle;
|
||||
}
|
||||
|
||||
export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMenu {
|
||||
// 자동 필터는 시트에 필터가 있거나 켤 때 불러옴(크기 예산)
|
||||
let filter: FilterHandle | null = null;
|
||||
let filterMod: Promise<typeof import("./spreadsheet_filter")> | null = null;
|
||||
const loadFilter = () =>
|
||||
(filterMod ??= import("./spreadsheet_filter").then((m) => {
|
||||
filter = m.attachFilter(ctx);
|
||||
filter.refresh();
|
||||
return m;
|
||||
}));
|
||||
const toggleFilter = (): void => void loadFilter().then((m) => m.toggleFilter(ctx));
|
||||
// 수식 분석(추적 · ⚠ 풍선 · 수식 계산)은 처음 쓸 때 — 메뉴 · 오류 칸 · 글 숫자 칸을 고르면
|
||||
type Audit = { trace: TraceHandle; tip: ErrorTipHandle };
|
||||
let audit: Audit | null = null;
|
||||
let loading: Promise<Audit> | null = null;
|
||||
const loadAudit = () =>
|
||||
(loading ??= Promise.all([
|
||||
import("./spreadsheet_trace"),
|
||||
import("./spreadsheet_errcheck"),
|
||||
]).then(([t, e]) => {
|
||||
const trace = t.attachTrace(ctx);
|
||||
audit = { trace, tip: e.attachErrorTip(ctx, trace) };
|
||||
audit.tip.refresh();
|
||||
return audit;
|
||||
}));
|
||||
const warnAt = (): boolean => {
|
||||
const { r, c } = ctx.selection.활성;
|
||||
const cell = ctx.sheet().칸[toA1(r, c)];
|
||||
if (!cell) return false;
|
||||
if (cell.식 !== undefined) return isError(ctx.engine.value(ctx.selection.시트, r, c));
|
||||
return typeof cell.값 === "string" && /\d/.test(cell.값);
|
||||
};
|
||||
let closeSteps: (() => void) | null = null;
|
||||
const traced = (more: boolean): void => {
|
||||
if (!more) showToast(TEXT.noMore, "info");
|
||||
back();
|
||||
};
|
||||
const act = mountActions(ctx, back);
|
||||
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();
|
||||
});
|
||||
async function openPanel(
|
||||
name: string,
|
||||
load: () => Promise<(c: SpreadsheetContext) => PartHandle>,
|
||||
): Promise<void> {
|
||||
const make = await load();
|
||||
closePanel();
|
||||
panel = make(ctx);
|
||||
title.textContent = name.replace("…", "");
|
||||
if (panel.root) body.append(panel.root);
|
||||
dialog.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
const sorting = () => import("./spreadsheet_sort_dialog");
|
||||
async function sortQuick(desc: boolean): Promise<void> {
|
||||
(await sorting()).sortSelection(ctx, desc);
|
||||
back();
|
||||
}
|
||||
async function sortDialog(): Promise<void> {
|
||||
const m = await sorting();
|
||||
closeSort?.();
|
||||
closeSort = m.openSortDialog(ctx);
|
||||
}
|
||||
async function evalSteps(): Promise<void> {
|
||||
const m = await import("./spreadsheet_eval_steps");
|
||||
closeSteps?.();
|
||||
closeSteps = m.openEvalSteps(ctx);
|
||||
}
|
||||
|
||||
function openMenu(anchor: HTMLElement): void {
|
||||
const r = anchor.getBoundingClientRect();
|
||||
const items: MapContextMenuItem[] = [
|
||||
[TEXT.sort, () => void sortDialog()],
|
||||
[TEXT.filterItem, () => (toggleFilter(), back())],
|
||||
[TEXT.condfmt, () => void openPanel(TEXT.condfmt, async () => createCondFmtPanel)],
|
||||
[TEXT.validation, () => void openPanel(TEXT.validation, async () => createValidationPanel)],
|
||||
[
|
||||
TEXT.names,
|
||||
() =>
|
||||
void openPanel(
|
||||
TEXT.names,
|
||||
async () => (await import("./spreadsheet_names")).createNameManagerPanel,
|
||||
),
|
||||
],
|
||||
...(ctx.root.classList.contains("ss--notabs") // 시트 탭 없음(산출근거) — 시트 창도 없음
|
||||
? []
|
||||
: [
|
||||
[
|
||||
TEXT.sheets,
|
||||
() => void openPanel(TEXT.sheets, async () => createSheetExtraPanel),
|
||||
] as MapContextMenuItem,
|
||||
]),
|
||||
[TEXT.grid, () => (toggleGridlines(), back())],
|
||||
...act.items,
|
||||
{
|
||||
label: TEXT.audit,
|
||||
children: [
|
||||
[TEXT.prec, () => void loadAudit().then((a) => traced(a.trace.precedents()))],
|
||||
[TEXT.dep, () => void loadAudit().then((a) => traced(a.trace.dependents()))],
|
||||
[TEXT.clear, () => (audit?.trace.clear(), back())],
|
||||
[TEXT.steps, () => void evalSteps()],
|
||||
],
|
||||
},
|
||||
];
|
||||
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);
|
||||
|
||||
/** 자동 합계 — 엑셀처럼 =SUM(잡은 범위) 로 편집을 열고 Enter 는 사용자 몫 */
|
||||
function autosum(): void {
|
||||
const g = autoSumRange(ctx);
|
||||
ctx.editor.begin(g ? `=SUM(${rangeToA1(g)})` : "=SUM()");
|
||||
}
|
||||
|
||||
const buttons = ctx.readOnly
|
||||
? []
|
||||
: [
|
||||
button("↑", TEXT.asc, () => void sortQuick(false), true),
|
||||
button("↓", TEXT.desc, () => void sortQuick(true), true),
|
||||
button("필터", TEXT.filter, toggleFilter),
|
||||
button("Σ", TEXT.sum, autosum, true),
|
||||
button(TEXT.menu, TEXT.menu, openMenu, true),
|
||||
];
|
||||
|
||||
return {
|
||||
buttons,
|
||||
keys: {
|
||||
filter: toggleFilter,
|
||||
autosum,
|
||||
more: act.key,
|
||||
dropdown() {
|
||||
const f = ctx.sheet().필터;
|
||||
const g = f && parseRange(f.범위);
|
||||
const { r, c } = ctx.selection.활성;
|
||||
if (g && filter && r === g.r0 && c >= g.c0 && c <= g.c1) return (filter.open(c), true);
|
||||
return openList();
|
||||
},
|
||||
},
|
||||
part: {
|
||||
root: null,
|
||||
refresh() {
|
||||
filter?.refresh();
|
||||
if (!filter && ctx.sheet().필터) void loadFilter();
|
||||
audit?.trace.refresh();
|
||||
audit?.tip.refresh();
|
||||
if (!audit && warnAt()) void loadAudit();
|
||||
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();
|
||||
closeSteps?.();
|
||||
audit?.trace.destroy();
|
||||
audit?.tip.destroy();
|
||||
menu.element.remove();
|
||||
dialog.remove();
|
||||
arrow.remove();
|
||||
buttons.forEach((b) => b.remove());
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_data_tools.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 데이터 도구 셋 — ① 텍스트 나누기(구분 기호 · 고정 폭 · 미리보기) ② 중복된 항목 제거
|
||||
* (열 고르기 · 지운 수 알림) ③ 채우기 연속 데이터(단계 값 · 끝 값 · 행/열).
|
||||
* 문서를 바꾸는 길은 「칸」 명령 하나뿐 — 세 도구 모두 이것만 씀.
|
||||
*
|
||||
* 잇는 법(D · sub7) — 메뉴 [데이터] 아래 세 항목을 만들어 `openTextToColumnsDialog(ctx)` ·
|
||||
* `openRemoveDuplicatesDialog(ctx)` · `openFillSeriesDialog(ctx)` 를 각각 부름
|
||||
* (`spreadsheet_menu.ts` 우클릭 메뉴에도 [중복된 항목 제거]는 달아 둘 만함).
|
||||
*
|
||||
* 고정 폭 나누기는 실제 엑셀처럼 눈금자를 끌어 자르지 않고 자를 자리(문자 수)를
|
||||
* 쉼표로 적는 입력 한 줄로 단순화함(미리보기로 바로 확인 가능 — 자리 표시만 다름).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { fracToString } from "@ui/sheet/ui_template_sheet_frac";
|
||||
import { colName, toA1 } from "./spreadsheet_address";
|
||||
import type { Cell, CellAddress, CellInput, CellRange, Scalar } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const TEXT = {
|
||||
textToColumns: "텍스트 나누기",
|
||||
byDelimiter: "구분 기호로",
|
||||
byFixedWidth: "고정 폭으로",
|
||||
delimiters: "구분 기호",
|
||||
tab: "탭",
|
||||
semicolon: "세미콜론",
|
||||
comma: "쉼표",
|
||||
space: "공백",
|
||||
custom: "사용자 지정",
|
||||
breaks: "자를 자리(문자 수, 쉼표로 구분)",
|
||||
preview: "미리보기",
|
||||
run: "실행",
|
||||
cancel: "취소",
|
||||
removeDuplicates: "중복된 항목 제거",
|
||||
hasHeader: "머리글 포함",
|
||||
keyColumns: "비교할 열",
|
||||
removed: (n: number) => `중복 ${n}개 지움`,
|
||||
fillSeries: "연속 데이터 채우기",
|
||||
direction: "방향",
|
||||
down: "아래로(열)",
|
||||
right: "오른쪽으로(행)",
|
||||
step: "단계 값",
|
||||
stop: "끝 값(선택)",
|
||||
};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 공용 — 값 ↔ 글, 작은 대화상자 뼈대
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function scalarText(v: Scalar): string {
|
||||
if (v == null) return "";
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v === "object" && "error" in v) return v.error;
|
||||
return fracToString(v);
|
||||
}
|
||||
|
||||
function cellInputFrom(text: string): CellInput {
|
||||
const n = Number(text);
|
||||
return text !== "" && !Number.isNaN(n) ? n : text;
|
||||
}
|
||||
|
||||
function row(label: string, ...controls: HTMLElement[]): HTMLElement {
|
||||
const r = el("div", {
|
||||
children: [el("span", { text: label }), ...controls],
|
||||
});
|
||||
r.style.cssText = "display:flex;align-items:center;gap:8px;margin-bottom:6px;";
|
||||
return r;
|
||||
}
|
||||
|
||||
/** `.ui-confirm` 배경 위에 제목 · 본문 · [실행]/[취소] 를 올린 작은 대화상자. */
|
||||
function modal(title: string, body: HTMLElement[], onRun: () => void, runLabel = TEXT.run): void {
|
||||
const overlay = el("div", { className: "ui-confirm" });
|
||||
const panel = el("div", { className: "ui-confirm__panel" });
|
||||
panel.style.minWidth = "360px";
|
||||
const close = (): void => overlay.remove();
|
||||
overlay.addEventListener("click", (ev) => {
|
||||
if (ev.target === overlay) close();
|
||||
});
|
||||
panel.append(el("h3", { text: title }), ...body);
|
||||
panel.append(
|
||||
el("div", {
|
||||
className: "ui-confirm__actions",
|
||||
children: [
|
||||
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
|
||||
createButton({
|
||||
label: runLabel,
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
onRun();
|
||||
close();
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
overlay.append(panel);
|
||||
document.body.append(overlay);
|
||||
}
|
||||
|
||||
function readRange(ctx: SpreadsheetContext): CellRange | null {
|
||||
return ctx.selection.범위[0] ?? null;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 1. 텍스트 나누기
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type DelimiterKind = "탭" | "세미콜론" | "쉼표" | "공백" | "사용자";
|
||||
|
||||
const DELIM_CHARS: Record<Exclude<DelimiterKind, "사용자">, string> = {
|
||||
탭: "\t",
|
||||
세미콜론: ";",
|
||||
쉼표: ",",
|
||||
공백: " ",
|
||||
};
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** 한 줄을 고른 구분 기호로 나눔 — 기호가 하나도 없으면 원문 그대로 한 칸. */
|
||||
export function splitDelimited(line: string, kinds: DelimiterKind[], custom: string): string[] {
|
||||
const chars = kinds
|
||||
.map((k) => (k === "사용자" ? custom : DELIM_CHARS[k]))
|
||||
.filter((c): c is string => !!c);
|
||||
if (chars.length === 0) return [line];
|
||||
return line.split(new RegExp(chars.map(escapeRegExp).join("|")));
|
||||
}
|
||||
|
||||
/** 한 줄을 자를 자리(문자 수, 오름차순)로 나눔. */
|
||||
export function splitFixedWidth(line: string, breaks: number[]): string[] {
|
||||
const points = [0, ...breaks.filter((b) => b > 0 && b < line.length), line.length];
|
||||
const sorted = [...new Set(points)].sort((a, b) => a - b);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < sorted.length - 1; i++) out.push(line.slice(sorted[i], sorted[i + 1]));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 나뉜 줄들을 `anchor` 를 왼위로 삼아 칸에 씀(빈 글은 칸 지움). */
|
||||
export function applyTextToColumns(
|
||||
ctx: SpreadsheetContext,
|
||||
anchor: CellAddress,
|
||||
rows: string[][],
|
||||
): void {
|
||||
const cells: Record<string, Cell | null> = {};
|
||||
rows.forEach((cols, ri) => {
|
||||
cols.forEach((text, ci) => {
|
||||
cells[toA1(anchor.r + ri, anchor.c + ci)] = text === "" ? null : { 값: cellInputFrom(text) };
|
||||
});
|
||||
});
|
||||
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
|
||||
}
|
||||
|
||||
export function openTextToColumnsDialog(ctx: SpreadsheetContext): void {
|
||||
if (ctx.readOnly) return;
|
||||
const range = readRange(ctx);
|
||||
if (!range) return;
|
||||
const lines: string[] = [];
|
||||
for (let r = range.r0; r <= range.r1; r++) {
|
||||
lines.push(scalarText(ctx.engine.value(ctx.selection.시트, r, range.c0)));
|
||||
}
|
||||
|
||||
let mode: "구분" | "고정" = "구분";
|
||||
let kinds: DelimiterKind[] = ["쉼표"];
|
||||
let custom = "";
|
||||
let breaksText = "";
|
||||
|
||||
const modeSelect = el("select", {
|
||||
className: "ss-toolbar__select",
|
||||
children: [
|
||||
el("option", { attrs: { value: "구분" }, text: TEXT.byDelimiter }),
|
||||
el("option", { attrs: { value: "고정" }, text: TEXT.byFixedWidth }),
|
||||
],
|
||||
}) as HTMLSelectElement;
|
||||
|
||||
const delimBox = el("div");
|
||||
const kindChecks = (["탭", "세미콜론", "쉼표", "공백", "사용자"] as DelimiterKind[]).map((k) => {
|
||||
const check = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
|
||||
check.checked = kinds.includes(k);
|
||||
check.addEventListener("change", () => {
|
||||
kinds = check.checked ? [...kinds, k] : kinds.filter((x) => x !== k);
|
||||
refreshPreview();
|
||||
});
|
||||
return row(
|
||||
k === "탭"
|
||||
? TEXT.tab
|
||||
: k === "세미콜론"
|
||||
? TEXT.semicolon
|
||||
: k === "쉼표"
|
||||
? TEXT.comma
|
||||
: k === "공백"
|
||||
? TEXT.space
|
||||
: TEXT.custom,
|
||||
check,
|
||||
);
|
||||
});
|
||||
const customInput = createInputField({
|
||||
label: TEXT.custom,
|
||||
onInput: (v) => {
|
||||
custom = v;
|
||||
refreshPreview();
|
||||
},
|
||||
});
|
||||
delimBox.append(...kindChecks, customInput.root);
|
||||
|
||||
const breaksField = createInputField({
|
||||
label: TEXT.breaks,
|
||||
placeholder: "5,10,20",
|
||||
onInput: (v) => {
|
||||
breaksText = v;
|
||||
refreshPreview();
|
||||
},
|
||||
});
|
||||
breaksField.root.hidden = true;
|
||||
|
||||
const previewBox = el("pre");
|
||||
previewBox.style.cssText =
|
||||
"max-height:160px;overflow:auto;background:var(--color-canvas);padding:6px;border-radius:4px;font-size:0.8rem;";
|
||||
|
||||
function splitAll(): string[][] {
|
||||
if (mode === "고정") {
|
||||
const breaks = breaksText
|
||||
.split(",")
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
return lines.map((l) => splitFixedWidth(l, breaks));
|
||||
}
|
||||
return lines.map((l) => splitDelimited(l, kinds, custom));
|
||||
}
|
||||
|
||||
function refreshPreview(): void {
|
||||
previewBox.textContent = splitAll()
|
||||
.slice(0, 8)
|
||||
.map((cols) => cols.join(" | "))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
modeSelect.addEventListener("change", () => {
|
||||
mode = modeSelect.value as "구분" | "고정";
|
||||
delimBox.hidden = mode !== "구분";
|
||||
breaksField.root.hidden = mode !== "고정";
|
||||
refreshPreview();
|
||||
});
|
||||
|
||||
refreshPreview();
|
||||
|
||||
modal(
|
||||
TEXT.textToColumns,
|
||||
[
|
||||
row(TEXT.byDelimiter + "/" + TEXT.byFixedWidth, modeSelect),
|
||||
delimBox,
|
||||
breaksField.root,
|
||||
el("div", { text: TEXT.preview }),
|
||||
previewBox,
|
||||
],
|
||||
() => applyTextToColumns(ctx, { r: range.r0, c: range.c0 }, splitAll()),
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 2. 중복된 항목 제거
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** `keyCols`(범위 안 0 부터 상대 열)로 값이 같은 뒤쪽 행들의 자리(0 부터 상대 행)를 돌려줌. */
|
||||
export function findDuplicateRowIndexes(rows: string[][], keyCols: number[]): number[] {
|
||||
const seen = new Set<string>();
|
||||
const dup: number[] = [];
|
||||
rows.forEach((r, i) => {
|
||||
const key = keyCols.map((c) => r[c] ?? "").join("\u0001");
|
||||
if (seen.has(key)) dup.push(i);
|
||||
else seen.add(key);
|
||||
});
|
||||
return dup;
|
||||
}
|
||||
|
||||
/** 범위 안 값을 읽어 중복 행을 지우고 위로 당김(범위 밖 칸은 안 건드림) — 지운 행 수를 돌려줌. */
|
||||
export function removeDuplicateRows(
|
||||
ctx: SpreadsheetContext,
|
||||
range: CellRange,
|
||||
keyCols: number[],
|
||||
skipHeader: boolean,
|
||||
): number {
|
||||
const width = range.c1 - range.c0 + 1;
|
||||
const bodyR0 = range.r0 + (skipHeader ? 1 : 0);
|
||||
const rows: string[][] = [];
|
||||
for (let r = bodyR0; r <= range.r1; r++) {
|
||||
const line: string[] = [];
|
||||
for (let c = 0; c < width; c++)
|
||||
line.push(scalarText(ctx.engine.value(ctx.selection.시트, r, range.c0 + c)));
|
||||
rows.push(line);
|
||||
}
|
||||
const dupIdx = new Set(findDuplicateRowIndexes(rows, keyCols));
|
||||
const kept = rows.filter((_, i) => !dupIdx.has(i));
|
||||
|
||||
const cells: Record<string, Cell | null> = {};
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = bodyR0 + i;
|
||||
for (let c = 0; c < width; c++) {
|
||||
const text = i < kept.length ? kept[i][c] : "";
|
||||
cells[toA1(r, range.c0 + c)] = text === "" ? null : { 값: cellInputFrom(text) };
|
||||
}
|
||||
}
|
||||
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
|
||||
return dupIdx.size;
|
||||
}
|
||||
|
||||
export function openRemoveDuplicatesDialog(ctx: SpreadsheetContext): void {
|
||||
if (ctx.readOnly) return;
|
||||
const range = readRange(ctx);
|
||||
if (!range) return;
|
||||
const width = range.c1 - range.c0 + 1;
|
||||
|
||||
let hasHeader = width > 1 || range.r1 > range.r0;
|
||||
const headerCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
|
||||
headerCheck.checked = hasHeader;
|
||||
|
||||
const keyCols = new Set<number>(Array.from({ length: width }, (_, i) => i));
|
||||
const colChecks = Array.from({ length: width }, (_, i) => {
|
||||
const c = range.c0 + i;
|
||||
const label = hasHeader
|
||||
? scalarText(ctx.engine.value(ctx.selection.시트, range.r0, c)) || colName(c)
|
||||
: colName(c);
|
||||
const check = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
|
||||
check.checked = true;
|
||||
check.addEventListener("change", () => {
|
||||
if (check.checked) keyCols.add(i);
|
||||
else keyCols.delete(i);
|
||||
});
|
||||
return row(label, check);
|
||||
});
|
||||
|
||||
headerCheck.addEventListener("change", () => {
|
||||
hasHeader = headerCheck.checked;
|
||||
});
|
||||
|
||||
modal(
|
||||
TEXT.removeDuplicates,
|
||||
[row(TEXT.hasHeader, headerCheck), el("div", { text: TEXT.keyColumns }), ...colChecks],
|
||||
() => {
|
||||
const removed = removeDuplicateRows(ctx, range, [...keyCols], hasHeader);
|
||||
showToast(TEXT.removed(removed), "success");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 3. 채우기 연속 데이터
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type SeriesDirection = "행" | "열";
|
||||
|
||||
/** 시작값 · 단계 · 끝값(넘으면 멈춤, 없으면 count 까지) · 개수로 등차수열을 지음. */
|
||||
export function buildLinearSeries(
|
||||
start: number,
|
||||
step: number,
|
||||
stop: number | null,
|
||||
count: number,
|
||||
): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const v = start + step * i;
|
||||
if (stop != null && ((step >= 0 && v > stop) || (step < 0 && v < stop))) break;
|
||||
out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function applyFillSeries(
|
||||
ctx: SpreadsheetContext,
|
||||
anchor: CellAddress,
|
||||
direction: SeriesDirection,
|
||||
values: number[],
|
||||
): void {
|
||||
const cells: Record<string, Cell | null> = {};
|
||||
values.forEach((v, i) => {
|
||||
const r = direction === "열" ? anchor.r + i : anchor.r;
|
||||
const c = direction === "행" ? anchor.c + i : anchor.c;
|
||||
cells[toA1(r, c)] = { 값: v };
|
||||
});
|
||||
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
|
||||
}
|
||||
|
||||
export function openFillSeriesDialog(ctx: SpreadsheetContext): void {
|
||||
if (ctx.readOnly) return;
|
||||
const range = readRange(ctx);
|
||||
if (!range) return;
|
||||
const at = ctx.selection.활성;
|
||||
const startText = scalarText(ctx.engine.value(ctx.selection.시트, at.r, at.c));
|
||||
const start = Number(startText) || 0;
|
||||
|
||||
let direction: SeriesDirection = range.r1 > range.r0 ? "열" : "행";
|
||||
let step = 1;
|
||||
let stop: number | null = null;
|
||||
|
||||
const dirSelect = el("select", {
|
||||
className: "ss-toolbar__select",
|
||||
children: [
|
||||
el("option", { attrs: { value: "열" }, text: TEXT.down }),
|
||||
el("option", { attrs: { value: "행" }, text: TEXT.right }),
|
||||
],
|
||||
}) as HTMLSelectElement;
|
||||
dirSelect.value = direction;
|
||||
dirSelect.addEventListener("change", () => (direction = dirSelect.value as SeriesDirection));
|
||||
|
||||
const stepField = createInputField({
|
||||
label: TEXT.step,
|
||||
type: "number",
|
||||
value: "1",
|
||||
onInput: (v) => (step = Number(v) || 0),
|
||||
});
|
||||
const stopField = createInputField({
|
||||
label: TEXT.stop,
|
||||
type: "number",
|
||||
onInput: (v) => (stop = v === "" ? null : Number(v)),
|
||||
});
|
||||
|
||||
modal(TEXT.fillSeries, [row(TEXT.direction, dirSelect), stepField.root, stopField.root], () => {
|
||||
const extent = direction === "열" ? range.r1 - range.r0 + 1 : range.c1 - range.c0 + 1;
|
||||
const values = buildLinearSeries(start, step, stop, extent);
|
||||
applyFillSeries(ctx, at, direction, values);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_editor.ts (주인 D)
|
||||
* 칸 편집기 · 수식 입력줄 · 주소 상자 · 한글 조합 · 참조 가리키기(식 편집 중 칸 · 범위를 눌러 넣기) ·
|
||||
* 참조 칸 색 테두리.
|
||||
* 한글: 숨은 textarea(`ta`) 가 늘 활성 칸 자리에서 초점을 쥠 → compositionstart · input 에서 편집을
|
||||
* 시작하고 같은 textarea 가 그대로 칸 편집기가 됨(첫 글자 조합이 안 끊김 · keydown 글자 방식 금지).
|
||||
* 방식(엑셀): enter = 바로 쳐서 시작(방향키가 확정 · 식이면 가리키기) · edit = F2 · 두 번 누르기 ·
|
||||
* 수식 입력줄(방향키가 글자 사이를 옮김). F2 가 둘을 바꿈.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { moveFormula } from "./spreadsheet_refshift";
|
||||
import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import type { FuncHelp } from "./spreadsheet_func_help";
|
||||
import { parseFormula, tokenize } from "./spreadsheet_parser";
|
||||
import { cellsOf, move, selectCell } from "./spreadsheet_selection";
|
||||
import { checkValue, findValidation } from "./spreadsheet_validation";
|
||||
import { cellStyleAt } from "./spreadsheet_cellstyle";
|
||||
import type { Cell, CellAddress, CellStyle, Command, Sheet } from "./spreadsheet_types";
|
||||
import type {
|
||||
CellBox,
|
||||
EditorHandle,
|
||||
RefHighlight,
|
||||
Selection,
|
||||
SpreadsheetContext,
|
||||
} from "./spreadsheet_view_types";
|
||||
|
||||
export interface Editor extends EditorHandle {
|
||||
/** 숨은 textarea = 칸 편집기(늘 초점) */
|
||||
ta: HTMLTextAreaElement;
|
||||
/** 주소 상자 + 수식 입력줄 한 줄 */
|
||||
bar: HTMLElement;
|
||||
formula: HTMLTextAreaElement;
|
||||
mode(): "enter" | "edit";
|
||||
toggleMode(): void;
|
||||
/** 지금 글자 자리에 참조를 넣을 수 있나(식 · 연산자 뒤) */
|
||||
pointable(): boolean;
|
||||
/** 가리킨 고름을 식 글에 넣음(같은 가리키기면 바꿔 적음) */
|
||||
point(sel: Selection): void;
|
||||
/** 방향키 가리키기 — 지금 가리킨 곳(없으면 편집 칸)에서 */
|
||||
pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void;
|
||||
/** 함수 자동 완성 목록이 떠 있으면 고른 이름을 넣음(Tab) */
|
||||
accept(): boolean;
|
||||
/** 자동 완성 목록 안 위아래(목록이 없으면 false) */
|
||||
suggestMove(d: number): boolean;
|
||||
/** 고름 칸 전부에 같은 입력(Ctrl+Enter) — 식은 활성 칸 기준 상대 이동 */
|
||||
commitAll(): void;
|
||||
/** 초점 되찾기(스크롤 없이) */
|
||||
focus(): void;
|
||||
/** 편집 중이면 칸 자리 다시(스크롤 · 폭 바뀜) */
|
||||
place(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
interface EditState {
|
||||
sheet: string;
|
||||
at: CellAddress;
|
||||
mode: "enter" | "edit";
|
||||
original: string;
|
||||
/** 가리키기 중 — 식 글 [start, end) 가 가리킨 참조 */
|
||||
point: { start: number; end: number; sel: Selection } | null;
|
||||
}
|
||||
|
||||
/** 수 입력 — 1,234 · -1.5e3 · 50% (엑셀 입력 규칙) · 아니면 null */
|
||||
export function parseNumber(text: string): number | null {
|
||||
let t = text.trim();
|
||||
const pct = t.endsWith("%");
|
||||
if (pct) t = t.slice(0, -1);
|
||||
if (!/\d/.test(t) || !/^[+-]?(\d{1,3}(,\d{3})+|\d*)(\.\d*)?([eE][+-]?\d+)?$/.test(t)) return null;
|
||||
const n = Number(t.replace(/,/g, ""));
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return pct ? n / 100 : n;
|
||||
}
|
||||
|
||||
const looksTyped = (s: string): boolean =>
|
||||
(s.length > 1 && s[0] === "=") ||
|
||||
s[0] === "'" ||
|
||||
parseNumber(s) !== null ||
|
||||
/^(true|false)$/i.test(s);
|
||||
|
||||
/** 친 글 → 칸(서식은 두고) · 지울 칸이면 null */
|
||||
export function textToCell(text: string, old?: Cell): Cell | null {
|
||||
const base: Cell = old?.서식 !== undefined ? { 서식: old.서식 } : {};
|
||||
if (text === "") return old?.서식 !== undefined ? base : null;
|
||||
if (text.length > 1 && text[0] === "=") return { ...base, 식: text.slice(1) };
|
||||
if (text[0] === "'") return { ...base, 값: text.slice(1) };
|
||||
const n = parseNumber(text);
|
||||
if (n !== null) return { ...base, 값: n };
|
||||
if (/^(true|false)$/i.test(text)) return { ...base, 값: text.toUpperCase() === "TRUE" };
|
||||
return { ...base, 값: text };
|
||||
}
|
||||
|
||||
/** 친 모양대로 숫자 형식(엑셀) — `50%` → `0%` · `12.5%` → `0.0%` · `1,234.5` → `#,##0.0` · 아니면 null */
|
||||
export function typedFormat(text: string): string | null {
|
||||
const t = text.trim();
|
||||
if (parseNumber(t) === null || /e/i.test(t)) return null;
|
||||
const dec = /\.(\d+)/.exec(t)?.[1].length ?? 0;
|
||||
const frac = dec ? "." + "0".repeat(dec) : "";
|
||||
if (t.endsWith("%")) return `0${frac}%`;
|
||||
if (t.includes(",")) return `#,##0${frac}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 식 글(앞 `=` 뺌) — 함수 · 참조 · 참거짓을 대문자로(엑셀) · 시트 이름 · 글 · 정의된 이름은 그대로 */
|
||||
export function upperFormula(body: string): string {
|
||||
return tokenize(body)
|
||||
.map((t) => {
|
||||
if (t.kind === "func" || t.kind === "bool") return t.text.toUpperCase();
|
||||
if (t.kind !== "ref") return t.text;
|
||||
const bang = t.text.lastIndexOf("!") + 1;
|
||||
return t.text.slice(0, bang) + t.text.slice(bang).toUpperCase();
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** 닫는 괄호만 모자란 식이면 채운 식(엑셀 자동 고침) · 아니면 null */
|
||||
export function closeParens(value: string): string | null {
|
||||
let depth = 0;
|
||||
for (const t of tokenize(value.slice(1)))
|
||||
if (t.kind === "paren") depth += t.text === "(" ? 1 : -1;
|
||||
if (depth <= 0) return null;
|
||||
const fixed = value + ")".repeat(depth);
|
||||
return parseFormula(fixed).ok ? fixed : null;
|
||||
}
|
||||
|
||||
/** 칸 → 편집 글(식은 `=` 붙임 · 수처럼 읽힐 글은 `'` 붙임) */
|
||||
export function cellToText(cell: Cell | undefined): string {
|
||||
if (!cell) return "";
|
||||
if (cell.식 !== undefined) return "=" + cell.식;
|
||||
const v = cell.값;
|
||||
if (v === undefined) return "";
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
if (typeof v === "number") return String(v);
|
||||
return looksTyped(v) ? "'" + v : v;
|
||||
}
|
||||
|
||||
/** 참조 넣을 자리 — 식이고 caret 앞(빈칸 건너)이 연산자 · 괄호 · 쉼표 · `=` */
|
||||
export function insertableAt(text: string, pos: number): boolean {
|
||||
if (text[0] !== "=") return false;
|
||||
if (pos < text.length && /[A-Za-z0-9$_.!']/.test(text[pos])) return false;
|
||||
let i = pos - 1;
|
||||
while (i >= 0 && text[i] === " ") i--;
|
||||
return i >= 0 && "=(,+-*/^&<>:;".includes(text[i]);
|
||||
}
|
||||
|
||||
/** F4 — caret 에 걸친 참조의 $ 를 A1 → $A$1 → A$1 → $A1 → A1 로(범위면 두 끝 같이) · caret 은 참조 끝 */
|
||||
export function cycleAbs(text: string, pos: number): { text: string; caret: number } | null {
|
||||
if (text[0] !== "=") return null;
|
||||
const t = tokenize(text.slice(1)).find(
|
||||
(t) => t.kind === "ref" && t.start < pos && pos <= t.end + 1,
|
||||
);
|
||||
if (!t) return null;
|
||||
const bang = t.text.lastIndexOf("!") + 1;
|
||||
const part = /(\$?)([A-Za-z]{1,3})(\$?)(\d+)/g;
|
||||
const first = part.exec(t.text.slice(bang));
|
||||
if (!first) return null;
|
||||
const state = (first[1] ? 1 : 0) + (first[3] ? 2 : 0); // 0 없음 · 3 둘 다 · 2 행만 · 1 열만
|
||||
const next = { 0: 3, 3: 2, 2: 1, 1: 0 }[state]!;
|
||||
const ref =
|
||||
t.text.slice(0, bang) +
|
||||
t.text
|
||||
.slice(bang)
|
||||
.replace(
|
||||
part,
|
||||
(_m, _a, col, _b, row) => (next & 1 ? "$" : "") + col + (next & 2 ? "$" : "") + row,
|
||||
);
|
||||
const end = t.start + 1 + ref.length;
|
||||
return { text: text.slice(0, t.start + 1) + ref + text.slice(t.end + 1), caret: end };
|
||||
}
|
||||
|
||||
/** 식 글 참조 → 색 테두리 목록(같은 글이면 같은 색) */
|
||||
export function refHighlights(
|
||||
text: string,
|
||||
host: string,
|
||||
sheetId: (name: string) => string | null,
|
||||
): RefHighlight[] {
|
||||
if (text[0] !== "=") return [];
|
||||
const out: RefHighlight[] = [];
|
||||
const colors = new Map<string, number>();
|
||||
for (const t of tokenize(text.slice(1))) {
|
||||
if (t.kind !== "ref") continue;
|
||||
const bang = t.text.lastIndexOf("!");
|
||||
let sheet: string | null = host;
|
||||
if (bang >= 0) {
|
||||
const raw = t.text.slice(0, bang);
|
||||
const name = raw[0] === "'" ? raw.slice(1, -1).replace(/''/g, "'") : raw;
|
||||
sheet = sheetId(name);
|
||||
}
|
||||
const range = parseRange(t.text.slice(bang + 1).replace(/\$/g, ""));
|
||||
if (!sheet || !range) continue;
|
||||
const key = t.text.toUpperCase();
|
||||
if (!colors.has(key)) colors.set(key, colors.size);
|
||||
out.push({ 시트: sheet, 범위: range, 색번호: colors.get(key)! });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 칸 서식(칸 → 행 → 열 → 기본) */
|
||||
function styleAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): CellStyle {
|
||||
const idx =
|
||||
sheet.칸[toA1(r, c)]?.서식 ??
|
||||
sheet.행?.[String(r + 1)]?.서식 ??
|
||||
sheet.열?.[colName(c)]?.서식 ??
|
||||
0;
|
||||
return ctx.book.서식[idx] ?? {};
|
||||
}
|
||||
|
||||
/** 식 글 caret 앞 → 함수 이름 치는 중(prefix) · 함수 인자 안(func · 몇 번째 인자 0 부터) · 아님 null */
|
||||
export function formulaHint(
|
||||
pre: string,
|
||||
): { prefix: string } | { func: string; arg: number } | null {
|
||||
if (pre[0] !== "=") return null;
|
||||
const m = /(?:^=|[(,+\-*/^&<>:;= ])([A-Za-z][A-Za-z0-9.]*)$/.exec(pre);
|
||||
if (m && !/^\$?[A-Za-z]{1,3}\$?\d+$/.test(m[1])) return { prefix: m[1] };
|
||||
let depth = 0;
|
||||
let arg = 0;
|
||||
let inStr = false;
|
||||
for (let i = pre.length - 1; i > 0; i--) {
|
||||
const ch = pre[i];
|
||||
if (ch === '"') inStr = !inStr;
|
||||
if (inStr) continue;
|
||||
if (ch === ")") depth++;
|
||||
else if (ch === "," && depth === 0) arg++;
|
||||
else if (ch === "(") {
|
||||
if (depth-- > 0) continue;
|
||||
const n = /([A-Za-z][A-Za-z0-9.]*)$/.exec(pre.slice(0, i));
|
||||
return n ? { func: n[1].toUpperCase(), arg } : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 편집기 · 미리보기 자리 — C `editorSlot` 의 box 는 격자 뿌리 기준이라 층(layer)이 뿌리 안에서
|
||||
* 비켜 놓인 만큼 빼서 층 안 좌표로 맞춤. */
|
||||
export function slotOf(
|
||||
ctx: SpreadsheetContext,
|
||||
r: number,
|
||||
c: number,
|
||||
): { layer: HTMLElement; box: CellBox } {
|
||||
const { layer, box } = ctx.grid.editorSlot(r, c);
|
||||
const a = layer.getBoundingClientRect();
|
||||
const g = ctx.grid.root.getBoundingClientRect();
|
||||
return { layer, box: { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) } };
|
||||
}
|
||||
|
||||
/** 칸 자리 → 층 안 자리 */
|
||||
export function boxStyle(node: HTMLElement, box: CellBox): void {
|
||||
node.style.left = `${box.x}px`;
|
||||
node.style.top = `${box.y}px`;
|
||||
node.style.minWidth = `${box.w}px`;
|
||||
node.style.minHeight = `${box.h}px`;
|
||||
}
|
||||
|
||||
export function createEditor(ctx: SpreadsheetContext): Editor {
|
||||
const ta = el("textarea", {
|
||||
className: "ss-input ss-input--idle",
|
||||
attrs: { spellcheck: "false", autocomplete: "off", rows: "1", "aria-label": "칸 입력" },
|
||||
});
|
||||
const addr = el("input", {
|
||||
className: "ss-addr",
|
||||
attrs: { spellcheck: "false", "aria-label": "주소 상자" },
|
||||
});
|
||||
const formula = el("textarea", {
|
||||
className: "ss-formula",
|
||||
attrs: { spellcheck: "false", rows: "1", "aria-label": "수식 입력줄" },
|
||||
});
|
||||
const bar = el("div", {
|
||||
className: "ss-bar",
|
||||
children: [addr, el("span", { className: "ss-fx", text: "fx" }), formula],
|
||||
});
|
||||
formula.readOnly = ctx.readOnly;
|
||||
ta.readOnly = ctx.readOnly;
|
||||
|
||||
let st: EditState | null = null;
|
||||
|
||||
const src = (): HTMLTextAreaElement => (document.activeElement === formula ? formula : ta);
|
||||
const text = (): string => src().value;
|
||||
const sheetOf = (id: string): Sheet => ctx.book.시트.find((s) => s.id === id) ?? ctx.sheet();
|
||||
const sheetId = (name: string): string | null =>
|
||||
ctx.book.시트.find((s) => s.이름.toUpperCase() === name.toUpperCase())?.id ?? null;
|
||||
|
||||
function place(): void {
|
||||
const at = st?.at ?? ctx.selection.활성;
|
||||
const { layer, box } = slotOf(ctx, at.r, at.c);
|
||||
if (ta.parentElement !== layer) {
|
||||
const had = document.activeElement === ta;
|
||||
layer.append(ta);
|
||||
if (had) focus();
|
||||
}
|
||||
boxStyle(ta, box);
|
||||
if (!st) return;
|
||||
const s = styleAt(ctx, sheetOf(st.sheet), at.r, at.c);
|
||||
ta.style.fontFamily = s.글꼴 ?? "";
|
||||
ta.style.fontSize = s.크기 ? `${s.크기}pt` : "";
|
||||
ta.style.fontWeight = s.굵게 ? "bold" : "";
|
||||
ta.style.fontStyle = s.기울임 ? "italic" : "";
|
||||
ta.style.textAlign = s.가로 === "right" || s.가로 === "center" ? s.가로 : "";
|
||||
// 글이 길면 오른쪽 · 아래로 늘림(엑셀)
|
||||
ta.style.width = "0";
|
||||
ta.style.height = "0";
|
||||
ta.style.width = `${Math.max(box.w, ta.scrollWidth + 4)}px`;
|
||||
ta.style.height = `${Math.max(box.h, ta.scrollHeight)}px`;
|
||||
}
|
||||
|
||||
function focus(): void {
|
||||
if (document.activeElement !== ta) ta.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function highlight(): void {
|
||||
const t = st ? text() : "";
|
||||
ctx.grid.setRefHighlights(st ? refHighlights(t, st.sheet, sheetId) : []);
|
||||
showHint();
|
||||
}
|
||||
|
||||
// ── 함수 자동 완성 · 인자 도움말 (spreadsheet_func_help · sub1) ────────────────
|
||||
const hint = el("div", { className: "ss-hint", attrs: { hidden: "" } });
|
||||
let sugg: FuncHelp[] = [];
|
||||
let pick = 0;
|
||||
let typed = "";
|
||||
let fh: typeof import("./spreadsheet_func_help") | null = null; // 식을 처음 칠 때 불러옴 — 크기 예산
|
||||
|
||||
function showHint(): void {
|
||||
const node = src();
|
||||
const h = st ? formulaHint(node.value.slice(0, node.selectionEnd)) : null;
|
||||
if (h && !fh) return void import("./spreadsheet_func_help").then((m) => ((fh = m), showHint()));
|
||||
sugg = h && "prefix" in h ? fh!.suggestFunctions(h.prefix).slice(0, 8) : [];
|
||||
typed = h && "prefix" in h ? h.prefix : "";
|
||||
const help = h && "func" in h ? fh!.funcHelp(h.func) : null;
|
||||
if (!sugg.length && !help) return void hint.setAttribute("hidden", "");
|
||||
pick = Math.min(pick, Math.max(0, sugg.length - 1));
|
||||
hint.replaceChildren(
|
||||
...(sugg.length
|
||||
? sugg.map((f, i) => {
|
||||
const row = el("div", {
|
||||
className: i === pick ? "ss-hint__item is-on" : "ss-hint__item",
|
||||
children: [el("b", { text: f.이름 }), ` ${f.뜻}`],
|
||||
});
|
||||
row.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
pick = i;
|
||||
accept();
|
||||
});
|
||||
return row;
|
||||
})
|
||||
: [el("div", { className: "ss-hint__help", text: `${help!.꼴} — ${help!.뜻}` })]),
|
||||
);
|
||||
if (hint.parentElement !== ta.parentElement) ta.parentElement?.append(hint);
|
||||
hint.style.left = ta.style.left;
|
||||
hint.style.top = `${parseFloat(ta.style.top || "0") + ta.offsetHeight}px`;
|
||||
hint.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
function accept(): boolean {
|
||||
if (!st || !sugg.length) return false;
|
||||
const node = src();
|
||||
const pos = node.selectionEnd;
|
||||
const name = `${sugg[pick].이름}(`;
|
||||
node.setRangeText(name, pos - typed.length, pos, "end");
|
||||
(node === ta ? formula : ta).value = node.value;
|
||||
pick = 0;
|
||||
place();
|
||||
highlight();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 편집 시작 — `value` 없으면 textarea 에 이미 든 글(조합 · 바로 친 글자) 그대로 */
|
||||
function start(mode: "enter" | "edit", value?: string, from: HTMLTextAreaElement = ta): void {
|
||||
if (ctx.readOnly || st) return;
|
||||
const sel = ctx.selection;
|
||||
const sheet = ctx.sheet();
|
||||
const original = cellToText(sheet.칸[toA1(sel.활성.r, sel.활성.c)]);
|
||||
st = { sheet: sheet.id, at: { ...sel.활성 }, mode, original, point: null };
|
||||
if (value !== undefined) from.value = value;
|
||||
(from === ta ? formula : ta).value = from.value;
|
||||
ta.classList.remove("ss-input--idle");
|
||||
ctx.root.classList.add("ss--editing");
|
||||
place();
|
||||
highlight();
|
||||
if (from === ta && value !== undefined) ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||
}
|
||||
|
||||
function end(): void {
|
||||
st = null;
|
||||
ta.value = "";
|
||||
ta.classList.add("ss-input--idle");
|
||||
ta.removeAttribute("style");
|
||||
ctx.root.classList.remove("ss--editing");
|
||||
ctx.grid.setRefHighlights([]);
|
||||
hint.setAttribute("hidden", "");
|
||||
sync();
|
||||
focus();
|
||||
}
|
||||
|
||||
/** 칸에 적음 — 식이 틀렸거나(인자 수 등) 유효성(sub4) 「막음」 을 어기면 안 적고 false(편집 그대로 · 엑셀처럼 다시 치게) */
|
||||
function write(cells: CellAddress[], value: string, from: CellAddress): boolean {
|
||||
if (!st) return true;
|
||||
const isFormula = value.length > 1 && value[0] === "=";
|
||||
if (isFormula && !parseFormula(value).ok) value = closeParens(value) ?? value;
|
||||
const parsed = isFormula ? parseFormula(value) : null;
|
||||
if (parsed?.ok) value = "=" + upperFormula(value.slice(1));
|
||||
if (parsed && !parsed.ok) {
|
||||
showToast(parsed.message, "error");
|
||||
const node = src();
|
||||
const at = Math.min(parsed.at + 1, node.value.length);
|
||||
st.mode = "edit"; // 방향키가 글자 사이를 움직이게
|
||||
st.point = null;
|
||||
node.setSelectionRange(at, at);
|
||||
return false;
|
||||
}
|
||||
const sheet = sheetOf(st.sheet);
|
||||
const out: Record<string, Cell | null> = {};
|
||||
const warn = new Set<string>();
|
||||
for (const a of cells) {
|
||||
let v = value;
|
||||
if (v.length > 1 && v[0] === "=" && (a.r !== from.r || a.c !== from.c))
|
||||
v = "=" + moveFormula(v.slice(1), a.r - from.r, a.c - from.c);
|
||||
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) return true;
|
||||
const cellCmd: Command = { 종류: "칸", 시트: sheet.id, 칸: out };
|
||||
const fmt = isFormula ? null : typedFormat(value);
|
||||
// `%` 는 글 형식(`@`) · 이미 % 형식만 빼고 바꿈 · 천 단위 쉼표는 일반 형식일 때만(엑셀)
|
||||
const takes = (code?: string): boolean =>
|
||||
!code ||
|
||||
/^(general|g\/표준)$/i.test(code) ||
|
||||
(fmt!.endsWith("%") && !code.includes("%") && code.trim() !== "@");
|
||||
const fmtAt = fmt
|
||||
? cells.filter((a) => takes(cellStyleAt(ctx.book, sheet, a.r, a.c).형식))
|
||||
: [];
|
||||
ctx.dispatch(
|
||||
fmtAt.length
|
||||
? {
|
||||
종류: "묶음",
|
||||
명령: [
|
||||
cellCmd,
|
||||
{
|
||||
종류: "서식",
|
||||
시트: sheet.id,
|
||||
범위: fmtAt.map((a) => ({ r0: a.r, c0: a.c, r1: a.r, c1: a.c })),
|
||||
바꿀: { 형식: fmt! },
|
||||
},
|
||||
],
|
||||
}
|
||||
: cellCmd,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function commit(): void {
|
||||
if (!st) return;
|
||||
const value = text();
|
||||
if (value !== st.original && !write([st.at], value, st.at)) return;
|
||||
end();
|
||||
}
|
||||
|
||||
function commitAll(): void {
|
||||
if (!st) return;
|
||||
if (!write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at)) return;
|
||||
end();
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
if (!st) return;
|
||||
end();
|
||||
}
|
||||
|
||||
function sync(): void {
|
||||
const sel = ctx.selection;
|
||||
addr.value = toA1(sel.활성.r, sel.활성.c);
|
||||
if (st) return;
|
||||
formula.value = cellToText(ctx.sheet().칸[toA1(sel.활성.r, sel.활성.c)]);
|
||||
place();
|
||||
}
|
||||
|
||||
function pointable(): boolean {
|
||||
if (!st) return false;
|
||||
const node = src();
|
||||
if (node.selectionStart !== node.selectionEnd) return false;
|
||||
const pos = node.selectionEnd;
|
||||
if (st.point && st.point.end === pos) return true;
|
||||
st.point = null;
|
||||
return insertableAt(node.value, pos);
|
||||
}
|
||||
|
||||
function point(sel: Selection): void {
|
||||
if (!st) return;
|
||||
const node = src();
|
||||
const other = node === ta ? formula : ta;
|
||||
const pos = node.selectionEnd;
|
||||
const p = st.point ?? { start: pos, end: pos, sel };
|
||||
const g = sel.범위[0];
|
||||
const ref = rangeToA1(g);
|
||||
node.value = node.value.slice(0, p.start) + ref + node.value.slice(p.end);
|
||||
other.value = node.value;
|
||||
st.point = { start: p.start, end: p.start + ref.length, sel };
|
||||
node.setSelectionRange(st.point.end, st.point.end);
|
||||
place();
|
||||
highlight();
|
||||
ctx.grid.reveal(sel.활성.r === g.r0 ? g.r1 : g.r0, sel.활성.c === g.c0 ? g.c1 : g.c0);
|
||||
}
|
||||
|
||||
function pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void {
|
||||
if (!st) return;
|
||||
const sheet = sheetOf(st.sheet);
|
||||
const from = st.point?.sel ?? selectCell(sheet, st.at.r, st.at.c);
|
||||
point(move(sheet, from, dr, dc, { extend, jump }));
|
||||
}
|
||||
|
||||
// ── 한글 조합 · 바로 치기 → 편집 시작 ─────────────────────────────────────
|
||||
ta.addEventListener("compositionstart", () => {
|
||||
if (!st) start("enter");
|
||||
});
|
||||
ta.addEventListener("input", () => {
|
||||
if (!st) return start("enter");
|
||||
formula.value = ta.value;
|
||||
if (st.point && ta.selectionEnd !== st.point.end) st.point = null;
|
||||
place();
|
||||
highlight();
|
||||
});
|
||||
// 수식 입력줄 — 누르면 edit 방식으로 시작
|
||||
formula.addEventListener("focus", () => {
|
||||
if (!st && !ctx.readOnly) start("edit", formula.value, formula);
|
||||
});
|
||||
formula.addEventListener("input", () => {
|
||||
if (!st) return start("edit", undefined, formula);
|
||||
ta.value = formula.value;
|
||||
if (st.point && formula.selectionEnd !== st.point.end) st.point = null;
|
||||
place();
|
||||
highlight();
|
||||
});
|
||||
// 주소 상자 — `B5` · `A1:C3` · `시트!B5` 로 가기
|
||||
addr.addEventListener("focus", () => addr.select());
|
||||
addr.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
sync();
|
||||
focus();
|
||||
}
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
goTo(addr.value.trim());
|
||||
focus();
|
||||
});
|
||||
|
||||
/** 이름 상자(sub4) — 있는 이름이면 그 범위로 · 없으면 지금 고름에 이름을 붙임(엑셀) */
|
||||
async function nameBox(name: string): Promise<void> {
|
||||
const { defineName, resolveName } = await import("./spreadsheet_names"); // 늦게 — 크기 예산
|
||||
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();
|
||||
if (bang >= 0) {
|
||||
const raw = value.slice(0, bang);
|
||||
const id = sheetId(raw[0] === "'" ? raw.slice(1, -1).replace(/''/g, "'") : raw);
|
||||
if (!id) return sync();
|
||||
if (id !== sheet.id) ctx.showSheet(id);
|
||||
sheet = ctx.sheet();
|
||||
}
|
||||
const body = value.slice(bang + 1).replace(/\$/g, "");
|
||||
const one = parseA1(body);
|
||||
const g = parseRange(body);
|
||||
if (!g) return void 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);
|
||||
}
|
||||
|
||||
return {
|
||||
ta,
|
||||
bar,
|
||||
formula,
|
||||
editing: () => st !== null,
|
||||
begin(initial?: string) {
|
||||
if (initial === undefined) {
|
||||
const sel = ctx.selection;
|
||||
start("edit", cellToText(ctx.sheet().칸[toA1(sel.활성.r, sel.활성.c)]));
|
||||
} else start("enter", initial);
|
||||
focus();
|
||||
},
|
||||
commit,
|
||||
commitAll,
|
||||
cancel,
|
||||
sync,
|
||||
mode: () => st?.mode ?? "enter",
|
||||
toggleMode() {
|
||||
if (st) ((st.mode = st.mode === "enter" ? "edit" : "enter"), (st.point = null));
|
||||
},
|
||||
pointable,
|
||||
point,
|
||||
pointMove,
|
||||
accept,
|
||||
suggestMove(d: number) {
|
||||
if (!st || !sugg.length) return false;
|
||||
pick = (pick + d + sugg.length) % sugg.length;
|
||||
showHint();
|
||||
return true;
|
||||
},
|
||||
focus,
|
||||
place() {
|
||||
if (st) place();
|
||||
},
|
||||
destroy() {
|
||||
hint.remove();
|
||||
ta.remove();
|
||||
bar.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_errcheck.ts (주인 A)
|
||||
* 오류 검사(엑셀 초록 세모) — 칸마다 까닭을 냄: 순환 참조 · 식 결과가 오류(#DIV/0! 등) · 숫자처럼 보이는 글.
|
||||
* 활성 칸에 까닭이 있으면 칸 왼쪽에 ⚠ 단추 · 올리면 까닭 풍선 · 누르면 메뉴(숫자로 변환 · 계산 단계 보기 ·
|
||||
* 선행 참조 추적 · 오류 무시). 「오류 무시」는 이 화면 동안만(문서에 안 적음).
|
||||
*
|
||||
* 잇는 법(C · sub6 · D · sub7):
|
||||
* · 부품 붙이기: `const tip = attachErrorTip(ctx, trace?)` — ⚠ 단추를 격자 overlay(editorSlot 층)에 얹음.
|
||||
* grid.render() · 고름 바뀜 뒤마다 `tip.refresh()`. `trace`(spreadsheet_trace 의 손잡이)를 주면 메뉴에 「선행 참조 추적」.
|
||||
* · 격자(C): 보이는 칸마다 `tip.warning(r, c)` 가 null 이 아니면 칸 왼쪽 위에 초록 세모(6px · `--color-success`).
|
||||
* 시트 전체를 훑지 말고 그리는 칸만 물음(값 하나 · 칸 하나 읽는 값싼 부름).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { toA1 } from "./spreadsheet_address";
|
||||
import { isError } from "./spreadsheet_eval";
|
||||
import { openEvalSteps } from "./spreadsheet_eval_steps";
|
||||
import type { TraceHandle } from "./spreadsheet_trace";
|
||||
import type { CalcEngine, Cell, Workbook } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import "./spreadsheet_analysis.css";
|
||||
|
||||
export type WarningKind = "순환" | "오류" | "글숫자";
|
||||
|
||||
export interface CellWarning {
|
||||
종류: WarningKind;
|
||||
까닭: string;
|
||||
}
|
||||
|
||||
/** 엑셀 오류 값 뜻(엔진 까닭이 없을 때) */
|
||||
const MEANING: Record<string, [string, string]> = {
|
||||
"#DIV/0!": ["0 으로 나눔", "Divide by zero"],
|
||||
"#VALUE!": ["값의 종류가 맞지 않음", "Wrong type of value"],
|
||||
"#REF!": ["지워진 칸을 가리킴", "Invalid cell reference"],
|
||||
"#NAME?": ["모르는 이름 · 함수", "Unrecognized name"],
|
||||
"#N/A": ["찾는 값이 없음", "Value not available"],
|
||||
"#NUM!": ["수가 맞지 않음", "Invalid number"],
|
||||
"#NULL!": ["겹치지 않는 범위", "Ranges do not intersect"],
|
||||
};
|
||||
|
||||
const TEXT = {
|
||||
Cycle: ["순환 참조", "Circular reference"],
|
||||
TextNumber: ["숫자가 글로 저장됨", "Number stored as text"],
|
||||
Convert: ["숫자로 변환", "Convert to number"],
|
||||
Steps: ["계산 단계 보기", "Show calculation steps"],
|
||||
Trace: ["선행 참조 추적", "Trace precedents"],
|
||||
Ignore: ["오류 무시", "Ignore error"],
|
||||
};
|
||||
const t = (k: keyof typeof TEXT) => TEXT[k][currentLanguageIndex] ?? TEXT[k][0];
|
||||
|
||||
/** 숫자로 읽히는 글(앞뒤 빈칸 · 천 단위 쉼표 · 지수 허용) */
|
||||
const NUMBER_LIKE = /^[+-]?(\d{1,3}(,\d{3})+|\d+)?(\.\d+)?(e[+-]?\d+)?$/i;
|
||||
|
||||
export function numberLike(text: string): number | null {
|
||||
const s = text.trim();
|
||||
if (!/\d/.test(s) || !NUMBER_LIKE.test(s)) return null;
|
||||
const n = Number(s.replace(/,/g, ""));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** 칸 하나 까닭 — 없으면 null */
|
||||
export function cellWarning(
|
||||
book: Workbook,
|
||||
engine: CalcEngine,
|
||||
sheet: string,
|
||||
r: number,
|
||||
c: number,
|
||||
): CellWarning | null {
|
||||
const cell: Cell | undefined = book.시트.find((s) => s.id === sheet)?.칸[toA1(r, c)];
|
||||
if (!cell) return null;
|
||||
if (cell.식 !== undefined) {
|
||||
const v = engine.value(sheet, r, c);
|
||||
if (!isError(v)) return null;
|
||||
if (v.error === "#CYCLE!") return { 종류: "순환", 까닭: v.why ?? t("Cycle") };
|
||||
const m = MEANING[v.error];
|
||||
const head = m ? `${v.error} ${m[currentLanguageIndex] ?? m[0]}` : v.error;
|
||||
return { 종류: "오류", 까닭: v.why ? `${v.error} ${v.why}` : head };
|
||||
}
|
||||
if (typeof cell.값 === "string" && numberLike(cell.값) !== null)
|
||||
return { 종류: "글숫자", 까닭: t("TextNumber") };
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ErrorTipHandle extends PartHandle {
|
||||
/** 격자 세모용 — 「오류 무시」한 칸은 null */
|
||||
warning(r: number, c: number): CellWarning | null;
|
||||
}
|
||||
|
||||
export function attachErrorTip(ctx: SpreadsheetContext, trace?: TraceHandle): ErrorTipHandle {
|
||||
const layer = el("div", { className: "ss-errtip" });
|
||||
const ignored = new Set<string>();
|
||||
let menu: HTMLElement | null = null;
|
||||
|
||||
const warning = (r: number, c: number) =>
|
||||
ignored.has(`${ctx.sheet().id}!${toA1(r, c)}`)
|
||||
? null
|
||||
: cellWarning(ctx.book, ctx.engine, ctx.sheet().id, r, c);
|
||||
|
||||
function closeMenu() {
|
||||
menu?.remove();
|
||||
menu = null;
|
||||
document.removeEventListener("mousedown", onOutside, true);
|
||||
}
|
||||
const onOutside = (ev: MouseEvent) => {
|
||||
if (menu && !layer.contains(ev.target as Node)) closeMenu();
|
||||
};
|
||||
|
||||
/** 고른 칸 중 글숫자를 모두 수로(서식은 그대로) */
|
||||
function convert() {
|
||||
const s = ctx.sheet();
|
||||
const cells: Record<string, Cell> = {};
|
||||
for (const g of ctx.selection.범위)
|
||||
for (let r = g.r0; r <= g.r1; r++)
|
||||
for (let c = g.c0; c <= g.c1; c++) {
|
||||
const a1 = toA1(r, c);
|
||||
const cell = s.칸[a1];
|
||||
const n =
|
||||
typeof cell?.값 === "string" && cell.식 === undefined ? numberLike(cell.값) : null;
|
||||
if (n !== null) cells[a1] = { ...cell, 값: n };
|
||||
}
|
||||
if (Object.keys(cells).length) ctx.dispatch({ 종류: "칸", 시트: s.id, 칸: cells });
|
||||
}
|
||||
|
||||
function place() {
|
||||
closeMenu();
|
||||
const { r, c } = ctx.selection.활성;
|
||||
const { layer: host, box } = ctx.grid.editorSlot(r, c);
|
||||
if (layer.parentElement !== host) host.append(layer);
|
||||
const w = warning(r, c);
|
||||
const v = ctx.grid.visibleRange();
|
||||
if (!w || !box.w || r < v.r0 || r > v.r1 || c < v.c0 || c > v.c1) {
|
||||
layer.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const a = host.getBoundingClientRect();
|
||||
const g = ctx.grid.root.getBoundingClientRect();
|
||||
layer.style.left = `${box.x - (a.left - g.left) - 20}px`;
|
||||
layer.style.top = `${box.y - (a.top - g.top)}px`;
|
||||
const btn = el("button", {
|
||||
className: "ss-errtip__btn",
|
||||
text: "⚠",
|
||||
attrs: { type: "button", "aria-label": w.까닭 },
|
||||
});
|
||||
const balloon = el("div", { className: "ss-errtip__balloon", text: w.까닭 });
|
||||
btn.addEventListener("mousedown", (ev) => ev.stopPropagation());
|
||||
btn.addEventListener("click", () => (menu ? closeMenu() : openMenu(w, r, c)));
|
||||
layer.replaceChildren(btn, balloon);
|
||||
}
|
||||
|
||||
function openMenu(w: CellWarning, r: number, c: number) {
|
||||
const item = (label: string, run: () => void) => {
|
||||
const b = el("button", {
|
||||
className: "ss-errtip__item",
|
||||
text: label,
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
closeMenu();
|
||||
run();
|
||||
});
|
||||
return b;
|
||||
};
|
||||
const items: HTMLElement[] = [el("div", { className: "ss-errtip__head", text: w.까닭 })];
|
||||
if (w.종류 === "글숫자" && !ctx.readOnly) items.push(item(t("Convert"), convert));
|
||||
if (w.종류 !== "글숫자") items.push(item(t("Steps"), () => openEvalSteps(ctx)));
|
||||
if (w.종류 !== "글숫자" && trace) items.push(item(t("Trace"), () => trace.precedents()));
|
||||
items.push(
|
||||
item(t("Ignore"), () => {
|
||||
ignored.add(`${ctx.sheet().id}!${toA1(r, c)}`);
|
||||
ctx.grid.render();
|
||||
place();
|
||||
}),
|
||||
);
|
||||
menu = el("div", { className: "ss-errtip__menu", attrs: { role: "menu" }, children: items });
|
||||
layer.append(menu);
|
||||
document.addEventListener("mousedown", onOutside, true);
|
||||
}
|
||||
|
||||
const onScroll = () => place();
|
||||
ctx.grid.root.addEventListener("scroll", onScroll, true);
|
||||
place();
|
||||
return {
|
||||
root: layer,
|
||||
refresh: place,
|
||||
warning,
|
||||
destroy() {
|
||||
closeMenu();
|
||||
ctx.grid.root.removeEventListener("scroll", onScroll, true);
|
||||
layer.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_eval.ts (주인 A)
|
||||
* 나무 풀기 · 오류 값 전파 · 형 바꾸기(엑셀 규칙). B 의 함수들이 형 바꾸기 도우미를 씀.
|
||||
* 빈 칸 = 수에서 0 · 글에서 "" · `"12"+1 = 13` · `TRUE+1 = 2` · 글이 수로 안 읽히면 `#VALUE!`.
|
||||
* 무리수(SQRT · 삼각 · PI · 소수 거듭제곱)는 double → 유효 15 자리 십진 → 분수(B · A 같이 `fromDouble`).
|
||||
* 오류 전파 = 왼쪽부터 처음 만난 오류. 비교 = 수 < 글 < 참거짓 · 글은 대소문자 가리지 않음.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
add,
|
||||
cmp,
|
||||
div,
|
||||
frac,
|
||||
fracToString,
|
||||
mul,
|
||||
parseDecimal,
|
||||
roundAt,
|
||||
sub,
|
||||
toFrac,
|
||||
ZERO,
|
||||
} from "@ui/sheet/ui_template_sheet_frac";
|
||||
import { normRange } from "./spreadsheet_address";
|
||||
import { getFunction } from "./spreadsheet_functions";
|
||||
import type {
|
||||
CalcValue,
|
||||
ErrorCode,
|
||||
ErrorValue,
|
||||
EvalContext,
|
||||
EvalResult,
|
||||
FormulaNode,
|
||||
Frac,
|
||||
RangeValue,
|
||||
Scalar,
|
||||
} from "./spreadsheet_types";
|
||||
|
||||
export function err(code: ErrorCode, why?: string): ErrorValue {
|
||||
return why === undefined ? { error: code } : { error: code, why };
|
||||
}
|
||||
|
||||
export function isError(value: unknown): value is ErrorValue {
|
||||
return typeof value === "object" && value !== null && "error" in value;
|
||||
}
|
||||
|
||||
export function isFrac(value: unknown): value is Frac {
|
||||
return typeof value === "object" && value !== null && "n" in value && "d" in value;
|
||||
}
|
||||
|
||||
export function isRange(value: unknown): value is RangeValue {
|
||||
return typeof value === "object" && value !== null && (value as RangeValue).kind === "range";
|
||||
}
|
||||
|
||||
const ONE = frac(1n);
|
||||
const HUNDRED = frac(100n);
|
||||
const BIG_D = 10n ** 40n;
|
||||
|
||||
/** 분모가 너무 커지면(나눗셈 사슬) 30 자리 십진으로 굳힘 */
|
||||
export function tame(x: Frac): Frac {
|
||||
return x.d > BIG_D ? parseDecimal(fracToString(x)) : x;
|
||||
}
|
||||
|
||||
/** 범위 → 칸 하나(1×1 일 때만) · 아니면 `#VALUE!` */
|
||||
function single(value: EvalResult): Scalar {
|
||||
if (!isRange(value)) return value;
|
||||
return value.rows === 1 && value.cols === 1
|
||||
? value.at(0, 0)
|
||||
: err("#VALUE!", "범위를 값 하나로 못 씀");
|
||||
}
|
||||
|
||||
/** 엔진이 만든 범위 — 적힌 자리(끝 자름 전) · 암묵 교차에 씀 */
|
||||
export interface PlacedRange extends RangeValue {
|
||||
r0: number;
|
||||
c0: number;
|
||||
r1: number;
|
||||
c1: number;
|
||||
}
|
||||
|
||||
/** 범위 → 칸 하나 — 1×1 이면 그 칸 · 아니면 식 든 행(한 열 범위) · 열(한 행 범위)과 겹친 칸(엑셀 암묵 교차) */
|
||||
export function intersect(value: EvalResult, at: { r: number; c: number }): Scalar {
|
||||
if (!isRange(value) || (value.rows === 1 && value.cols === 1)) return single(value);
|
||||
const p = value as Partial<PlacedRange>;
|
||||
if (p.r0 !== undefined && p.c0 !== undefined && p.r1 !== undefined && p.c1 !== undefined) {
|
||||
if (p.c0 === p.c1 && at.r >= p.r0 && at.r <= p.r1) return value.at(at.r - p.r0, 0);
|
||||
if (p.r0 === p.r1 && at.c >= p.c0 && at.c <= p.c1) return value.at(0, at.c - p.c0);
|
||||
}
|
||||
return err("#VALUE!", "범위가 식 든 행 · 열과 안 겹침");
|
||||
}
|
||||
|
||||
/** 수로 — 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` · 범위는 칸 하나일 때만 */
|
||||
export function asNumber(value: EvalResult): Frac | ErrorValue {
|
||||
const v = single(value);
|
||||
if (v === null) return ZERO;
|
||||
if (typeof v === "boolean") return v ? ONE : ZERO;
|
||||
if (typeof v === "string") {
|
||||
const t = v.trim();
|
||||
const pct = t.endsWith("%");
|
||||
try {
|
||||
const n = parseDecimal(pct ? t.slice(0, -1) : t);
|
||||
return pct ? div(n, HUNDRED) : n;
|
||||
} catch {
|
||||
return err("#VALUE!", `수로 못 읽는 글: "${v}"`);
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 일반 형식 글 — 유효 15 자리 · 1e15 넘거나 1e-9 밑이면 `1.5E+18` 꼴(엑셀 `&` 와 같게) */
|
||||
export function numberText(x: Frac): string {
|
||||
if (x.n === 0n) return "0";
|
||||
const d = parseFloat(fracToString(x));
|
||||
const e = Math.floor(Math.log10(Math.abs(d)));
|
||||
if (e >= 15 || e < -9) {
|
||||
const [m, ex] = d.toExponential(14).split("e");
|
||||
const mant = m.replace(/\.?0+$/, "");
|
||||
const n = Number(ex);
|
||||
return `${mant}E${n < 0 ? "-" : "+"}${String(Math.abs(n)).padStart(2, "0")}`;
|
||||
}
|
||||
return fracToString(roundAt(x, 14 - e, "round"));
|
||||
}
|
||||
|
||||
/** 글로 — 수는 일반 형식 글(엑셀 `&` 와 같게) · 참거짓 `TRUE`/`FALSE` */
|
||||
export function asText(value: EvalResult): string | ErrorValue {
|
||||
const v = single(value);
|
||||
if (v === null) return "";
|
||||
if (typeof v === "string" || isError(v)) return v;
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
return numberText(v);
|
||||
}
|
||||
|
||||
export function asBool(value: EvalResult): boolean | ErrorValue {
|
||||
const v = single(value);
|
||||
if (v === null) return false;
|
||||
if (typeof v === "boolean" || isError(v)) return v;
|
||||
if (typeof v === "string") {
|
||||
const t = v.toUpperCase();
|
||||
return t === "TRUE"
|
||||
? true
|
||||
: t === "FALSE"
|
||||
? false
|
||||
: err("#VALUE!", `참거짓으로 못 읽는 글: "${v}"`);
|
||||
}
|
||||
return v.n !== 0n;
|
||||
}
|
||||
|
||||
/** double → 유효 15 자리 십진 → 분수 · NaN · 무한은 `#NUM!` */
|
||||
export function fromDouble(x: number): Frac | ErrorValue {
|
||||
if (!Number.isFinite(x)) return err("#NUM!", "수가 너무 크거나 뜻이 없음");
|
||||
return x === 0 ? ZERO : parseDecimal(x.toPrecision(15));
|
||||
}
|
||||
|
||||
export const toDouble = (x: Frac): number => parseFloat(fracToString(x));
|
||||
|
||||
/** 저장 모양 — 수는 십진 글(30 자리까지) */
|
||||
export function toCalcValue(value: Scalar): CalcValue {
|
||||
if (value === null) return { 수: "0" };
|
||||
if (typeof value === "string") return { 글: value };
|
||||
if (typeof value === "boolean") return { 참: value };
|
||||
if (isError(value))
|
||||
return value.why ? { 오류: value.error, 까닭: value.why } : { 오류: value.error };
|
||||
return { 수: fracToString(value) };
|
||||
}
|
||||
|
||||
// ── 연산 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function power(a: Frac, b: Frac): Frac | ErrorValue {
|
||||
if (a.n === 0n) {
|
||||
if (b.n === 0n) return err("#NUM!", "0^0");
|
||||
return b.n < 0n ? err("#DIV/0!", "0 의 음수 거듭제곱") : ZERO;
|
||||
}
|
||||
if (b.d === 1n) {
|
||||
const e = b.n < 0n ? -b.n : b.n;
|
||||
const bits = BigInt(a.n.toString(2).length + a.d.toString(2).length);
|
||||
// ponytail: 4,000 비트 넘는 정확 거듭제곱은 double 로 — 실무 식엔 없음
|
||||
if (bits * e <= 4000n) {
|
||||
const r = frac(a.n ** e, a.d ** e);
|
||||
return tame(b.n < 0n ? div(ONE, r) : r);
|
||||
}
|
||||
} else if (a.n < 0n) return err("#NUM!", "음수의 소수 거듭제곱");
|
||||
return fromDouble(Math.pow(toDouble(a), toDouble(b)));
|
||||
}
|
||||
|
||||
function arith(op: string, a: Frac, b: Frac): Frac | ErrorValue {
|
||||
switch (op) {
|
||||
case "+":
|
||||
return tame(add(a, b));
|
||||
case "-":
|
||||
return tame(sub(a, b));
|
||||
case "*":
|
||||
return tame(mul(a, b));
|
||||
case "/":
|
||||
return b.n === 0n ? err("#DIV/0!", "0 으로 나눔") : tame(div(a, b));
|
||||
default:
|
||||
return power(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
const RANK = (v: Scalar) => (isFrac(v) ? 0 : typeof v === "string" ? 1 : 2);
|
||||
|
||||
/** 엑셀 비교 — 빈 칸은 맞은편 형의 빈 값(0 · "" · FALSE) · 형이 다르면 수 < 글 < 참거짓 */
|
||||
export function compareValues(a: Scalar, b: Scalar): number {
|
||||
const blank = (other: Scalar): Scalar =>
|
||||
isFrac(other)
|
||||
? ZERO
|
||||
: typeof other === "string"
|
||||
? ""
|
||||
: typeof other === "boolean"
|
||||
? false
|
||||
: ZERO;
|
||||
if (a === null) a = blank(b);
|
||||
if (b === null) b = blank(a);
|
||||
const ra = RANK(a);
|
||||
const rb = RANK(b);
|
||||
if (ra !== rb) return ra < rb ? -1 : 1;
|
||||
if (isFrac(a)) return cmp(a, b as Frac);
|
||||
if (typeof a === "string") {
|
||||
const x = a.toLowerCase();
|
||||
const y = (b as string).toLowerCase();
|
||||
return x === y ? 0 : x < y ? -1 : 1;
|
||||
}
|
||||
return a === b ? 0 : a ? 1 : -1;
|
||||
}
|
||||
|
||||
function compareOp(op: string, a: Scalar, b: Scalar): boolean {
|
||||
const c = compareValues(a, b);
|
||||
return op === "="
|
||||
? c === 0
|
||||
: op === "<>"
|
||||
? c !== 0
|
||||
: op === "<"
|
||||
? c < 0
|
||||
: op === "<="
|
||||
? c <= 0
|
||||
: op === ">"
|
||||
? c > 0
|
||||
: c >= 0;
|
||||
}
|
||||
|
||||
// ── 풀기 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function evaluate(node: FormulaNode, ctx: EvalContext): EvalResult {
|
||||
switch (node.type) {
|
||||
case "number":
|
||||
return node.value;
|
||||
case "string":
|
||||
return node.value;
|
||||
case "bool":
|
||||
return node.value;
|
||||
case "error":
|
||||
return err(node.code);
|
||||
case "name":
|
||||
return err("#NAME?", `모르는 이름: ${node.name}`);
|
||||
case "cell":
|
||||
case "range": {
|
||||
const sheet = node.sheet === null ? ctx.sheet : ctx.sheetId(node.sheet);
|
||||
if (sheet === null) return err("#REF!", `없는 시트: ${node.sheet}`);
|
||||
if (node.type === "cell") return ctx.cell(sheet, node.ref.r, node.ref.c);
|
||||
return ctx.range(sheet, normRange(node.from.r, node.from.c, node.to.r, node.to.c));
|
||||
}
|
||||
case "unary": {
|
||||
const v = intersect(evaluate(node.arg, ctx), ctx);
|
||||
if (node.op === "+") return v;
|
||||
const n = asNumber(v);
|
||||
if (isError(n)) return n;
|
||||
return node.op === "-" ? frac(-n.n, n.d) : div(n, HUNDRED);
|
||||
}
|
||||
case "binary": {
|
||||
const left = intersect(evaluate(node.left, ctx), ctx);
|
||||
const right = intersect(evaluate(node.right, ctx), ctx);
|
||||
if (node.op === "&") {
|
||||
const a = asText(left);
|
||||
if (isError(a)) return a;
|
||||
const b = asText(right);
|
||||
return isError(b) ? b : a + b;
|
||||
}
|
||||
if (PLAIN_OPS.has(node.op)) {
|
||||
const a = asNumber(left);
|
||||
if (isError(a)) return a;
|
||||
const b = asNumber(right);
|
||||
return isError(b) ? b : arith(node.op, a, b);
|
||||
}
|
||||
if (isError(left)) return left;
|
||||
return isError(right) ? right : compareOp(node.op, left, right);
|
||||
}
|
||||
case "call": {
|
||||
const fn = getFunction(node.name);
|
||||
if (!fn) return err("#NAME?", `모르는 함수: ${node.name}`);
|
||||
// 인자 수는 파서가 막음(spreadsheet_parser)
|
||||
return fn.call(
|
||||
node.args.map((a) => () => evaluate(a, ctx)),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PLAIN_OPS = new Set(["+", "-", "*", "/", "^"]);
|
||||
|
||||
/** 수로 온 입력 값 → 풀이 값 */
|
||||
export function inputValue(v: number | string | boolean): Scalar {
|
||||
return typeof v === "number" ? toFrac(v) : v;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_eval_steps.ts (주인 A)
|
||||
* 수식 계산 단계 보기(엑셀 [수식 계산] 창) — 식을 왼쪽부터 한 마디씩 값으로 바꿔 보여 줌.
|
||||
* 엑셀 규칙: 칸 참조 → 값(빈 칸은 0 · 글은 따옴표) · 연산 · 함수는 인자가 다 값이 된 뒤 · 범위는 함수가 통째로 풂 ·
|
||||
* IF · IFERROR · CHOOSE 류는 첫 인자만 먼저 풀고 고른 쪽은 함수 한 걸음으로 · ROW(A1) 같은 참조 인자는 그대로.
|
||||
* 마디 값은 늘 엔진 풀이(`evalSafe`)로 냄 — 보이는 글은 보여 주기만.
|
||||
*
|
||||
* 잇는 법(D · sub7):
|
||||
* · 도구 모음 [수식 분석] 「수식 계산」 → `openEvalSteps(ctx)` — 활성 칸 식의 창을 `ctx.root` 에 띄우고 닫는 함수를 돌려줌.
|
||||
* · 식 없는 칸이면 토스트로 알리고 창을 안 띄움. 들어가기(참조 칸 식 속으로)는 아직 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
|
||||
import { isError, isRange, numberText } from "./spreadsheet_eval";
|
||||
import type { PlacedRange } from "./spreadsheet_eval";
|
||||
import { bindNames, evalSafe } from "./spreadsheet_graph";
|
||||
import { formulaToText, parseFormula } from "./spreadsheet_parser";
|
||||
import type {
|
||||
CalcEngine,
|
||||
CellRange,
|
||||
EvalContext,
|
||||
FormulaNode,
|
||||
Scalar,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import "./spreadsheet_analysis.css";
|
||||
|
||||
/** 한 걸음 — 글과 다음에 풀 곳(밑줄 · 글 안 [시작, 끝)) */
|
||||
export interface EvalStep {
|
||||
글: string;
|
||||
밑줄: [number, number] | null;
|
||||
}
|
||||
|
||||
/** 첫 인자만 먼저 풂 */
|
||||
const LAZY = new Set(["IF", "IFS", "IFERROR", "IFNA", "CHOOSE", "SWITCH"]);
|
||||
/** 칸 참조 인자를 값으로 안 바꿈 */
|
||||
const REF_ARGS = new Set([
|
||||
"ROW",
|
||||
"COLUMN",
|
||||
"ROWS",
|
||||
"COLUMNS",
|
||||
"OFFSET",
|
||||
"INDEX",
|
||||
"ISREF",
|
||||
"ISBLANK",
|
||||
"CELL",
|
||||
"ISFORMULA",
|
||||
"FORMULATEXT",
|
||||
"SUMIF",
|
||||
"SUMIFS",
|
||||
"COUNTIF",
|
||||
"COUNTIFS",
|
||||
"AVERAGEIF",
|
||||
"AVERAGEIFS",
|
||||
"COUNTBLANK",
|
||||
]);
|
||||
|
||||
/** 푸는 차례(왼쪽 먼저 · 아래 먼저) */
|
||||
function order(node: FormulaNode, out: FormulaNode[], refArg = false): void {
|
||||
switch (node.type) {
|
||||
case "cell":
|
||||
if (!refArg) out.push(node);
|
||||
return;
|
||||
case "name":
|
||||
out.push(node);
|
||||
return;
|
||||
case "unary":
|
||||
if (node.op !== "%" && node.arg.type === "number") return; // -5 는 글 그대로
|
||||
order(node.arg, out);
|
||||
out.push(node);
|
||||
return;
|
||||
case "binary":
|
||||
order(node.left, out);
|
||||
order(node.right, out);
|
||||
out.push(node);
|
||||
return;
|
||||
case "call": {
|
||||
const args = LAZY.has(node.name) ? node.args.slice(0, 1) : node.args;
|
||||
for (const a of args) order(a, out, REF_ARGS.has(node.name));
|
||||
out.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function valueText(v: Scalar): string {
|
||||
if (v === null) return "0";
|
||||
if (typeof v === "string") return `"${v.replace(/"/g, '""')}"`;
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
if (isError(v)) return v.error;
|
||||
return numberText(v);
|
||||
}
|
||||
|
||||
/** 엔진 없이 쓸 풀이 문맥 — 칸 값은 엔진에서 · 범위는 값 든 끝까지(엔진과 같게) */
|
||||
function contextFor(book: Workbook, engine: CalcEngine, sheet: string, r: number, c: number) {
|
||||
const ext = new Map<string, { r: number; c: number }>();
|
||||
const extent = (id: string) => {
|
||||
let e = ext.get(id);
|
||||
if (!e) {
|
||||
e = { r: 0, c: 0 };
|
||||
for (const [a1, cell] of Object.entries(book.시트.find((s) => s.id === id)?.칸 ?? {})) {
|
||||
const at = cell.식 !== undefined || cell.값 !== undefined ? parseA1(a1) : null;
|
||||
if (at) e = { r: Math.max(e.r, at.r), c: Math.max(e.c, at.c) };
|
||||
}
|
||||
ext.set(id, e);
|
||||
}
|
||||
return e;
|
||||
};
|
||||
const ctx: EvalContext = {
|
||||
sheet,
|
||||
r,
|
||||
c,
|
||||
cell: (s, rr, cc) => engine.value(s, rr, cc),
|
||||
range(s, rg: CellRange): PlacedRange {
|
||||
const e = extent(s);
|
||||
const r1 = Math.min(rg.r1, Math.max(rg.r0, e.r));
|
||||
const c1 = Math.min(rg.c1, Math.max(rg.c0, e.c));
|
||||
return {
|
||||
kind: "range",
|
||||
rows: r1 - rg.r0 + 1,
|
||||
cols: c1 - rg.c0 + 1,
|
||||
at: (i, j) => engine.value(s, rg.r0 + i, rg.c0 + j),
|
||||
...rg,
|
||||
};
|
||||
},
|
||||
sheetId: (name) =>
|
||||
book.시트.find((s) => s.이름.toLowerCase() === name.toLowerCase())?.id ?? null,
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** 칸 식의 모든 걸음(첫 글 → … → 값) · 식이 없으면 null */
|
||||
export function evalSteps(
|
||||
book: Workbook,
|
||||
engine: CalcEngine,
|
||||
sheet: string,
|
||||
r: number,
|
||||
c: number,
|
||||
): EvalStep[] | null {
|
||||
const text = book.시트.find((s) => s.id === sheet)?.칸[toA1(r, c)]?.식;
|
||||
if (text === undefined) return null;
|
||||
const p = parseFormula(text);
|
||||
if (!p.ok)
|
||||
return [
|
||||
{ 글: text, 밑줄: null },
|
||||
{ 글: valueText(engine.value(sheet, r, c)), 밑줄: null },
|
||||
];
|
||||
const ctx = contextFor(book, engine, sheet, r, c);
|
||||
const nodes: FormulaNode[] = [];
|
||||
order(p.node, nodes);
|
||||
// 식 전체가 범위(`=A1:A3`)면 마지막 걸음으로 엔진 값(암묵 교차)
|
||||
if (nodes[nodes.length - 1] !== p.node && p.node.type === "range") nodes.push(p.node);
|
||||
const vals = new Map<FormulaNode, Scalar>();
|
||||
const steps: FormulaNode[] = [];
|
||||
for (const n of nodes) {
|
||||
if (n === p.node) vals.set(n, engine.value(sheet, r, c));
|
||||
else {
|
||||
const v = evalSafe(bindNames(n, book, sheet), ctx);
|
||||
if (isRange(v)) continue; // 범위 값(INDEX 등)은 글 그대로
|
||||
vals.set(n, v);
|
||||
}
|
||||
steps.push(n);
|
||||
}
|
||||
const done = new Map<FormulaNode, string>();
|
||||
const out: EvalStep[] = [];
|
||||
for (let i = 0; i <= steps.length; i++) {
|
||||
const raw = formulaToText(p.node, (n) => done.get(n), steps[i]);
|
||||
const a = raw.indexOf("\u0001");
|
||||
const b = raw.indexOf("\u0002");
|
||||
out.push(
|
||||
a < 0
|
||||
? { 글: raw, 밑줄: null }
|
||||
: { 글: raw.replace(/[\u0001\u0002]/g, ""), 밑줄: [a, b - 1] },
|
||||
);
|
||||
if (i < steps.length) done.set(steps[i], valueText(vals.get(steps[i]) ?? null));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 창 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const TEXT = {
|
||||
Title: ["수식 계산", "Evaluate Formula"],
|
||||
Ref: ["참조", "Reference"],
|
||||
Hint: [
|
||||
"밑줄 친 식의 값을 보려면 [계산] 을 누름",
|
||||
"To show the result of the underlined expression, click Evaluate.",
|
||||
],
|
||||
Eval: ["계산", "Evaluate"],
|
||||
Restart: ["다시 시작", "Restart"],
|
||||
Close: ["닫기", "Close"],
|
||||
NoFormula: ["식이 없는 칸", "The cell has no formula"],
|
||||
};
|
||||
const t = (k: keyof typeof TEXT) => TEXT[k][currentLanguageIndex] ?? TEXT[k][0];
|
||||
|
||||
export function openEvalSteps(ctx: SpreadsheetContext): () => void {
|
||||
const sheet = ctx.sheet();
|
||||
const { r, c } = ctx.selection.활성;
|
||||
const steps = evalSteps(ctx.book, ctx.engine, sheet.id, r, c);
|
||||
if (!steps) {
|
||||
showToast(t("NoFormula"), "info");
|
||||
return () => {};
|
||||
}
|
||||
let i = 0;
|
||||
const view = el("div", { className: "ss-steps__text", attrs: { "aria-live": "polite" } });
|
||||
const next = createButton({
|
||||
label: t("Eval"),
|
||||
onClick: () => go(i + 1 < steps.length ? i + 1 : 0),
|
||||
});
|
||||
const box = el("div", {
|
||||
className: "ss-steps__box",
|
||||
attrs: { role: "dialog", "aria-label": t("Title") },
|
||||
});
|
||||
const shade = el("div", { className: "ss-steps", children: [box] });
|
||||
|
||||
function go(to: number) {
|
||||
i = to;
|
||||
const s = steps![i];
|
||||
if (!s.밑줄) view.replaceChildren(s.글);
|
||||
else {
|
||||
const [a, b] = s.밑줄;
|
||||
view.replaceChildren(s.글.slice(0, a), el("u", { text: s.글.slice(a, b) }), s.글.slice(b));
|
||||
}
|
||||
next.textContent = i + 1 < steps!.length ? t("Eval") : t("Restart");
|
||||
}
|
||||
const close = () => {
|
||||
shade.remove();
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Escape") close();
|
||||
else if (ev.key === "Enter") next.click();
|
||||
else return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
box.append(
|
||||
el("div", { className: "ss-steps__title", text: t("Title") }),
|
||||
el("div", {
|
||||
className: "ss-steps__meta",
|
||||
text: `${t("Ref")}: ${ctx.book.시트.length > 1 ? `${quoteSheet(sheet.이름)}!` : ""}${toA1(r, c)}`,
|
||||
}),
|
||||
view,
|
||||
el("div", { className: "ss-steps__meta", text: t("Hint") }),
|
||||
el("div", {
|
||||
className: "ss-steps__foot",
|
||||
children: [next, createButton({ label: t("Close"), variant: "ghost", onClick: close })],
|
||||
}),
|
||||
);
|
||||
shade.addEventListener("mousedown", (ev) => {
|
||||
if (ev.target === shade) close();
|
||||
});
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
go(0);
|
||||
ctx.root.append(shade);
|
||||
next.focus();
|
||||
return close;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_extras.ts (주인 D)
|
||||
* 2단계 부품 잇기(sub1 파일들의 「잇는 법」) — 찾기 패널 · 메모 풍선(마우스 올림) · 메모 편집(Shift+F2) ·
|
||||
* 셀 서식 창(Ctrl+1) · 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) ·
|
||||
* 도구 모음을 누른 뒤 초점을 격자로 되돌림 · 데이터 단추 · 메뉴는 `spreadsheet_data_menu.ts`.
|
||||
* 빨간 세모는 C 격자 · 우클릭 [메모 …] 는 E 메뉴 몫(브레인 배정).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { watchClose } from "./spreadsheet_actions";
|
||||
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 type { FindPanelHandle } from "./spreadsheet_find_panel";
|
||||
import type { FormatPainterHandle } from "./spreadsheet_format_painter";
|
||||
import type { KeyHooks } from "./spreadsheet_keys";
|
||||
import type { Memo } from "./spreadsheet_memo";
|
||||
import type { MouseHooks } from "./spreadsheet_mouse";
|
||||
import type { PasteSpecialMode } from "./spreadsheet_paste_special";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const TEXT = {
|
||||
format: "셀 서식(Ctrl+1)",
|
||||
painter: "서식 붓 — 한 번 누르면 한 번 · 두 번 누르면 Esc 까지 계속",
|
||||
pasteTitle: "골라 붙여넣기",
|
||||
modes: { 값: "값만", 서식: "서식만", 수식: "수식만" } as Record<PasteSpecialMode, string>,
|
||||
};
|
||||
|
||||
export interface Extras {
|
||||
keys: KeyHooks;
|
||||
mouse: MouseHooks;
|
||||
parts: PartHandle[];
|
||||
}
|
||||
|
||||
export function attachExtras(
|
||||
ctx: SpreadsheetContext,
|
||||
ed: Editor,
|
||||
toolbarRoot: HTMLElement | null,
|
||||
): Extras {
|
||||
const back = (): void => ed.focus();
|
||||
/** 칸 오른쪽 자리(부품 뿌리 기준 px) — C editorSlot box 는 격자 뿌리 기준 */
|
||||
const beside = (r: number, c: number): { x: number; y: number } => {
|
||||
const { box } = ctx.grid.editorSlot(r, c);
|
||||
const g = ctx.grid.root.getBoundingClientRect();
|
||||
const o = ctx.root.getBoundingClientRect();
|
||||
return { x: box.x + box.w + g.left - o.left + 4, y: box.y + g.top - o.top };
|
||||
};
|
||||
const put = (node: HTMLElement, r: number, c: number): void => {
|
||||
const p = beside(r, c);
|
||||
node.style.left = `${p.x}px`;
|
||||
node.style.top = `${p.y}px`;
|
||||
};
|
||||
|
||||
// ── 찾기(처음 열 때 불러옴 — 크기 예산) ─────────────────────────────────────
|
||||
let find: FindPanelHandle | null = null;
|
||||
async function openFind(replace: boolean): Promise<void> {
|
||||
find ??= (await import("./spreadsheet_find_panel")).mountFindPanel(ctx, back);
|
||||
find.open(replace);
|
||||
}
|
||||
|
||||
// ── 메모 — 풍선 · 편집 ─────────────────────────────────────────────────
|
||||
const comments = attachComments(ctx);
|
||||
let hovered = "";
|
||||
let memo: Memo | null = null; // 처음 쓸 때 불러옴 — 크기 예산
|
||||
async function editMemo(): Promise<void> {
|
||||
if (ctx.readOnly) return;
|
||||
const { r, c } = ctx.selection.활성;
|
||||
memo ??= (await import("./spreadsheet_memo")).createMemo(ctx, back, {
|
||||
get: getComment,
|
||||
set: setComment,
|
||||
});
|
||||
comments.hideBalloon();
|
||||
memo.open(r, c, (node) => put(node, r, c));
|
||||
}
|
||||
|
||||
// ── 서식 붓 ─────────────────────────────────────────────────────────────
|
||||
let painter: FormatPainterHandle | null = null; // 처음 누를 때 불러옴 — 크기 예산
|
||||
let sticky = false;
|
||||
const brush = el("button", {
|
||||
className: "ss-brush",
|
||||
text: "붓",
|
||||
attrs: { type: "button", title: TEXT.painter },
|
||||
});
|
||||
const showBrush = (): void => {
|
||||
const on = !!painter?.active();
|
||||
brush.classList.toggle("is-on", on);
|
||||
ctx.root.classList.toggle("ss--painting", on);
|
||||
};
|
||||
brush.addEventListener("click", async (e) => {
|
||||
painter ??= (await import("./spreadsheet_format_painter")).createFormatPainter(ctx);
|
||||
if (e.detail === 2) sticky = true;
|
||||
else if (painter.active()) (painter.clear(), (sticky = false));
|
||||
else (painter.pick(), (sticky = false));
|
||||
showBrush();
|
||||
back();
|
||||
});
|
||||
// 셀 서식 창 — Ctrl+1 을 브라우저 · ORCA 가 가로챌 때 누를 자리
|
||||
const fmt = el("button", {
|
||||
className: "ss-brush",
|
||||
text: "서식…",
|
||||
attrs: { type: "button", title: TEXT.format },
|
||||
});
|
||||
/** 창이 닫히면(확인 · 취소) 초점을 격자로 */
|
||||
async function openFormat(): Promise<void> {
|
||||
if (ctx.readOnly) return;
|
||||
(await import("./spreadsheet_format_dialog")).openFormatDialog(ctx); // 늦게 — 크기 예산
|
||||
watchClose(back);
|
||||
}
|
||||
fmt.addEventListener("click", () => void openFormat());
|
||||
if (!ctx.readOnly) ed.bar.prepend(fmt, brush);
|
||||
|
||||
// ── 데이터 단추 · 메뉴(정렬 · 필터 · 조건부 서식 · 유효성 · 이름 · 눈금선) ─────────
|
||||
const data = mountDataMenu(ctx, back);
|
||||
brush.after(...data.buttons);
|
||||
|
||||
// ── 골라 붙여넣기 ─────────────────────────────────────────────────────────
|
||||
let armed = false;
|
||||
const picker = el("div", { className: "ss-pick", attrs: { hidden: "", title: TEXT.pasteTitle } });
|
||||
ctx.root.append(picker);
|
||||
const onPaste = (e: ClipboardEvent): void => {
|
||||
if (!armed || ed.editing()) return;
|
||||
armed = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // E 클립보드의 보통 붙여넣기를 막음
|
||||
const at = { ...ctx.selection.활성 };
|
||||
const data = e.clipboardData;
|
||||
const block = parseClipboard(
|
||||
data?.getData("text/html") ?? "",
|
||||
data?.getData("text/plain") ?? "",
|
||||
at,
|
||||
);
|
||||
if (!block) return;
|
||||
// ponytail: 우리 복사면 원본을 같은 시트로 봄(ClipBlock 에 시트가 없음) — 값만 때 식 칸 계산값용
|
||||
const source =
|
||||
block.출처 === "aislo" && block.원점
|
||||
? { 시트: ctx.selection.시트, ...block.원점 }
|
||||
: undefined;
|
||||
picker.replaceChildren(
|
||||
...(Object.keys(TEXT.modes) as PasteSpecialMode[]).map((mode) => {
|
||||
const b = el("button", {
|
||||
text: TEXT.modes[mode],
|
||||
attrs: { type: "button", "data-mode": mode },
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
picker.setAttribute("hidden", "");
|
||||
// 늦게 — 크기 예산
|
||||
void import("./spreadsheet_paste_special").then((m) => {
|
||||
m.pasteSpecial(ctx, block, at, mode, source);
|
||||
back();
|
||||
});
|
||||
});
|
||||
return b;
|
||||
}),
|
||||
);
|
||||
picker.removeAttribute("hidden");
|
||||
put(picker, at.r, at.c);
|
||||
(picker.firstElementChild as HTMLElement).focus();
|
||||
};
|
||||
picker.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
picker.setAttribute("hidden", "");
|
||||
back();
|
||||
});
|
||||
ctx.root.addEventListener("paste", onPaste, true);
|
||||
|
||||
// ── 도구 모음 뒤 초점 ───────────────────────────────────────────────────
|
||||
const onToolbarClick = (e: Event): void => {
|
||||
if ((e.target as HTMLElement).closest("button")) back();
|
||||
};
|
||||
toolbarRoot?.addEventListener("click", onToolbarClick);
|
||||
toolbarRoot?.addEventListener("change", back);
|
||||
|
||||
const self: PartHandle = {
|
||||
root: null,
|
||||
refresh: () => showBrush(),
|
||||
destroy() {
|
||||
ctx.root.removeEventListener("paste", onPaste, true);
|
||||
toolbarRoot?.removeEventListener("click", onToolbarClick);
|
||||
toolbarRoot?.removeEventListener("change", back);
|
||||
find?.destroy();
|
||||
memo?.destroy();
|
||||
picker.remove();
|
||||
brush.remove();
|
||||
fmt.remove();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
keys: {
|
||||
find: (replace) => void openFind(replace),
|
||||
pasteSpecial: () => (armed = true),
|
||||
comment: () => void editMemo(),
|
||||
formatDialog: () => void openFormat(),
|
||||
...data.keys,
|
||||
escape() {
|
||||
painter?.clear();
|
||||
sticky = false;
|
||||
showBrush();
|
||||
},
|
||||
},
|
||||
mouse: {
|
||||
selected() {
|
||||
if (!painter?.active()) return;
|
||||
painter.paint(ctx.selection.범위[0], sticky);
|
||||
showBrush();
|
||||
},
|
||||
hover(hit) {
|
||||
const key = hit?.kind === "cell" ? `${hit.r},${hit.c}` : "";
|
||||
if (key === hovered) return;
|
||||
hovered = key;
|
||||
if (!hit || !key || memo?.busy() || !getComment(ctx.sheet(), hit.r, hit.c))
|
||||
return comments.hideBalloon();
|
||||
comments.showBalloon(hit.r, hit.c);
|
||||
put(comments.root!, hit.r, hit.c); // showBalloon 의 cellBox 자리 → 화면 자리로 고침
|
||||
},
|
||||
},
|
||||
parts: [comments, data.part, self],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_fill.ts (주인 B)
|
||||
* 채우기 핸들 — 원본 범위를 대상 범위로 늘림: 수 하나 = 복사 · 수 둘 이상 = 등차 연속 ·
|
||||
* 글 끝 번호(`구-01` → `구-02`) · 식 = 상대 참조 옮김(A `moveFormula`) · 서식 같이.
|
||||
* 결과는 `{ 종류: "칸" }` 명령에 넣을 칸 뭉치.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Cell, CellRange, FillDirection, Workbook } from "./spreadsheet_types";
|
||||
import { moveFormula } from "./spreadsheet_refshift";
|
||||
import { toA1 } from "./spreadsheet_address";
|
||||
|
||||
function detectDirection(source: CellRange, target: CellRange): FillDirection {
|
||||
if (target.r1 > source.r1 && target.c0 === source.c0 && target.c1 === source.c1) return "down";
|
||||
if (target.r0 < source.r0 && target.c0 === source.c0 && target.c1 === source.c1) return "up";
|
||||
if (target.c1 > source.c1 && target.r0 === source.r0 && target.r1 === source.r1) return "right";
|
||||
return "left";
|
||||
}
|
||||
|
||||
/** 글 끝 숫자를 옮김(`구-01`,step 1 → `구-02`) · 끝 숫자가 없으면 그대로 */
|
||||
function incrementTrailingNumber(text: string, step: number): string {
|
||||
const m = /^(.*?)(\d+)$/.exec(text);
|
||||
if (!m) return text;
|
||||
const [, prefix, digits] = m;
|
||||
const next = BigInt(digits) + BigInt(step);
|
||||
const clamped = next < 0n ? 0n : next;
|
||||
return prefix + clamped.toString().padStart(digits.length, "0");
|
||||
}
|
||||
|
||||
/** `target` 은 `source` 를 한 방향으로 늘린 범위(방향은 둘을 견줘 앎) · A1 → 칸(null = 비움) */
|
||||
export function fillCells(
|
||||
book: Workbook,
|
||||
sheet: string,
|
||||
source: CellRange,
|
||||
target: CellRange,
|
||||
): Record<string, Cell | null> {
|
||||
const sheetObj = book.시트.find((s) => s.id === sheet);
|
||||
const out: Record<string, Cell | null> = {};
|
||||
if (!sheetObj) return out;
|
||||
|
||||
const direction = detectDirection(source, target);
|
||||
const vertical = direction === "down" || direction === "up";
|
||||
const lineCount = vertical ? source.c1 - source.c0 + 1 : source.r1 - source.r0 + 1;
|
||||
|
||||
for (let li = 0; li < lineCount; li++) {
|
||||
const fixed = vertical ? source.c0 + li : source.r0 + li;
|
||||
const sourceStart = vertical ? source.r0 : source.c0;
|
||||
const sourceEnd = vertical ? source.r1 : source.c1;
|
||||
const targetStart = vertical ? target.r0 : target.c0;
|
||||
const targetEnd = vertical ? target.r1 : target.c1;
|
||||
const lineLen = sourceEnd - sourceStart + 1;
|
||||
|
||||
const key = (pos: number): string => (vertical ? toA1(pos, fixed) : toA1(fixed, pos));
|
||||
const sourceCells: (Cell | undefined)[] = [];
|
||||
for (let p = sourceStart; p <= sourceEnd; p++) sourceCells.push(sheetObj.칸[key(p)]);
|
||||
|
||||
const allNumeric = sourceCells.every(
|
||||
(c) => c !== undefined && c.식 === undefined && typeof c.값 === "number",
|
||||
);
|
||||
const nums = allNumeric ? sourceCells.map((c) => c!.값 as number) : [];
|
||||
const step = nums.length > 1 ? (nums[nums.length - 1] - nums[0]) / (nums.length - 1) : 0;
|
||||
|
||||
for (let p = targetStart; p <= targetEnd; p++) {
|
||||
if (p >= sourceStart && p <= sourceEnd) continue;
|
||||
const rel = p - sourceStart;
|
||||
const slot = ((rel % lineLen) + lineLen) % lineLen;
|
||||
const period = (rel - slot) / lineLen;
|
||||
const template = sourceCells[slot];
|
||||
const outKey = key(p);
|
||||
if (template === undefined) {
|
||||
out[outKey] = null;
|
||||
continue;
|
||||
}
|
||||
const cell: Cell = {};
|
||||
if (template.서식 !== undefined) cell.서식 = template.서식;
|
||||
if (allNumeric) {
|
||||
cell.값 = nums[0] + step * rel;
|
||||
} else if (template.식 !== undefined) {
|
||||
const dr = vertical ? period * lineLen : 0;
|
||||
const dc = vertical ? 0 : period * lineLen;
|
||||
cell.식 = moveFormula(template.식, dr, dc);
|
||||
} else if (typeof template.값 === "string" && lineLen === 1) {
|
||||
cell.값 = incrementTrailingNumber(template.값, rel);
|
||||
} else if (template.값 !== undefined) {
|
||||
cell.값 = template.값;
|
||||
}
|
||||
out[outKey] = cell;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_filter.ts (주인 A · 화면)
|
||||
* 자동 필터 — 머리 행 칸마다 ▼ 단추 · 값 목록 창(모두 선택 · 값마다 체크 · 이 열 정렬 · 이 열 필터 지움).
|
||||
* 걸러진 행은 엔진이 `행.걸러짐` 으로 적음(사용자 숨김과 따로) · 명령은 `필터`(spreadsheet_sortfilter.ts).
|
||||
*
|
||||
* 잇는 법(D · sub7 · C · sub6):
|
||||
* · 부품 붙이기: `const filter = attachFilter(ctx)` — 단추 층을 격자 overlay(editorSlot 층)에 얹음.
|
||||
* grid.render() 뒤마다 `filter.refresh()`(단추 자리 · 켜짐 표시) · 스크롤은 스스로 따라감.
|
||||
* · 도구 단추 「필터」 · Ctrl+Shift+L → `toggleFilter(ctx)`(켜기 = 고른 범위 · 칸 하나면 현재 영역 / 끄기).
|
||||
* · Alt+↓(활성 칸이 머리 행이면) → `filter.open(활성 열)`.
|
||||
* · 격자(C): 행 `걸러짐` 을 `숨김` 처럼 높이 0 으로 — 머리의 숨김 표시(두 줄)는 사용자 `숨김` 만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { colName, parseRange, rangeToA1 } from "./spreadsheet_address";
|
||||
import { CommandError } from "./spreadsheet_commands";
|
||||
import { currentRegion, filterCommand, filterItems, sortCommand } from "./spreadsheet_sortfilter";
|
||||
import type { AutoFilter, Command } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import "./spreadsheet_sortfilter.css";
|
||||
|
||||
const TEXT = {
|
||||
Button: ["필터", "Filter"],
|
||||
All: ["(모두 선택)", "(Select all)"],
|
||||
Blank: ["(빈 칸)", "(Blanks)"],
|
||||
SortAsc: ["오름차순 정렬", "Sort ascending"],
|
||||
SortDesc: ["내림차순 정렬", "Sort descending"],
|
||||
Clear: ["이 열 필터 지우기", "Clear filter from column"],
|
||||
Ok: ["확인", "OK"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
};
|
||||
const t = (key: keyof typeof TEXT) => TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
|
||||
|
||||
function send(ctx: SpreadsheetContext, make: () => Command): void {
|
||||
try {
|
||||
ctx.dispatch(make());
|
||||
} catch (e) {
|
||||
if (!(e instanceof CommandError)) throw e;
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** 필터 켜기 · 끄기 */
|
||||
export function toggleFilter(ctx: SpreadsheetContext): void {
|
||||
if (ctx.readOnly) return;
|
||||
const s = ctx.sheet();
|
||||
if (s.필터) return send(ctx, () => filterCommand(ctx.book, ctx.engine, s.id, null));
|
||||
const g = ctx.selection.범위[0];
|
||||
const rg = g.r0 === g.r1 && g.c0 === g.c1 ? currentRegion(ctx.book, s.id, g.r0, g.c0) : g;
|
||||
send(ctx, () => ({ 종류: "필터", 시트: s.id, 필터: { 범위: rangeToA1(rg) }, 걸러짐: [] }));
|
||||
}
|
||||
|
||||
export interface FilterHandle extends PartHandle {
|
||||
/** 열 `c` 값 목록 창 열기(필터 범위 안일 때) */
|
||||
open(c: number): void;
|
||||
}
|
||||
|
||||
export function attachFilter(ctx: SpreadsheetContext): FilterHandle {
|
||||
const layer = el("div", { className: "ss-filter__layer" });
|
||||
let panel: HTMLElement | null = null;
|
||||
|
||||
const filterOf = () => {
|
||||
const f = ctx.sheet().필터;
|
||||
const rg = f && parseRange(f.범위);
|
||||
return f && rg ? { f, rg } : null;
|
||||
};
|
||||
|
||||
/** 칸 자리 → 단추 층 안 자리(editorSlot 상자는 격자 뿌리 기준 — 층 원점과 다를 수 있어 실제 자리로 맞춤) */
|
||||
function boxIn(r: number, c: number) {
|
||||
const { layer: host, box } = ctx.grid.editorSlot(r, c);
|
||||
const a = host.getBoundingClientRect();
|
||||
const g = ctx.grid.root.getBoundingClientRect();
|
||||
return { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) };
|
||||
}
|
||||
|
||||
/** 머리 칸이 지금 보이나(틀고정 판이면 늘 보임) */
|
||||
function shown(r: number, c: number): boolean {
|
||||
const v = ctx.grid.visibleRange();
|
||||
const fz = ctx.sheet().틀고정 ?? {};
|
||||
const rowOk = r < (fz.행 ?? 0) || (r >= v.r0 && r <= v.r1);
|
||||
const colOk = c < (fz.열 ?? 0) || (c >= v.c0 && c <= v.c1);
|
||||
return rowOk && colOk;
|
||||
}
|
||||
|
||||
function place(): void {
|
||||
const { layer: host } = ctx.grid.editorSlot(0, 0);
|
||||
if (layer.parentElement !== host) host.append(layer);
|
||||
const cur = filterOf();
|
||||
const btns: HTMLElement[] = [];
|
||||
if (cur)
|
||||
for (let c = cur.rg.c0; c <= cur.rg.c1; c++) {
|
||||
const box = boxIn(cur.rg.r0, c);
|
||||
if (!box.w || !box.h || !shown(cur.rg.r0, c)) continue;
|
||||
const on = !!cur.f.조건?.[colName(c)];
|
||||
const b = el("button", {
|
||||
className: on ? "ss-filter__btn is-on" : "ss-filter__btn",
|
||||
text: "▼",
|
||||
attrs: {
|
||||
type: "button",
|
||||
"aria-label": `${t("Button")} ${colName(c)}`,
|
||||
"data-col": String(c),
|
||||
},
|
||||
});
|
||||
b.style.left = `${box.x + box.w - 17}px`;
|
||||
b.style.top = `${box.y + box.h - 17}px`;
|
||||
b.addEventListener("mousedown", (ev) => ev.stopPropagation());
|
||||
b.addEventListener("click", () => open(c));
|
||||
btns.push(b);
|
||||
}
|
||||
layer.replaceChildren(...btns, ...(panel ? [panel] : []));
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
panel?.remove();
|
||||
panel = null;
|
||||
document.removeEventListener("mousedown", onOutside, true);
|
||||
}
|
||||
|
||||
const onOutside = (ev: MouseEvent) => {
|
||||
if (panel && !panel.contains(ev.target as Node)) close();
|
||||
};
|
||||
|
||||
function apply(next: AutoFilter): void {
|
||||
close();
|
||||
send(ctx, () => filterCommand(ctx.book, ctx.engine, ctx.sheet().id, next));
|
||||
}
|
||||
|
||||
function open(c: number): void {
|
||||
const cur = filterOf();
|
||||
if (!cur || c < cur.rg.c0 || c > cur.rg.c1 || ctx.readOnly) return;
|
||||
close();
|
||||
const sheetId = ctx.sheet().id;
|
||||
const items = filterItems(ctx.book, ctx.engine, sheetId, c);
|
||||
const boxes = items.map((it) => {
|
||||
const cb = el("input", { attrs: { type: "checkbox" } });
|
||||
cb.checked = it.켬;
|
||||
return cb;
|
||||
});
|
||||
const all = el("input", { attrs: { type: "checkbox" } });
|
||||
const ok = createButton({
|
||||
label: t("Ok"),
|
||||
onClick: () => {
|
||||
const conds = { ...cur.f.조건 };
|
||||
if (boxes.every((b) => b.checked)) delete conds[colName(c)];
|
||||
else conds[colName(c)] = items.filter((_, i) => boxes[i].checked).map((it) => it.글);
|
||||
apply({ ...cur.f, 조건: conds });
|
||||
},
|
||||
});
|
||||
const sync = () => {
|
||||
all.checked = boxes.every((b) => b.checked);
|
||||
all.indeterminate = !all.checked && boxes.some((b) => b.checked);
|
||||
ok.disabled = !boxes.some((b) => b.checked);
|
||||
};
|
||||
all.addEventListener("change", () => {
|
||||
for (const b of boxes) b.checked = all.checked;
|
||||
sync();
|
||||
});
|
||||
for (const b of boxes) b.addEventListener("change", sync);
|
||||
const link = (label: string, run: () => void) => {
|
||||
const a = el("button", {
|
||||
className: "ss-filter__link",
|
||||
text: label,
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
a.addEventListener("click", run);
|
||||
return a;
|
||||
};
|
||||
const sortBy = (desc: boolean) => () => {
|
||||
close();
|
||||
send(ctx, () =>
|
||||
sortCommand(ctx.book, ctx.engine, sheetId, cur.rg, {
|
||||
머리: true,
|
||||
기준: [{ 열: c, 내림: desc }],
|
||||
}),
|
||||
);
|
||||
};
|
||||
const rest = { ...cur.f.조건 };
|
||||
delete rest[colName(c)];
|
||||
|
||||
panel = el("div", {
|
||||
className: "ss-filter__panel",
|
||||
attrs: { role: "dialog", "aria-label": `${t("Button")} ${colName(c)}` },
|
||||
children: [
|
||||
link(t("SortAsc"), sortBy(false)),
|
||||
link(t("SortDesc"), sortBy(true)),
|
||||
link(t("Clear"), () => apply({ ...cur.f, 조건: rest })),
|
||||
el("div", {
|
||||
className: "ss-filter__list",
|
||||
children: [
|
||||
el("label", { children: [all, t("All")] }),
|
||||
...items.map((it, i) =>
|
||||
el("label", { children: [boxes[i], it.글 === "" ? t("Blank") : it.글] }),
|
||||
),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ss-filter__foot",
|
||||
children: [createButton({ label: t("Cancel"), variant: "ghost", onClick: close }), ok],
|
||||
}),
|
||||
],
|
||||
});
|
||||
panel.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Escape") close();
|
||||
ev.stopPropagation();
|
||||
});
|
||||
const box = boxIn(cur.rg.r0, c);
|
||||
panel.style.left = `${box.x}px`;
|
||||
panel.style.top = `${box.y + box.h}px`;
|
||||
sync();
|
||||
layer.append(panel);
|
||||
document.addEventListener("mousedown", onOutside, true);
|
||||
}
|
||||
|
||||
const onScroll = () => place();
|
||||
ctx.grid.root.addEventListener("scroll", onScroll, true);
|
||||
place();
|
||||
|
||||
return {
|
||||
root: layer,
|
||||
refresh() {
|
||||
close();
|
||||
place();
|
||||
},
|
||||
open,
|
||||
destroy() {
|
||||
close();
|
||||
ctx.grid.root.removeEventListener("scroll", onScroll, true);
|
||||
layer.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_find.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 찾기 · 바꾸기 — 대소문자 구분 · 전체 칸 일치 · 「수식 안」(식 글 속 글자도 뒤짐) 옵션.
|
||||
* 값 칸은 `값`(수 · 참거짓은 글로 바꿔 견줌) · 식 칸은 기본으로 식 글(`=` 뺀 것)을 뒤짐(계산값은
|
||||
* 이 계층에서 모름 — CalcEngine 은 C·D 쪽에만 있음). 바꾸기는 `dispatch({종류:"칸", …})` 하나로만.
|
||||
*
|
||||
* 잇는 법(D · sub7) — 1단계 격자 · 편집기가 서면:
|
||||
* 1) 도구 모음이나 Ctrl+F 단축키로 작은 패널을 띄우고 `createFindPanel(ctx)` 를 그 안에 붙임.
|
||||
* 2) 패널의 「다음 찾기」 는 `findNext` 결과로 `ctx.select` + `ctx.grid.reveal` 호출.
|
||||
* 3) 「모두 바꾸기」 버튼은 `replaceAll` 을 부르고 끝나면 바뀐 수를 토스트로.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CellInput, SheetCellAddress, Workbook } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
export interface FindOptions {
|
||||
대소문자?: boolean;
|
||||
전체칸?: boolean;
|
||||
/** 식 칸은 식 글(수식) 속에서도 찾음 — 꺼지면 값 칸만 뒤짐(식 칸은 건너뜀) */
|
||||
수식안?: boolean;
|
||||
}
|
||||
|
||||
const norm = (text: string, opts: FindOptions | undefined): string =>
|
||||
opts?.대소문자 ? text : text.toLowerCase();
|
||||
|
||||
/** 칸 하나를 글로 — 식 칸은 `수식안` 이 있어야 식 글, 없으면 후보에서 빠짐(null). */
|
||||
function cellText(
|
||||
book: Workbook,
|
||||
sheetId: string,
|
||||
r: number,
|
||||
c: number,
|
||||
opts?: FindOptions,
|
||||
): string | null {
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
const cell = sheet?.칸[a1(r, c)];
|
||||
if (!cell) return null;
|
||||
if (cell.식 !== undefined) return opts?.수식안 ? cell.식 : null;
|
||||
const v = cell.값;
|
||||
if (v === undefined) return null;
|
||||
return typeof v === "boolean" ? (v ? "TRUE" : "FALSE") : String(v);
|
||||
}
|
||||
|
||||
function a1(r: number, c: number): string {
|
||||
let col = c + 1;
|
||||
let letters = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
letters = String.fromCharCode(65 + rem) + letters;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return `${letters}${r + 1}`;
|
||||
}
|
||||
|
||||
const matches = (haystack: string, query: string, opts: FindOptions | undefined): boolean => {
|
||||
const h = norm(haystack, opts);
|
||||
const q = norm(query, opts);
|
||||
return opts?.전체칸 ? h === q : h.includes(q);
|
||||
};
|
||||
|
||||
/** 시트 하나 안 찾는 순서 — 왼위부터 오른아래로(행 우선). */
|
||||
function* cellsInOrder(book: Workbook, sheetId: string): Generator<SheetCellAddress> {
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
if (!sheet) return;
|
||||
const addrs = Object.keys(sheet.칸)
|
||||
.map((key) => parseA1(key))
|
||||
.sort((x, y) => x.r - y.r || x.c - y.c);
|
||||
for (const { r, c } of addrs) yield { 시트: sheetId, r, c };
|
||||
}
|
||||
|
||||
function parseA1(key: string): { r: number; c: number } {
|
||||
const m = /^([A-Z]+)(\d+)$/.exec(key);
|
||||
if (!m) return { r: 0, c: 0 };
|
||||
let col = 0;
|
||||
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
return { r: Number(m[2]) - 1, c: col - 1 };
|
||||
}
|
||||
|
||||
/** 통합문서 전체(활성 시트 먼저 뒤지고 싶으면 `sheetOrder` 로 순서를 줌)에서 다 찾음. */
|
||||
export function findAll(
|
||||
book: Workbook,
|
||||
query: string,
|
||||
opts?: FindOptions,
|
||||
sheetOrder?: string[],
|
||||
): SheetCellAddress[] {
|
||||
if (!query) return [];
|
||||
const ids = sheetOrder ?? book.시트.map((s) => s.id);
|
||||
const out: SheetCellAddress[] = [];
|
||||
for (const id of ids) {
|
||||
for (const at of cellsInOrder(book, id)) {
|
||||
const text = cellText(book, at.시트, at.r, at.c, opts);
|
||||
if (text !== null && matches(text, query, opts)) out.push(at);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `from` 다음 칸부터 한 바퀴 돌아 처음 걸리는 것(순환) · 없으면 null. */
|
||||
export function findNext(
|
||||
book: Workbook,
|
||||
from: SheetCellAddress,
|
||||
query: string,
|
||||
opts?: FindOptions,
|
||||
): SheetCellAddress | null {
|
||||
const all = findAll(book, query, opts, [
|
||||
from.시트,
|
||||
...book.시트.map((s) => s.id).filter((id) => id !== from.시트),
|
||||
]);
|
||||
if (all.length === 0) return null;
|
||||
const idx = all.findIndex((m) => m.시트 === from.시트 && m.r === from.r && m.c === from.c);
|
||||
return all[(idx + 1) % all.length];
|
||||
}
|
||||
|
||||
/** 한 칸 바꾸기 — 식 칸(`수식안`)이면 식 글 속 글자를 바꿈 · 값 칸이면 값 전체(전체칸)나 부분(글만)을 바꿈. */
|
||||
export function replaceOne(
|
||||
ctx: SpreadsheetContext,
|
||||
at: SheetCellAddress,
|
||||
query: string,
|
||||
replacement: string,
|
||||
opts?: FindOptions,
|
||||
): void {
|
||||
const sheet = ctx.book.시트.find((s) => s.id === at.시트);
|
||||
const cell = sheet?.칸[a1(at.r, at.c)];
|
||||
if (!sheet || !cell) return;
|
||||
const next: { 값?: CellInput; 식?: string; 서식?: number } = { 서식: cell.서식 };
|
||||
if (cell.식 !== undefined && opts?.수식안) {
|
||||
next.식 = replaceText(cell.식, query, replacement, opts);
|
||||
} else if (cell.값 !== undefined) {
|
||||
const text = typeof cell.값 === "boolean" ? (cell.값 ? "TRUE" : "FALSE") : String(cell.값);
|
||||
next.값 = replaceText(text, query, replacement, opts);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
ctx.dispatch({ 종류: "칸", 시트: at.시트, 칸: { [a1(at.r, at.c)]: next } });
|
||||
}
|
||||
|
||||
function replaceText(
|
||||
text: string,
|
||||
query: string,
|
||||
replacement: string,
|
||||
opts: FindOptions | undefined,
|
||||
): string {
|
||||
if (opts?.전체칸) return matches(text, query, opts) ? replacement : text;
|
||||
if (opts?.대소문자) return text.split(query).join(replacement);
|
||||
const re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
||||
return text.replace(re, replacement);
|
||||
}
|
||||
|
||||
/** 모두 바꾸기 — 한 번의 되돌리기로 묶어 적용 · 바뀐 칸 수를 돌려줌. */
|
||||
export function replaceAll(
|
||||
ctx: SpreadsheetContext,
|
||||
query: string,
|
||||
replacement: string,
|
||||
opts?: FindOptions,
|
||||
): number {
|
||||
const hits = findAll(ctx.book, query, opts);
|
||||
if (hits.length === 0) return 0;
|
||||
const bySheet = new Map<string, SheetCellAddress[]>();
|
||||
for (const at of hits) bySheet.set(at.시트, [...(bySheet.get(at.시트) ?? []), at]);
|
||||
const commands = [...bySheet.entries()].map(([sheetId, ats]) => {
|
||||
const sheet = ctx.book.시트.find((s) => s.id === sheetId)!;
|
||||
const 칸: Record<string, { 값?: CellInput; 식?: string; 서식?: number }> = {};
|
||||
for (const at of ats) {
|
||||
const cell = sheet.칸[a1(at.r, at.c)];
|
||||
if (!cell) continue;
|
||||
if (cell.식 !== undefined && opts?.수식안) {
|
||||
칸[a1(at.r, at.c)] = {
|
||||
식: replaceText(cell.식, query, replacement, opts),
|
||||
서식: cell.서식,
|
||||
};
|
||||
} else if (cell.값 !== undefined) {
|
||||
const text = typeof cell.값 === "boolean" ? (cell.값 ? "TRUE" : "FALSE") : String(cell.값);
|
||||
칸[a1(at.r, at.c)] = { 값: replaceText(text, query, replacement, opts), 서식: cell.서식 };
|
||||
}
|
||||
}
|
||||
return { 종류: "칸" as const, 시트: sheetId, 칸 };
|
||||
});
|
||||
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
|
||||
return hits.length;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_find_panel.ts (주인 D)
|
||||
* 찾기(Ctrl+F) · 바꾸기(Ctrl+H) 작은 패널 — 부품 오른위에 뜸. 찾기 규칙은 `spreadsheet_find.ts`(sub1).
|
||||
* Enter = 다음 · Esc = 닫고 격자로. 바꾸기 = 활성 칸이 맞으면 바꾸고 다음으로(엑셀).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { findAll, findNext, replaceAll, replaceOne, type FindOptions } from "./spreadsheet_find";
|
||||
import { selectCell } from "./spreadsheet_selection";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const TEXT = {
|
||||
find: "찾을 내용",
|
||||
replace: "바꿀 내용",
|
||||
next: "다음",
|
||||
one: "바꾸기",
|
||||
all: "모두 바꾸기",
|
||||
close: "닫기",
|
||||
case: "대소문자",
|
||||
whole: "전체 칸",
|
||||
formula: "수식 안",
|
||||
none: "찾을 수 없음",
|
||||
replaced: "{n} 개 바꿈",
|
||||
};
|
||||
|
||||
export interface FindPanelHandle extends PartHandle {
|
||||
open(replace: boolean): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export function mountFindPanel(ctx: SpreadsheetContext, back: () => void): FindPanelHandle {
|
||||
const query = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.find } });
|
||||
const repl = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.replace } });
|
||||
const status = el("span", { className: "ss-find__status" });
|
||||
const button = (text: string, onClick: () => void): HTMLButtonElement => {
|
||||
const b = el("button", { className: "ss-find__btn", text, attrs: { type: "button" } });
|
||||
b.addEventListener("click", onClick);
|
||||
return b;
|
||||
};
|
||||
const check = (text: string): HTMLInputElement => {
|
||||
const box = el("input", { attrs: { type: "checkbox" } });
|
||||
options.append(el("label", { className: "ss-find__opt", children: [box, text] }));
|
||||
return box;
|
||||
};
|
||||
const closeBtn = button("×", close);
|
||||
closeBtn.title = TEXT.close;
|
||||
const options = el("div", { className: "ss-find__row" });
|
||||
const caseBox = check(TEXT.case);
|
||||
const wholeBox = check(TEXT.whole);
|
||||
const formulaBox = check(TEXT.formula);
|
||||
const replaceRow = el("div", {
|
||||
className: "ss-find__row",
|
||||
children: [repl, button(TEXT.one, one), button(TEXT.all, all)],
|
||||
});
|
||||
const root = el("div", {
|
||||
className: "ss-find",
|
||||
attrs: { hidden: "" },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "ss-find__row",
|
||||
children: [query, button(TEXT.next, next), closeBtn],
|
||||
}),
|
||||
replaceRow,
|
||||
options,
|
||||
status,
|
||||
],
|
||||
});
|
||||
ctx.root.append(root);
|
||||
|
||||
const opts = (): FindOptions => ({
|
||||
대소문자: caseBox.checked,
|
||||
전체칸: wholeBox.checked,
|
||||
수식안: formulaBox.checked,
|
||||
});
|
||||
const here = () => ({ 시트: ctx.selection.시트, ...ctx.selection.활성 });
|
||||
|
||||
function next(): void {
|
||||
status.textContent = "";
|
||||
if (!query.value) return;
|
||||
const hit = findNext(ctx.book, here(), query.value, opts());
|
||||
if (!hit) return void (status.textContent = TEXT.none);
|
||||
if (hit.시트 !== ctx.selection.시트) ctx.showSheet(hit.시트);
|
||||
ctx.select(selectCell(ctx.sheet(), hit.r, hit.c), true);
|
||||
}
|
||||
|
||||
function one(): void {
|
||||
if (ctx.readOnly || !query.value) return;
|
||||
const at = here();
|
||||
const onIt = findAll(ctx.book, query.value, opts(), [at.시트]).some(
|
||||
(h) => h.r === at.r && h.c === at.c,
|
||||
);
|
||||
if (onIt) replaceOne(ctx, at, query.value, repl.value, opts());
|
||||
next();
|
||||
}
|
||||
|
||||
function all(): void {
|
||||
if (ctx.readOnly || !query.value) return;
|
||||
const n = replaceAll(ctx, query.value, repl.value, opts());
|
||||
status.textContent = n ? TEXT.replaced.replace("{n}", String(n)) : TEXT.none;
|
||||
}
|
||||
|
||||
function open(replace: boolean): void {
|
||||
replaceRow.hidden = !replace || ctx.readOnly;
|
||||
root.removeAttribute("hidden");
|
||||
status.textContent = "";
|
||||
query.focus();
|
||||
query.select();
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
root.setAttribute("hidden", "");
|
||||
back();
|
||||
}
|
||||
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Escape") close();
|
||||
else if (e.key === "Enter") e.target === repl ? one() : next();
|
||||
else return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
root.addEventListener("keydown", onKey);
|
||||
|
||||
return {
|
||||
root,
|
||||
open,
|
||||
close,
|
||||
refresh() {},
|
||||
destroy: () => root.remove(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_format_code.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 셀 서식 대화상자(표시 형식 탭)의 범주 → 형식 코드 짓기 — DOM · CSS 없음(시험이 바로 부름).
|
||||
* 실무 자주 쓰는 모양만(일반 · 숫자 · 통화 · 회계 · 백분율 · 텍스트 · 사용자 지정).
|
||||
* ========================================================================== */
|
||||
|
||||
export const CATEGORIES = [
|
||||
"일반",
|
||||
"숫자",
|
||||
"통화",
|
||||
"회계",
|
||||
"백분율",
|
||||
"텍스트",
|
||||
"사용자 지정",
|
||||
] as const;
|
||||
export type Category = (typeof CATEGORIES)[number];
|
||||
export type NegStyle = "minus" | "paren" | "red-minus" | "red-paren";
|
||||
|
||||
/** 범주 · 소수 자릿수 · 천단위 · 음수 표시로 형식 코드를 지음. */
|
||||
export function buildCode(
|
||||
cat: Category,
|
||||
decimals: number,
|
||||
thousands: boolean,
|
||||
neg: NegStyle,
|
||||
): string {
|
||||
if (cat === "일반") return "";
|
||||
if (cat === "텍스트") return "@";
|
||||
const digits = decimals > 0 ? `0.${"0".repeat(decimals)}` : "0";
|
||||
const grouped = thousands ? `#,##${digits}` : digits;
|
||||
if (cat === "백분율") return `${grouped}%`;
|
||||
const prefix = cat === "통화" ? '"₩"' : "";
|
||||
const positive = `${prefix}${grouped}`;
|
||||
if (cat === "회계") {
|
||||
const acc = decimals > 0 ? "0.00" : "0";
|
||||
return `_-* #,##${acc}_-;-* #,##${acc}_-;_-* "-"_-;_-@_-`;
|
||||
}
|
||||
switch (neg) {
|
||||
case "minus":
|
||||
return positive;
|
||||
case "paren":
|
||||
return `${positive};(${positive})`;
|
||||
case "red-minus":
|
||||
return `${positive};[Red]-${positive}`;
|
||||
case "red-paren":
|
||||
return `${positive};[Red](${positive})`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 있는 형식 코드를 보고 범주를 어림(대화상자를 열 때 초기 범주 고르기). */
|
||||
export function guessCategory(code: string | undefined): Category {
|
||||
if (!code) return "일반";
|
||||
if (code === "@") return "텍스트";
|
||||
if (code.includes("%")) return "백분율";
|
||||
if (code.includes("_-*")) return "회계";
|
||||
if (code.includes("₩")) return "통화";
|
||||
if (/[0#]/.test(code)) return "숫자";
|
||||
return "사용자 지정";
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_format_dialog.css (2단계 · 주인 sub_laptop_1)
|
||||
* 셀 서식 대화상자 — 뿌리 배경(`.ui-confirm`)은 공용 확인 모달과 같은 것을 그대로 씀.
|
||||
* 색 · 간격은 테마 토큰만(`ui_template_theme.css`).
|
||||
* ========================================================================== */
|
||||
|
||||
.ss-fmtdlg__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(560px, 92vw);
|
||||
max-height: 84vh;
|
||||
padding: var(--spacing-16);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss-fmtdlg__title {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.ss-fmtdlg__tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ss-fmtdlg__tabbtn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-bottom: 0;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
background: var(--color-paper);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-fmtdlg__tabbtn.is-active {
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ss-fmtdlg__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-12) 2px;
|
||||
}
|
||||
|
||||
.ss-fmtdlg__tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ss-fmtdlg__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ss-fmtdlg__label {
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ss-fmtdlg__row select,
|
||||
.ss-fmtdlg__row input[type="text"],
|
||||
.ss-fmtdlg__row input:not([type]),
|
||||
.ss-fmtdlg__row input[type="number"],
|
||||
.ss-fmtdlg__code {
|
||||
height: 28px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ss-fmtdlg__code {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ss-fmtdlg__subrows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding-left: var(--spacing-16);
|
||||
}
|
||||
|
||||
.ss-fmtdlg__preview-text {
|
||||
padding: 4px 8px;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.ss-fmtdlg__font-sample {
|
||||
margin-top: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
.ss-fmtdlg__border-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.ss-fmtdlg__border-preview {
|
||||
width: 96px;
|
||||
height: 64px;
|
||||
margin-top: var(--spacing-8);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
.ss-fmtdlg__fill-swatch {
|
||||
width: 96px;
|
||||
height: 40px;
|
||||
margin-top: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.ss-fmtdlg__note {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.ss-fmtdlg__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
padding-top: var(--spacing-12);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_format_dialog.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 셀 서식 대화상자(Ctrl+1) — 엑셀과 같은 탭 여섯: 표시 형식 · 맞춤 · 글꼴 · 테두리 · 채우기 · 보호.
|
||||
* [확인] 을 누를 때까지 아무 것도 안 바꿈(도구 모음처럼 칸마다 바로 안 바꿈) — 눌러야
|
||||
* 「서식」 명령 하나(+ 병합이 바뀌었으면 「병합」·「병합풀기」)로 한 번에 나감.
|
||||
*
|
||||
* 들여쓰기 · 보호(잠금·숨김)는 화면 자리만 — 0 계약 `CellStyle`·`Command` 에 그 칸이 없어
|
||||
* 실제로는 안 바뀜(보호는 브레인 지시대로 「자리만」).
|
||||
*
|
||||
* 잇는 법(D · sub7) — Ctrl+1(`spreadsheet_shortcuts.ts` 의 `openFormatDialog`) 을
|
||||
* `spreadsheet_keys.ts` navKey 에 새 case 로 더해 `openFormatDialog(ctx)` 를 부름.
|
||||
* 우클릭 메뉴 [셀 서식] 도 같은 함수를 부르면 됨(`spreadsheet_menu.ts`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { toFrac } from "@ui/sheet/ui_template_sheet_frac";
|
||||
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import {
|
||||
buildCode,
|
||||
CATEGORIES,
|
||||
guessCategory,
|
||||
type Category,
|
||||
type NegStyle,
|
||||
} from "./spreadsheet_format_code";
|
||||
import { isAutoInk } from "./spreadsheet_cellstyle";
|
||||
import { formatValue } from "./spreadsheet_numfmt";
|
||||
import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import "./spreadsheet_format_dialog.css";
|
||||
|
||||
const TEXT = {
|
||||
title: "셀 서식",
|
||||
tabNumber: "표시 형식",
|
||||
tabAlign: "맞춤",
|
||||
tabFont: "글꼴",
|
||||
tabBorder: "테두리",
|
||||
tabFill: "채우기",
|
||||
tabProtect: "보호",
|
||||
ok: "확인",
|
||||
cancel: "취소",
|
||||
category: "범주",
|
||||
decimals: "소수 자릿수",
|
||||
thousands: "천 단위 구분 기호 사용",
|
||||
negative: "음수 표시 형식",
|
||||
customCode: "사용자 지정 형식 코드",
|
||||
preview: "보기",
|
||||
hAlign: "가로",
|
||||
vAlign: "세로",
|
||||
wrap: "줄 바꿈",
|
||||
merge: "셀 병합",
|
||||
indent: "들여쓰기",
|
||||
fontName: "글꼴",
|
||||
fontSize: "크기",
|
||||
bold: "굵게",
|
||||
italic: "기울임",
|
||||
underline: "밑줄",
|
||||
strike: "취소선",
|
||||
fontColor: "글자색",
|
||||
lineStyle: "선 모양",
|
||||
lineColor: "선 색",
|
||||
none: "없음",
|
||||
all: "모두",
|
||||
top: "위",
|
||||
bottom: "아래",
|
||||
left: "왼",
|
||||
right: "오른",
|
||||
fillColor: "채움색",
|
||||
clear: "지움",
|
||||
protectNote: "칸 잠금 · 숨김은 자리만 둠(시트 보호 자체가 아직 없음)",
|
||||
lock: "잠금",
|
||||
hidden: "수식 숨김",
|
||||
};
|
||||
|
||||
/** 활성 칸의 실제 서식 — 칸 → 행 → 열 → 시트 기본 → 0(계약 주석과 같은 차례). */
|
||||
function styleAt(ctx: SpreadsheetContext, r: number, c: number): CellStyle {
|
||||
const sheet = ctx.sheet();
|
||||
const cell = sheet.칸[toA1(r, c)];
|
||||
const idx = cell?.서식 ?? sheet.행?.[String(r + 1)]?.서식 ?? sheet.열?.[colName(c)]?.서식 ?? 0;
|
||||
return ctx.book.서식[idx] ?? {};
|
||||
}
|
||||
|
||||
function row(label: string, ...controls: HTMLElement[]): HTMLElement {
|
||||
return el("div", {
|
||||
className: "ss-fmtdlg__row",
|
||||
children: [el("span", { className: "ss-fmtdlg__label", text: label }), ...controls],
|
||||
});
|
||||
}
|
||||
|
||||
function borderCss(side?: BorderSide): string {
|
||||
if (!side) return "none";
|
||||
const color = isAutoInk(side.색) ? "var(--color-text-body)" : (side.색 as string);
|
||||
const width = side.선 === "medium" ? 2 : side.선 === "thick" || side.선 === "double" ? 3 : 1;
|
||||
const style = side.선 === "hair" ? "dotted" : side.선 === "double" ? "double" : side.선;
|
||||
return `${width}px ${style} ${color}`;
|
||||
}
|
||||
|
||||
export function openFormatDialog(ctx: SpreadsheetContext): void {
|
||||
if (ctx.readOnly) return;
|
||||
const range = ctx.selection.범위[0];
|
||||
if (!range) return;
|
||||
const at = ctx.selection.활성;
|
||||
const base = styleAt(ctx, at.r, at.c);
|
||||
const draft: CellStyle = structuredClone(base);
|
||||
const spansMany = range.r0 !== range.r1 || range.c0 !== range.c1;
|
||||
let mergeWanted = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
|
||||
|
||||
const overlay = el("div", { className: "ui-confirm ss-fmtdlg" });
|
||||
const panel = el("div", { className: "ss-fmtdlg__panel" });
|
||||
const tabsBar = el("div", { className: "ss-fmtdlg__tabs" });
|
||||
const body = el("div", { className: "ss-fmtdlg__body" });
|
||||
overlay.append(panel);
|
||||
panel.append(el("h2", { className: "ss-fmtdlg__title", text: TEXT.title }), tabsBar, body);
|
||||
|
||||
const close = (): void => overlay.remove();
|
||||
overlay.addEventListener("click", (ev) => {
|
||||
if (ev.target === overlay) close();
|
||||
});
|
||||
|
||||
// ── 표시 형식 ────────────────────────────────────────────────────────
|
||||
let category: Category = guessCategory(draft.형식);
|
||||
let decimals = (draft.형식?.match(/\.0+/)?.[0].length ?? 1) - 1;
|
||||
let thousands = draft.형식?.includes(",") ?? false;
|
||||
let negStyle: NegStyle = draft.형식?.includes("[Red]")
|
||||
? draft.형식.includes("(")
|
||||
? "red-paren"
|
||||
: "red-minus"
|
||||
: draft.형식?.includes(";(")
|
||||
? "paren"
|
||||
: "minus";
|
||||
let customCode = draft.형식 ?? "";
|
||||
let useCustom = category === "사용자 지정";
|
||||
|
||||
function numberTab(): HTMLElement {
|
||||
const catSelect = el("select", {
|
||||
children: CATEGORIES.map((c) => el("option", { attrs: { value: c }, text: c })),
|
||||
}) as HTMLSelectElement;
|
||||
catSelect.value = category;
|
||||
|
||||
const decimalsInput = el("input", {
|
||||
attrs: { type: "number", min: "0", max: "10" },
|
||||
}) as HTMLInputElement;
|
||||
decimalsInput.value = String(Math.max(0, decimals));
|
||||
|
||||
const thousandsCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
|
||||
thousandsCheck.checked = thousands;
|
||||
|
||||
const negSelect = el("select", {
|
||||
children: [
|
||||
el("option", { attrs: { value: "minus" }, text: "-1,234.00" }),
|
||||
el("option", { attrs: { value: "red-minus" }, text: "-1,234.00(빨강)" }),
|
||||
el("option", { attrs: { value: "paren" }, text: "(1,234.00)" }),
|
||||
el("option", { attrs: { value: "red-paren" }, text: "(1,234.00)(빨강)" }),
|
||||
],
|
||||
}) as HTMLSelectElement;
|
||||
negSelect.value = negStyle;
|
||||
|
||||
const customInput = el("input", { className: "ss-fmtdlg__code" }) as HTMLInputElement;
|
||||
customInput.value = customCode;
|
||||
|
||||
const previewText = el("span", { className: "ss-fmtdlg__preview-text" });
|
||||
|
||||
const numberRows = el("div", { className: "ss-fmtdlg__subrows" });
|
||||
const customRow = row(TEXT.customCode, customInput);
|
||||
|
||||
function syncControls(): void {
|
||||
const showsNumberControls =
|
||||
category === "숫자" || category === "통화" || category === "백분율";
|
||||
numberRows.hidden = !showsNumberControls;
|
||||
customRow.hidden = category !== "사용자 지정";
|
||||
decimalsInput.disabled = category === "회계" ? false : !showsNumberControls;
|
||||
}
|
||||
|
||||
function refreshPreview(): void {
|
||||
const code = useCustom ? customCode : buildCode(category, decimals, thousands, negStyle);
|
||||
draft.형식 = code || undefined;
|
||||
const cellValue = ctx.engine.value(ctx.selection.시트, at.r, at.c);
|
||||
const sample = cellValue ?? toFrac(-1234.5);
|
||||
previewText.textContent = formatValue(sample, code).글;
|
||||
}
|
||||
|
||||
catSelect.addEventListener("change", () => {
|
||||
category = catSelect.value as Category;
|
||||
useCustom = category === "사용자 지정";
|
||||
syncControls();
|
||||
refreshPreview();
|
||||
});
|
||||
decimalsInput.addEventListener("input", () => {
|
||||
decimals = Math.max(0, Math.min(10, Number(decimalsInput.value) || 0));
|
||||
refreshPreview();
|
||||
});
|
||||
thousandsCheck.addEventListener("change", () => {
|
||||
thousands = thousandsCheck.checked;
|
||||
refreshPreview();
|
||||
});
|
||||
negSelect.addEventListener("change", () => {
|
||||
negStyle = negSelect.value as NegStyle;
|
||||
refreshPreview();
|
||||
});
|
||||
customInput.addEventListener("input", () => {
|
||||
customCode = customInput.value;
|
||||
refreshPreview();
|
||||
});
|
||||
|
||||
numberRows.append(
|
||||
row(TEXT.decimals, decimalsInput),
|
||||
row(TEXT.thousands, thousandsCheck),
|
||||
row(TEXT.negative, negSelect),
|
||||
);
|
||||
syncControls();
|
||||
refreshPreview();
|
||||
|
||||
return el("div", {
|
||||
className: "ss-fmtdlg__tab",
|
||||
children: [
|
||||
row(TEXT.category, catSelect),
|
||||
numberRows,
|
||||
customRow,
|
||||
row(TEXT.preview, previewText),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ── 맞춤 ────────────────────────────────────────────────────────────
|
||||
function alignTab(): HTMLElement {
|
||||
const hSelect = el("select", {
|
||||
children: [
|
||||
["general", "일반"],
|
||||
["left", "왼쪽"],
|
||||
["center", "가운데"],
|
||||
["right", "오른쪽"],
|
||||
["distributed", "균등 분할"],
|
||||
["centerContinuous", "선택 영역 가운데"],
|
||||
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
|
||||
}) as HTMLSelectElement;
|
||||
hSelect.value = draft.가로 ?? "general";
|
||||
hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign));
|
||||
|
||||
const vSelect = el("select", {
|
||||
children: [
|
||||
["top", "위"],
|
||||
["center", "가운데"],
|
||||
["bottom", "아래"],
|
||||
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
|
||||
}) as HTMLSelectElement;
|
||||
vSelect.value = draft.세로 ?? "bottom";
|
||||
vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign));
|
||||
|
||||
const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
|
||||
wrapCheck.checked = !!draft.줄바꿈;
|
||||
wrapCheck.addEventListener("change", () => (draft.줄바꿈 = wrapCheck.checked));
|
||||
|
||||
const mergeCheck = el("input", {
|
||||
attrs: { type: "checkbox", ...(spansMany ? {} : { disabled: "true" }) },
|
||||
}) as HTMLInputElement;
|
||||
mergeCheck.checked = mergeWanted;
|
||||
mergeCheck.addEventListener("change", () => (mergeWanted = mergeCheck.checked));
|
||||
|
||||
const indentInput = el("input", {
|
||||
attrs: { type: "number", min: "0", max: "15", disabled: "true", title: "0 계약 밖 — 자리만" },
|
||||
}) as HTMLInputElement;
|
||||
indentInput.value = "0";
|
||||
|
||||
return el("div", {
|
||||
className: "ss-fmtdlg__tab",
|
||||
children: [
|
||||
row(TEXT.hAlign, hSelect),
|
||||
row(TEXT.vAlign, vSelect),
|
||||
row(TEXT.wrap, wrapCheck),
|
||||
row(TEXT.merge, mergeCheck),
|
||||
row(TEXT.indent, indentInput),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ── 글꼴 ────────────────────────────────────────────────────────────
|
||||
function fontTab(): HTMLElement {
|
||||
const nameInput = el("input") as HTMLInputElement;
|
||||
nameInput.value = draft.글꼴 ?? "";
|
||||
const sizeInput = el("input", {
|
||||
attrs: { type: "number", min: "1", max: "409", step: "0.5" },
|
||||
}) as HTMLInputElement;
|
||||
sizeInput.value = draft.크기 != null ? String(draft.크기) : "";
|
||||
const boldCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
|
||||
boldCheck.checked = !!draft.굵게;
|
||||
const italicCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
|
||||
italicCheck.checked = !!draft.기울임;
|
||||
const underlineCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
|
||||
underlineCheck.checked = !!draft.밑줄;
|
||||
const strikeCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
|
||||
strikeCheck.checked = !!draft.취소선;
|
||||
const colorInput = el("input", { attrs: { type: "color" } }) as HTMLInputElement;
|
||||
colorInput.value = draft.글자색 ?? "#000000";
|
||||
|
||||
const sample = el("div", { className: "ss-fmtdlg__font-sample", text: "가나다 ABC 123" });
|
||||
function refreshSample(): void {
|
||||
sample.style.fontFamily = nameInput.value || "";
|
||||
sample.style.fontSize = sizeInput.value ? `${sizeInput.value}pt` : "";
|
||||
sample.style.fontWeight = boldCheck.checked ? "700" : "400";
|
||||
sample.style.fontStyle = italicCheck.checked ? "italic" : "normal";
|
||||
sample.style.textDecorationLine = [
|
||||
underlineCheck.checked && "underline",
|
||||
strikeCheck.checked && "line-through",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
sample.style.color = isAutoInk(colorInput.value) ? "" : colorInput.value;
|
||||
}
|
||||
nameInput.addEventListener("input", () => {
|
||||
draft.글꼴 = nameInput.value || undefined;
|
||||
refreshSample();
|
||||
});
|
||||
sizeInput.addEventListener("input", () => {
|
||||
const n = Number(sizeInput.value);
|
||||
draft.크기 = sizeInput.value && n > 0 ? n : undefined;
|
||||
refreshSample();
|
||||
});
|
||||
boldCheck.addEventListener("change", () => {
|
||||
draft.굵게 = boldCheck.checked || undefined;
|
||||
refreshSample();
|
||||
});
|
||||
italicCheck.addEventListener("change", () => {
|
||||
draft.기울임 = italicCheck.checked || undefined;
|
||||
refreshSample();
|
||||
});
|
||||
underlineCheck.addEventListener("change", () => {
|
||||
draft.밑줄 = underlineCheck.checked || undefined;
|
||||
refreshSample();
|
||||
});
|
||||
strikeCheck.addEventListener("change", () => {
|
||||
draft.취소선 = strikeCheck.checked || undefined;
|
||||
refreshSample();
|
||||
});
|
||||
colorInput.addEventListener("input", () => {
|
||||
draft.글자색 = colorInput.value;
|
||||
refreshSample();
|
||||
});
|
||||
refreshSample();
|
||||
|
||||
return el("div", {
|
||||
className: "ss-fmtdlg__tab",
|
||||
children: [
|
||||
row(TEXT.fontName, nameInput),
|
||||
row(TEXT.fontSize, sizeInput),
|
||||
row(TEXT.bold, boldCheck),
|
||||
row(TEXT.italic, italicCheck),
|
||||
row(TEXT.underline, underlineCheck),
|
||||
row(TEXT.strike, strikeCheck),
|
||||
row(TEXT.fontColor, colorInput),
|
||||
sample,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ── 테두리 ────────────────────────────────────────────────────────────
|
||||
function borderTab(): HTMLElement {
|
||||
const lineSelect = el("select", {
|
||||
children: (
|
||||
["hair", "thin", "medium", "thick", "double", "dotted", "dashed"] as BorderLine[]
|
||||
).map((v) => el("option", { attrs: { value: v }, text: v })),
|
||||
}) as HTMLSelectElement;
|
||||
const colorInput = el("input", { attrs: { type: "color" } }) as HTMLInputElement;
|
||||
colorInput.value = "#000000";
|
||||
|
||||
const preview = el("div", { className: "ss-fmtdlg__border-preview" });
|
||||
function refreshPreview(): void {
|
||||
const t = draft.테두리 ?? {};
|
||||
preview.style.borderTop = borderCss(t.위);
|
||||
preview.style.borderBottom = borderCss(t.아래);
|
||||
preview.style.borderLeft = borderCss(t.왼);
|
||||
preview.style.borderRight = borderCss(t.오른);
|
||||
}
|
||||
refreshPreview();
|
||||
|
||||
const set = (sides: ("위" | "아래" | "왼" | "오른")[] | null): void => {
|
||||
if (sides === null) {
|
||||
draft.테두리 = undefined;
|
||||
} else {
|
||||
const cur = { ...(draft.테두리 ?? {}) };
|
||||
const side: BorderSide = { 선: lineSelect.value as BorderLine, 색: colorInput.value };
|
||||
for (const s of sides) cur[s] = side;
|
||||
draft.테두리 = cur;
|
||||
}
|
||||
refreshPreview();
|
||||
};
|
||||
|
||||
// 「바깥(outline)」은 범위 가장자리 칸만 따로 골라야 해서(토대는 toolbar.ts cellsFor) 여기선 뺌 —
|
||||
// 여러 칸을 한 서식 값으로 통째로 바꾸는 이 대화상자는 위·아래·왼·오른·모두만 (모두 = 칸마다 네 변).
|
||||
const presets: [string, ("위" | "아래" | "왼" | "오른")[] | null][] = [
|
||||
[TEXT.none, null],
|
||||
[TEXT.top, ["위"]],
|
||||
[TEXT.bottom, ["아래"]],
|
||||
[TEXT.left, ["왼"]],
|
||||
[TEXT.right, ["오른"]],
|
||||
[TEXT.all, ["위", "아래", "왼", "오른"]],
|
||||
];
|
||||
const presetRow = el("div", {
|
||||
className: "ss-fmtdlg__border-presets",
|
||||
children: presets.map(([label, sides]) =>
|
||||
createButton({ label, variant: "ghost", onClick: () => set(sides) }),
|
||||
),
|
||||
});
|
||||
|
||||
return el("div", {
|
||||
className: "ss-fmtdlg__tab",
|
||||
children: [
|
||||
row(TEXT.lineStyle, lineSelect),
|
||||
row(TEXT.lineColor, colorInput),
|
||||
presetRow,
|
||||
preview,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ── 채우기 ────────────────────────────────────────────────────────────
|
||||
function fillTab(): HTMLElement {
|
||||
const colorInput = el("input", { attrs: { type: "color" } }) as HTMLInputElement;
|
||||
colorInput.value = draft.채움 ?? "#ffffff";
|
||||
const swatch = el("div", { className: "ss-fmtdlg__fill-swatch" });
|
||||
function refresh(): void {
|
||||
swatch.style.background = draft.채움 ?? "transparent";
|
||||
}
|
||||
colorInput.addEventListener("input", () => {
|
||||
draft.채움 = colorInput.value;
|
||||
refresh();
|
||||
});
|
||||
const clearBtn = createButton({
|
||||
label: TEXT.clear,
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
draft.채움 = undefined;
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
refresh();
|
||||
return el("div", {
|
||||
className: "ss-fmtdlg__tab",
|
||||
children: [row(TEXT.fillColor, colorInput, clearBtn), swatch],
|
||||
});
|
||||
}
|
||||
|
||||
// ── 보호(자리만) ────────────────────────────────────────────────────
|
||||
function protectTab(): HTMLElement {
|
||||
const lockCheck = el("input", {
|
||||
attrs: { type: "checkbox", checked: "true", disabled: "true" },
|
||||
});
|
||||
const hiddenCheck = el("input", { attrs: { type: "checkbox", disabled: "true" } });
|
||||
return el("div", {
|
||||
className: "ss-fmtdlg__tab",
|
||||
children: [
|
||||
el("p", { className: "ss-fmtdlg__note", text: TEXT.protectNote }),
|
||||
row(TEXT.lock, lockCheck),
|
||||
row(TEXT.hidden, hiddenCheck),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const tabs: [string, () => HTMLElement][] = [
|
||||
[TEXT.tabNumber, numberTab],
|
||||
[TEXT.tabAlign, alignTab],
|
||||
[TEXT.tabFont, fontTab],
|
||||
[TEXT.tabBorder, borderTab],
|
||||
[TEXT.tabFill, fillTab],
|
||||
[TEXT.tabProtect, protectTab],
|
||||
];
|
||||
|
||||
function showTab(index: number): void {
|
||||
body.replaceChildren(tabs[index][1]());
|
||||
tabsBar.querySelectorAll(".ss-fmtdlg__tabbtn").forEach((btn, i) => {
|
||||
btn.classList.toggle("is-active", i === index);
|
||||
});
|
||||
}
|
||||
|
||||
tabs.forEach(([label], i) => {
|
||||
const btn = el("button", {
|
||||
className: "ss-fmtdlg__tabbtn",
|
||||
text: label,
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
btn.addEventListener("click", () => showTab(i));
|
||||
tabsBar.append(btn);
|
||||
});
|
||||
|
||||
const actions = el("div", {
|
||||
className: "ss-fmtdlg__actions",
|
||||
children: [
|
||||
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
|
||||
createButton({
|
||||
label: TEXT.ok,
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
ctx.dispatch({
|
||||
종류: "서식",
|
||||
시트: ctx.selection.시트,
|
||||
범위: ctx.selection.범위,
|
||||
바꿀: draft,
|
||||
});
|
||||
const isMerged = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
|
||||
if (mergeWanted !== isMerged) {
|
||||
ctx.dispatch({
|
||||
종류: mergeWanted ? "병합" : "병합풀기",
|
||||
시트: ctx.selection.시트,
|
||||
범위: range,
|
||||
});
|
||||
}
|
||||
close();
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
panel.append(actions);
|
||||
|
||||
showTab(0);
|
||||
document.body.append(overlay);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_format_painter.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 서식 붓 — 활성 칸의 서식을 묻혀 다른 범위에 바름. 한 번 누르면 한 번만 · 두 번 누르면(sticky)
|
||||
* 끌 때까지 계속. 서식 찾기는 칸 → 행 → 열 → 기본(0) 차례(`spreadsheet_types.ts` `Cell.서식` 규칙).
|
||||
*
|
||||
* 잇는 법(D · sub7 · E 도구 모음) — 도구 모음에 버튼을 두고 `createFormatPainter(ctx)` 하나를 붙임.
|
||||
* 한 번 누르면 `pick()` → 커서를 붓 모양으로 → 범위를 고르면(mouseup) `paint(range)`.
|
||||
* 두 번 누르면 `pick()` 뒤 `sticky=true` 로 두고 Esc 나 버튼을 다시 누르면 꺼짐(`active()` 로 표시).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CellRange, CellStyle, Workbook } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const EMPTY_STYLE: CellStyle = {};
|
||||
|
||||
/** 칸 하나의 실제 서식 — 칸 자기 서식 없으면 행 서식 · 없으면 열 서식 · 없으면 기본(0). */
|
||||
export function resolveCellStyle(book: Workbook, sheetId: string, r: number, c: number): CellStyle {
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
if (!sheet) return EMPTY_STYLE;
|
||||
const idx = cellStyleIndex(sheet, r, c);
|
||||
return book.서식[idx] ?? EMPTY_STYLE;
|
||||
}
|
||||
|
||||
function cellStyleIndex(
|
||||
sheet: {
|
||||
칸: Record<string, { 서식?: number }>;
|
||||
열?: Record<string, { 서식?: number }>;
|
||||
행?: Record<string, { 서식?: number }>;
|
||||
},
|
||||
r: number,
|
||||
c: number,
|
||||
): number {
|
||||
const key = a1(r, c);
|
||||
const cellIdx = sheet.칸[key]?.서식;
|
||||
if (cellIdx !== undefined) return cellIdx;
|
||||
const rowIdx = sheet.행?.[String(r + 1)]?.서식;
|
||||
if (rowIdx !== undefined) return rowIdx;
|
||||
const colIdx = sheet.열?.[colLetters(c)]?.서식;
|
||||
if (colIdx !== undefined) return colIdx;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function colLetters(c: number): string {
|
||||
let col = c + 1;
|
||||
let letters = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
letters = String.fromCharCode(65 + rem) + letters;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return letters;
|
||||
}
|
||||
|
||||
const a1 = (r: number, c: number): string => `${colLetters(c)}${r + 1}`;
|
||||
|
||||
export interface FormatPainterHandle extends PartHandle {
|
||||
/** 지금 활성 칸의 서식을 붓에 묻힘 */
|
||||
pick(): void;
|
||||
/** 묻힌 서식이 있으면 범위에 바름 · `sticky` 가 아니면 한 번 쓰고 스스로 끔 */
|
||||
paint(range: CellRange, sticky?: boolean): void;
|
||||
active(): boolean;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export function createFormatPainter(ctx: SpreadsheetContext): FormatPainterHandle {
|
||||
let picked: CellStyle | null = null;
|
||||
let sticky = false;
|
||||
|
||||
return {
|
||||
root: null,
|
||||
pick(): void {
|
||||
const at = ctx.selection.활성;
|
||||
picked = resolveCellStyle(ctx.book, ctx.selection.시트, at.r, at.c);
|
||||
},
|
||||
paint(range: CellRange, keepOn = false): void {
|
||||
if (!picked) return;
|
||||
sticky = keepOn;
|
||||
ctx.dispatch({ 종류: "서식", 시트: ctx.selection.시트, 범위: [range], 바꿀: { ...picked } });
|
||||
if (!sticky) picked = null;
|
||||
},
|
||||
active(): boolean {
|
||||
return picked !== null;
|
||||
},
|
||||
clear(): void {
|
||||
picked = null;
|
||||
sticky = false;
|
||||
},
|
||||
refresh(): void {
|
||||
/* 상태 없는 표시만 씀 — 버튼 눌림은 active() 로 E 가 그림 */
|
||||
},
|
||||
destroy(): void {
|
||||
picked = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_func_help.ts (2단계 · 주인 sub_laptop_1 · 2단계 묶음은 B 가 대신 고침 2026-09-27)
|
||||
* 함수 자동 완성 목록 · 인자 도움말 — 1단계 함수 48 개(`spreadsheet_types.ts` 머리 「1단계 함수」) +
|
||||
* B 의 2단계 묶음(`spreadsheet_functions.ts` 의 `FUNC_HELP_BATCH2` · `FUNC_HELP_BATCH3`)을 이어 붙임.
|
||||
* 이름 · 인자 모양 · 한 줄 뜻. B `spreadsheet_functions.ts`(실제 풀이)와는 따로 — 이 표는 화면 도움말 전용
|
||||
* 정적 자료라 B 가 아직 없어도 씀. B 가 완성돼도 이름 목록이 갈리지 않게 이 표를 그대로 둠.
|
||||
*
|
||||
* 잇는 법(D · sub7 · 편집기) — 식 입력 중 `=` 뒤 낱말을 `suggestFunctions` 로 목록을 보여주고
|
||||
* `(` 를 친 뒤엔 `funcHelp(name)` 로 인자 도움말 풍선을 씀(지금 몇 번째 인자인지는 D 가 콤마를 세어 넘김).
|
||||
* ========================================================================== */
|
||||
|
||||
import { FUNC_HELP_BATCH2, FUNC_HELP_BATCH3 } from "./spreadsheet_functions";
|
||||
|
||||
export interface FuncHelp {
|
||||
이름: string;
|
||||
/** 인자 모양 — 대괄호는 생략 가능 */
|
||||
꼴: string;
|
||||
뜻: string;
|
||||
}
|
||||
|
||||
const TABLE: FuncHelp[] = [
|
||||
{ 이름: "SUM", 꼴: "SUM(수1, [수2], …)", 뜻: "수를 더함" },
|
||||
{ 이름: "PRODUCT", 꼴: "PRODUCT(수1, [수2], …)", 뜻: "수를 곱함" },
|
||||
{ 이름: "SUMPRODUCT", 꼴: "SUMPRODUCT(범위1, [범위2], …)", 뜻: "같은 자리끼리 곱한 뒤 더함" },
|
||||
{ 이름: "ROUND", 꼴: "ROUND(수, 자릿수)", 뜻: "반올림" },
|
||||
{ 이름: "ROUNDUP", 꼴: "ROUNDUP(수, 자릿수)", 뜻: "올림" },
|
||||
{ 이름: "ROUNDDOWN", 꼴: "ROUNDDOWN(수, 자릿수)", 뜻: "내림" },
|
||||
{ 이름: "INT", 꼴: "INT(수)", 뜻: "정수로 내림(음수는 작은 쪽)" },
|
||||
{ 이름: "TRUNC", 꼴: "TRUNC(수, [자릿수])", 뜻: "소수점을 버림(0 쪽으로)" },
|
||||
{ 이름: "CEILING", 꼴: "CEILING(수, 배수)", 뜻: "배수 단위로 올림" },
|
||||
{ 이름: "FLOOR", 꼴: "FLOOR(수, 배수)", 뜻: "배수 단위로 내림" },
|
||||
{ 이름: "ABS", 꼴: "ABS(수)", 뜻: "절댓값" },
|
||||
{ 이름: "MOD", 꼴: "MOD(수, 나눌수)", 뜻: "나눈 나머지" },
|
||||
{ 이름: "POWER", 꼴: "POWER(수, 지수)", 뜻: "거듭제곱" },
|
||||
{ 이름: "SQRT", 꼴: "SQRT(수)", 뜻: "제곱근" },
|
||||
{ 이름: "PI", 꼴: "PI()", 뜻: "원주율" },
|
||||
{ 이름: "SIN", 꼴: "SIN(각도값)", 뜻: "사인(라디안)" },
|
||||
{ 이름: "COS", 꼴: "COS(각도값)", 뜻: "코사인(라디안)" },
|
||||
{ 이름: "TAN", 꼴: "TAN(각도값)", 뜻: "탄젠트(라디안)" },
|
||||
{ 이름: "ASIN", 꼴: "ASIN(수)", 뜻: "아크사인(라디안)" },
|
||||
{ 이름: "ACOS", 꼴: "ACOS(수)", 뜻: "아크코사인(라디안)" },
|
||||
{ 이름: "ATAN", 꼴: "ATAN(수)", 뜻: "아크탄젠트(라디안)" },
|
||||
{ 이름: "RADIANS", 꼴: "RADIANS(각도)", 뜻: "도 → 라디안" },
|
||||
{ 이름: "DEGREES", 꼴: "DEGREES(라디안)", 뜻: "라디안 → 도" },
|
||||
{ 이름: "MIN", 꼴: "MIN(수1, [수2], …)", 뜻: "가장 작은 수" },
|
||||
{ 이름: "MAX", 꼴: "MAX(수1, [수2], …)", 뜻: "가장 큰 수" },
|
||||
{ 이름: "AVERAGE", 꼴: "AVERAGE(수1, [수2], …)", 뜻: "평균" },
|
||||
{ 이름: "IF", 꼴: "IF(조건, 참일때, [거짓일때])", 뜻: "조건에 따라 값을 고름" },
|
||||
{ 이름: "AND", 꼴: "AND(조건1, [조건2], …)", 뜻: "모두 참이면 참" },
|
||||
{ 이름: "OR", 꼴: "OR(조건1, [조건2], …)", 뜻: "하나라도 참이면 참" },
|
||||
{ 이름: "NOT", 꼴: "NOT(조건)", 뜻: "참 · 거짓을 뒤집음" },
|
||||
{ 이름: "IFERROR", 꼴: "IFERROR(식, 오류일때)", 뜻: "오류면 대신 값을 씀" },
|
||||
{ 이름: "CONCATENATE", 꼴: "CONCATENATE(글1, [글2], …)", 뜻: "글을 이어 붙임" },
|
||||
{ 이름: "FIXED", 꼴: "FIXED(수, [소수자리], [쉼표뺌])", 뜻: "소수 자리를 정해 글로 바꿈" },
|
||||
{ 이름: "TEXT", 꼴: "TEXT(값, 형식)", 뜻: "형식 코드로 값을 글로 바꿈" },
|
||||
{ 이름: "LEN", 꼴: "LEN(글)", 뜻: "글자 수" },
|
||||
{ 이름: "LEFT", 꼴: "LEFT(글, [글자수])", 뜻: "왼쪽부터 글자를 뗌" },
|
||||
{ 이름: "RIGHT", 꼴: "RIGHT(글, [글자수])", 뜻: "오른쪽부터 글자를 뗌" },
|
||||
{ 이름: "MID", 꼴: "MID(글, 시작, 글자수)", 뜻: "가운데 글자를 뗌" },
|
||||
{ 이름: "VALUE", 꼴: "VALUE(글)", 뜻: "숫자 모양 글을 수로 바꿈" },
|
||||
{
|
||||
이름: "VLOOKUP",
|
||||
꼴: "VLOOKUP(찾을값, 표범위, 열번호, [정확히])",
|
||||
뜻: "표 왼쪽에서 찾아 그 행의 값",
|
||||
},
|
||||
{
|
||||
이름: "HLOOKUP",
|
||||
꼴: "HLOOKUP(찾을값, 표범위, 행번호, [정확히])",
|
||||
뜻: "표 위쪽에서 찾아 그 열의 값",
|
||||
},
|
||||
{ 이름: "INDEX", 꼴: "INDEX(범위, 행번호, [열번호])", 뜻: "범위 안 자리 값" },
|
||||
{ 이름: "MATCH", 꼴: "MATCH(찾을값, 범위, [맞춤꼴])", 뜻: "범위 안 자리 번호" },
|
||||
{ 이름: "CHOOSE", 꼴: "CHOOSE(순번, 값1, [값2], …)", 뜻: "순번에 맞는 값을 고름" },
|
||||
{ 이름: "COUNT", 꼴: "COUNT(값1, [값2], …)", 뜻: "수가 든 칸 개수" },
|
||||
{ 이름: "COUNTA", 꼴: "COUNTA(값1, [값2], …)", 뜻: "비어 있지 않은 칸 개수" },
|
||||
{ 이름: "COUNTIF", 꼴: "COUNTIF(범위, 조건)", 뜻: "조건에 맞는 칸 개수" },
|
||||
{ 이름: "SUMIF", 꼴: "SUMIF(범위, 조건, [더할범위])", 뜻: "조건에 맞는 칸(또는 짝 칸)의 합" },
|
||||
...FUNC_HELP_BATCH2,
|
||||
...FUNC_HELP_BATCH3,
|
||||
];
|
||||
|
||||
const BY_NAME = new Map(TABLE.map((f) => [f.이름, f]));
|
||||
|
||||
/** `=` 뒤 지금 치는 낱말(대소문자 안 가림)로 시작하는 함수 이름 — 가나다(알파벳)순. */
|
||||
export function suggestFunctions(prefix: string): FuncHelp[] {
|
||||
const p = prefix.toUpperCase();
|
||||
if (!p) return [];
|
||||
return TABLE.filter((f) => f.이름.startsWith(p));
|
||||
}
|
||||
|
||||
/** 함수 하나의 도움말(없으면 null) — 이름은 대소문자 안 가림. */
|
||||
export function funcHelp(name: string): FuncHelp | null {
|
||||
return BY_NAME.get(name.toUpperCase()) ?? null;
|
||||
}
|
||||
|
||||
/** 아는 함수 전부 개수(1단계 48 + 2단계 묶음 · 시험용) */
|
||||
export function knownFunctionCount(): number {
|
||||
return TABLE.length;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_functions.ts (주인 B)
|
||||
* 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` ·
|
||||
* `_stat.ts` 에 두고 여기서 모음. 1단계 약 45 + 2단계 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2` ·
|
||||
* `FUNC_HELP_BATCH3`(sub1 의 `spreadsheet_func_help.ts` 가 읽어 이름 목록에 둘 다 더할 것 —
|
||||
* 그 파일은 sub1 몫이라 안 고침).
|
||||
* 없는 이름 = `#NAME?` · 인자 수가 min · max 밖이면 A 의 eval 이 `#VALUE!` + 까닭(엑셀은 입력을 막음).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SheetFunction } from "./spreadsheet_types";
|
||||
import { MATH_FUNCTIONS } from "./spreadsheet_functions_math";
|
||||
import { MISC_FUNCTIONS } from "./spreadsheet_functions_misc";
|
||||
import { LOOKUP_FUNCTIONS } from "./spreadsheet_functions_lookup";
|
||||
import { STAT_FUNCTIONS } from "./spreadsheet_functions_stat";
|
||||
|
||||
const TABLE: Record<string, SheetFunction> = {
|
||||
...MATH_FUNCTIONS,
|
||||
...MISC_FUNCTIONS,
|
||||
...LOOKUP_FUNCTIONS,
|
||||
...STAT_FUNCTIONS,
|
||||
};
|
||||
|
||||
export function getFunction(name: string): SheetFunction | undefined {
|
||||
return TABLE[name.toUpperCase()];
|
||||
}
|
||||
|
||||
/** 아는 함수 이름 전부(가나다 · 자동 완성은 2단계) */
|
||||
export function functionNames(): string[] {
|
||||
return Object.keys(TABLE).sort();
|
||||
}
|
||||
|
||||
/** 2단계 첫 묶음(2026-09-27) — sub1 `spreadsheet_func_help.ts` 의 `TABLE` 에 이 모양대로 더할 것.
|
||||
* `이름` · `꼴`(인자 모양 · 대괄호 = 생략 가능) · `뜻`(한 줄). */
|
||||
export interface FuncHelpEntry {
|
||||
이름: string;
|
||||
꼴: string;
|
||||
뜻: string;
|
||||
}
|
||||
|
||||
export const FUNC_HELP_BATCH2: FuncHelpEntry[] = [
|
||||
{ 이름: "MROUND", 꼴: "MROUND(수, 배수)", 뜻: "배수의 가장 가까운 정수 배로 반올림" },
|
||||
{ 이름: "ODD", 꼴: "ODD(수)", 뜻: "홀수로 올림(0 에서 먼 쪽)" },
|
||||
{ 이름: "EVEN", 꼴: "EVEN(수)", 뜻: "짝수로 올림(0 에서 먼 쪽)" },
|
||||
{ 이름: "LOG", 꼴: "LOG(수, [밑=10])", 뜻: "로그(밑 지정)" },
|
||||
{ 이름: "LOG10", 꼴: "LOG10(수)", 뜻: "상용로그" },
|
||||
{ 이름: "LN", 꼴: "LN(수)", 뜻: "자연로그" },
|
||||
{ 이름: "EXP", 꼴: "EXP(수)", 뜻: "e 의 거듭제곱" },
|
||||
{
|
||||
이름: "SUMIFS",
|
||||
꼴: "SUMIFS(합할범위, 조건범위1, 조건1, [조건범위2, 조건2], …)",
|
||||
뜻: "여러 조건에 다 맞는 칸의 합",
|
||||
},
|
||||
{
|
||||
이름: "COUNTIFS",
|
||||
꼴: "COUNTIFS(조건범위1, 조건1, [조건범위2, 조건2], …)",
|
||||
뜻: "여러 조건에 다 맞는 칸 개수",
|
||||
},
|
||||
{
|
||||
이름: "AVERAGEIF",
|
||||
꼴: "AVERAGEIF(범위, 조건, [평균범위])",
|
||||
뜻: "조건에 맞는 칸(또는 짝 칸)의 평균",
|
||||
},
|
||||
{
|
||||
이름: "AVERAGEIFS",
|
||||
꼴: "AVERAGEIFS(평균범위, 조건범위1, 조건1, …)",
|
||||
뜻: "여러 조건에 다 맞는 칸의 평균",
|
||||
},
|
||||
{
|
||||
이름: "MAXIFS",
|
||||
꼴: "MAXIFS(최대범위, 조건범위1, 조건1, …)",
|
||||
뜻: "여러 조건에 다 맞는 칸의 최댓값",
|
||||
},
|
||||
{
|
||||
이름: "MINIFS",
|
||||
꼴: "MINIFS(최소범위, 조건범위1, 조건1, …)",
|
||||
뜻: "여러 조건에 다 맞는 칸의 최솟값",
|
||||
},
|
||||
{
|
||||
이름: "IFS",
|
||||
꼴: "IFS(조건1, 값1, [조건2, 값2], …)",
|
||||
뜻: "처음으로 참인 조건의 값",
|
||||
},
|
||||
{
|
||||
이름: "SWITCH",
|
||||
꼴: "SWITCH(식, 값1, 결과1, [값2, 결과2], …, [기본값])",
|
||||
뜻: "식과 같은 값을 찾아 그 결과",
|
||||
},
|
||||
{
|
||||
이름: "XLOOKUP",
|
||||
꼴: "XLOOKUP(찾을값, 찾을범위, 돌려줄범위, [못찾으면], [맞춤꼴], [찾는방향])",
|
||||
뜻: "찾을범위에서 찾아 돌려줄범위의 같은 자리 값",
|
||||
},
|
||||
{
|
||||
이름: "TEXTJOIN",
|
||||
꼴: "TEXTJOIN(구분자, 빈칸건너뜀, 글1, [글2], …)",
|
||||
뜻: "구분자로 여러 글을 이어 붙임",
|
||||
},
|
||||
{ 이름: "TRIM", 꼴: "TRIM(글)", 뜻: "앞뒤 · 겹친 빈칸을 뺌" },
|
||||
{ 이름: "UPPER", 꼴: "UPPER(글)", 뜻: "대문자로" },
|
||||
{ 이름: "LOWER", 꼴: "LOWER(글)", 뜻: "소문자로" },
|
||||
{
|
||||
이름: "SUBSTITUTE",
|
||||
꼴: "SUBSTITUTE(글, 옛글, 새글, [몇번째])",
|
||||
뜻: "글 안 옛글을 새글로 바꿈",
|
||||
},
|
||||
{ 이름: "REPLACE", 꼴: "REPLACE(글, 시작, 글자수, 새글)", 뜻: "자리로 콕 집어 바꿈" },
|
||||
{ 이름: "FIND", 꼴: "FIND(찾을글, 안글, [시작])", 뜻: "글자 그대로 찾음(대소문자 가림)" },
|
||||
{
|
||||
이름: "SEARCH",
|
||||
꼴: "SEARCH(찾을글, 안글, [시작])",
|
||||
뜻: "찾음(대소문자 안 가림 · * ? 자리표시)",
|
||||
},
|
||||
{ 이름: "REPT", 꼴: "REPT(글, 번)", 뜻: "글을 번 만큼 되풀이" },
|
||||
{ 이름: "ISNUMBER", 꼴: "ISNUMBER(값)", 뜻: "수면 참" },
|
||||
{ 이름: "ISTEXT", 꼴: "ISTEXT(값)", 뜻: "글이면 참" },
|
||||
{ 이름: "ISBLANK", 꼴: "ISBLANK(값)", 뜻: "빈 칸이면 참" },
|
||||
{ 이름: "ISERROR", 꼴: "ISERROR(값)", 뜻: "오류 값이면 참" },
|
||||
{ 이름: "NA", 꼴: "NA()", 뜻: "#N/A 오류를 만듦" },
|
||||
{ 이름: "TODAY", 꼴: "TODAY()", 뜻: "오늘 날짜(일련번호)" },
|
||||
{ 이름: "DATE", 꼴: "DATE(년, 월, 일)", 뜻: "날짜 일련번호를 만듦" },
|
||||
{ 이름: "YEAR", 꼴: "YEAR(일련번호)", 뜻: "연도" },
|
||||
{ 이름: "MONTH", 꼴: "MONTH(일련번호)", 뜻: "월" },
|
||||
{ 이름: "DAY", 꼴: "DAY(일련번호)", 뜻: "일" },
|
||||
];
|
||||
|
||||
/** 2단계 셋째 묶음(2026-09-27) — 위 `FUNC_HELP_BATCH2` 와 같은 모양 · 같이 sub1 표에 더할 것. */
|
||||
export const FUNC_HELP_BATCH3: FuncHelpEntry[] = [
|
||||
{ 이름: "MEDIAN", 꼴: "MEDIAN(수1, [수2], …)", 뜻: "가운데 값" },
|
||||
{ 이름: "MODE", 꼴: "MODE(수1, [수2], …)", 뜻: "가장 자주 나오는 값" },
|
||||
{ 이름: "STDEV", 꼴: "STDEV(수1, [수2], …)", 뜻: "표본 표준편차" },
|
||||
{ 이름: "STDEV.S", 꼴: "STDEV.S(수1, [수2], …)", 뜻: "표본 표준편차" },
|
||||
{ 이름: "STDEV.P", 꼴: "STDEV.P(수1, [수2], …)", 뜻: "모집단 표준편차" },
|
||||
{ 이름: "VAR", 꼴: "VAR(수1, [수2], …)", 뜻: "표본 분산" },
|
||||
{ 이름: "RANK", 꼴: "RANK(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" },
|
||||
{ 이름: "RANK.EQ", 꼴: "RANK.EQ(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" },
|
||||
{ 이름: "LARGE", 꼴: "LARGE(범위, k)", 뜻: "k 번째로 큰 값" },
|
||||
{ 이름: "SMALL", 꼴: "SMALL(범위, k)", 뜻: "k 번째로 작은 값" },
|
||||
{ 이름: "PERCENTILE", 꼴: "PERCENTILE(범위, k)", 뜻: "k(0~1) 자리 백분위 값" },
|
||||
{ 이름: "QUARTILE", 꼴: "QUARTILE(범위, 사분위수)", 뜻: "사분위수(0~4)" },
|
||||
{ 이름: "COUNTBLANK", 꼴: "COUNTBLANK(범위)", 뜻: "빈 칸 개수" },
|
||||
{ 이름: "NOW", 꼴: "NOW()", 뜻: "지금 날짜 · 시각(일련번호)" },
|
||||
{ 이름: "WEEKDAY", 꼴: "WEEKDAY(일련번호, [유형=1])", 뜻: "요일 번호" },
|
||||
{ 이름: "EDATE", 꼴: "EDATE(시작일, 개월수)", 뜻: "개월 뒤(앞) 같은 날" },
|
||||
{ 이름: "EOMONTH", 꼴: "EOMONTH(시작일, 개월수)", 뜻: "개월 뒤(앞) 달의 말일" },
|
||||
{ 이름: "DATEDIF", 꼴: "DATEDIF(시작일, 종료일, 단위)", 뜻: "두 날짜 차(단위 Y·M·D·YM·MD·YD)" },
|
||||
{
|
||||
이름: "NETWORKDAYS",
|
||||
꼴: "NETWORKDAYS(시작일, 종료일, [휴일범위])",
|
||||
뜻: "평일 일수(토·일 · 휴일 뺌)",
|
||||
},
|
||||
{ 이름: "HOUR", 꼴: "HOUR(일련번호)", 뜻: "시" },
|
||||
{ 이름: "MINUTE", 꼴: "MINUTE(일련번호)", 뜻: "분" },
|
||||
{ 이름: "TIME", 꼴: "TIME(시, 분, 초)", 뜻: "시각 일련번호를 만듦" },
|
||||
];
|
||||
@@ -0,0 +1,536 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_functions_lookup.ts (주인 B · misc 700 줄 넘어 2026-09-27 쪼갬)
|
||||
* 찾기 · 조건부 세기·합·평균·최대·최소 함수. `spreadsheet_functions_misc.ts` 에서
|
||||
* VLOOKUP · HLOOKUP · INDEX · MATCH · CHOOSE · COUNT · COUNTA · COUNTIF · SUMIF ·
|
||||
* SUMIFS · COUNTIFS · AVERAGEIF(S) · MAXIFS · MINIFS · IFS · SWITCH · XLOOKUP 를 옮겨 옴.
|
||||
* 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
ZERO,
|
||||
add,
|
||||
cmp,
|
||||
div,
|
||||
frac,
|
||||
parseDecimal,
|
||||
type Frac,
|
||||
} from "@ui/sheet/ui_template_sheet_frac";
|
||||
import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types";
|
||||
import {
|
||||
bool,
|
||||
collectNumbers,
|
||||
fracToDouble,
|
||||
isErr,
|
||||
isFrac,
|
||||
isRange,
|
||||
mkErr,
|
||||
num,
|
||||
toNumber,
|
||||
toScalar,
|
||||
} from "./spreadsheet_functions_math";
|
||||
|
||||
const ONE = frac(1n);
|
||||
|
||||
// ── 값 견줌(찾기 · 세기 함수 공용) ───────────────────────────────────────────
|
||||
|
||||
function typeRank(v: Scalar): number {
|
||||
if (v === null) return -1;
|
||||
if (isFrac(v)) return 0;
|
||||
if (typeof v === "string") return 1;
|
||||
if (typeof v === "boolean") return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function compareScalars(a: Scalar, b: Scalar): number {
|
||||
if (isFrac(a) && isFrac(b)) return cmp(a, b);
|
||||
if (typeof a === "string" && typeof b === "string") {
|
||||
const la = a.toUpperCase();
|
||||
const lb = b.toUpperCase();
|
||||
return la < lb ? -1 : la > lb ? 1 : 0;
|
||||
}
|
||||
if (typeof a === "boolean" && typeof b === "boolean") return a === b ? 0 : a ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function equalScalars(a: Scalar, b: Scalar): boolean {
|
||||
return typeRank(a) === typeRank(b) && compareScalars(a, b) === 0;
|
||||
}
|
||||
|
||||
function wrapScalar(v: Scalar): RangeValue {
|
||||
return { kind: "range", rows: 1, cols: 1, at: () => v };
|
||||
}
|
||||
|
||||
// ── 조건 글(`>10` · `<=5` · `사과*`) ─────────────────────────────────────────
|
||||
|
||||
function wildcardToRegex(text: string): RegExp {
|
||||
const escaped = text
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
return new RegExp(`^${escaped}$`, "i");
|
||||
}
|
||||
|
||||
function parseCriteria(crit: Scalar): (cell: Scalar) => boolean {
|
||||
if (crit === null) return (cell) => cell === null;
|
||||
if (typeof crit === "boolean") return (cell) => typeof cell === "boolean" && cell === crit;
|
||||
if (isFrac(crit)) return (cell) => isFrac(cell) && cmp(cell, crit) === 0;
|
||||
if (isErr(crit)) return () => false;
|
||||
const m = /^(<=|>=|<>|<|>|=)?(.*)$/.exec(crit);
|
||||
const op = m?.[1] ?? "";
|
||||
const rest = m?.[2] ?? crit;
|
||||
let numRest: Frac | null = null;
|
||||
try {
|
||||
numRest = rest.trim() === "" ? null : parseDecimal(rest);
|
||||
} catch {
|
||||
numRest = null;
|
||||
}
|
||||
if (numRest !== null) {
|
||||
const nr = numRest;
|
||||
return (cell) => {
|
||||
if (!isFrac(cell)) return false;
|
||||
const c = cmp(cell, nr);
|
||||
if (op === "<=") return c <= 0;
|
||||
if (op === ">=") return c >= 0;
|
||||
if (op === "<>") return c !== 0;
|
||||
if (op === "<") return c < 0;
|
||||
if (op === ">") return c > 0;
|
||||
return c === 0;
|
||||
};
|
||||
}
|
||||
const pat = wildcardToRegex(rest);
|
||||
if (op === "<>") return (cell) => !(typeof cell === "string" && pat.test(cell));
|
||||
return (cell) => typeof cell === "string" && pat.test(cell);
|
||||
}
|
||||
|
||||
/** `args[from]` 부터 (범위,조건) 짝을 이어가며 모두 맞는 자리만 참 — *IFS 함수 공용.
|
||||
* `shape` 범위와 크기가 다른 조건 범위가 있으면 `#VALUE!`. */
|
||||
function criteriaMask(args: LazyArg[], from: number, shape: RangeValue): boolean[] | ErrorValue {
|
||||
const mask = new Array<boolean>(shape.rows * shape.cols).fill(true);
|
||||
for (let i = from; i + 1 < args.length; i += 2) {
|
||||
const rangeArg = args[i]();
|
||||
if (!isRange(rangeArg)) return mkErr("#VALUE!");
|
||||
if (rangeArg.rows !== shape.rows || rangeArg.cols !== shape.cols) {
|
||||
return mkErr("#VALUE!", "조건 범위 크기가 다름");
|
||||
}
|
||||
const crit = toScalar(args[i + 1]());
|
||||
if (isErr(crit)) return crit;
|
||||
const pred = parseCriteria(crit);
|
||||
let k = 0;
|
||||
for (let r = 0; r < shape.rows; r++) {
|
||||
for (let c = 0; c < shape.cols; c++) {
|
||||
if (mask[k] && !pred(rangeArg.at(r, c))) mask[k] = false;
|
||||
k++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
// ── 함수 표 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const LOOKUP_FUNCTIONS: Record<string, SheetFunction> = {
|
||||
VLOOKUP: {
|
||||
min: 3,
|
||||
max: 4,
|
||||
call: (args) => {
|
||||
const lookup = toScalar(args[0]());
|
||||
if (isErr(lookup)) return lookup;
|
||||
const table = args[1]();
|
||||
if (!isRange(table)) return mkErr("#VALUE!");
|
||||
const colN = num(args[2]());
|
||||
if (isErr(colN)) return colN;
|
||||
const col = Math.trunc(fracToDouble(colN)) - 1;
|
||||
if (col < 0 || col >= table.cols) return mkErr("#REF!");
|
||||
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
|
||||
if (isErr(approxRaw)) return approxRaw;
|
||||
if (approxRaw) {
|
||||
let best = -1;
|
||||
for (let r = 0; r < table.rows; r++) {
|
||||
const key = table.at(r, 0);
|
||||
if (typeRank(key) !== typeRank(lookup)) {
|
||||
if (best >= 0) break;
|
||||
continue;
|
||||
}
|
||||
if (compareScalars(key, lookup) <= 0) best = r;
|
||||
else break;
|
||||
}
|
||||
if (best < 0) return mkErr("#N/A");
|
||||
return table.at(best, col);
|
||||
}
|
||||
for (let r = 0; r < table.rows; r++) {
|
||||
if (equalScalars(table.at(r, 0), lookup)) return table.at(r, col);
|
||||
}
|
||||
return mkErr("#N/A");
|
||||
},
|
||||
},
|
||||
HLOOKUP: {
|
||||
min: 3,
|
||||
max: 4,
|
||||
call: (args) => {
|
||||
const lookup = toScalar(args[0]());
|
||||
if (isErr(lookup)) return lookup;
|
||||
const table = args[1]();
|
||||
if (!isRange(table)) return mkErr("#VALUE!");
|
||||
const rowN = num(args[2]());
|
||||
if (isErr(rowN)) return rowN;
|
||||
const row = Math.trunc(fracToDouble(rowN)) - 1;
|
||||
if (row < 0 || row >= table.rows) return mkErr("#REF!");
|
||||
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
|
||||
if (isErr(approxRaw)) return approxRaw;
|
||||
if (approxRaw) {
|
||||
let best = -1;
|
||||
for (let c = 0; c < table.cols; c++) {
|
||||
const key = table.at(0, c);
|
||||
if (typeRank(key) !== typeRank(lookup)) {
|
||||
if (best >= 0) break;
|
||||
continue;
|
||||
}
|
||||
if (compareScalars(key, lookup) <= 0) best = c;
|
||||
else break;
|
||||
}
|
||||
if (best < 0) return mkErr("#N/A");
|
||||
return table.at(row, best);
|
||||
}
|
||||
for (let c = 0; c < table.cols; c++) {
|
||||
if (equalScalars(table.at(0, c), lookup)) return table.at(row, c);
|
||||
}
|
||||
return mkErr("#N/A");
|
||||
},
|
||||
},
|
||||
INDEX: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const rangeArg = args[0]();
|
||||
if (isErr(rangeArg)) return rangeArg;
|
||||
const range = isRange(rangeArg) ? rangeArg : wrapScalar(rangeArg);
|
||||
const rowN = num(args[1]());
|
||||
if (isErr(rowN)) return rowN;
|
||||
const row = Math.trunc(fracToDouble(rowN));
|
||||
const colN = args.length > 2 ? num(args[2]()) : frac(range.cols === 1 ? 1n : 0n);
|
||||
if (isErr(colN)) return colN;
|
||||
const col = Math.trunc(fracToDouble(colN));
|
||||
if (row === 0 && col === 0) return mkErr("#VALUE!");
|
||||
if (row === 0) {
|
||||
if (col < 1 || col > range.cols) return mkErr("#REF!");
|
||||
const c = col - 1;
|
||||
return { kind: "range", rows: range.rows, cols: 1, at: (r: number) => range.at(r, c) };
|
||||
}
|
||||
if (col === 0) {
|
||||
if (row < 1 || row > range.rows) return mkErr("#REF!");
|
||||
const r = row - 1;
|
||||
return {
|
||||
kind: "range",
|
||||
rows: 1,
|
||||
cols: range.cols,
|
||||
at: (_r: number, c: number) => range.at(r, c),
|
||||
};
|
||||
}
|
||||
if (row < 1 || row > range.rows || col < 1 || col > range.cols) return mkErr("#REF!");
|
||||
return range.at(row - 1, col - 1);
|
||||
},
|
||||
},
|
||||
MATCH: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const lookup = toScalar(args[0]());
|
||||
if (isErr(lookup)) return lookup;
|
||||
const rangeArg = args[1]();
|
||||
if (!isRange(rangeArg)) return mkErr("#VALUE!");
|
||||
if (rangeArg.rows !== 1 && rangeArg.cols !== 1) {
|
||||
return mkErr("#VALUE!", "한 줄 · 한 칸짜리 범위만");
|
||||
}
|
||||
const n = rangeArg.rows === 1 ? rangeArg.cols : rangeArg.rows;
|
||||
const at = (i: number): Scalar =>
|
||||
rangeArg.rows === 1 ? rangeArg.at(0, i) : rangeArg.at(i, 0);
|
||||
const modeN = args.length > 2 ? num(args[2]()) : ONE;
|
||||
if (isErr(modeN)) return modeN;
|
||||
const mode = Math.trunc(fracToDouble(modeN));
|
||||
if (mode === 0) {
|
||||
for (let i = 0; i < n; i++) if (equalScalars(at(i), lookup)) return frac(BigInt(i + 1));
|
||||
return mkErr("#N/A");
|
||||
}
|
||||
let best = -1;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const key = at(i);
|
||||
if (typeRank(key) !== typeRank(lookup)) {
|
||||
if (best >= 0) break;
|
||||
continue;
|
||||
}
|
||||
const c = compareScalars(key, lookup);
|
||||
if (mode > 0 ? c <= 0 : c >= 0) best = i;
|
||||
else break;
|
||||
}
|
||||
if (best < 0) return mkErr("#N/A");
|
||||
return frac(BigInt(best + 1));
|
||||
},
|
||||
},
|
||||
CHOOSE: {
|
||||
min: 2,
|
||||
call: (args) => {
|
||||
const idxN = num(args[0]());
|
||||
if (isErr(idxN)) return idxN;
|
||||
const idx = Math.trunc(fracToDouble(idxN));
|
||||
if (idx < 1 || idx > args.length - 1) return mkErr("#VALUE!");
|
||||
return args[idx]();
|
||||
},
|
||||
},
|
||||
COUNT: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, false, false);
|
||||
return isErr(values) ? values : frac(BigInt(values.length));
|
||||
},
|
||||
},
|
||||
COUNTA: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
let count = 0;
|
||||
for (const a of args) {
|
||||
const v = a();
|
||||
if (isRange(v)) {
|
||||
for (let r = 0; r < v.rows; r++) {
|
||||
for (let c = 0; c < v.cols; c++) if (v.at(r, c) !== null) count++;
|
||||
}
|
||||
} else if (v !== null) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return frac(BigInt(count));
|
||||
},
|
||||
},
|
||||
COUNTIF: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const crit = toScalar(args[1]());
|
||||
if (isErr(crit)) return crit;
|
||||
const pred = parseCriteria(crit);
|
||||
let count = 0;
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) if (pred(range.at(r, c))) count++;
|
||||
}
|
||||
return frac(BigInt(count));
|
||||
},
|
||||
},
|
||||
SUMIF: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const crit = toScalar(args[1]());
|
||||
if (isErr(crit)) return crit;
|
||||
const pred = parseCriteria(crit);
|
||||
const sumRange = args.length > 2 ? args[2]() : range;
|
||||
if (!isRange(sumRange)) return mkErr("#VALUE!");
|
||||
if (sumRange.rows !== range.rows || sumRange.cols !== range.cols) {
|
||||
return mkErr("#VALUE!", "합 범위 크기가 다름");
|
||||
}
|
||||
let total = ZERO;
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) {
|
||||
if (!pred(range.at(r, c))) continue;
|
||||
const n = toNumber(sumRange.at(r, c));
|
||||
if (isErr(n)) return n;
|
||||
total = add(total, n);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
},
|
||||
},
|
||||
SUMIFS: {
|
||||
min: 3,
|
||||
call: (args) => {
|
||||
const sumRange = args[0]();
|
||||
if (!isRange(sumRange)) return mkErr("#VALUE!");
|
||||
const mask = criteriaMask(args, 1, sumRange);
|
||||
if (isErr(mask)) return mask;
|
||||
let total = ZERO;
|
||||
let k = 0;
|
||||
for (let r = 0; r < sumRange.rows; r++) {
|
||||
for (let c = 0; c < sumRange.cols; c++) {
|
||||
if (mask[k]) {
|
||||
const n = toNumber(sumRange.at(r, c));
|
||||
if (isErr(n)) return n;
|
||||
total = add(total, n);
|
||||
}
|
||||
k++;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
},
|
||||
},
|
||||
COUNTIFS: {
|
||||
min: 2,
|
||||
call: (args) => {
|
||||
const first = args[0]();
|
||||
if (!isRange(first)) return mkErr("#VALUE!");
|
||||
const mask = criteriaMask(args, 0, first);
|
||||
if (isErr(mask)) return mask;
|
||||
return frac(BigInt(mask.filter(Boolean).length));
|
||||
},
|
||||
},
|
||||
AVERAGEIF: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const crit = toScalar(args[1]());
|
||||
if (isErr(crit)) return crit;
|
||||
const pred = parseCriteria(crit);
|
||||
const avgRange = args.length > 2 ? args[2]() : range;
|
||||
if (!isRange(avgRange)) return mkErr("#VALUE!");
|
||||
if (avgRange.rows !== range.rows || avgRange.cols !== range.cols) {
|
||||
return mkErr("#VALUE!", "평균 범위 크기가 다름");
|
||||
}
|
||||
let total = ZERO;
|
||||
let count = 0;
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) {
|
||||
if (!pred(range.at(r, c))) continue;
|
||||
const n = toNumber(avgRange.at(r, c));
|
||||
if (isErr(n)) return n;
|
||||
total = add(total, n);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count)));
|
||||
},
|
||||
},
|
||||
AVERAGEIFS: {
|
||||
min: 3,
|
||||
call: (args) => {
|
||||
const avgRange = args[0]();
|
||||
if (!isRange(avgRange)) return mkErr("#VALUE!");
|
||||
const mask = criteriaMask(args, 1, avgRange);
|
||||
if (isErr(mask)) return mask;
|
||||
let total = ZERO;
|
||||
let count = 0;
|
||||
let k = 0;
|
||||
for (let r = 0; r < avgRange.rows; r++) {
|
||||
for (let c = 0; c < avgRange.cols; c++) {
|
||||
if (mask[k]) {
|
||||
const n = toNumber(avgRange.at(r, c));
|
||||
if (isErr(n)) return n;
|
||||
total = add(total, n);
|
||||
count++;
|
||||
}
|
||||
k++;
|
||||
}
|
||||
}
|
||||
return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count)));
|
||||
},
|
||||
},
|
||||
MAXIFS: {
|
||||
min: 3,
|
||||
call: (args) => {
|
||||
const targetRange = args[0]();
|
||||
if (!isRange(targetRange)) return mkErr("#VALUE!");
|
||||
const mask = criteriaMask(args, 1, targetRange);
|
||||
if (isErr(mask)) return mask;
|
||||
let best: Frac | null = null;
|
||||
let k = 0;
|
||||
for (let r = 0; r < targetRange.rows; r++) {
|
||||
for (let c = 0; c < targetRange.cols; c++) {
|
||||
if (mask[k]) {
|
||||
const cell = targetRange.at(r, c);
|
||||
if (isFrac(cell) && (best === null || cmp(cell, best) > 0)) best = cell;
|
||||
}
|
||||
k++;
|
||||
}
|
||||
}
|
||||
return best ?? ZERO;
|
||||
},
|
||||
},
|
||||
MINIFS: {
|
||||
min: 3,
|
||||
call: (args) => {
|
||||
const targetRange = args[0]();
|
||||
if (!isRange(targetRange)) return mkErr("#VALUE!");
|
||||
const mask = criteriaMask(args, 1, targetRange);
|
||||
if (isErr(mask)) return mask;
|
||||
let best: Frac | null = null;
|
||||
let k = 0;
|
||||
for (let r = 0; r < targetRange.rows; r++) {
|
||||
for (let c = 0; c < targetRange.cols; c++) {
|
||||
if (mask[k]) {
|
||||
const cell = targetRange.at(r, c);
|
||||
if (isFrac(cell) && (best === null || cmp(cell, best) < 0)) best = cell;
|
||||
}
|
||||
k++;
|
||||
}
|
||||
}
|
||||
return best ?? ZERO;
|
||||
},
|
||||
},
|
||||
IFS: {
|
||||
min: 2,
|
||||
call: (args) => {
|
||||
for (let i = 0; i + 1 < args.length; i += 2) {
|
||||
const c = bool(args[i]());
|
||||
if (isErr(c)) return c;
|
||||
if (c) return args[i + 1]();
|
||||
}
|
||||
return mkErr("#N/A");
|
||||
},
|
||||
},
|
||||
SWITCH: {
|
||||
min: 3,
|
||||
call: (args) => {
|
||||
const target = toScalar(args[0]());
|
||||
if (isErr(target)) return target;
|
||||
let i = 1;
|
||||
for (; i + 1 < args.length; i += 2) {
|
||||
const cand = toScalar(args[i]());
|
||||
if (isErr(cand)) return cand;
|
||||
if (equalScalars(target, cand)) return args[i + 1]();
|
||||
}
|
||||
return i < args.length ? args[i]() : mkErr("#N/A");
|
||||
},
|
||||
},
|
||||
XLOOKUP: {
|
||||
min: 3,
|
||||
max: 6,
|
||||
call: (args) => {
|
||||
const lookup = toScalar(args[0]());
|
||||
if (isErr(lookup)) return lookup;
|
||||
const lookupRange = args[1]();
|
||||
const returnRange = args[2]();
|
||||
if (!isRange(lookupRange) || !isRange(returnRange)) return mkErr("#VALUE!");
|
||||
const n = lookupRange.rows * lookupRange.cols;
|
||||
if (n !== returnRange.rows * returnRange.cols) {
|
||||
return mkErr("#VALUE!", "찾을 범위 · 돌려줄 범위 크기가 다름");
|
||||
}
|
||||
const at = (rv: RangeValue, i: number): Scalar => (rv.rows === 1 ? rv.at(0, i) : rv.at(i, 0));
|
||||
const modeN = args.length > 4 ? num(args[4]()) : ZERO;
|
||||
if (isErr(modeN)) return modeN;
|
||||
const mode = Math.trunc(fracToDouble(modeN));
|
||||
const searchN = args.length > 5 ? num(args[5]()) : ONE;
|
||||
if (isErr(searchN)) return searchN;
|
||||
const reverse = Math.trunc(fracToDouble(searchN)) < 0;
|
||||
let best = -1;
|
||||
for (let step = 0; step < n; step++) {
|
||||
const i = reverse ? n - 1 - step : step;
|
||||
const key = at(lookupRange, i);
|
||||
if (typeRank(key) !== typeRank(lookup)) continue;
|
||||
const c = compareScalars(key, lookup);
|
||||
if (c === 0) {
|
||||
best = i;
|
||||
break;
|
||||
}
|
||||
if (mode === 1 && c > 0) {
|
||||
if (best < 0 || compareScalars(key, at(lookupRange, best)) < 0) best = i;
|
||||
} else if (mode === -1 && c < 0) {
|
||||
if (best < 0 || compareScalars(key, at(lookupRange, best)) > 0) best = i;
|
||||
}
|
||||
}
|
||||
if (best < 0) return args.length > 3 ? args[3]() : mkErr("#N/A");
|
||||
return at(returnRange, best);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,490 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_functions_math.ts (주인 B)
|
||||
* 수학 · 삼각 · 끝수 함수 + 함수 표 전체가 쓰는 형 바꾸기 도우미(공용은 여기서 export).
|
||||
* 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만 씀 · 속에 안 넣음).
|
||||
* 무리수(SQRT · 삼각 · PI · 소수 거듭제곱)는 double → 유효 15 자리 십진 → 분수.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
ZERO,
|
||||
add,
|
||||
cmp,
|
||||
div,
|
||||
frac,
|
||||
mul,
|
||||
parseDecimal,
|
||||
roundAt,
|
||||
sub,
|
||||
toInteger,
|
||||
type Frac,
|
||||
} from "@ui/sheet/ui_template_sheet_frac";
|
||||
import type {
|
||||
ErrorCode,
|
||||
ErrorValue,
|
||||
EvalResult,
|
||||
LazyArg,
|
||||
RangeValue,
|
||||
Scalar,
|
||||
SheetFunction,
|
||||
} from "./spreadsheet_types";
|
||||
|
||||
const ONE = frac(1n);
|
||||
|
||||
// ── 공용 도우미(math · misc 둘 다 씀) ────────────────────────────────────────
|
||||
|
||||
export function mkErr(code: ErrorCode, why?: string): ErrorValue {
|
||||
return why === undefined ? { error: code } : { error: code, why };
|
||||
}
|
||||
|
||||
export function isErr(v: unknown): v is ErrorValue {
|
||||
return typeof v === "object" && v !== null && "error" in (v as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function isFrac(v: unknown): v is Frac {
|
||||
return typeof v === "object" && v !== null && "n" in (v as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function isRange(v: EvalResult): v is RangeValue {
|
||||
return typeof v === "object" && v !== null && (v as RangeValue).kind === "range";
|
||||
}
|
||||
|
||||
/** 범위면 1×1 일 때만 칸 하나로 · 아니면 `#VALUE!` */
|
||||
export function toScalar(v: EvalResult): Scalar | ErrorValue {
|
||||
if (!isRange(v)) return v;
|
||||
if (v.rows === 1 && v.cols === 1) return v.at(0, 0);
|
||||
return mkErr("#VALUE!", "범위를 칸 하나 자리에 씀");
|
||||
}
|
||||
|
||||
/** 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` */
|
||||
export function toNumber(v: Scalar): Frac | ErrorValue {
|
||||
if (v === null) return ZERO;
|
||||
if (isErr(v)) return v;
|
||||
if (typeof v === "boolean") return v ? ONE : ZERO;
|
||||
if (typeof v === "string") {
|
||||
try {
|
||||
return parseDecimal(v);
|
||||
} catch {
|
||||
return mkErr("#VALUE!", `글을 수로 못 읽음: ${v}`);
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 인자 하나(범위는 1×1 만) → 수 */
|
||||
export function num(v: EvalResult): Frac | ErrorValue {
|
||||
const s = toScalar(v);
|
||||
return isErr(s) ? s : toNumber(s);
|
||||
}
|
||||
|
||||
/** 글로 — 수는 일반 형식(간단 십진) · 참거짓 TRUE/FALSE */
|
||||
export function toText(v: Scalar): string | ErrorValue {
|
||||
if (v === null) return "";
|
||||
if (isErr(v)) return v;
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
return generalNumberText(v);
|
||||
}
|
||||
|
||||
export function str(v: EvalResult): string | ErrorValue {
|
||||
const s = toScalar(v);
|
||||
return isErr(s) ? s : toText(s);
|
||||
}
|
||||
|
||||
export function toBool(v: Scalar): boolean | ErrorValue {
|
||||
if (v === null) return false;
|
||||
if (isErr(v)) return v;
|
||||
if (typeof v === "boolean") return v;
|
||||
if (typeof v === "string") {
|
||||
const up = v.trim().toUpperCase();
|
||||
if (up === "TRUE") return true;
|
||||
if (up === "FALSE") return false;
|
||||
return mkErr("#VALUE!", `글을 참거짓으로 못 읽음: ${v}`);
|
||||
}
|
||||
return cmp(v, ZERO) !== 0;
|
||||
}
|
||||
|
||||
export function bool(v: EvalResult): boolean | ErrorValue {
|
||||
const s = toScalar(v);
|
||||
return isErr(s) ? s : toBool(s);
|
||||
}
|
||||
|
||||
/** double → 유효 15 자리 십진 → 분수 · NaN · 무한은 `#NUM!` */
|
||||
export function fromDouble(x: number): Frac | ErrorValue {
|
||||
if (!Number.isFinite(x)) return mkErr("#NUM!");
|
||||
if (x === 0) return ZERO;
|
||||
return parseDecimal(x.toPrecision(15));
|
||||
}
|
||||
|
||||
export function fracToDouble(x: Frac): number {
|
||||
return Number(x.n) / Number(x.d);
|
||||
}
|
||||
|
||||
/** 엑셀 「일반」 형식과 같은 십진 글(수식 `&` · TEXT 안 형식 자리가 없을 때) */
|
||||
export function generalNumberText(x: Frac): string {
|
||||
const scaled = toInteger(mul(x, frac(10n ** 15n)), "round");
|
||||
const negative = scaled < 0n;
|
||||
const digits = (negative ? -scaled : scaled).toString().padStart(16, "0");
|
||||
const whole = digits.slice(0, -15).replace(/^0+(?=\d)/, "");
|
||||
const fraction = digits.slice(-15).replace(/0+$/, "");
|
||||
const body = fraction ? `${whole}.${fraction}` : whole;
|
||||
return negative && body !== "0" ? `-${body}` : body;
|
||||
}
|
||||
|
||||
/** 인자 목록에서 수만 모음 — 범위는 수 아닌 값 조용히 건너뜀 · 낱값은 형 바꿈(실패시 strict 면 오류)
|
||||
* `propagateErrors` = 범위 · 낱값 오류를 그대로 돌려줄지(COUNT 는 false) */
|
||||
export function collectNumbers(
|
||||
args: LazyArg[],
|
||||
propagateErrors: boolean,
|
||||
strictScalar: boolean,
|
||||
): Frac[] | ErrorValue {
|
||||
const out: Frac[] = [];
|
||||
for (const a of args) {
|
||||
const v = a();
|
||||
if (isRange(v)) {
|
||||
for (let r = 0; r < v.rows; r++) {
|
||||
for (let c = 0; c < v.cols; c++) {
|
||||
const cell = v.at(r, c);
|
||||
if (isErr(cell)) {
|
||||
if (propagateErrors) return cell;
|
||||
continue;
|
||||
}
|
||||
if (isFrac(cell)) out.push(cell);
|
||||
}
|
||||
}
|
||||
} else if (isErr(v)) {
|
||||
if (propagateErrors) return v;
|
||||
} else if (v !== null) {
|
||||
const n = toNumber(v);
|
||||
if (isErr(n)) {
|
||||
if (strictScalar) return n;
|
||||
} else {
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function digitsArg(args: LazyArg[], index: number, fallback: number): number | ErrorValue {
|
||||
if (index >= args.length) return fallback;
|
||||
const n = num(args[index]());
|
||||
return isErr(n) ? n : Math.trunc(fracToDouble(n));
|
||||
}
|
||||
|
||||
function unaryDouble(fn: (x: number) => number): SheetFunction {
|
||||
return {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
return fromDouble(fn(fracToDouble(n)));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 함수 표 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const MATH_FUNCTIONS: Record<string, SheetFunction> = {
|
||||
SUM: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
if (isErr(values)) return values;
|
||||
return values.reduce(add, ZERO);
|
||||
},
|
||||
},
|
||||
PRODUCT: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
if (isErr(values)) return values;
|
||||
return values.length === 0 ? ZERO : values.reduce(mul, ONE);
|
||||
},
|
||||
},
|
||||
SUMPRODUCT: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const arrays = args.map((a) => a());
|
||||
const grids = arrays.map((v) =>
|
||||
isRange(v) ? v : { rows: 1, cols: 1, at: () => (isErr(v) ? v : (v as Scalar)) },
|
||||
);
|
||||
const rows = grids[0].rows;
|
||||
const cols = grids[0].cols;
|
||||
for (const g of grids) {
|
||||
if (g.rows !== rows || g.cols !== cols) return mkErr("#VALUE!", "배열 크기가 다름");
|
||||
}
|
||||
let sum = ZERO;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
let prod = ONE;
|
||||
for (const g of grids) {
|
||||
const n = toNumber(g.at(r, c));
|
||||
if (isErr(n)) return n;
|
||||
prod = mul(prod, n);
|
||||
}
|
||||
sum = add(sum, prod);
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
},
|
||||
},
|
||||
ROUND: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const d = digitsArg(args, 1, 0);
|
||||
if (isErr(d)) return d;
|
||||
return roundAt(n, d, "round");
|
||||
},
|
||||
},
|
||||
ROUNDUP: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const d = digitsArg(args, 1, 0);
|
||||
if (isErr(d)) return d;
|
||||
return roundAt(n, d, "away");
|
||||
},
|
||||
},
|
||||
ROUNDDOWN: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const d = digitsArg(args, 1, 0);
|
||||
if (isErr(d)) return d;
|
||||
return roundAt(n, d, "trunc");
|
||||
},
|
||||
},
|
||||
INT: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
return frac(toInteger(n, "floor"));
|
||||
},
|
||||
},
|
||||
TRUNC: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const d = digitsArg(args, 1, 0);
|
||||
if (isErr(d)) return d;
|
||||
return roundAt(n, d, "trunc");
|
||||
},
|
||||
},
|
||||
CEILING: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const sig = num(args[1]());
|
||||
if (isErr(sig)) return sig;
|
||||
if (cmp(sig, ZERO) === 0) return ZERO;
|
||||
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(sig, ZERO) > 0) {
|
||||
return mkErr("#NUM!", "수 · 기준의 부호가 다름");
|
||||
}
|
||||
return mul(frac(toInteger(div(n, sig), "away")), sig);
|
||||
},
|
||||
},
|
||||
FLOOR: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const sig = num(args[1]());
|
||||
if (isErr(sig)) return sig;
|
||||
if (cmp(sig, ZERO) === 0) return ZERO;
|
||||
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(sig, ZERO) > 0) {
|
||||
return mkErr("#NUM!", "수 · 기준의 부호가 다름");
|
||||
}
|
||||
return mul(frac(toInteger(div(n, sig), "trunc")), sig);
|
||||
},
|
||||
},
|
||||
ABS: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
return frac(n.n < 0n ? -n.n : n.n, n.d);
|
||||
},
|
||||
},
|
||||
MOD: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const d = num(args[1]());
|
||||
if (isErr(d)) return d;
|
||||
if (cmp(d, ZERO) === 0) return mkErr("#DIV/0!");
|
||||
const q = frac(toInteger(div(n, d), "floor"));
|
||||
return sub(n, mul(d, q));
|
||||
},
|
||||
},
|
||||
POWER: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const p = num(args[1]());
|
||||
if (isErr(p)) return p;
|
||||
if (p.d === 1n) {
|
||||
const exp = p.n;
|
||||
if (exp >= 0n) return frac(n.n ** exp, n.d ** exp);
|
||||
if (n.n === 0n) return mkErr("#DIV/0!");
|
||||
return frac(n.d ** -exp, n.n ** -exp);
|
||||
}
|
||||
return fromDouble(Math.pow(fracToDouble(n), fracToDouble(p)));
|
||||
},
|
||||
},
|
||||
SQRT: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
if (cmp(n, ZERO) < 0) return mkErr("#NUM!");
|
||||
return fromDouble(Math.sqrt(fracToDouble(n)));
|
||||
},
|
||||
},
|
||||
PI: { min: 0, max: 0, call: () => fromDouble(Math.PI) },
|
||||
SIN: unaryDouble(Math.sin),
|
||||
COS: unaryDouble(Math.cos),
|
||||
TAN: unaryDouble(Math.tan),
|
||||
ASIN: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const x = fracToDouble(n);
|
||||
if (x < -1 || x > 1) return mkErr("#NUM!");
|
||||
return fromDouble(Math.asin(x));
|
||||
},
|
||||
},
|
||||
ACOS: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const x = fracToDouble(n);
|
||||
if (x < -1 || x > 1) return mkErr("#NUM!");
|
||||
return fromDouble(Math.acos(x));
|
||||
},
|
||||
},
|
||||
ATAN: unaryDouble(Math.atan),
|
||||
RADIANS: unaryDouble((x) => (x * Math.PI) / 180),
|
||||
DEGREES: unaryDouble((x) => (x * 180) / Math.PI),
|
||||
MIN: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
if (isErr(values)) return values;
|
||||
return values.length === 0 ? ZERO : values.reduce((a, b) => (cmp(a, b) <= 0 ? a : b));
|
||||
},
|
||||
},
|
||||
MAX: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
if (isErr(values)) return values;
|
||||
return values.length === 0 ? ZERO : values.reduce((a, b) => (cmp(a, b) >= 0 ? a : b));
|
||||
},
|
||||
},
|
||||
AVERAGE: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
if (isErr(values)) return values;
|
||||
if (values.length === 0) return mkErr("#DIV/0!");
|
||||
return div(values.reduce(add, ZERO), frac(BigInt(values.length)));
|
||||
},
|
||||
},
|
||||
MROUND: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const m = num(args[1]());
|
||||
if (isErr(m)) return m;
|
||||
if (cmp(m, ZERO) === 0) return ZERO;
|
||||
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(m, ZERO) > 0) {
|
||||
return mkErr("#NUM!", "수 · 배수의 부호가 다름");
|
||||
}
|
||||
return mul(frac(toInteger(div(n, m), "round")), m);
|
||||
},
|
||||
},
|
||||
ODD: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
let v = toInteger(n, "away");
|
||||
if (v === 0n) v = 1n;
|
||||
else if (v % 2n === 0n) v += v < 0n ? -1n : 1n;
|
||||
return frac(v);
|
||||
},
|
||||
},
|
||||
EVEN: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
let v = toInteger(n, "away");
|
||||
if (v % 2n !== 0n) v += v < 0n ? -1n : 1n;
|
||||
return frac(v);
|
||||
},
|
||||
},
|
||||
LOG: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
|
||||
const base = args.length > 1 ? num(args[1]()) : frac(10n);
|
||||
if (isErr(base)) return base;
|
||||
if (cmp(base, ZERO) <= 0 || cmp(base, ONE) === 0) return mkErr("#NUM!");
|
||||
return fromDouble(Math.log(fracToDouble(n)) / Math.log(fracToDouble(base)));
|
||||
},
|
||||
},
|
||||
LOG10: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
|
||||
return fromDouble(Math.log10(fracToDouble(n)));
|
||||
},
|
||||
},
|
||||
LN: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
|
||||
return fromDouble(Math.log(fracToDouble(n)));
|
||||
},
|
||||
},
|
||||
EXP: unaryDouble(Math.exp),
|
||||
};
|
||||
@@ -0,0 +1,656 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_functions_misc.ts (주인 B)
|
||||
* 논리 · 글 · 정보 · 날짜 함수. 찾기 · 조건부 세기/합/평균/최대/최소는
|
||||
* `spreadsheet_functions_lookup.ts` 로 옮김(700 줄 규칙 · 2026-09-27).
|
||||
* 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
ZERO,
|
||||
cmp,
|
||||
frac,
|
||||
mul,
|
||||
parseDecimal,
|
||||
roundAt,
|
||||
toInteger,
|
||||
type Frac,
|
||||
} from "@ui/sheet/ui_template_sheet_frac";
|
||||
import type { ErrorValue, LazyArg, SheetFunction } from "./spreadsheet_types";
|
||||
import { formatValue } from "./spreadsheet_numfmt";
|
||||
import {
|
||||
bool,
|
||||
fracToDouble,
|
||||
fromDouble,
|
||||
isErr,
|
||||
isFrac,
|
||||
isRange,
|
||||
mkErr,
|
||||
num,
|
||||
str,
|
||||
toBool,
|
||||
toScalar,
|
||||
toText,
|
||||
} from "./spreadsheet_functions_math";
|
||||
|
||||
const ONE = frac(1n);
|
||||
|
||||
/** SEARCH — 통짜 맞춤이 아니라 안 어디서든 찾음(자리 없이 그대로 씀). */
|
||||
function wildcardToSearchRegex(text: string): RegExp {
|
||||
const escaped = text
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
return new RegExp(escaped, "i");
|
||||
}
|
||||
|
||||
// ── 숫자 → 고정 소수 글(FIXED) ───────────────────────────────────────────────
|
||||
|
||||
function groupThousands(digits: string): string {
|
||||
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
function formatFixed(n: Frac, decimals: number, commas: boolean): string {
|
||||
const d = Math.max(0, decimals);
|
||||
const rounded = roundAt(n, d, "round");
|
||||
const negative = cmp(rounded, ZERO) < 0;
|
||||
const magnitude = negative ? frac(-rounded.n, rounded.d) : rounded;
|
||||
const scaled = toInteger(mul(magnitude, frac(10n ** BigInt(d))), "round");
|
||||
const digits = scaled.toString().padStart(d + 1, "0");
|
||||
let whole = d > 0 ? digits.slice(0, -d) : digits;
|
||||
const fraction = d > 0 ? digits.slice(-d) : "";
|
||||
if (commas) whole = groupThousands(whole);
|
||||
const body = fraction ? `${whole}.${fraction}` : whole;
|
||||
return negative ? `-${body}` : body;
|
||||
}
|
||||
|
||||
function collectBools(args: LazyArg[]): boolean[] | ErrorValue {
|
||||
const out: boolean[] = [];
|
||||
for (const a of args) {
|
||||
const v = a();
|
||||
if (isRange(v)) {
|
||||
for (let r = 0; r < v.rows; r++) {
|
||||
for (let c = 0; c < v.cols; c++) {
|
||||
const cell = v.at(r, c);
|
||||
if (isErr(cell)) return cell;
|
||||
if (typeof cell === "boolean") out.push(cell);
|
||||
else if (isFrac(cell)) out.push(cmp(cell, ZERO) !== 0);
|
||||
}
|
||||
}
|
||||
} else if (isErr(v)) {
|
||||
return v;
|
||||
} else if (v !== null) {
|
||||
const b = toBool(v);
|
||||
if (isErr(b)) return b;
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 날짜 일련번호(엑셀 1900 체계 · 1970-01-01 = 25569) ───────────────────────
|
||||
// 1900 을 윤년 취급하는 옛 버그는 그대로 두지 않음(1900 년대 자체를 실무가 안 씀) —
|
||||
// 유닉스 자정 기준으로 바로 셈해 1900 년 2 월 안쪽만 어긋남(1단계 밖).
|
||||
|
||||
const MS_PER_DAY = 86400000;
|
||||
const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569;
|
||||
|
||||
function serialToDate(serial: number): Date {
|
||||
return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY));
|
||||
}
|
||||
|
||||
function dateToSerial(d: Date): number {
|
||||
return d.getTime() / MS_PER_DAY + UNIX_EPOCH_AS_EXCEL_SERIAL;
|
||||
}
|
||||
|
||||
/** DATEDIF 낱자리(Y·M·D·YM·MD·YD) — 끝이 처음보다 이르면 `#NUM!` */
|
||||
function dateDiff(startSerial: number, endSerial: number, unit: string): number | ErrorValue {
|
||||
const start = serialToDate(startSerial);
|
||||
const end = serialToDate(endSerial);
|
||||
if (end.getTime() < start.getTime()) return mkErr("#NUM!");
|
||||
const u = unit.toUpperCase();
|
||||
const sy = start.getUTCFullYear();
|
||||
const sm = start.getUTCMonth();
|
||||
const sd = start.getUTCDate();
|
||||
const ey = end.getUTCFullYear();
|
||||
const em = end.getUTCMonth();
|
||||
const ed = end.getUTCDate();
|
||||
if (u === "Y") {
|
||||
let y = ey - sy;
|
||||
if (em < sm || (em === sm && ed < sd)) y--;
|
||||
return y;
|
||||
}
|
||||
if (u === "M") {
|
||||
let m = (ey - sy) * 12 + (em - sm);
|
||||
if (ed < sd) m--;
|
||||
return m;
|
||||
}
|
||||
if (u === "D") return Math.round((end.getTime() - start.getTime()) / MS_PER_DAY);
|
||||
if (u === "YM") {
|
||||
let m = em - sm;
|
||||
if (ed < sd) m--;
|
||||
return ((m % 12) + 12) % 12;
|
||||
}
|
||||
if (u === "MD") {
|
||||
let d = ed - sd;
|
||||
if (d < 0) d += new Date(Date.UTC(ey, em, 0)).getUTCDate();
|
||||
return d;
|
||||
}
|
||||
if (u === "YD") {
|
||||
let days = Math.round((end.getTime() - Date.UTC(ey, sm, sd)) / MS_PER_DAY);
|
||||
if (days < 0) days = Math.round((end.getTime() - Date.UTC(ey - 1, sm, sd)) / MS_PER_DAY);
|
||||
return days;
|
||||
}
|
||||
return mkErr("#NUM!");
|
||||
}
|
||||
|
||||
/** 일련번호의 소수부(하루 안 시각) → 시 · 분 · 초 */
|
||||
function timeParts(serial: number): { h: number; mnt: number; s: number } {
|
||||
const dayFrac = serial - Math.floor(serial);
|
||||
const totalSeconds = Math.round(dayFrac * 86400);
|
||||
return {
|
||||
h: Math.floor(totalSeconds / 3600) % 24,
|
||||
mnt: Math.floor(totalSeconds / 60) % 60,
|
||||
s: totalSeconds % 60,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 함수 표 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
|
||||
IF: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const c = bool(args[0]());
|
||||
if (isErr(c)) return c;
|
||||
if (c) return args[1]();
|
||||
return args.length > 2 ? args[2]() : false;
|
||||
},
|
||||
},
|
||||
AND: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const bs = collectBools(args);
|
||||
if (isErr(bs)) return bs;
|
||||
if (bs.length === 0) return mkErr("#VALUE!");
|
||||
return bs.every(Boolean);
|
||||
},
|
||||
},
|
||||
OR: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const bs = collectBools(args);
|
||||
if (isErr(bs)) return bs;
|
||||
if (bs.length === 0) return mkErr("#VALUE!");
|
||||
return bs.some(Boolean);
|
||||
},
|
||||
},
|
||||
NOT: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const b = bool(args[0]());
|
||||
return isErr(b) ? b : !b;
|
||||
},
|
||||
},
|
||||
IFERROR: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const v = args[0]();
|
||||
if (!isRange(v) && isErr(v)) return args[1]();
|
||||
return v;
|
||||
},
|
||||
},
|
||||
CONCATENATE: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
let out = "";
|
||||
for (const a of args) {
|
||||
const v = a();
|
||||
if (isRange(v)) {
|
||||
for (let r = 0; r < v.rows; r++) {
|
||||
for (let c = 0; c < v.cols; c++) {
|
||||
const t = toText(v.at(r, c));
|
||||
if (isErr(t)) return t;
|
||||
out += t;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const t = str(v);
|
||||
if (isErr(t)) return t;
|
||||
out += t;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
},
|
||||
FIXED: {
|
||||
min: 1,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const decArg = args.length > 1 ? num(args[1]()) : frac(2n);
|
||||
if (isErr(decArg)) return decArg;
|
||||
const decimals = Math.trunc(fracToDouble(decArg));
|
||||
const noCommaArg = args.length > 2 ? bool(args[2]()) : false;
|
||||
if (isErr(noCommaArg)) return noCommaArg;
|
||||
return formatFixed(n, decimals, !noCommaArg);
|
||||
},
|
||||
},
|
||||
TEXT: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const v = toScalar(args[0]());
|
||||
if (isErr(v)) return v;
|
||||
const code = str(args[1]());
|
||||
if (isErr(code)) return code;
|
||||
return formatValue(v, code).글;
|
||||
},
|
||||
},
|
||||
LEN: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
return isErr(s) ? s : frac(BigInt(s.length));
|
||||
},
|
||||
},
|
||||
LEFT: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
if (isErr(s)) return s;
|
||||
const nArg = args.length > 1 ? num(args[1]()) : ONE;
|
||||
if (isErr(nArg)) return nArg;
|
||||
const n = Math.max(0, Math.trunc(fracToDouble(nArg)));
|
||||
return s.slice(0, n);
|
||||
},
|
||||
},
|
||||
RIGHT: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
if (isErr(s)) return s;
|
||||
const nArg = args.length > 1 ? num(args[1]()) : ONE;
|
||||
if (isErr(nArg)) return nArg;
|
||||
const n = Math.max(0, Math.trunc(fracToDouble(nArg)));
|
||||
return n === 0 ? "" : s.slice(-n);
|
||||
},
|
||||
},
|
||||
MID: {
|
||||
min: 3,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
if (isErr(s)) return s;
|
||||
const startN = num(args[1]());
|
||||
if (isErr(startN)) return startN;
|
||||
const lenN = num(args[2]());
|
||||
if (isErr(lenN)) return lenN;
|
||||
const start = Math.trunc(fracToDouble(startN));
|
||||
const len = Math.max(0, Math.trunc(fracToDouble(lenN)));
|
||||
if (start < 1) return mkErr("#VALUE!");
|
||||
return s.slice(start - 1, start - 1 + len);
|
||||
},
|
||||
},
|
||||
VALUE: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
if (isErr(s)) return s;
|
||||
const trimmed = s.trim();
|
||||
const pct = trimmed.endsWith("%");
|
||||
const body = pct ? trimmed.slice(0, -1) : trimmed;
|
||||
try {
|
||||
const v = parseDecimal(body);
|
||||
return pct ? { n: v.n, d: v.d * 100n } : v;
|
||||
} catch {
|
||||
return mkErr("#VALUE!", `글을 수로 못 읽음: ${s}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
TEXTJOIN: {
|
||||
min: 3,
|
||||
call: (args) => {
|
||||
const delim = str(args[0]());
|
||||
if (isErr(delim)) return delim;
|
||||
const ignoreEmpty = bool(args[1]());
|
||||
if (isErr(ignoreEmpty)) return ignoreEmpty;
|
||||
const parts: string[] = [];
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
const v = args[i]();
|
||||
if (isRange(v)) {
|
||||
for (let r = 0; r < v.rows; r++) {
|
||||
for (let c = 0; c < v.cols; c++) {
|
||||
const t = toText(v.at(r, c));
|
||||
if (isErr(t)) return t;
|
||||
if (!(ignoreEmpty && t === "")) parts.push(t);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const t = str(v);
|
||||
if (isErr(t)) return t;
|
||||
if (!(ignoreEmpty && t === "")) parts.push(t);
|
||||
}
|
||||
}
|
||||
return parts.join(delim);
|
||||
},
|
||||
},
|
||||
TRIM: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
return isErr(s) ? s : s.split(" ").filter(Boolean).join(" ");
|
||||
},
|
||||
},
|
||||
UPPER: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
return isErr(s) ? s : s.toUpperCase();
|
||||
},
|
||||
},
|
||||
LOWER: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
return isErr(s) ? s : s.toLowerCase();
|
||||
},
|
||||
},
|
||||
SUBSTITUTE: {
|
||||
min: 3,
|
||||
max: 4,
|
||||
call: (args) => {
|
||||
const text = str(args[0]());
|
||||
if (isErr(text)) return text;
|
||||
const oldT = str(args[1]());
|
||||
if (isErr(oldT)) return oldT;
|
||||
const newT = str(args[2]());
|
||||
if (isErr(newT)) return newT;
|
||||
if (oldT === "") return text;
|
||||
if (args.length <= 3) return text.split(oldT).join(newT);
|
||||
const nthArg = num(args[3]());
|
||||
if (isErr(nthArg)) return nthArg;
|
||||
const nth = Math.trunc(fracToDouble(nthArg));
|
||||
let idx = -1;
|
||||
let count = 0;
|
||||
let pos = 0;
|
||||
for (;;) {
|
||||
const found = text.indexOf(oldT, pos);
|
||||
if (found < 0) break;
|
||||
count++;
|
||||
if (count === nth) {
|
||||
idx = found;
|
||||
break;
|
||||
}
|
||||
pos = found + oldT.length;
|
||||
}
|
||||
return idx < 0 ? text : text.slice(0, idx) + newT + text.slice(idx + oldT.length);
|
||||
},
|
||||
},
|
||||
REPLACE: {
|
||||
min: 4,
|
||||
max: 4,
|
||||
call: (args) => {
|
||||
const text = str(args[0]());
|
||||
if (isErr(text)) return text;
|
||||
const startN = num(args[1]());
|
||||
if (isErr(startN)) return startN;
|
||||
const lenN = num(args[2]());
|
||||
if (isErr(lenN)) return lenN;
|
||||
const newText = str(args[3]());
|
||||
if (isErr(newText)) return newText;
|
||||
const start = Math.trunc(fracToDouble(startN));
|
||||
const len = Math.max(0, Math.trunc(fracToDouble(lenN)));
|
||||
if (start < 1) return mkErr("#VALUE!");
|
||||
return text.slice(0, start - 1) + newText + text.slice(start - 1 + len);
|
||||
},
|
||||
},
|
||||
FIND: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const findT = str(args[0]());
|
||||
if (isErr(findT)) return findT;
|
||||
const within = str(args[1]());
|
||||
if (isErr(within)) return within;
|
||||
const startArg = args.length > 2 ? num(args[2]()) : ONE;
|
||||
if (isErr(startArg)) return startArg;
|
||||
const start = Math.trunc(fracToDouble(startArg));
|
||||
if (start < 1) return mkErr("#VALUE!");
|
||||
const idx = within.indexOf(findT, start - 1);
|
||||
return idx < 0 ? mkErr("#VALUE!") : frac(BigInt(idx + 1));
|
||||
},
|
||||
},
|
||||
SEARCH: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const findT = str(args[0]());
|
||||
if (isErr(findT)) return findT;
|
||||
const within = str(args[1]());
|
||||
if (isErr(within)) return within;
|
||||
const startArg = args.length > 2 ? num(args[2]()) : ONE;
|
||||
if (isErr(startArg)) return startArg;
|
||||
const start = Math.trunc(fracToDouble(startArg));
|
||||
if (start < 1) return mkErr("#VALUE!");
|
||||
const pat = wildcardToSearchRegex(findT);
|
||||
const m = pat.exec(within.slice(start - 1));
|
||||
return m ? frac(BigInt(m.index + start)) : mkErr("#VALUE!");
|
||||
},
|
||||
},
|
||||
REPT: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
if (isErr(s)) return s;
|
||||
const nArg = num(args[1]());
|
||||
if (isErr(nArg)) return nArg;
|
||||
const n = Math.trunc(fracToDouble(nArg));
|
||||
return n < 0 ? mkErr("#VALUE!") : s.repeat(n);
|
||||
},
|
||||
},
|
||||
ISNUMBER: { min: 1, max: 1, call: (args) => isFrac(toScalar(args[0]())) },
|
||||
ISTEXT: { min: 1, max: 1, call: (args) => typeof toScalar(args[0]()) === "string" },
|
||||
ISBLANK: { min: 1, max: 1, call: (args) => toScalar(args[0]()) === null },
|
||||
ISERROR: { min: 1, max: 1, call: (args) => isErr(toScalar(args[0]())) },
|
||||
NA: { min: 0, max: 0, call: () => mkErr("#N/A") },
|
||||
TODAY: {
|
||||
min: 0,
|
||||
max: 0,
|
||||
call: () => {
|
||||
const now = new Date();
|
||||
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
return fromDouble(dateToSerial(new Date(midnight)));
|
||||
},
|
||||
},
|
||||
DATE: {
|
||||
min: 3,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const y = num(args[0]());
|
||||
if (isErr(y)) return y;
|
||||
const m = num(args[1]());
|
||||
if (isErr(m)) return m;
|
||||
const d = num(args[2]());
|
||||
if (isErr(d)) return d;
|
||||
const date = new Date(
|
||||
Date.UTC(
|
||||
Math.trunc(fracToDouble(y)),
|
||||
Math.trunc(fracToDouble(m)) - 1,
|
||||
Math.trunc(fracToDouble(d)),
|
||||
),
|
||||
);
|
||||
return fromDouble(dateToSerial(date));
|
||||
},
|
||||
},
|
||||
YEAR: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCFullYear()));
|
||||
},
|
||||
},
|
||||
MONTH: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCMonth() + 1));
|
||||
},
|
||||
},
|
||||
DAY: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCDate()));
|
||||
},
|
||||
},
|
||||
NOW: { min: 0, max: 0, call: () => fromDouble(dateToSerial(new Date())) },
|
||||
WEEKDAY: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const typeArg = args.length > 1 ? num(args[1]()) : ONE;
|
||||
if (isErr(typeArg)) return typeArg;
|
||||
const type = Math.trunc(fracToDouble(typeArg));
|
||||
const dow = serialToDate(fracToDouble(n)).getUTCDay(); // 0=일 .. 6=토
|
||||
if (type === 2) return frac(BigInt(((dow + 6) % 7) + 1));
|
||||
if (type === 3) return frac(BigInt((dow + 6) % 7));
|
||||
return frac(BigInt(dow + 1));
|
||||
},
|
||||
},
|
||||
EDATE: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const mArg = num(args[1]());
|
||||
if (isErr(mArg)) return mArg;
|
||||
const months = Math.trunc(fracToDouble(mArg));
|
||||
const d = serialToDate(fracToDouble(n));
|
||||
const targetMonth = d.getUTCMonth() + months;
|
||||
// 달 끝을 넘는 날짜(1/31 + 1개월 등)는 다음 달로 넘기지 않고 그 달 말일로 눌러 담음(엑셀과 같음).
|
||||
const lastDay = new Date(Date.UTC(d.getUTCFullYear(), targetMonth + 1, 0)).getUTCDate();
|
||||
const day = Math.min(d.getUTCDate(), lastDay);
|
||||
const date = new Date(Date.UTC(d.getUTCFullYear(), targetMonth, day));
|
||||
return fromDouble(dateToSerial(date));
|
||||
},
|
||||
},
|
||||
EOMONTH: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const mArg = num(args[1]());
|
||||
if (isErr(mArg)) return mArg;
|
||||
const months = Math.trunc(fracToDouble(mArg));
|
||||
const d = serialToDate(fracToDouble(n));
|
||||
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months + 1, 0));
|
||||
return fromDouble(dateToSerial(date));
|
||||
},
|
||||
},
|
||||
DATEDIF: {
|
||||
min: 3,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const s = num(args[0]());
|
||||
if (isErr(s)) return s;
|
||||
const e = num(args[1]());
|
||||
if (isErr(e)) return e;
|
||||
const unit = str(args[2]());
|
||||
if (isErr(unit)) return unit;
|
||||
const result = dateDiff(fracToDouble(s), fracToDouble(e), unit);
|
||||
return isErr(result) ? result : frac(BigInt(result));
|
||||
},
|
||||
},
|
||||
NETWORKDAYS: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const s = num(args[0]());
|
||||
if (isErr(s)) return s;
|
||||
const e = num(args[1]());
|
||||
if (isErr(e)) return e;
|
||||
let startSerial = Math.round(fracToDouble(s));
|
||||
let endSerial = Math.round(fracToDouble(e));
|
||||
let sign = 1;
|
||||
if (startSerial > endSerial) {
|
||||
[startSerial, endSerial] = [endSerial, startSerial];
|
||||
sign = -1;
|
||||
}
|
||||
const holidays = new Set<number>();
|
||||
if (args.length > 2) {
|
||||
const holidayArg = args[2]();
|
||||
if (isRange(holidayArg)) {
|
||||
for (let r = 0; r < holidayArg.rows; r++) {
|
||||
for (let c = 0; c < holidayArg.cols; c++) {
|
||||
const cell = holidayArg.at(r, c);
|
||||
if (isFrac(cell)) holidays.add(Math.round(fracToDouble(cell)));
|
||||
}
|
||||
}
|
||||
} else if (isFrac(holidayArg)) {
|
||||
holidays.add(Math.round(fracToDouble(holidayArg)));
|
||||
}
|
||||
}
|
||||
let count = 0;
|
||||
for (let day = startSerial; day <= endSerial; day++) {
|
||||
const dow = serialToDate(day).getUTCDay();
|
||||
if (dow === 0 || dow === 6) continue;
|
||||
if (holidays.has(day)) continue;
|
||||
count++;
|
||||
}
|
||||
return frac(BigInt(sign * count));
|
||||
},
|
||||
},
|
||||
HOUR: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).h));
|
||||
},
|
||||
},
|
||||
MINUTE: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).mnt));
|
||||
},
|
||||
},
|
||||
TIME: {
|
||||
min: 3,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const hArg = num(args[0]());
|
||||
if (isErr(hArg)) return hArg;
|
||||
const mArg = num(args[1]());
|
||||
if (isErr(mArg)) return mArg;
|
||||
const sArg = num(args[2]());
|
||||
if (isErr(sArg)) return sArg;
|
||||
const totalSeconds =
|
||||
Math.trunc(fracToDouble(hArg)) * 3600 +
|
||||
Math.trunc(fracToDouble(mArg)) * 60 +
|
||||
Math.trunc(fracToDouble(sArg));
|
||||
const dayFraction = ((totalSeconds % 86400) + 86400) % 86400;
|
||||
return fromDouble(dayFraction / 86400);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_functions_stat.ts (주인 B · 3차 · 2026-09-27)
|
||||
* 통계 함수 — MEDIAN · MODE · STDEV(.S/.P) · VAR · RANK(.EQ) · LARGE · SMALL ·
|
||||
* PERCENTILE · QUARTILE · COUNTBLANK. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import { ZERO, add, cmp, div, frac, mul, sub, type Frac } from "@ui/sheet/ui_template_sheet_frac";
|
||||
import type {
|
||||
ErrorValue,
|
||||
EvalContext,
|
||||
EvalResult,
|
||||
LazyArg,
|
||||
RangeValue,
|
||||
SheetFunction,
|
||||
} from "./spreadsheet_types";
|
||||
import {
|
||||
collectNumbers,
|
||||
fracToDouble,
|
||||
fromDouble,
|
||||
isErr,
|
||||
isFrac,
|
||||
isRange,
|
||||
mkErr,
|
||||
num,
|
||||
} from "./spreadsheet_functions_math";
|
||||
|
||||
function rangeNumbers(range: RangeValue): Frac[] | ErrorValue {
|
||||
const out: Frac[] = [];
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) {
|
||||
const cell = range.at(r, c);
|
||||
if (isErr(cell)) return cell;
|
||||
if (isFrac(cell)) out.push(cell);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function meanAndSumSq(values: Frac[]): { mean: Frac; sumSq: Frac } {
|
||||
const n = frac(BigInt(values.length));
|
||||
const mean = div(values.reduce(add, ZERO), n);
|
||||
const sumSq = values.reduce((acc, v) => add(acc, mul(sub(v, mean), sub(v, mean))), ZERO);
|
||||
return { mean, sumSq };
|
||||
}
|
||||
|
||||
function sampleVariance(values: Frac[]): Frac | ErrorValue {
|
||||
if (values.length < 2) return mkErr("#DIV/0!");
|
||||
return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length - 1)));
|
||||
}
|
||||
|
||||
function populationVariance(values: Frac[]): Frac | ErrorValue {
|
||||
if (values.length < 1) return mkErr("#DIV/0!");
|
||||
return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length)));
|
||||
}
|
||||
|
||||
function stdevOf(variance: Frac | ErrorValue): Frac | ErrorValue {
|
||||
if (isErr(variance)) return variance;
|
||||
return fromDouble(Math.sqrt(fracToDouble(variance)));
|
||||
}
|
||||
|
||||
/** 정렬한 값에서 0 ≤ k ≤ 1 자리의 백분위(직선 보간 · 엑셀 PERCENTILE.INC 와 같음) */
|
||||
function percentileOf(values: Frac[], k: number): Frac | ErrorValue {
|
||||
if (values.length === 0) return mkErr("#NUM!");
|
||||
if (k < 0 || k > 1) return mkErr("#NUM!");
|
||||
const sorted = [...values].sort((a, b) => cmp(a, b));
|
||||
const pos = k * (sorted.length - 1);
|
||||
const lowIdx = Math.floor(pos);
|
||||
const highIdx = Math.ceil(pos);
|
||||
if (lowIdx === highIdx) return sorted[lowIdx];
|
||||
const weight = fromDouble(pos - lowIdx);
|
||||
if (isErr(weight)) return weight;
|
||||
return add(sorted[lowIdx], mul(weight, sub(sorted[highIdx], sorted[lowIdx])));
|
||||
}
|
||||
|
||||
function nthOrderStat(range: RangeValue, k: number, largest: boolean): Frac | ErrorValue {
|
||||
const values = rangeNumbers(range);
|
||||
if (isErr(values)) return values;
|
||||
if (k < 1 || k > values.length) return mkErr("#NUM!");
|
||||
const sorted = [...values].sort((a, b) => cmp(a, b));
|
||||
return sorted[largest ? sorted.length - k : k - 1];
|
||||
}
|
||||
|
||||
function rankCall(args: LazyArg[], _ctx: EvalContext): EvalResult {
|
||||
const target = num(args[0]());
|
||||
if (isErr(target)) return target;
|
||||
const range = args[1]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const orderArg = args.length > 2 ? num(args[2]()) : ZERO;
|
||||
if (isErr(orderArg)) return orderArg;
|
||||
const ascending = cmp(orderArg, ZERO) !== 0;
|
||||
let rank = 1;
|
||||
let found = false;
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) {
|
||||
const cell = range.at(r, c);
|
||||
if (!isFrac(cell)) continue;
|
||||
if (cmp(cell, target) === 0) found = true;
|
||||
if (ascending ? cmp(cell, target) < 0 : cmp(cell, target) > 0) rank++;
|
||||
}
|
||||
}
|
||||
return found ? frac(BigInt(rank)) : mkErr("#N/A");
|
||||
}
|
||||
|
||||
export const STAT_FUNCTIONS: Record<string, SheetFunction> = {
|
||||
MEDIAN: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
if (isErr(values)) return values;
|
||||
if (values.length === 0) return mkErr("#NUM!");
|
||||
const sorted = [...values].sort((a, b) => cmp(a, b));
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 1) return sorted[mid];
|
||||
return div(add(sorted[mid - 1], sorted[mid]), frac(2n));
|
||||
},
|
||||
},
|
||||
MODE: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
if (isErr(values)) return values;
|
||||
let bestIdx = -1;
|
||||
let bestCount = 1;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
let count = 0;
|
||||
for (const v of values) if (cmp(values[i], v) === 0) count++;
|
||||
if (count > bestCount) {
|
||||
bestCount = count;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
return bestIdx < 0 ? mkErr("#N/A") : values[bestIdx];
|
||||
},
|
||||
},
|
||||
STDEV: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
return isErr(values) ? values : stdevOf(sampleVariance(values));
|
||||
},
|
||||
},
|
||||
"STDEV.S": {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
return isErr(values) ? values : stdevOf(sampleVariance(values));
|
||||
},
|
||||
},
|
||||
"STDEV.P": {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
return isErr(values) ? values : stdevOf(populationVariance(values));
|
||||
},
|
||||
},
|
||||
VAR: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
return isErr(values) ? values : sampleVariance(values);
|
||||
},
|
||||
},
|
||||
RANK: { min: 2, max: 3, call: rankCall },
|
||||
"RANK.EQ": { min: 2, max: 3, call: rankCall },
|
||||
LARGE: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const kArg = num(args[1]());
|
||||
if (isErr(kArg)) return kArg;
|
||||
return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), true);
|
||||
},
|
||||
},
|
||||
SMALL: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const kArg = num(args[1]());
|
||||
if (isErr(kArg)) return kArg;
|
||||
return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), false);
|
||||
},
|
||||
},
|
||||
PERCENTILE: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const kArg = num(args[1]());
|
||||
if (isErr(kArg)) return kArg;
|
||||
const values = rangeNumbers(range);
|
||||
if (isErr(values)) return values;
|
||||
return percentileOf(values, fracToDouble(kArg));
|
||||
},
|
||||
},
|
||||
QUARTILE: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const qArg = num(args[1]());
|
||||
if (isErr(qArg)) return qArg;
|
||||
const q = Math.trunc(fracToDouble(qArg));
|
||||
if (q < 0 || q > 4) return mkErr("#NUM!");
|
||||
const values = rangeNumbers(range);
|
||||
if (isErr(values)) return values;
|
||||
return percentileOf(values, q / 4);
|
||||
},
|
||||
},
|
||||
COUNTBLANK: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
let count = 0;
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) if (range.at(r, c) === null) count++;
|
||||
}
|
||||
return frac(BigInt(count));
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,399 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_graph.ts (주인 A)
|
||||
* 계산 엔진 — 식마다 가리키는 칸 · 범위로 의존 그래프 · 바뀐 칸에 딸린 칸만 차례로 다시 풂.
|
||||
* 순환은 고리의 칸마다 `#CYCLE!`(까닭 = 고리 칸 목록) · 반복 계산 안 함 · 나머지 칸은 계속 풂
|
||||
* (고리를 읽는 칸은 오류 전파로 `#CYCLE!`). 식 나무는 식 글마다 캐시.
|
||||
* 풀이는 필요할 때 끌어 풂(앞 칸이 안 풀렸으면 먼저) — 차례는 저절로 위상 순서.
|
||||
* ========================================================================== */
|
||||
|
||||
import { FormulaError, ZERO } from "@ui/sheet/ui_template_sheet_frac";
|
||||
import { cellId, fromCellId, inRange, parseA1, toA1 } from "./spreadsheet_address";
|
||||
import { err, evaluate, inputValue, intersect, toCalcValue } from "./spreadsheet_eval";
|
||||
import type { PlacedRange } from "./spreadsheet_eval";
|
||||
import { parseFormula } from "./spreadsheet_parser";
|
||||
import type {
|
||||
CalcEngine,
|
||||
CalcValues,
|
||||
Cell,
|
||||
CellRange,
|
||||
EvalContext,
|
||||
EvalResult,
|
||||
FormulaNode,
|
||||
ParseResult,
|
||||
Scalar,
|
||||
Sheet,
|
||||
SheetCellAddress,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
|
||||
interface SheetIndex {
|
||||
id: string;
|
||||
cells: Map<number, Cell>;
|
||||
/** 값 든 칸의 가장 먼 행 · 열 — 행 · 열 전체 범위를 여기까지만 읽음 */
|
||||
maxR: number;
|
||||
maxC: number;
|
||||
}
|
||||
|
||||
interface Dep {
|
||||
sheet: string;
|
||||
range: CellRange;
|
||||
}
|
||||
|
||||
const DONE = 2;
|
||||
const ACTIVE = 1;
|
||||
|
||||
const keyOf = (sheet: string, id: number) => `${sheet}!${id}`;
|
||||
|
||||
function splitKey(key: string): SheetCellAddress {
|
||||
const i = key.lastIndexOf("!");
|
||||
return { 시트: key.slice(0, i), ...fromCellId(Number(key.slice(i + 1))) };
|
||||
}
|
||||
|
||||
/** 나무 안 칸 · 범위 참조(시트 이름 → id · 없는 시트는 뺌) */
|
||||
function collectRefs(
|
||||
node: FormulaNode,
|
||||
host: string,
|
||||
sheetId: (n: string) => string | null,
|
||||
out: Dep[],
|
||||
) {
|
||||
switch (node.type) {
|
||||
case "cell":
|
||||
case "range": {
|
||||
const sheet = node.sheet === null ? host : sheetId(node.sheet);
|
||||
if (sheet === null) return;
|
||||
const [a, b] = node.type === "cell" ? [node.ref, node.ref] : [node.from, node.to];
|
||||
out.push({
|
||||
sheet,
|
||||
range: {
|
||||
r0: Math.min(a.r, b.r),
|
||||
c0: Math.min(a.c, b.c),
|
||||
r1: Math.max(a.r, b.r),
|
||||
c1: Math.max(a.c, b.c),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "unary":
|
||||
return collectRefs(node.arg, host, sheetId, out);
|
||||
case "binary":
|
||||
collectRefs(node.left, host, sheetId, out);
|
||||
return collectRefs(node.right, host, sheetId, out);
|
||||
case "call":
|
||||
for (const a of node.args) collectRefs(a, host, sheetId, out);
|
||||
}
|
||||
}
|
||||
|
||||
/** 이름(`값들`) → 정의한 범위 참조(자기 시트 이름 먼저 · 없으면 다른 시트) · 없는 이름은 그대로(→ `#NAME?`) */
|
||||
export function bindNames(node: FormulaNode, book: Workbook, host: string): FormulaNode {
|
||||
const go = (n: FormulaNode): FormulaNode => {
|
||||
switch (n.type) {
|
||||
case "name": {
|
||||
const low = n.name.toLowerCase();
|
||||
const def = (s?: Sheet) => s?.이름정의?.find((d) => d.이름.toLowerCase() === low);
|
||||
const s = [book.시트.find((x) => x.id === host), ...book.시트].find(def);
|
||||
if (!s) return n;
|
||||
const g = def(s)!.범위;
|
||||
const sheet = s.id === host ? null : s.이름;
|
||||
const from = { r: g.r0, c: g.c0, absR: true, absC: true };
|
||||
const to = { r: g.r1, c: g.c1, absR: true, absC: true };
|
||||
return g.r0 === g.r1 && g.c0 === g.c1
|
||||
? { type: "cell", sheet, ref: from }
|
||||
: { type: "range", sheet, from, to };
|
||||
}
|
||||
case "unary":
|
||||
return { ...n, arg: go(n.arg) };
|
||||
case "binary":
|
||||
return { ...n, left: go(n.left), right: go(n.right) };
|
||||
case "call":
|
||||
return { ...n, args: n.args.map(go) };
|
||||
}
|
||||
return n;
|
||||
};
|
||||
return book.시트.some((s) => s.이름정의?.length) ? go(node) : node;
|
||||
}
|
||||
|
||||
/** 나무 하나 풂 —던진 오류는 오류 값으로(0 나눔 → `#DIV/0!` · 나머지 `#VALUE!`) */
|
||||
export function evalSafe(node: FormulaNode, ctx: EvalContext): EvalResult {
|
||||
try {
|
||||
return evaluate(node, ctx);
|
||||
} catch (e) {
|
||||
if (e instanceof FormulaError && e.message.startsWith("0 으로"))
|
||||
return err("#DIV/0!", e.message);
|
||||
return err("#VALUE!", e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** 문서를 참조로 쥠 — 만들 때 전부 풂. */
|
||||
export function createCalcEngine(book: Workbook): CalcEngine {
|
||||
const parsed = new Map<string, ParseResult>();
|
||||
let sheets = new Map<string, SheetIndex>();
|
||||
let names = new Map<string, string>();
|
||||
/** 식 칸 → 가리키는 칸 · 범위 */
|
||||
let precs = new Map<string, Dep[]>();
|
||||
/** 칸 하나 → 그 칸을 가리키는 식 칸 */
|
||||
let cellDeps = new Map<string, Set<string>>();
|
||||
/** 시트 → 범위를 가리키는 식 칸 */
|
||||
let rangeDeps = new Map<string, Map<string, CellRange[]>>();
|
||||
let vals = new Map<string, Scalar>();
|
||||
let state = new Map<string, number>();
|
||||
const stack: string[] = [];
|
||||
const cycle = new Map<string, string>();
|
||||
|
||||
const sheetId = (name: string) => names.get(name.toLowerCase()) ?? null;
|
||||
|
||||
/** 식 나무(이름은 범위로 바꿈) — 시트마다 캐시 · rebuild 때 비움 */
|
||||
const parse = (text: string, host: string) => {
|
||||
const k = `${host}!${text}`;
|
||||
let p = parsed.get(k);
|
||||
if (!p) {
|
||||
p = parseFormula(text);
|
||||
if (p.ok) p.node = bindNames(p.node, book, host);
|
||||
parsed.set(k, p);
|
||||
}
|
||||
return p;
|
||||
};
|
||||
|
||||
function unlink(key: string) {
|
||||
for (const d of precs.get(key) ?? []) {
|
||||
if (d.range.r0 === d.range.r1 && d.range.c0 === d.range.c1)
|
||||
cellDeps.get(keyOf(d.sheet, cellId(d.range.r0, d.range.c0)))?.delete(key);
|
||||
else rangeDeps.get(d.sheet)?.delete(key);
|
||||
}
|
||||
precs.delete(key);
|
||||
}
|
||||
|
||||
/** 칸 하나를 색인에 올림(식이면 가리키는 곳 잇기) */
|
||||
function index(sheet: SheetIndex, id: number, cell: Cell | undefined) {
|
||||
const key = keyOf(sheet.id, id);
|
||||
unlink(key);
|
||||
vals.delete(key);
|
||||
state.delete(key);
|
||||
cycle.delete(key);
|
||||
if (!cell || (cell.식 === undefined && cell.값 === undefined)) {
|
||||
sheet.cells.delete(id);
|
||||
return;
|
||||
}
|
||||
sheet.cells.set(id, cell);
|
||||
const { r, c } = fromCellId(id);
|
||||
sheet.maxR = Math.max(sheet.maxR, r);
|
||||
sheet.maxC = Math.max(sheet.maxC, c);
|
||||
if (cell.식 === undefined) {
|
||||
vals.set(key, inputValue(cell.값!));
|
||||
return;
|
||||
}
|
||||
const p = parse(cell.식, sheet.id);
|
||||
const deps: Dep[] = [];
|
||||
if (p.ok) collectRefs(p.node, sheet.id, sheetId, deps);
|
||||
precs.set(key, deps);
|
||||
for (const d of deps) {
|
||||
if (d.range.r0 === d.range.r1 && d.range.c0 === d.range.c1) {
|
||||
const k = keyOf(d.sheet, cellId(d.range.r0, d.range.c0));
|
||||
let set = cellDeps.get(k);
|
||||
if (!set) cellDeps.set(k, (set = new Set()));
|
||||
set.add(key);
|
||||
} else {
|
||||
let m = rangeDeps.get(d.sheet);
|
||||
if (!m) rangeDeps.set(d.sheet, (m = new Map()));
|
||||
let list = m.get(key);
|
||||
if (!list) m.set(key, (list = []));
|
||||
list.push(d.range);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rebuild(next: Workbook) {
|
||||
book = next;
|
||||
parsed.clear();
|
||||
sheets = new Map();
|
||||
names = new Map();
|
||||
precs = new Map();
|
||||
cellDeps = new Map();
|
||||
rangeDeps = new Map();
|
||||
vals = new Map();
|
||||
state = new Map();
|
||||
cycle.clear();
|
||||
for (const s of book.시트) {
|
||||
sheets.set(s.id, { id: s.id, cells: new Map(), maxR: 0, maxC: 0 });
|
||||
names.set(s.이름.toLowerCase(), s.id);
|
||||
}
|
||||
for (const s of book.시트) {
|
||||
const idx = sheets.get(s.id)!;
|
||||
for (const [a1, cell] of Object.entries(s.칸)) {
|
||||
const at = parseA1(a1);
|
||||
if (at) index(idx, cellId(at.r, at.c), cell);
|
||||
}
|
||||
}
|
||||
for (const key of precs.keys()) ensure(key);
|
||||
}
|
||||
|
||||
function ctxFor(sheet: string, r: number, c: number): EvalContext {
|
||||
return { sheet, r, c, cell: read, range, sheetId };
|
||||
}
|
||||
|
||||
function read(sheet: string, r: number, c: number): Scalar {
|
||||
const key = keyOf(sheet, cellId(r, c));
|
||||
if (precs.has(key)) return compute(key);
|
||||
return vals.get(key) ?? null;
|
||||
}
|
||||
|
||||
function range(sheet: string, rg: CellRange): PlacedRange {
|
||||
const s = sheets.get(sheet);
|
||||
// ponytail: 행 · 열 전체 범위는 값 든 끝까지만 — INDEX(A:A, 끝 너머)는 #REF! 로 갈림
|
||||
const r1 = Math.min(rg.r1, Math.max(rg.r0, s?.maxR ?? 0));
|
||||
const c1 = Math.min(rg.c1, Math.max(rg.c0, s?.maxC ?? 0));
|
||||
return {
|
||||
kind: "range",
|
||||
rows: r1 - rg.r0 + 1,
|
||||
cols: c1 - rg.c0 + 1,
|
||||
at: (i, j) => read(sheet, rg.r0 + i, rg.c0 + j),
|
||||
...rg,
|
||||
};
|
||||
}
|
||||
|
||||
/** 가리키는 식 칸들(범위는 넓이 · 든 칸 수 중 작은 쪽으로 훑음) */
|
||||
function formulaPrecs(key: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const d of precs.get(key) ?? []) {
|
||||
const s = sheets.get(d.sheet);
|
||||
if (!s) continue;
|
||||
const r1 = Math.min(d.range.r1, s.maxR);
|
||||
const c1 = Math.min(d.range.c1, s.maxC);
|
||||
if ((r1 - d.range.r0 + 1) * (c1 - d.range.c0 + 1) <= s.cells.size) {
|
||||
for (let r = d.range.r0; r <= r1; r++)
|
||||
for (let c = d.range.c0; c <= c1; c++) {
|
||||
const k = keyOf(d.sheet, cellId(r, c));
|
||||
if (precs.has(k)) out.push(k);
|
||||
}
|
||||
} else
|
||||
for (const id of s.cells.keys()) {
|
||||
const k = keyOf(d.sheet, id);
|
||||
const { r, c } = fromCellId(id);
|
||||
if (precs.has(k) && inRange(d.range, r, c)) out.push(k);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 앞 식 칸부터 반복으로 풂 — 깊은 사슬에도 스택이 안 넘침 · 고리는 compute 가 잡음 */
|
||||
function ensure(key: string): Scalar {
|
||||
const todo: [string, boolean][] = [[key, false]];
|
||||
const seen = new Set<string>();
|
||||
while (todo.length) {
|
||||
const [k, ready] = todo.pop()!;
|
||||
if (state.get(k) === DONE) continue;
|
||||
if (ready) {
|
||||
compute(k);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
todo.push([k, true]);
|
||||
for (const d of formulaPrecs(k))
|
||||
if (!seen.has(d) && state.get(d) !== DONE) todo.push([d, false]);
|
||||
}
|
||||
return vals.get(key) ?? null;
|
||||
}
|
||||
|
||||
/** 식 칸 하나 풂 — 풀이 중인 칸을 다시 만나면 그 사이가 고리 */
|
||||
function compute(key: string): Scalar {
|
||||
const st = state.get(key);
|
||||
if (st === DONE) return vals.get(key) ?? null;
|
||||
if (st === ACTIVE) {
|
||||
const ring = stack.slice(stack.indexOf(key));
|
||||
const why = `순환 참조: ${ring.map(label).join(" → ")} → ${label(key)}`;
|
||||
for (const k of ring) if (!cycle.has(k)) cycle.set(k, why);
|
||||
return err("#CYCLE!", why);
|
||||
}
|
||||
state.set(key, ACTIVE);
|
||||
stack.push(key);
|
||||
const { 시트, r, c } = splitKey(key);
|
||||
const cell = sheets.get(시트)!.cells.get(cellId(r, c))!;
|
||||
let v: Scalar;
|
||||
try {
|
||||
v = run(cell.식!, ctxFor(시트, r, c));
|
||||
} catch (e) {
|
||||
state.delete(key);
|
||||
throw e;
|
||||
} finally {
|
||||
stack.pop();
|
||||
}
|
||||
const why = cycle.get(key);
|
||||
if (why) v = err("#CYCLE!", why);
|
||||
vals.set(key, v);
|
||||
state.set(key, DONE);
|
||||
return v;
|
||||
}
|
||||
|
||||
function label(key: string) {
|
||||
const { 시트, r, c } = splitKey(key);
|
||||
const s = book.시트.find((x) => x.id === 시트);
|
||||
return book.시트.length > 1 && s ? `${s.이름}!${toA1(r, c)}` : toA1(r, c);
|
||||
}
|
||||
|
||||
function run(text: string, ctx: EvalContext): Scalar {
|
||||
const p = parse(text, ctx.sheet);
|
||||
if (!p.ok) return err("#NAME?", `식을 못 읽음: ${p.message} (${p.at + 1}번째 글자)`);
|
||||
const one = intersect(evalSafe(p.node, ctx), ctx);
|
||||
return one === null ? ZERO : one;
|
||||
}
|
||||
|
||||
function dependents(key: string, out: Set<string>) {
|
||||
for (const k of cellDeps.get(key) ?? []) out.add(k);
|
||||
const { 시트, r, c } = splitKey(key);
|
||||
for (const [k, list] of rangeDeps.get(시트) ?? [])
|
||||
if (list.some((rg) => inRange(rg, r, c))) out.add(k);
|
||||
}
|
||||
|
||||
rebuild(book);
|
||||
|
||||
return {
|
||||
update(changed) {
|
||||
const dirty = new Set<string>();
|
||||
for (const a of changed) {
|
||||
const s = sheets.get(a.시트);
|
||||
const sheet = book.시트.find((x) => x.id === a.시트);
|
||||
if (!s || !sheet) continue;
|
||||
const id = cellId(a.r, a.c);
|
||||
index(s, id, sheet.칸[toA1(a.r, a.c)]);
|
||||
dirty.add(keyOf(a.시트, id));
|
||||
}
|
||||
// 딸린 칸을 끝까지 모음(고리였던 칸도 다시)
|
||||
const queue = [...dirty];
|
||||
while (queue.length) {
|
||||
const next = new Set<string>();
|
||||
dependents(queue.pop()!, next);
|
||||
for (const k of next)
|
||||
if (!dirty.has(k)) {
|
||||
dirty.add(k);
|
||||
queue.push(k);
|
||||
}
|
||||
}
|
||||
for (const k of dirty)
|
||||
if (precs.has(k)) {
|
||||
state.delete(k);
|
||||
cycle.delete(k);
|
||||
}
|
||||
for (const k of dirty) if (precs.has(k)) ensure(k);
|
||||
return [...dirty].map(splitKey);
|
||||
},
|
||||
rebuild,
|
||||
value(sheet, r, c) {
|
||||
const key = keyOf(sheet, cellId(r, c));
|
||||
return precs.has(key) ? ensure(key) : (vals.get(key) ?? null);
|
||||
},
|
||||
precedents(sheet, r, c) {
|
||||
return (precs.get(keyOf(sheet, cellId(r, c))) ?? []).map((d) => ({
|
||||
시트: d.sheet,
|
||||
범위: d.range,
|
||||
}));
|
||||
},
|
||||
snapshot() {
|
||||
const out: CalcValues = {};
|
||||
for (const key of precs.keys()) {
|
||||
const { 시트, r, c } = splitKey(key);
|
||||
(out[시트] ??= {})[toA1(r, c)] = toCalcValue(ensure(key));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_grid.css (주인 C)
|
||||
* 격자 4 판 · 머리 · 칸 기본 모양. 색 · 간격은 여기 변수만(다른 파일은 이 클래스만 씀).
|
||||
* ========================================================================== */
|
||||
|
||||
.aislo-grid {
|
||||
/* 앱 전역 테마 토큰(ui_template_theme.css · :root[data-theme])을 그대로 물려받음 —
|
||||
* 여기서 값을 다시 안 적음(다크 전환 시 앱 토큰이 바뀌면 같이 바뀜). */
|
||||
--aislo-grid-bg: var(--color-surface-raised);
|
||||
--aislo-grid-fg: var(--color-text-body);
|
||||
--aislo-grid-line: var(--color-border);
|
||||
--aislo-grid-head-bg: var(--color-surface);
|
||||
--aislo-grid-head-fg: var(--color-text-secondary);
|
||||
--aislo-grid-head-border: var(--color-border);
|
||||
--aislo-grid-freeze-shadow: rgba(0, 0, 0, 0.12);
|
||||
--aislo-grid-selected: var(--color-accent);
|
||||
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font:
|
||||
10pt "굴림",
|
||||
sans-serif;
|
||||
background: var(--aislo-grid-bg);
|
||||
color: var(--aislo-grid-fg);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.aislo-grid__corner,
|
||||
.aislo-grid__colhead-frozen,
|
||||
.aislo-grid__colhead-scroll,
|
||||
.aislo-grid__rowhead-frozen,
|
||||
.aislo-grid__rowhead-scroll,
|
||||
.aislo-grid__pane {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.aislo-grid__pane--br {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.aislo-grid__corner {
|
||||
z-index: 5;
|
||||
background: var(--aislo-grid-head-bg);
|
||||
border-right: 1px solid var(--aislo-grid-head-border);
|
||||
border-bottom: 1px solid var(--aislo-grid-head-border);
|
||||
}
|
||||
|
||||
.aislo-grid__colhead-frozen,
|
||||
.aislo-grid__colhead-scroll {
|
||||
z-index: 4;
|
||||
background: var(--aislo-grid-head-bg);
|
||||
border-bottom: 1px solid var(--aislo-grid-head-border);
|
||||
}
|
||||
|
||||
.aislo-grid__rowhead-frozen,
|
||||
.aislo-grid__rowhead-scroll {
|
||||
z-index: 4;
|
||||
background: var(--aislo-grid-head-bg);
|
||||
border-right: 1px solid var(--aislo-grid-head-border);
|
||||
}
|
||||
|
||||
.aislo-grid__colhead-frozen,
|
||||
.aislo-grid__rowhead-frozen {
|
||||
z-index: 6;
|
||||
box-shadow: 2px 2px 4px var(--aislo-grid-freeze-shadow);
|
||||
}
|
||||
|
||||
.aislo-grid__colhead-inner,
|
||||
.aislo-grid__rowhead-inner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.aislo-grid__colhead,
|
||||
.aislo-grid__rowhead {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
color: var(--aislo-grid-head-fg);
|
||||
border-right: 1px solid var(--aislo-grid-head-border);
|
||||
border-bottom: 1px solid var(--aislo-grid-head-border);
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.aislo-grid__rowhead {
|
||||
left: 0;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
.aislo-grid__colhead--hidden-before,
|
||||
.aislo-grid__rowhead--hidden-before {
|
||||
border-left: 3px double var(--aislo-grid-head-fg);
|
||||
}
|
||||
|
||||
.aislo-grid__head-label {
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.aislo-grid__resize {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.aislo-grid__resize--col {
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.aislo-grid__outline-btn {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
border: 1px solid var(--aislo-grid-head-border);
|
||||
background: var(--aislo-grid-head-bg);
|
||||
color: var(--aislo-grid-head-fg);
|
||||
font-size: 0.7em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.aislo-grid__resize--row {
|
||||
left: 0;
|
||||
bottom: -3px;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.aislo-grid__pane-inner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.aislo-grid__pane--br .aislo-grid__spacer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.aislo-cell {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0 2px;
|
||||
background: var(--aislo-grid-bg);
|
||||
cursor: cell;
|
||||
}
|
||||
|
||||
.aislo-grid--gridlines .aislo-cell {
|
||||
border-right-style: solid;
|
||||
border-bottom-style: solid;
|
||||
border-right-width: var(--aislo-grid-gridline-w, 1px);
|
||||
border-bottom-width: var(--aislo-grid-gridline-w, 1px);
|
||||
border-right-color: var(--aislo-grid-line);
|
||||
border-bottom-color: var(--aislo-grid-line);
|
||||
}
|
||||
|
||||
.aislo-cell__text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: inherit;
|
||||
}
|
||||
|
||||
.aislo-cell.is-error {
|
||||
color: var(--color-danger, #c00);
|
||||
}
|
||||
|
||||
/* 오류 칸 — 엑셀 오류 검사 초록 세모(왼 위 · sub3 오류 까닭은 title 풍선). */
|
||||
.aislo-cell.is-error::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 6px 6px 0 0;
|
||||
border-color: #2e7d32 transparent transparent transparent;
|
||||
}
|
||||
|
||||
.aislo-cell.is-selected {
|
||||
outline: 2px solid var(--aislo-grid-selected);
|
||||
outline-offset: -1px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 메모 칸 — 엑셀처럼 오른 위 모서리 빨간 세모. */
|
||||
.aislo-cell.has-comment::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 0 6px 6px 0;
|
||||
border-color: transparent #c00 transparent transparent;
|
||||
}
|
||||
|
||||
.aislo-grid__ref-highlight {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
border: 1.5px solid;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.aislo-grid__fill-handle {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--aislo-grid-selected);
|
||||
border: 1px solid var(--aislo-grid-bg);
|
||||
cursor: crosshair;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.aislo-grid__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.aislo-grid__overlay > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 긴 글 넘침(`spreadsheet_spill.ts`) — 옆 빈 칸 위로 글을 폄 · 덮은 칸 사이 눈금선은 감춤(엑셀) ·
|
||||
* 고치는 중에는 활성 칸 넘침을 감춤(편집기와 안 겹치게). */
|
||||
.aislo-cell.is-spill {
|
||||
overflow: visible;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.aislo-grid--gridlines .aislo-cell.is-spill-r {
|
||||
border-right-color: transparent;
|
||||
}
|
||||
|
||||
.ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text {
|
||||
visibility: hidden;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_headers.ts (주인 C)
|
||||
* 열 글자 · 행 번호 머리 · 폭 · 높이 재기(숨김 = 0) · 끌기 · 숨김 표시.
|
||||
* `LineGeometry` = 자리 하나 재기 — 자리 대부분 기본값이라(성긴 `열`·`행` 표) 겹친 만큼만
|
||||
* 어긋난 표로 쥐고 이분 탐색으로 자리 ↔ 픽셀을 오간다(1만 행이어도 가벼움).
|
||||
* 0 계약 머리 — 몸은 C 가 채움. 계약은 `spreadsheet_types.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { colIndex, colName } from "./spreadsheet_address";
|
||||
import type { RowInfo, Sheet, Workbook } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
export const DEFAULT_COL_CHARS = 8.43;
|
||||
export const DEFAULT_ROW_PT = 15;
|
||||
/** 엑셀 글자 폭 → px 근사(굴림 10pt 기준 · Calibri 공식과 같은 꼴). */
|
||||
const CHAR_PX = 7;
|
||||
const CHAR_PAD_PX = 5;
|
||||
const PT_PX = 4 / 3;
|
||||
/** 엑셀 열 폭 상한(글자 단위) — 이 위로 재는 값이 들어와도 격자는 여기서 막음(자동 맞춤 폭주 방어). */
|
||||
export const MAX_COL_CHARS = 255;
|
||||
|
||||
export const colCharsToPx = (chars: number): number =>
|
||||
Math.round(Math.min(chars, MAX_COL_CHARS) * CHAR_PX + CHAR_PAD_PX);
|
||||
export const pxToColChars = (px: number): number => Math.max(0, (px - CHAR_PAD_PX) / CHAR_PX);
|
||||
export const ptToPx = (pt: number): number => Math.round(pt * PT_PX);
|
||||
export const pxToPt = (px: number): number => px / PT_PX;
|
||||
|
||||
export const colLabel = (c: number): string => colName(c);
|
||||
export const rowLabel = (r: number): string => String(r + 1);
|
||||
|
||||
/** 자리 하나 재기 — 성긴 기본값 어긋남 표(정렬된 색인)로 자리 ↔ 픽셀 변환. */
|
||||
export interface LineGeometry {
|
||||
/** 자리 `i` 폭(px) — 숨겼으면 0. */
|
||||
size(i: number): number;
|
||||
/** 자리 `i` 가 0 부터 시작하는 픽셀 자리(내용 좌표). */
|
||||
offset(i: number): number;
|
||||
/** 자리 `count` 개(0..count-1)를 합친 픽셀 길이. */
|
||||
total(count: number): number;
|
||||
/** 픽셀 자리 `px` 가 든 자리 번호(0 부터). */
|
||||
indexAt(px: number): number;
|
||||
}
|
||||
|
||||
class SparseGeometry implements LineGeometry {
|
||||
private readonly idx: number[] = [];
|
||||
private readonly prefix: number[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly base: number,
|
||||
overrides: Map<number, number>,
|
||||
) {
|
||||
const keys = [...overrides.keys()].sort((a, b) => a - b);
|
||||
let acc = 0;
|
||||
for (const k of keys) {
|
||||
acc += overrides.get(k)! - base;
|
||||
this.idx.push(k);
|
||||
this.prefix.push(acc);
|
||||
}
|
||||
}
|
||||
|
||||
/** 어긋난 표에서 `i` 가 들어갈 자리(첫 idx[k] >= i). */
|
||||
private lowerBound(i: number): number {
|
||||
let lo = 0;
|
||||
let hi = this.idx.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (this.idx[mid] < i) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
size(i: number): number {
|
||||
const at = this.lowerBound(i);
|
||||
if (this.idx[at] === i) {
|
||||
const before = at > 0 ? this.prefix[at - 1] : 0;
|
||||
return this.base + (this.prefix[at] - before);
|
||||
}
|
||||
return this.base;
|
||||
}
|
||||
|
||||
offset(i: number): number {
|
||||
const at = this.lowerBound(i);
|
||||
const acc = at > 0 ? this.prefix[at - 1] : 0;
|
||||
return i * this.base + acc;
|
||||
}
|
||||
|
||||
total(count: number): number {
|
||||
return this.offset(count);
|
||||
}
|
||||
|
||||
indexAt(px: number): number {
|
||||
if (px <= 0) return 0;
|
||||
let hi = Math.max(1, Math.ceil(px / Math.max(this.base, 1)) + this.idx.length + 2);
|
||||
while (this.offset(hi) <= px) hi *= 2;
|
||||
let lo = 0;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (this.offset(mid) <= px) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildColGeometry(book: Workbook, sheet: Sheet): LineGeometry {
|
||||
const base = colCharsToPx(sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS);
|
||||
const overrides = new Map<number, number>();
|
||||
for (const [key, info] of Object.entries(sheet.열 ?? {})) {
|
||||
const c = colIndex(key);
|
||||
if (c < 0) continue;
|
||||
if (info.숨김 || info.묶음?.접힘) overrides.set(c, 0);
|
||||
else if (info.폭 !== undefined) overrides.set(c, colCharsToPx(info.폭));
|
||||
}
|
||||
return new SparseGeometry(base, overrides);
|
||||
}
|
||||
|
||||
export function buildRowGeometry(book: Workbook, sheet: Sheet): LineGeometry {
|
||||
const base = ptToPx(sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT);
|
||||
const overrides = new Map<number, number>();
|
||||
for (const [key, info] of Object.entries(sheet.행 ?? {}) as [string, RowInfo][]) {
|
||||
const r = Number(key) - 1;
|
||||
if (!Number.isInteger(r) || r < 0) continue;
|
||||
if (info.숨김 || info.걸러짐 || info.묶음?.접힘) overrides.set(r, 0);
|
||||
else if (info.높이 !== undefined) overrides.set(r, ptToPx(info.높이));
|
||||
}
|
||||
return new SparseGeometry(base, overrides);
|
||||
}
|
||||
|
||||
/** 접힌 윤곽 묶음(연속 자리) — 묶음 있는 자리를 모아 붙은 것끼리 한 덩이로(같은 접힘 값끼리만). */
|
||||
export interface OutlineRun {
|
||||
from: number;
|
||||
to: number;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export function buildOutlineRuns<T extends { 묶음?: { 층: number; 접힘?: boolean } }>(
|
||||
records: Record<string, T> | undefined,
|
||||
indexOf: (key: string) => number,
|
||||
): OutlineRun[] {
|
||||
const items = Object.entries(records ?? {})
|
||||
.map(([key, info]) => ({ i: indexOf(key), collapsed: !!info.묶음?.접힘, has: !!info.묶음 }))
|
||||
.filter((x) => x.has && x.i >= 0)
|
||||
.sort((a, b) => a.i - b.i);
|
||||
const runs: OutlineRun[] = [];
|
||||
for (const it of items) {
|
||||
const last = runs[runs.length - 1];
|
||||
if (last && last.to === it.i - 1 && last.collapsed === it.collapsed) last.to = it.i;
|
||||
else runs.push({ from: it.i, to: it.i, collapsed: it.collapsed });
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** 그 자리가 숨김 · 걸러짐(사용자 · 필터) 인가 — 묶음 접힘은 빼고(화면 표시가 다름). */
|
||||
export function isExplicitHidden(info: { 숨김?: boolean; 걸러짐?: boolean } | undefined): boolean {
|
||||
return !!info?.숨김 || !!info?.걸러짐;
|
||||
}
|
||||
|
||||
/** `from..to` 안에서 숨기지 않은 자리만. */
|
||||
export function visibleIndices(geo: LineGeometry, from: number, to: number): number[] {
|
||||
const list: number[] = [];
|
||||
for (let i = from; i <= to; i += 1) if (geo.size(i) > 0) list.push(i);
|
||||
return list;
|
||||
}
|
||||
|
||||
export interface HeaderCell {
|
||||
el: HTMLElement;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/** 열 머리 칸 — 글자 · 폭 끌기 손잡이 · 숨김 표시(왼쪽에 건너뛴 열이 있으면). */
|
||||
export function makeColHeaderCell(
|
||||
c: number,
|
||||
x: number,
|
||||
w: number,
|
||||
hiddenBefore: boolean,
|
||||
): HeaderCell {
|
||||
const cellEl = document.createElement("div");
|
||||
cellEl.className = "aislo-grid__colhead";
|
||||
if (hiddenBefore) cellEl.classList.add("aislo-grid__colhead--hidden-before");
|
||||
cellEl.style.left = `${x}px`;
|
||||
cellEl.style.width = `${w}px`;
|
||||
cellEl.dataset.col = String(c);
|
||||
const label = document.createElement("span");
|
||||
label.className = "aislo-grid__head-label";
|
||||
label.textContent = colLabel(c);
|
||||
cellEl.append(label);
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "aislo-grid__resize aislo-grid__resize--col";
|
||||
cellEl.append(handle);
|
||||
return { el: cellEl, index: c };
|
||||
}
|
||||
|
||||
/** 행 머리 칸 — 번호 · 높이 끌기 손잡이 · 숨김 표시(위에 건너뛴 행이 있으면). */
|
||||
export function makeRowHeaderCell(
|
||||
r: number,
|
||||
y: number,
|
||||
h: number,
|
||||
hiddenBefore: boolean,
|
||||
): HeaderCell {
|
||||
const cellEl = document.createElement("div");
|
||||
cellEl.className = "aislo-grid__rowhead";
|
||||
if (hiddenBefore) cellEl.classList.add("aislo-grid__rowhead--hidden-before");
|
||||
cellEl.style.top = `${y}px`;
|
||||
cellEl.style.height = `${h}px`;
|
||||
cellEl.dataset.row = String(r);
|
||||
const label = document.createElement("span");
|
||||
label.className = "aislo-grid__head-label";
|
||||
label.textContent = rowLabel(r);
|
||||
cellEl.append(label);
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "aislo-grid__resize aislo-grid__resize--row";
|
||||
cellEl.append(handle);
|
||||
return { el: cellEl, index: r };
|
||||
}
|
||||
|
||||
export interface ResizeDragOptions {
|
||||
handle: HTMLElement;
|
||||
axis: "col" | "row";
|
||||
currentPx: () => number;
|
||||
onPreview: (px: number) => void;
|
||||
onCommit: (px: number) => void;
|
||||
}
|
||||
|
||||
/** 폭 · 높이 끌기 — 누르는 순간 시작 픽셀을 쥐고 포인터가 옮긴 만큼 더함. */
|
||||
export function startDragResize(opts: ResizeDragOptions): void {
|
||||
opts.handle.addEventListener("pointerdown", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const startClient = opts.axis === "col" ? ev.clientX : ev.clientY;
|
||||
const startPx = opts.currentPx();
|
||||
opts.handle.setPointerCapture(ev.pointerId);
|
||||
const next = (e: PointerEvent): number => {
|
||||
const cur = opts.axis === "col" ? e.clientX : e.clientY;
|
||||
return Math.max(4, startPx + (cur - startClient));
|
||||
};
|
||||
const move = (e: PointerEvent): void => opts.onPreview(next(e));
|
||||
const up = (e: PointerEvent): void => {
|
||||
opts.handle.removeEventListener("pointermove", move);
|
||||
opts.handle.removeEventListener("pointerup", up);
|
||||
opts.onCommit(next(e));
|
||||
};
|
||||
opts.handle.addEventListener("pointermove", move);
|
||||
opts.handle.addEventListener("pointerup", up);
|
||||
});
|
||||
}
|
||||
|
||||
/** 폭 끌기 다 됨 → `열폭` 명령(글자 단위로 되돌려 보냄). */
|
||||
export function commitColWidth(ctx: SpreadsheetContext, c: number, px: number): void {
|
||||
ctx.dispatch({ 종류: "열폭", 시트: ctx.sheet().id, 열: [c], 폭: pxToColChars(px) });
|
||||
}
|
||||
|
||||
/** 높이 끌기 다 됨 → `행높이` 명령(pt 단위로 되돌려 보냄). */
|
||||
export function commitRowHeight(ctx: SpreadsheetContext, r: number, px: number): void {
|
||||
ctx.dispatch({ 종류: "행높이", 시트: ctx.sheet().id, 행: [r], 높이: pxToPt(px) });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_history.ts (주인 A)
|
||||
* 되돌리기 · 다시 — 명령과 되돌림 짝을 쌓음(메모리만 · 캐시에 안 넣음).
|
||||
* undo() 는 되돌림 짝을 · redo() 는 원래 명령을 돌려줌 — 적용은 부른 쪽(`applyCommand`).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Command, History } from "./spreadsheet_types";
|
||||
|
||||
/** `limit` = 쌓을 수(넘으면 오래된 것부터 버림) */
|
||||
export function createHistory(limit = 100): History {
|
||||
let done: [Command, Command][] = [];
|
||||
let undone: [Command, Command][] = [];
|
||||
return {
|
||||
push(command, undo) {
|
||||
done.push([command, undo]);
|
||||
if (done.length > limit) done.shift();
|
||||
undone = [];
|
||||
},
|
||||
undo() {
|
||||
const pair = done.pop();
|
||||
if (!pair) return null;
|
||||
undone.push(pair);
|
||||
return pair[1];
|
||||
},
|
||||
redo() {
|
||||
const pair = undone.pop();
|
||||
if (!pair) return null;
|
||||
done.push(pair);
|
||||
return pair[0];
|
||||
},
|
||||
canUndo: () => done.length > 0,
|
||||
canRedo: () => undone.length > 0,
|
||||
clear() {
|
||||
done = [];
|
||||
undone = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_insert_dialog.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 칸 삽입(Ctrl+Shift++) · 칸 삭제(Ctrl+-) 대화상자 — 엑셀처럼 네 갈래:
|
||||
* 삽입 = 셀을 오른쪽으로 밀기 · 셀을 아래로 밀기 · 행 전체 · 열 전체
|
||||
* 삭제 = 셀을 왼쪽으로 밀기 · 셀을 위로 밀기 · 행 전체 · 열 전체
|
||||
* 「행 전체」·「열 전체」는 A 의 행넣기 · 행지우기 · 열넣기 · 열지우기 명령을 그대로 씀
|
||||
* (참조 옮김까지 A 가 다 함). 「오른쪽 · 아래로 · 왼쪽 · 위로 밀기」는 0 계약에 전용 명령이
|
||||
* 없어(부분 범위 이동 명령 없음) 「칸」 명령으로 손수 값 · 식 글 · 서식 번호를 옮김 —
|
||||
* ⚠ 식의 상대 참조는 안 고쳐짐 · 행 · 열 단위 기본서식(`행`·`열` 레코드)도 안 옮김
|
||||
* (부분 이동은 칸 레코드만). 온전한 참조 옮김이 필요하면 「행 전체」·「열 전체」를 쓰거나
|
||||
* A 의 `spreadsheet_refshift.ts` 가 부분 범위까지 다루게 된 뒤 이 파일의 shift* 함수만 고치면 됨.
|
||||
*
|
||||
* 잇는 법(D · sub7) — Ctrl+Shift+= → `openInsertCellsDialog(ctx)` · Ctrl+- →
|
||||
* `openDeleteCellsDialog(ctx)` 를 `spreadsheet_keys.ts` navKey 에 더함. 우클릭 메뉴
|
||||
* [삽입]·[삭제] 도 같은 함수를 부르면 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { parseA1, toA1 } from "./spreadsheet_address";
|
||||
import type { Cell, CellRange, Sheet } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const TEXT = {
|
||||
insertTitle: "삽입",
|
||||
deleteTitle: "삭제",
|
||||
shiftRight: "셀을 오른쪽으로 밀기",
|
||||
shiftDown: "셀을 아래로 밀기",
|
||||
shiftLeft: "셀을 왼쪽으로 밀기",
|
||||
shiftUp: "셀을 위로 밀기",
|
||||
wholeRow: "행 전체",
|
||||
wholeCol: "열 전체",
|
||||
ok: "확인",
|
||||
cancel: "취소",
|
||||
};
|
||||
|
||||
function readRange(ctx: SpreadsheetContext): CellRange | null {
|
||||
return ctx.selection.범위[0] ?? null;
|
||||
}
|
||||
|
||||
/** 시트 `칸` 레코드 안에서 실제 값이 있는 가장 큰 행 · 열(없으면 -1). */
|
||||
function sheetUsedBounds(sheet: Sheet): { maxR: number; maxC: number } {
|
||||
let maxR = -1;
|
||||
let maxC = -1;
|
||||
for (const key of Object.keys(sheet.칸)) {
|
||||
const at = parseA1(key);
|
||||
if (!at) continue;
|
||||
if (at.r > maxR) maxR = at.r;
|
||||
if (at.c > maxC) maxC = at.c;
|
||||
}
|
||||
return { maxR, maxC };
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 부분 범위 밀기 — 「칸」 명령으로 값 · 식 · 서식 번호만 옮김(위 머리 주석의 한계 참고)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function insertShiftRight(ctx: SpreadsheetContext, range: CellRange): void {
|
||||
const sheet = ctx.sheet();
|
||||
const width = range.c1 - range.c0 + 1;
|
||||
const lastCol = Math.max(sheetUsedBounds(sheet).maxC + width, range.c1);
|
||||
const cells: Record<string, Cell | null> = {};
|
||||
for (let r = range.r0; r <= range.r1; r++) {
|
||||
for (let c = lastCol; c >= range.c0; c--) {
|
||||
const src = c - width;
|
||||
cells[toA1(r, c)] = src >= range.c0 ? (sheet.칸[toA1(r, src)] ?? null) : null;
|
||||
}
|
||||
}
|
||||
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
|
||||
}
|
||||
|
||||
export function insertShiftDown(ctx: SpreadsheetContext, range: CellRange): void {
|
||||
const sheet = ctx.sheet();
|
||||
const height = range.r1 - range.r0 + 1;
|
||||
const lastRow = Math.max(sheetUsedBounds(sheet).maxR + height, range.r1);
|
||||
const cells: Record<string, Cell | null> = {};
|
||||
for (let c = range.c0; c <= range.c1; c++) {
|
||||
for (let r = lastRow; r >= range.r0; r--) {
|
||||
const src = r - height;
|
||||
cells[toA1(r, c)] = src >= range.r0 ? (sheet.칸[toA1(src, c)] ?? null) : null;
|
||||
}
|
||||
}
|
||||
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
|
||||
}
|
||||
|
||||
export function deleteShiftLeft(ctx: SpreadsheetContext, range: CellRange): void {
|
||||
const sheet = ctx.sheet();
|
||||
const width = range.c1 - range.c0 + 1;
|
||||
const lastCol = Math.max(sheetUsedBounds(sheet).maxC, range.c1);
|
||||
const cells: Record<string, Cell | null> = {};
|
||||
for (let r = range.r0; r <= range.r1; r++) {
|
||||
for (let c = range.c0; c <= lastCol; c++) {
|
||||
const src = c + width;
|
||||
cells[toA1(r, c)] = src <= lastCol ? (sheet.칸[toA1(r, src)] ?? null) : null;
|
||||
}
|
||||
}
|
||||
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
|
||||
}
|
||||
|
||||
export function deleteShiftUp(ctx: SpreadsheetContext, range: CellRange): void {
|
||||
const sheet = ctx.sheet();
|
||||
const height = range.r1 - range.r0 + 1;
|
||||
const lastRow = Math.max(sheetUsedBounds(sheet).maxR, range.r1);
|
||||
const cells: Record<string, Cell | null> = {};
|
||||
for (let c = range.c0; c <= range.c1; c++) {
|
||||
for (let r = range.r0; r <= lastRow; r++) {
|
||||
const src = r + height;
|
||||
cells[toA1(r, c)] = src <= lastRow ? (sheet.칸[toA1(src, c)] ?? null) : null;
|
||||
}
|
||||
}
|
||||
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 대화상자
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function radioGroup(
|
||||
name: string,
|
||||
options: [string, string][],
|
||||
): { root: HTMLElement; value(): string } {
|
||||
const inputs: HTMLInputElement[] = [];
|
||||
const root = el("div", {
|
||||
children: options.map(([value, label], i) => {
|
||||
const input = el("input", {
|
||||
attrs: { type: "radio", name, value, ...(i === 0 ? { checked: "true" } : {}) },
|
||||
}) as HTMLInputElement;
|
||||
inputs.push(input);
|
||||
const wrap = el("label", { children: [input, el("span", { text: label })] });
|
||||
wrap.style.cssText = "display:flex;align-items:center;gap:6px;margin-bottom:4px;";
|
||||
return wrap;
|
||||
}),
|
||||
});
|
||||
return { root, value: () => inputs.find((i) => i.checked)?.value ?? options[0][0] };
|
||||
}
|
||||
|
||||
function modal(
|
||||
title: string,
|
||||
body: HTMLElement,
|
||||
onOk: (choice: string) => void,
|
||||
choice: () => string,
|
||||
): void {
|
||||
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();
|
||||
});
|
||||
panel.append(
|
||||
el("h3", { text: title }),
|
||||
body,
|
||||
el("div", {
|
||||
className: "ui-confirm__actions",
|
||||
children: [
|
||||
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
|
||||
createButton({
|
||||
label: TEXT.ok,
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
onOk(choice());
|
||||
close();
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
overlay.append(panel);
|
||||
document.body.append(overlay);
|
||||
}
|
||||
|
||||
export function openInsertCellsDialog(ctx: SpreadsheetContext): void {
|
||||
if (ctx.readOnly) return;
|
||||
const range = readRange(ctx);
|
||||
if (!range) return;
|
||||
const group = radioGroup("insertMode", [
|
||||
["right", TEXT.shiftRight],
|
||||
["down", TEXT.shiftDown],
|
||||
["row", TEXT.wholeRow],
|
||||
["col", TEXT.wholeCol],
|
||||
]);
|
||||
modal(
|
||||
TEXT.insertTitle,
|
||||
group.root,
|
||||
(choice) => {
|
||||
if (choice === "right") insertShiftRight(ctx, range);
|
||||
else if (choice === "down") insertShiftDown(ctx, range);
|
||||
else if (choice === "row") {
|
||||
ctx.dispatch({
|
||||
종류: "행넣기",
|
||||
시트: ctx.selection.시트,
|
||||
at: range.r0,
|
||||
수: range.r1 - range.r0 + 1,
|
||||
});
|
||||
} else {
|
||||
ctx.dispatch({
|
||||
종류: "열넣기",
|
||||
시트: ctx.selection.시트,
|
||||
at: range.c0,
|
||||
수: range.c1 - range.c0 + 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
group.value,
|
||||
);
|
||||
}
|
||||
|
||||
export function openDeleteCellsDialog(ctx: SpreadsheetContext): void {
|
||||
if (ctx.readOnly) return;
|
||||
const range = readRange(ctx);
|
||||
if (!range) return;
|
||||
const group = radioGroup("deleteMode", [
|
||||
["left", TEXT.shiftLeft],
|
||||
["up", TEXT.shiftUp],
|
||||
["row", TEXT.wholeRow],
|
||||
["col", TEXT.wholeCol],
|
||||
]);
|
||||
modal(
|
||||
TEXT.deleteTitle,
|
||||
group.root,
|
||||
(choice) => {
|
||||
if (choice === "left") deleteShiftLeft(ctx, range);
|
||||
else if (choice === "up") deleteShiftUp(ctx, range);
|
||||
else if (choice === "row") {
|
||||
ctx.dispatch({
|
||||
종류: "행지우기",
|
||||
시트: ctx.selection.시트,
|
||||
at: range.r0,
|
||||
수: range.r1 - range.r0 + 1,
|
||||
});
|
||||
} else {
|
||||
ctx.dispatch({
|
||||
종류: "열지우기",
|
||||
시트: ctx.selection.시트,
|
||||
at: range.c0,
|
||||
수: range.c1 - range.c0 + 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
group.value,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_keys.ts (주인 D)
|
||||
* 엑셀 단축키 지도 — 숨은 textarea · 수식 입력줄 keydown 하나로 받음.
|
||||
* 편집 중: Enter/Tab 확정 이동 · Alt+Enter 줄바꿈 · Ctrl+Enter 고름 전부 · Esc · F2 방식 바꿈 ·
|
||||
* enter 방식 방향키 = 확정 이동(식 참조 자리면 가리키기).
|
||||
* 고름 중: 방향(Shift 늘림 · Ctrl 끝) · Home · Ctrl+Home/End · PgUp/PgDn · Enter/Tab(고름 안 돌기) ·
|
||||
* F2 · Delete · Backspace · Ctrl+A · Shift/Ctrl+Space · Ctrl+Z/Y · Ctrl+D/R · 그 밖은 hooks.more.
|
||||
* 식 편집 중 F4 = 참조 $ 돌리기(A1 → $A$1 → A$1 → $A1).
|
||||
* 글자 키는 막지 않음 — textarea 의 input · compositionstart 가 편집을 엶(한글 첫 글자).
|
||||
* 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { cycleAbs } from "./spreadsheet_editor";
|
||||
import { fillCells } from "./spreadsheet_fill";
|
||||
import {
|
||||
cycle,
|
||||
LAST_R,
|
||||
move,
|
||||
selectAll,
|
||||
selectCell,
|
||||
selectCols,
|
||||
selectRows,
|
||||
selectSpan,
|
||||
usedEnd,
|
||||
} from "./spreadsheet_selection";
|
||||
import type { Editor } from "./spreadsheet_editor";
|
||||
import type { Selection, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const ARROWS: Record<string, [number, number]> = {
|
||||
ArrowUp: [-1, 0],
|
||||
ArrowDown: [1, 0],
|
||||
ArrowLeft: [0, -1],
|
||||
ArrowRight: [0, 1],
|
||||
};
|
||||
|
||||
/** 2단계 잇기 자리 — 부른 쪽(spreadsheet.ts)이 채움 */
|
||||
export interface KeyHooks {
|
||||
/** Ctrl+F · Shift+F5 찾기 · Ctrl+H 바꾸기 */
|
||||
find?(replace: boolean): void;
|
||||
/** Ctrl+Shift+V — 곧 올 paste 사건을 골라 붙여넣기로 */
|
||||
pasteSpecial?(): void;
|
||||
/** Shift+F2 메모 편집 */
|
||||
comment?(): void;
|
||||
/** 고름 중 Esc(서식 붓 끄기 등) */
|
||||
escape?(): void;
|
||||
/** Ctrl+1 셀 서식 창 */
|
||||
formatDialog?(): void;
|
||||
/** Alt+= 자동 합계 */
|
||||
autosum?(): void;
|
||||
/** Ctrl+Shift+L 필터 켜기 · 끄기 */
|
||||
filter?(): void;
|
||||
/** Alt+↓ — 필터 머리 칸이면 값 목록을 열고 true */
|
||||
dropdown?(): boolean;
|
||||
/** 고름 중 그 밖 단축키(`spreadsheet_actions.ts`) — 받았으면 true */
|
||||
more?(e: KeyboardEvent): boolean;
|
||||
}
|
||||
|
||||
export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks = {}): () => void {
|
||||
const go = (sel: Selection): void => ctx.select(sel, true);
|
||||
|
||||
/** 확정 뒤 한 칸 — 고름이 여러 칸이면 그 안에서 돎 */
|
||||
function next(dr: number, dc: number): void {
|
||||
const sheet = ctx.sheet();
|
||||
go(cycle(sheet, ctx.selection, dr, dc) ?? move(sheet, ctx.selection, dr, dc));
|
||||
}
|
||||
|
||||
function editingKey(e: KeyboardEvent, inBar: boolean): boolean {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
switch (e.key) {
|
||||
case "Enter": {
|
||||
const node = e.target as HTMLTextAreaElement;
|
||||
if (e.altKey) {
|
||||
node.setRangeText("\n", node.selectionStart, node.selectionEnd, "end");
|
||||
node.dispatchEvent(new Event("input"));
|
||||
return true;
|
||||
}
|
||||
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":
|
||||
ed.cancel();
|
||||
return true;
|
||||
case "F2":
|
||||
ed.toggleMode();
|
||||
return true;
|
||||
case "F4": {
|
||||
const node = e.target as HTMLTextAreaElement;
|
||||
const got = cycleAbs(node.value, node.selectionStart);
|
||||
if (!got) return false;
|
||||
node.value = got.text;
|
||||
node.setSelectionRange(got.caret, got.caret);
|
||||
node.dispatchEvent(new Event("input"));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const arrow = ARROWS[e.key];
|
||||
if (arrow && !arrow[1] && !e.shiftKey && ed.suggestMove(arrow[0])) return true;
|
||||
if (!arrow || inBar || ed.mode() === "edit") return false;
|
||||
if (ed.pointable()) {
|
||||
ed.pointMove(arrow[0], arrow[1], e.shiftKey, ctrl);
|
||||
return true;
|
||||
}
|
||||
if (e.shiftKey || ctrl) return false; // 글자 고르기 · 낱말 건너기는 textarea 에 맡김
|
||||
ed.commit();
|
||||
if (!ed.editing()) go(move(ctx.sheet(), ctx.selection, arrow[0], arrow[1]));
|
||||
return true;
|
||||
}
|
||||
|
||||
function navKey(e: KeyboardEvent): boolean {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
const sheet = ctx.sheet();
|
||||
const sel = ctx.selection;
|
||||
const arrow = ARROWS[e.key];
|
||||
if (e.altKey && e.key === "ArrowDown" && hooks.dropdown?.()) return true;
|
||||
if (hooks.more?.(e)) return true;
|
||||
if (e.altKey && e.key === "=" && hooks.autosum && !ctx.readOnly) return (hooks.autosum(), true);
|
||||
if (arrow && !e.altKey) {
|
||||
go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl }));
|
||||
return true;
|
||||
}
|
||||
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
if (ctrl && !e.altKey) {
|
||||
switch (k) {
|
||||
case "z":
|
||||
e.shiftKey ? ctx.redo() : ctx.undo();
|
||||
return true;
|
||||
case "y":
|
||||
ctx.redo();
|
||||
return true;
|
||||
case "a":
|
||||
go(selectAll(sheet, sel.활성));
|
||||
return true;
|
||||
case "Home":
|
||||
go(e.shiftKey ? selectSpan(sheet, sel.기준, { r: 0, c: 0 }) : selectCell(sheet, 0, 0));
|
||||
return true;
|
||||
case "End": {
|
||||
const end = usedEnd(sheet);
|
||||
go(e.shiftKey ? selectSpan(sheet, sel.기준, end) : selectCell(sheet, end.r, end.c));
|
||||
return true;
|
||||
}
|
||||
case " ": {
|
||||
const g = sel.범위[0];
|
||||
go(selectCols(sheet, g.c0, g.c1, sel.활성.c));
|
||||
return true;
|
||||
}
|
||||
case "d":
|
||||
case "r":
|
||||
fill(k === "d");
|
||||
return true;
|
||||
case "f":
|
||||
case "h":
|
||||
if (!hooks.find) return false;
|
||||
hooks.find(k === "h");
|
||||
return true;
|
||||
case "1":
|
||||
if (!hooks.formatDialog) return false;
|
||||
hooks.formatDialog();
|
||||
return true;
|
||||
case "l":
|
||||
if (!e.shiftKey || !hooks.filter) return false;
|
||||
hooks.filter();
|
||||
return true;
|
||||
case "v":
|
||||
if (e.shiftKey) hooks.pasteSpecial?.();
|
||||
return false; // paste 사건은 그대로 — E 클립보드 · 골라 붙여넣기가 받음
|
||||
}
|
||||
return false;
|
||||
}
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
next(e.shiftKey ? -1 : 1, 0);
|
||||
return true;
|
||||
case "Tab":
|
||||
next(0, e.shiftKey ? -1 : 1);
|
||||
return true;
|
||||
case "F5": // Shift+F5 = 찾기(엑셀 · Ctrl+F 를 브라우저가 가로챌 때)
|
||||
if (!e.shiftKey || !hooks.find) return false;
|
||||
hooks.find(false);
|
||||
return true;
|
||||
case "F2":
|
||||
if (e.shiftKey) hooks.comment?.();
|
||||
else ed.begin();
|
||||
return true;
|
||||
case "Escape":
|
||||
hooks.escape?.();
|
||||
return !!hooks.escape;
|
||||
case "Delete":
|
||||
if (!ctx.readOnly) ctx.dispatch({ 종류: "내용지움", 시트: sheet.id, 범위: sel.범위 });
|
||||
return true;
|
||||
case "Backspace":
|
||||
ed.begin("");
|
||||
return true;
|
||||
case "Home":
|
||||
go(
|
||||
e.shiftKey
|
||||
? selectSpan(sheet, sel.기준, { r: sel.기준.r, c: 0 })
|
||||
: selectCell(sheet, sel.활성.r, 0),
|
||||
);
|
||||
return true;
|
||||
case "PageDown":
|
||||
case "PageUp": {
|
||||
const v = ctx.grid.visibleRange();
|
||||
const n = Math.max(1, v.r1 - v.r0) * (e.key === "PageDown" ? 1 : -1);
|
||||
const r = Math.min(LAST_R, Math.max(0, sel.활성.r + n));
|
||||
go(selectCell(sheet, r, sel.활성.c));
|
||||
return true;
|
||||
}
|
||||
case " ":
|
||||
if (!e.shiftKey) return false;
|
||||
go(selectRows(sheet, sel.범위[0].r0, sel.범위[0].r1, sel.활성.r));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Ctrl+D 아래로 · Ctrl+R 오른쪽으로 — 고름 첫 행(열)을 나머지에 */
|
||||
function fill(down: boolean): void {
|
||||
if (ctx.readOnly) return;
|
||||
const sheet = ctx.sheet();
|
||||
const g = ctx.selection.범위[0];
|
||||
const src = down ? { ...g, r1: g.r0 } : { ...g, c1: g.c0 };
|
||||
if ((down ? g.r1 : g.c1) === (down ? src.r1 : src.c1)) return;
|
||||
ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: fillCells(ctx.book, sheet.id, src, g) });
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.isComposing || e.keyCode === 229) return; // 한글 조합 중 — 브라우저 몫
|
||||
const inBar = e.target === ed.formula;
|
||||
const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e);
|
||||
if (done) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
ed.ta.addEventListener("keydown", onKey);
|
||||
ed.formula.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
ed.ta.removeEventListener("keydown", onKey);
|
||||
ed.formula.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/* spreadsheet_links.css (주인 D) — 위 알약 막대 · 연결 칸 세모 · 자재 고르기 창 */
|
||||
|
||||
.ss-links {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.ss-link {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-4);
|
||||
padding: 2px 10px;
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-link small {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ss-link code {
|
||||
font-size: 11px;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* 연결됨 — 흐림(연결 현황) */
|
||||
.ss-link.is-linked {
|
||||
opacity: 0.5;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.ss-link.is-linked:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ss-link--add {
|
||||
border-style: dashed;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 연결 칸 왼아래 세모(초록 오류 세모는 왼위 · 빨강 메모는 오른위) */
|
||||
.ss-link-dot {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 6px solid transparent;
|
||||
border-left: 6px solid var(--color-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ss-links__pick {
|
||||
position: absolute;
|
||||
z-index: var(--z-dropdown);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 320px;
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss-links__search {
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.ss-links__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ss-links__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: var(--spacing-8);
|
||||
padding: 4px 6px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-links__row:hover {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.ss-links__note {
|
||||
padding: 4px 6px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 알약으로 이동한 칸 — 굵은 테두리 + 채움 깜빡임(1.6초 · 끝나면 스스로 지워짐) */
|
||||
.ss-link-flash {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
box-sizing: border-box;
|
||||
border: 3px solid var(--color-accent);
|
||||
pointer-events: none;
|
||||
animation: ss-link-flash 1.6s ease-out 1 both;
|
||||
}
|
||||
|
||||
@keyframes ss-link-flash {
|
||||
0%,
|
||||
40%,
|
||||
80% {
|
||||
background: color-mix(in srgb, var(--color-accent) 40%, transparent);
|
||||
}
|
||||
20%,
|
||||
60% {
|
||||
background: transparent;
|
||||
}
|
||||
100% {
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_links.ts (주인 D)
|
||||
* 위 알약 막대 — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장).
|
||||
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결(알약 흐려짐 + 칸 주소 · 칸 왼아래 작은 세모) ·
|
||||
* 흐린 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
|
||||
* 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] ·
|
||||
* 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음).
|
||||
* 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가
|
||||
* 연결 칸을 옮김(지운 칸이면 빠짐). `createSpreadsheet` 가 `links` 선택 또는 연결 있는 문서일 때만 불러옴(크기 예산).
|
||||
* ========================================================================== */
|
||||
|
||||
import "./spreadsheet_links.css";
|
||||
import { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
|
||||
import { applyCommand } from "./spreadsheet_commands";
|
||||
import type { slotOf } from "./spreadsheet_editor";
|
||||
import { shiftSpan } from "./spreadsheet_refshift";
|
||||
import { selectCell } from "./spreadsheet_selection";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type {
|
||||
CellLink,
|
||||
Command,
|
||||
CommandEffect,
|
||||
LinkMaterial,
|
||||
Sheet,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
/** 알약 한 개(입력값) — `키` 는 부른 쪽(일위대가 입력값 키) */
|
||||
export interface LinkItem {
|
||||
키: string;
|
||||
이름: string;
|
||||
단위?: string;
|
||||
/** 자재 알약 — 연결하면 이 정보가 칸 연결에 들어감 */
|
||||
자재?: LinkMaterial;
|
||||
}
|
||||
|
||||
export interface LinkOptions {
|
||||
항목: LinkItem[];
|
||||
/** 입력 = 항목이 알약 · 자재 = 항목(자재 정보 포함 · 호스트 목록) + [자재 더하기] 로 더한 자재 */
|
||||
모드: "입력" | "자재";
|
||||
/** 자재 모드에서 목록이 바뀔 때(더하기 · 빼기) 새 목록을 알림 — 저장은 부른 쪽 몫 */
|
||||
onItemsChange?: (항목: LinkItem[]) => void;
|
||||
/** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */
|
||||
자재찾기?: (글: string) => Promise<LinkMaterial[]>;
|
||||
}
|
||||
|
||||
export interface Links extends PartHandle {
|
||||
/** `applyCommand` + 연결 따라가기 */
|
||||
apply(book: Workbook, command: Command): CommandEffect;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
bar: "알약 — 칸을 고른 채 누르면 연결 · 흐린 알약은 누르면 연결 칸으로 · 우클릭 풀기",
|
||||
free: "칸을 고른 채 누르면 이 칸에 연결",
|
||||
linked: "누르면 연결 칸으로 · 우클릭 연결 풀기",
|
||||
unlink: "연결 풀기",
|
||||
remove: "목록에서 빼기",
|
||||
removed: "목록에서 뺌 — 연결도 풀림",
|
||||
add: "+ 자재 더하기",
|
||||
search: "자재 이름 · 규격 · 코드",
|
||||
searching: "찾는 중…",
|
||||
none: "맞는 자재 없음",
|
||||
fail: "자재 목록을 못 받음",
|
||||
picked: "칸을 고른 뒤 새 알약을 누르면 연결",
|
||||
dup: "이미 목록에 있는 자재",
|
||||
};
|
||||
|
||||
type RowCol = Extract<Command, { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기" }>;
|
||||
|
||||
const isRowCol = (c: Command): c is RowCol =>
|
||||
c.종류 === "행넣기" || c.종류 === "행지우기" || c.종류 === "열넣기" || c.종류 === "열지우기";
|
||||
const flat = (c: Command): Command[] => (c.종류 === "묶음" ? c.명령.flatMap(flat) : [c]);
|
||||
|
||||
/** 행열 넣기 · 지우기 한 번에 맞춰 연결 칸을 옮김 — 지운 칸 연결은 뺌(순수 · 시험이 부름) */
|
||||
export function shiftLinks(links: CellLink[], cmd: RowCol): CellLink[] {
|
||||
const rows = cmd.종류.startsWith("행");
|
||||
const insert = cmd.종류.endsWith("넣기");
|
||||
return links.flatMap((link) => {
|
||||
const p = parseA1(link.칸);
|
||||
if (!p) return [link];
|
||||
const i = rows ? p.r : p.c;
|
||||
const to = shiftSpan(i, i, insert, cmd.at, cmd.수, rows ? MAX_ROWS : MAX_COLS)?.[0];
|
||||
if (to === undefined) return [];
|
||||
return [{ ...link, 칸: rows ? toA1(to, p.c) : toA1(p.r, to) }];
|
||||
});
|
||||
}
|
||||
|
||||
const setLinks = (s: Sheet, list: CellLink[]): void => {
|
||||
if (list.length) s.연결 = list;
|
||||
else delete s.연결;
|
||||
};
|
||||
|
||||
/** 명령 적용 — 행열 명령이 연결 있는 시트를 건드리면 연결도 옮기고 되돌림 짝 끝에 명령 전 시트를 더함
|
||||
* (A 의 되돌림은 바뀐 시트만 통째로 — 연결만 바뀐 시트도 돌아오게) */
|
||||
export function applyLinked(book: Workbook, command: Command): CommandEffect {
|
||||
const ops = flat(command).filter(isRowCol);
|
||||
const hit = new Set(ops.map((o) => o.시트));
|
||||
const before = book.시트
|
||||
.filter((s) => hit.has(s.id) && s.연결?.length)
|
||||
.map((s) => structuredClone(s));
|
||||
const eff = applyCommand(book, command);
|
||||
if (!before.length) return eff;
|
||||
for (const op of ops) {
|
||||
const s = book.시트.find((x) => x.id === op.시트);
|
||||
if (s?.연결) setLinks(s, shiftLinks(s.연결, op));
|
||||
}
|
||||
const restore = before.map((시트): Command => ({ 종류: "시트통째", 시트 }));
|
||||
return { ...eff, undo: { 종류: "묶음", 명령: [eff.undo, ...restore] } };
|
||||
}
|
||||
|
||||
/** 자재 → 알약(키 = 자재 코드) */
|
||||
const ofMaterial = (m: LinkMaterial): LinkItem => ({
|
||||
키: m.코드,
|
||||
이름: m.규격 ? `${m.이름} ${m.규격}` : m.이름,
|
||||
단위: m.단위,
|
||||
자재: m,
|
||||
});
|
||||
|
||||
export function mountLinks(
|
||||
ctx: SpreadsheetContext,
|
||||
opts: LinkOptions | undefined,
|
||||
back: () => void,
|
||||
/** 편집기 `slotOf` — 부른 쪽이 넘김(여기서 끌어오면 편집기가 따로 쪼개져 크기가 늚) */
|
||||
slot: typeof slotOf,
|
||||
): Links {
|
||||
const bar = el("div", { className: "ss-links", attrs: { title: TEXT.bar } });
|
||||
if (opts) ctx.root.prepend(bar);
|
||||
const menu = createMapContextMenu("ss");
|
||||
ctx.root.append(menu.element);
|
||||
/** 알약 목록 — 호스트가 준 항목 + 더한 자재(자재 모드 · 바뀌면 호스트에 알림) */
|
||||
const items: LinkItem[] = opts ? [...opts.항목] : [];
|
||||
let dots: HTMLElement[] = [];
|
||||
let pick: HTMLElement | null = null;
|
||||
|
||||
/** 키 → 연결(시트 · 줄) */
|
||||
function found(): Map<string, { sheet: Sheet; link: CellLink }> {
|
||||
const out = new Map<string, { sheet: Sheet; link: CellLink }>();
|
||||
for (const sheet of ctx.book.시트)
|
||||
for (const link of sheet.연결 ?? []) out.set(link.키, { sheet, link });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 시트 연결을 바꿔 넣음 — `시트통째` 라 되돌리기 한 번 */
|
||||
function put(sheet: Sheet, list: CellLink[]): void {
|
||||
const next = structuredClone(sheet);
|
||||
setLinks(next, list);
|
||||
ctx.dispatch({ 종류: "시트통째", 시트: next });
|
||||
}
|
||||
|
||||
function link(item: LinkItem): void {
|
||||
if (ctx.readOnly) return;
|
||||
const sheet = ctx.sheet();
|
||||
const 칸 = toA1(ctx.selection.활성.r, ctx.selection.활성.c);
|
||||
const rest = (sheet.연결 ?? []).filter((l) => l.칸 !== 칸 && l.키 !== item.키);
|
||||
const one: CellLink = { 키: item.키, 이름: item.이름, 칸 };
|
||||
if (item.단위) one.단위 = item.단위;
|
||||
if (item.자재) one.자재 = item.자재;
|
||||
put(sheet, [...rest, one]);
|
||||
back();
|
||||
}
|
||||
|
||||
/** 그 칸으로 가서 눈에 띄게 깜빡임(굵은 테두리 + 채움 · 테마 색 · 끝나면 스스로 사라짐) */
|
||||
function goTo(sheet: Sheet, a1: string): void {
|
||||
const p = parseA1(a1);
|
||||
if (!p) return;
|
||||
if (sheet.id !== ctx.selection.시트) ctx.showSheet(sheet.id);
|
||||
ctx.select(selectCell(ctx.sheet(), p.r, p.c), true);
|
||||
back();
|
||||
requestAnimationFrame(() => {
|
||||
const { layer, box } = slot(ctx, p.r, p.c);
|
||||
if (!box.w || !box.h) return;
|
||||
const f = el("div", { className: "ss-link-flash" });
|
||||
f.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
|
||||
f.addEventListener("animationend", () => f.remove());
|
||||
layer.append(f);
|
||||
});
|
||||
}
|
||||
|
||||
const commit = (): void => opts?.onItemsChange?.(items.map((i) => ({ ...i })));
|
||||
|
||||
function pill(item: LinkItem, hit: { sheet: Sheet; link: CellLink } | undefined) {
|
||||
const where = hit
|
||||
? hit.sheet.id === ctx.selection.시트
|
||||
? hit.link.칸
|
||||
: `${quoteSheet(hit.sheet.이름)}!${hit.link.칸}`
|
||||
: "";
|
||||
const b = el("button", {
|
||||
className: hit ? "ss-link is-linked" : "ss-link",
|
||||
attrs: { type: "button", title: hit ? TEXT.linked : TEXT.free, "data-key": item.키 },
|
||||
children: [
|
||||
el("span", { text: item.이름 }),
|
||||
...(item.단위 ? [el("small", { text: item.단위 })] : []),
|
||||
...(where ? [el("code", { text: where })] : []),
|
||||
],
|
||||
});
|
||||
b.addEventListener("mousedown", (e) => e.preventDefault()); // 초점은 격자에 둠
|
||||
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item)));
|
||||
const unlink = (): void => {
|
||||
if (hit)
|
||||
put(
|
||||
hit.sheet,
|
||||
(hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키),
|
||||
);
|
||||
};
|
||||
b.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const inList = opts?.모드 === "자재" && items.some((i) => i.키 === item.키);
|
||||
if (ctx.readOnly || !(hit || inList)) return;
|
||||
const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 안 절대 위치 — 누른 자리에 뜨게
|
||||
menu.open(e.clientX - o.left, e.clientY - o.top, [
|
||||
...(hit ? [[TEXT.unlink, () => (unlink(), back())] as [string, () => void]] : []),
|
||||
...(inList
|
||||
? [
|
||||
[
|
||||
TEXT.remove,
|
||||
() => {
|
||||
unlink();
|
||||
items.splice(0, items.length, ...items.filter((i) => i.키 !== item.키));
|
||||
commit();
|
||||
renderBar();
|
||||
if (hit) showToast(TEXT.removed, "info");
|
||||
back();
|
||||
},
|
||||
] as [string, () => void],
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 옛 키(로직 키 등)로 저장된 연결 — 키가 알약 목록에 없으면 이름 + 단위가 같은 알약으로 이어
|
||||
* 연결 키를 새 키로 바꿔 둠(다음 저장 때 새 키로 나감 · 안 맞으면 그대로 두어 늘 보임) */
|
||||
function align(): void {
|
||||
if (opts?.모드 !== "입력") return;
|
||||
const keys = new Set(items.map((i) => i.키));
|
||||
const taken = new Set<string>();
|
||||
for (const sheet of ctx.book.시트)
|
||||
for (const l of sheet.연결 ?? []) if (keys.has(l.키)) taken.add(l.키);
|
||||
for (const sheet of ctx.book.시트)
|
||||
for (const l of sheet.연결 ?? []) {
|
||||
if (keys.has(l.키)) continue;
|
||||
const hit = items.find(
|
||||
(i) => !taken.has(i.키) && i.이름 === l.이름 && (i.단위 ?? "") === (l.단위 ?? ""),
|
||||
);
|
||||
if (!hit) continue;
|
||||
l.키 = hit.키;
|
||||
taken.add(hit.키);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBar(): void {
|
||||
if (!opts) return;
|
||||
align();
|
||||
const map = found();
|
||||
const shown: LinkItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (item: LinkItem) => {
|
||||
if (seen.has(item.키)) return;
|
||||
seen.add(item.키);
|
||||
shown.push(item);
|
||||
};
|
||||
for (const item of items) add(item);
|
||||
// 문서에 있는 연결(목록에서 빠진 것) — 풀 수 있게 늘 보임
|
||||
for (const { link } of map.values())
|
||||
add(
|
||||
link.자재
|
||||
? ofMaterial(link.자재)
|
||||
: { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 },
|
||||
);
|
||||
const pills = shown.map((item) => pill(item, map.get(item.키)));
|
||||
if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) {
|
||||
const more = el("button", {
|
||||
className: "ss-link ss-link--add",
|
||||
text: TEXT.add,
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
more.addEventListener("click", () => openPick(more));
|
||||
pills.push(more);
|
||||
}
|
||||
bar.replaceChildren(...pills);
|
||||
}
|
||||
|
||||
/** 칸 왼아래 작은 세모 — 칸이 든 판 층에 얹어 스크롤을 따라감 */
|
||||
function placeDots(): void {
|
||||
for (const d of dots) d.remove();
|
||||
dots = [];
|
||||
for (const l of ctx.sheet().연결 ?? []) {
|
||||
const p = parseA1(l.칸);
|
||||
if (!p) continue;
|
||||
const { layer, box } = slot(ctx, p.r, p.c);
|
||||
if (!box.w || !box.h) continue; // 숨긴 행열
|
||||
const dot = el("div", { className: "ss-link-dot" });
|
||||
dot.style.left = `${box.x}px`;
|
||||
dot.style.top = `${box.y + box.h - 7}px`;
|
||||
layer.append(dot);
|
||||
dots.push(dot);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 자재 고르기 창 ──────────────────────────────────────────────────────
|
||||
function closePick(): void {
|
||||
pick?.remove();
|
||||
pick = null;
|
||||
document.removeEventListener("mousedown", onOutside, true);
|
||||
}
|
||||
const onOutside = (e: MouseEvent): void => {
|
||||
if (pick && !pick.contains(e.target as Node)) closePick();
|
||||
};
|
||||
|
||||
function openPick(anchor: HTMLElement): void {
|
||||
closePick();
|
||||
const input = el("input", {
|
||||
className: "ss-links__search",
|
||||
attrs: { type: "search", placeholder: TEXT.search },
|
||||
}) as HTMLInputElement;
|
||||
const list = el("div", { className: "ss-links__list" });
|
||||
const note = (text: string) => el("div", { className: "ss-links__note", text });
|
||||
pick = el("div", { className: "ss-links__pick", children: [input, list] });
|
||||
const a = anchor.getBoundingClientRect();
|
||||
const o = ctx.root.getBoundingClientRect();
|
||||
pick.style.left = `${a.left - o.left}px`;
|
||||
pick.style.top = `${a.bottom - o.top + 4}px`;
|
||||
ctx.root.append(pick);
|
||||
document.addEventListener("mousedown", onOutside, true);
|
||||
|
||||
let seq = 0;
|
||||
let timer = 0;
|
||||
const choose = (m: LinkMaterial) => {
|
||||
closePick();
|
||||
if (items.some((i) => i.키 === m.코드)) showToast(TEXT.dup, "info");
|
||||
else {
|
||||
items.push(ofMaterial(m));
|
||||
commit();
|
||||
renderBar();
|
||||
showToast(TEXT.picked, "info");
|
||||
}
|
||||
back();
|
||||
};
|
||||
const search = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
list.replaceChildren(note(TEXT.searching));
|
||||
try {
|
||||
const got = await opts!.자재찾기!(input.value.trim());
|
||||
if (mine !== seq) return;
|
||||
list.replaceChildren(
|
||||
...(got.length
|
||||
? got.map((m) => {
|
||||
const row = el("button", {
|
||||
className: "ss-links__row",
|
||||
attrs: { type: "button", title: m.코드 },
|
||||
children: [
|
||||
el("span", { text: m.이름 }),
|
||||
el("span", { text: m.규격 }),
|
||||
el("small", { text: m.단위 }),
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", () => choose(m));
|
||||
return row;
|
||||
})
|
||||
: [note(TEXT.none)]),
|
||||
);
|
||||
} catch {
|
||||
if (mine === seq) list.replaceChildren(note(TEXT.fail));
|
||||
}
|
||||
};
|
||||
input.addEventListener("input", () => {
|
||||
clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void search(), 250);
|
||||
});
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") (e.stopPropagation(), closePick(), back());
|
||||
if (e.key === "Enter") list.querySelector<HTMLElement>(".ss-links__row")?.click();
|
||||
});
|
||||
input.focus();
|
||||
void search();
|
||||
}
|
||||
|
||||
const onZoom = (): void => placeDots();
|
||||
ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom);
|
||||
|
||||
return {
|
||||
root: opts ? bar : null,
|
||||
apply: applyLinked,
|
||||
refresh() {
|
||||
renderBar();
|
||||
placeDots();
|
||||
},
|
||||
destroy() {
|
||||
ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom);
|
||||
closePick();
|
||||
for (const d of dots) d.remove();
|
||||
menu.element.remove();
|
||||
bar.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_memo.ts (주인 D)
|
||||
* 메모 편집(Shift+F2) — 칸 옆 글상자 · Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소.
|
||||
* 처음 쓸 때 `spreadsheet_extras.ts` 가 불러옴(크기 예산).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { getComment, setComment } from "./spreadsheet_comments";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const TITLE = "메모 — Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소";
|
||||
|
||||
export interface Memo {
|
||||
/** (r, c) 메모를 열고 `place` 로 칸 옆에 둠 */
|
||||
open(r: number, c: number, place: (node: HTMLElement) => void): void;
|
||||
/** 편집 중인가 — 풍선을 띄우지 않음 */
|
||||
busy(): boolean;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/** 메모 읽기 · 쓰기는 부른 쪽이 넘김 — 여기서 끌어오면 메모 조각이 따로 쪼개져 크기가 늘어남 */
|
||||
export function createMemo(
|
||||
ctx: SpreadsheetContext,
|
||||
back: () => void,
|
||||
io: { get: typeof getComment; set: typeof setComment },
|
||||
): Memo {
|
||||
const memo = el("textarea", { className: "ss-memo", attrs: { hidden: "", title: TITLE } });
|
||||
ctx.root.append(memo);
|
||||
let memoAt: { r: number; c: number } | null = null;
|
||||
const close = (save: boolean): void => {
|
||||
if (!memoAt) return;
|
||||
const at = memoAt;
|
||||
memoAt = null;
|
||||
memo.setAttribute("hidden", "");
|
||||
if (save && memo.value !== (io.get(ctx.sheet(), at.r, at.c) ?? ""))
|
||||
io.set(ctx, at.r, at.c, memo.value.trim());
|
||||
back();
|
||||
};
|
||||
memo.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" || (e.key === "Enter" && e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close(e.key === "Enter");
|
||||
}
|
||||
});
|
||||
memo.addEventListener("blur", () => close(true));
|
||||
|
||||
return {
|
||||
open(r, c, place) {
|
||||
memoAt = { r, c };
|
||||
memo.value = io.get(ctx.sheet(), r, c) ?? "";
|
||||
memo.removeAttribute("hidden");
|
||||
place(memo);
|
||||
memo.focus();
|
||||
},
|
||||
busy: () => !!memoAt,
|
||||
destroy: () => memo.remove(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_menu.ts (주인 E)
|
||||
* 우클릭 메뉴 — 칸 · 행 머리 · 열 머리마다: 잘라내기 · 복사 · (붙여넣기 = 「Ctrl+V 를 쓸 것」 안내) ·
|
||||
* 행열 넣기 · 지우기 · 숨기기 · 보이기 · 내용 지우기 · 서식 지우기 · 병합 · 풀기 · 메모 삽입/편집/삭제.
|
||||
* `ui_template_context_menu.ts` 재사용 — 우클릭 밖 자리는 그냥 눌러 선택부터 옮김.
|
||||
* 메모는 `spreadsheet_comments.ts`(sub1 · 브레인 답 2026-09-27)의 `setComment` · `deleteComment` ·
|
||||
* `getComment` 를 부름 — 안에서 「시트통째」 명령 · 되돌리기 짝까지 만듦. 글 입력 창은 이 파일이
|
||||
* 직접 띄움(작은 오버레이 · `showConfirmDialog` 와 같은 결의 자체 모달 · native prompt 안 씀).
|
||||
*
|
||||
* 이음(D `spreadsheet.ts` 잇는 자리) — `attachMenu(ctx)` 한 번 부름(반환 `root` = 메뉴 껍데기 ·
|
||||
* 안 붙여도 됨 · `ctx.root` 안에 스스로 넣음). `ctx.grid.hitTest` 가 준비된 뒤(C 격자 붙은 뒤) 불러야 함.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { rangeToA1 } from "./spreadsheet_address";
|
||||
import { deleteComment, getComment, setComment } from "./spreadsheet_comments";
|
||||
import { st } from "./spreadsheet_text";
|
||||
import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
import "./spreadsheet_toolbar.css";
|
||||
|
||||
function within(range: CellRange, r: number, c: number): boolean {
|
||||
return r >= range.r0 && r <= range.r1 && c >= range.c0 && c <= range.c1;
|
||||
}
|
||||
|
||||
/** `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);
|
||||
|
||||
function isMerged(range: CellRange): boolean {
|
||||
return (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
|
||||
}
|
||||
|
||||
function cutCopy(kind: "cut" | "copy"): void {
|
||||
document.execCommand(kind);
|
||||
}
|
||||
|
||||
/** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는
|
||||
* 문제는 `showConfirmDialog` 머리 주석과 같은 까닭). 저장을 누르면 `onSave`. */
|
||||
function openCommentEditor(initial: string, onSave: (text: string) => void): void {
|
||||
const overlay = el("div", { className: "ss-comment-editor" });
|
||||
const panel = el("div", { className: "ss-comment-editor__panel" });
|
||||
const textarea = el("textarea", {
|
||||
className: "ss-comment-editor__input",
|
||||
}) as HTMLTextAreaElement;
|
||||
textarea.value = initial;
|
||||
const done = (): void => overlay.remove();
|
||||
const actions = el("div", {
|
||||
className: "ss-comment-editor__actions",
|
||||
children: [
|
||||
createButton({ label: "취소", variant: "ghost", onClick: done }),
|
||||
createButton({
|
||||
label: "저장",
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
onSave(textarea.value);
|
||||
done();
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
panel.append(textarea, actions);
|
||||
overlay.append(panel);
|
||||
document.body.append(overlay);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
}
|
||||
|
||||
function cellMenuItems(range: CellRange, r: number, c: number): MapContextMenuItem[] {
|
||||
const note = getComment(ctx.sheet(), r, c);
|
||||
return [
|
||||
[st("MenuCut"), () => cutCopy("cut")],
|
||||
[st("MenuCopy"), () => cutCopy("copy")],
|
||||
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
|
||||
[
|
||||
st("MenuClearContent"),
|
||||
() => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }),
|
||||
],
|
||||
[
|
||||
st("MenuClearFormat"),
|
||||
() => ctx.dispatch({ 종류: "서식지움", 시트: ctx.selection.시트, 범위: [range] }),
|
||||
],
|
||||
[
|
||||
isMerged(range) ? st("MenuUnmerge") : st("MenuMerge"),
|
||||
() =>
|
||||
ctx.dispatch(
|
||||
isMerged(range)
|
||||
? { 종류: "병합풀기", 시트: ctx.selection.시트, 범위: range }
|
||||
: { 종류: "병합", 시트: ctx.selection.시트, 범위: range },
|
||||
),
|
||||
],
|
||||
[
|
||||
note ? st("MenuCommentEdit") : st("MenuCommentInsert"),
|
||||
() => openCommentEditor(note ?? "", (text) => setComment(ctx, r, c, text)),
|
||||
],
|
||||
...(note
|
||||
? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[])
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
function rowMenuItems(rows: number[]): MapContextMenuItem[] {
|
||||
const at = Math.min(...rows);
|
||||
const 수 = rows.length;
|
||||
return [
|
||||
[st("MenuCopy"), () => cutCopy("copy")],
|
||||
[
|
||||
st("MenuInsertRow"),
|
||||
() => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }),
|
||||
],
|
||||
[
|
||||
st("MenuDeleteRow"),
|
||||
() => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }),
|
||||
],
|
||||
[
|
||||
st("MenuHide"),
|
||||
() =>
|
||||
ctx.dispatch({
|
||||
종류: "숨김",
|
||||
시트: ctx.selection.시트,
|
||||
축: "행",
|
||||
번호: rows,
|
||||
숨김: true,
|
||||
}),
|
||||
],
|
||||
[
|
||||
st("MenuUnhide"),
|
||||
() =>
|
||||
ctx.dispatch({
|
||||
종류: "숨김",
|
||||
시트: ctx.selection.시트,
|
||||
축: "행",
|
||||
번호: rows,
|
||||
숨김: false,
|
||||
}),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function colMenuItems(cols: number[]): MapContextMenuItem[] {
|
||||
const at = Math.min(...cols);
|
||||
const 수 = cols.length;
|
||||
return [
|
||||
[st("MenuCopy"), () => cutCopy("copy")],
|
||||
[
|
||||
st("MenuInsertCol"),
|
||||
() => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }),
|
||||
],
|
||||
[
|
||||
st("MenuDeleteCol"),
|
||||
() => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }),
|
||||
],
|
||||
[
|
||||
st("MenuHide"),
|
||||
() =>
|
||||
ctx.dispatch({
|
||||
종류: "숨김",
|
||||
시트: ctx.selection.시트,
|
||||
축: "열",
|
||||
번호: cols,
|
||||
숨김: true,
|
||||
}),
|
||||
],
|
||||
[
|
||||
st("MenuUnhide"),
|
||||
() =>
|
||||
ctx.dispatch({
|
||||
종류: "숨김",
|
||||
시트: ctx.selection.시트,
|
||||
축: "열",
|
||||
번호: cols,
|
||||
숨김: false,
|
||||
}),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function range(r0: number, c0: number, r1: number, c1: number): CellRange {
|
||||
return { r0, c0, r1, c1 };
|
||||
}
|
||||
|
||||
function onContextMenu(ev: MouseEvent): void {
|
||||
const hit = ctx.grid.hitTest(ev.clientX, ev.clientY);
|
||||
if (!hit) return;
|
||||
ev.preventDefault();
|
||||
const box = ctx.root.getBoundingClientRect();
|
||||
const x = ev.clientX - box.left;
|
||||
const y = ev.clientY - box.top;
|
||||
|
||||
if (hit.kind === "rowHead") {
|
||||
const covered = ctx.selection.범위.find(
|
||||
(rg) => rg.c0 === 0 && rg.c1 >= MAX_COLS - 1 && within(rg, hit.r, 0),
|
||||
);
|
||||
const rows = covered
|
||||
? Array.from({ length: covered.r1 - covered.r0 + 1 }, (_, i) => covered.r0 + i)
|
||||
: [hit.r];
|
||||
if (!covered)
|
||||
ctx.select({
|
||||
시트: ctx.selection.시트,
|
||||
범위: [range(hit.r, 0, hit.r, MAX_COLS - 1)],
|
||||
활성: { r: hit.r, c: 0 },
|
||||
기준: { r: hit.r, c: 0 },
|
||||
});
|
||||
menu.open(x, y, rowMenuItems(rows));
|
||||
return;
|
||||
}
|
||||
|
||||
if (hit.kind === "colHead") {
|
||||
const covered = ctx.selection.범위.find(
|
||||
(rg) => rg.r0 === 0 && rg.r1 >= MAX_ROWS - 1 && within(rg, 0, hit.c),
|
||||
);
|
||||
const cols = covered
|
||||
? Array.from({ length: covered.c1 - covered.c0 + 1 }, (_, i) => covered.c0 + i)
|
||||
: [hit.c];
|
||||
if (!covered)
|
||||
ctx.select({
|
||||
시트: ctx.selection.시트,
|
||||
범위: [range(0, hit.c, MAX_ROWS - 1, hit.c)],
|
||||
활성: { r: 0, c: hit.c },
|
||||
기준: { r: 0, c: hit.c },
|
||||
});
|
||||
menu.open(x, y, colMenuItems(cols));
|
||||
return;
|
||||
}
|
||||
|
||||
if (hit.kind === "cell") {
|
||||
const covered = ctx.selection.범위.some((rg) => within(rg, hit.r, hit.c));
|
||||
if (!covered) {
|
||||
ctx.select({
|
||||
시트: ctx.selection.시트,
|
||||
범위: [range(hit.r, hit.c, hit.r, hit.c)],
|
||||
활성: { r: hit.r, c: hit.c },
|
||||
기준: { r: hit.r, c: hit.c },
|
||||
});
|
||||
}
|
||||
const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c);
|
||||
menu.open(x, y, cellMenuItems(active, hit.r, hit.c));
|
||||
}
|
||||
}
|
||||
|
||||
ctx.root.addEventListener("contextmenu", onContextMenu);
|
||||
|
||||
return {
|
||||
root: menu.element,
|
||||
refresh() {},
|
||||
destroy() {
|
||||
ctx.root.removeEventListener("contextmenu", onContextMenu);
|
||||
menu.close();
|
||||
menu.element.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_mouse.ts (주인 D)
|
||||
* 마우스 — 칸 · 범위 끌기 · Shift 늘림 · 행열 머리 · 모서리(전체) · 식 편집 중 참조 가리키기 ·
|
||||
* 채우기 핸들 끌기(늘림 · 안으로 끌면 지움) · 두 번 누르기(칸 편집 · 핸들이면 옆 열 끝까지 아래로) ·
|
||||
* 우클릭 자리(고름 밖이면 그 칸을 고름 — 메뉴는 E).
|
||||
* 머리 끝(폭 · 높이 끌기)은 C 몫 — 여기서 건드리지 않음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fillCells } from "./spreadsheet_fill";
|
||||
import {
|
||||
hasValue,
|
||||
inRange,
|
||||
LAST_C,
|
||||
LAST_R,
|
||||
selectAll,
|
||||
selectCell,
|
||||
selectCols,
|
||||
selectRows,
|
||||
selectSpan,
|
||||
} from "./spreadsheet_selection";
|
||||
import { boxStyle, slotOf, type Editor } from "./spreadsheet_editor";
|
||||
import type { CellAddress, CellRange } from "./spreadsheet_types";
|
||||
import type { HitResult, Selection, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
/** 채우기 끌기 목표 — 늘림(fill) · 안으로 줄임(clear = 지울 부분) */
|
||||
export function fillTarget(
|
||||
src: CellRange,
|
||||
p: CellAddress,
|
||||
): { target: CellRange; clear: CellRange | null } {
|
||||
if (inRange(src, p.r, p.c)) {
|
||||
const up = src.r1 - p.r;
|
||||
const left = src.c1 - p.c;
|
||||
if (up === 0 && left === 0) return { target: src, clear: null };
|
||||
if (up >= left) return { target: { ...src, r1: p.r }, clear: { ...src, r0: p.r + 1 } };
|
||||
return { target: { ...src, c1: p.c }, clear: { ...src, c0: p.c + 1 } };
|
||||
}
|
||||
const down = p.r - src.r1;
|
||||
const upOut = src.r0 - p.r;
|
||||
const right = p.c - src.c1;
|
||||
const leftOut = src.c0 - p.c;
|
||||
const best = Math.max(down, upOut, right, leftOut);
|
||||
if (best === down) return { target: { ...src, r1: p.r }, clear: null };
|
||||
if (best === upOut) return { target: { ...src, r0: p.r }, clear: null };
|
||||
if (best === right) return { target: { ...src, c1: p.c }, clear: null };
|
||||
return { target: { ...src, c0: p.c }, clear: null };
|
||||
}
|
||||
|
||||
/** 2단계 잇기 자리 — 부른 쪽(spreadsheet.ts)이 채움 */
|
||||
export interface MouseHooks {
|
||||
/** 칸 끌어 고르기가 끝남(서식 붓 바르기) */
|
||||
selected?(): void;
|
||||
/** 누르지 않고 움직일 때 가리킨 것(메모 풍선) — 격자 밖이면 null */
|
||||
hover?(hit: HitResult | null): void;
|
||||
}
|
||||
|
||||
export function attachMouse(
|
||||
ctx: SpreadsheetContext,
|
||||
ed: Editor,
|
||||
hooks: MouseHooks = {},
|
||||
): () => void {
|
||||
const root = ctx.grid.root;
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "ss-fill-preview";
|
||||
let stopDrag: (() => void) | null = null;
|
||||
|
||||
/** 끌기 — 누른 채 움직이면 `onMove(칸)` · 떼면 `onUp` · 격자 밖이면 가장자리 칸(스크롤 따라감) */
|
||||
function drag(onMove: (p: HitResult) => void, onUp?: () => void): void {
|
||||
const move = (e: MouseEvent): void => {
|
||||
const box = root.getBoundingClientRect();
|
||||
const x = Math.min(box.right - 2, Math.max(box.left + 2, e.clientX));
|
||||
const y = Math.min(box.bottom - 2, Math.max(box.top + 2, e.clientY));
|
||||
const hit = ctx.grid.hitTest(x, y);
|
||||
if (!hit || hit.r < 0 || hit.c < 0) return;
|
||||
onMove(hit);
|
||||
if (x !== e.clientX || y !== e.clientY) ctx.grid.reveal(hit.r, hit.c);
|
||||
};
|
||||
const up = (): void => {
|
||||
stopDrag?.();
|
||||
onUp?.();
|
||||
};
|
||||
stopDrag = () => {
|
||||
window.removeEventListener("mousemove", move);
|
||||
window.removeEventListener("mouseup", up);
|
||||
stopDrag = null;
|
||||
};
|
||||
window.addEventListener("mousemove", move);
|
||||
window.addEventListener("mouseup", up);
|
||||
}
|
||||
|
||||
const inSelection = (r: number, c: number): boolean =>
|
||||
ctx.selection.범위.some((g) => inRange(g, r, c));
|
||||
|
||||
function showPreview(g: CellRange | null): void {
|
||||
if (!g) return preview.remove();
|
||||
const a = slotOf(ctx, g.r0, g.c0);
|
||||
const b = slotOf(ctx, Math.min(g.r1, LAST_R), Math.min(g.c1, LAST_C));
|
||||
if (preview.parentElement !== a.layer) a.layer.append(preview);
|
||||
boxStyle(preview, {
|
||||
x: a.box.x,
|
||||
y: a.box.y,
|
||||
w: b.box.x + b.box.w - a.box.x,
|
||||
h: b.box.y + b.box.h - a.box.y,
|
||||
});
|
||||
}
|
||||
|
||||
function fillDrag(): void {
|
||||
const sheet = ctx.sheet();
|
||||
const src = ctx.selection.범위[ctx.selection.범위.length - 1];
|
||||
let result = { target: src, clear: null as CellRange | null };
|
||||
drag(
|
||||
(p) => {
|
||||
result = fillTarget(src, p);
|
||||
showPreview(result.clear ?? result.target);
|
||||
},
|
||||
() => {
|
||||
showPreview(null);
|
||||
applyFill(sheet.id, src, result.target, result.clear);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function applyFill(
|
||||
sheet: string,
|
||||
src: CellRange,
|
||||
target: CellRange,
|
||||
clear: CellRange | null,
|
||||
): void {
|
||||
const at = ctx.selection.활성;
|
||||
const sel = (g: CellRange): Selection => ({
|
||||
시트: sheet,
|
||||
범위: [g],
|
||||
활성: at,
|
||||
기준: { ...at },
|
||||
});
|
||||
if (clear) {
|
||||
ctx.dispatch({ 종류: "내용지움", 시트: sheet, 범위: [clear] });
|
||||
ctx.select(sel(target));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target.r0 === src.r0 &&
|
||||
target.r1 === src.r1 &&
|
||||
target.c0 === src.c0 &&
|
||||
target.c1 === src.c1
|
||||
)
|
||||
return;
|
||||
ctx.dispatch({ 종류: "칸", 시트: sheet, 칸: fillCells(ctx.book, sheet, src, target) });
|
||||
ctx.select(sel(target));
|
||||
}
|
||||
|
||||
/** 핸들 두 번 누르기 — 왼쪽(없으면 오른쪽) 열 값이 이어진 끝까지 아래로 */
|
||||
function fillDown(): void {
|
||||
const sheet = ctx.sheet();
|
||||
const src = ctx.selection.범위[0];
|
||||
const side = [src.c0 - 1, src.c1 + 1].find(
|
||||
(c) => c >= 0 && c <= LAST_C && hasValue(sheet, src.r1 + 1, c),
|
||||
);
|
||||
if (side === undefined) return;
|
||||
let r = src.r1 + 1;
|
||||
while (r < LAST_R && hasValue(sheet, r + 1, side)) r++;
|
||||
applyFill(sheet.id, src, { ...src, r1: r }, null);
|
||||
}
|
||||
|
||||
function onDown(e: MouseEvent): void {
|
||||
if (e.target === ed.ta) return; // 편집 칸 안 — 글자 자리 옮김
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
if (!hit || hit.kind === "colEdge" || hit.kind === "rowEdge") return;
|
||||
const sheet = ctx.sheet();
|
||||
if (e.button === 2) {
|
||||
if (ed.editing() || (hit.kind === "cell" && inSelection(hit.r, hit.c))) return;
|
||||
if (hit.kind === "cell") ctx.select(selectCell(sheet, hit.r, hit.c));
|
||||
else if (hit.kind === "rowHead" && !inSelection(hit.r, ctx.selection.활성.c))
|
||||
ctx.select(selectRows(sheet, hit.r, hit.r));
|
||||
else if (hit.kind === "colHead" && !inSelection(ctx.selection.활성.r, hit.c))
|
||||
ctx.select(selectCols(sheet, hit.c, hit.c));
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault(); // 초점은 편집기 · 수식 입력줄에 둠
|
||||
if (ed.editing()) {
|
||||
if (hit.kind === "cell" && ed.pointable()) {
|
||||
const at = { r: hit.r, c: hit.c };
|
||||
ed.point(selectCell(sheet, hit.r, hit.c));
|
||||
drag((p) => ed.point(selectSpan(sheet, at, { r: p.r, c: p.c })));
|
||||
return;
|
||||
}
|
||||
ed.commit();
|
||||
if (ed.editing()) return; // 유효성에 막힘
|
||||
}
|
||||
ed.focus();
|
||||
if (ctx.readOnly && hit.kind === "fillHandle") hit.kind = "cell";
|
||||
switch (hit.kind) {
|
||||
case "fillHandle":
|
||||
return fillDrag();
|
||||
case "corner":
|
||||
return ctx.select(selectAll(sheet));
|
||||
case "rowHead": {
|
||||
const a = e.shiftKey ? ctx.selection.기준.r : hit.r;
|
||||
ctx.select(selectRows(sheet, a, hit.r, a));
|
||||
return drag((p) => ctx.select(selectRows(sheet, a, p.r, a)));
|
||||
}
|
||||
case "colHead": {
|
||||
const a = e.shiftKey ? ctx.selection.기준.c : hit.c;
|
||||
ctx.select(selectCols(sheet, a, hit.c, a));
|
||||
return drag((p) => ctx.select(selectCols(sheet, a, p.c, a)));
|
||||
}
|
||||
case "cell": {
|
||||
// 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}`;
|
||||
return drag(
|
||||
(p) => {
|
||||
if (`${p.r},${p.c}` === last) return;
|
||||
last = `${p.r},${p.c}`;
|
||||
const span = selectSpan(sheet, anchor, p);
|
||||
ctx.select({ ...span, 범위: [...keep, ...span.범위] });
|
||||
},
|
||||
() => hooks.selected?.(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDbl(e: MouseEvent): void {
|
||||
if (e.target === ed.ta || ctx.readOnly) return;
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
if (!hit) return;
|
||||
if (hit.kind === "fillHandle") return fillDown();
|
||||
if (hit.kind === "colEdge") {
|
||||
// 고른 열 전체의 경계면 고른 열 모두(엑셀)
|
||||
const g = ctx.selection.범위[0];
|
||||
const whole = g.r0 === 0 && g.r1 === LAST_R && inRange(g, 0, hit.c);
|
||||
void import("./spreadsheet_autofit").then((m) => {
|
||||
for (let c = whole ? g.c0 : hit.c; c <= (whole ? g.c1 : hit.c); c++)
|
||||
m.autofitColumn(ctx, c);
|
||||
}); // 늦게 — 크기 예산
|
||||
return;
|
||||
}
|
||||
if (hit.kind === "cell" && !ed.editing()) ed.begin();
|
||||
}
|
||||
|
||||
const onHover = (e: MouseEvent): void => {
|
||||
if (!stopDrag && !e.buttons) hooks.hover?.(ctx.grid.hitTest(e.clientX, e.clientY));
|
||||
};
|
||||
const onLeave = (): void => hooks.hover?.(null);
|
||||
|
||||
root.addEventListener("mousedown", onDown);
|
||||
root.addEventListener("dblclick", onDbl);
|
||||
root.addEventListener("mousemove", onHover);
|
||||
root.addEventListener("mouseleave", onLeave);
|
||||
return () => {
|
||||
root.removeEventListener("mousemove", onHover);
|
||||
root.removeEventListener("mouseleave", onLeave);
|
||||
stopDrag?.();
|
||||
preview.remove();
|
||||
root.removeEventListener("mousedown", onDown);
|
||||
root.removeEventListener("dblclick", onDbl);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_names.ts (3단계 · 주인 sub_laptop_4)
|
||||
* 이름 정의 — 범위에 이름을 붙이고 수식에서 `=단가*수량` 처럼 씀. 저장 자리 `Sheet.이름정의`(0 계약에
|
||||
* 방금 더함) — 이 이름을 정의한 시트 안 범위만 가리킴(다른 시트 참조는 3단계 밖).
|
||||
*
|
||||
* 수식 나무는 이미 `{type:"name", name}` 갈래를 가짐(계약 `spreadsheet_types.ts` 3절 · 정의 없으면
|
||||
* `#NAME?`). 이 파일은 이름을 저장 · 관리만 하고 실제로 식을 풀 때 이름을 찾는 일은 A 몫.
|
||||
*
|
||||
* 잇는 법(A · D · sub7 — 지금은 하나도 안 이어짐):
|
||||
* 1) `spreadsheet_eval.ts` 가 `{type:"name"}` 나무를 만나면 `resolveName(book, 식이든시트, 이름)` 을
|
||||
* 불러 범위를 얻고, 범위가 칸 하나면 그 칸 값 · 여러 칸이면 `RangeValue` 로 바꿔 씀(없으면 지금처럼 `#NAME?`).
|
||||
* 2) 편집기 이름 상자(주소 상자)에서 범위를 고른 채 이름을 치고 Enter 하면 `defineName(ctx, 이름, 범위)`.
|
||||
* 3) 도구 모음 · 메뉴에 [이름 관리] 항목을 두고 `createNameManagerPanel(ctx)` 를 띄움.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el } from "@ui/ui_template_elements";
|
||||
import { rangeToA1 } from "./spreadsheet_address";
|
||||
import type { CellRange, NamedRange, Sheet, Workbook } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
|
||||
|
||||
export type { NamedRange };
|
||||
|
||||
const CELL_LIKE = /^[A-Za-z]{1,3}\d+$/;
|
||||
const RESERVED = /^(TRUE|FALSE)$/i;
|
||||
const NAME_RE = /^[\p{L}_][\p{L}\p{N}_.]*$/u;
|
||||
|
||||
/** 이름 규칙 — 글자 · 밑줄로 시작 · 칸 주소처럼 안 보임 · 겹치지 않음. 어기면 까닭 글 · 되면 null. */
|
||||
export function invalidNameReason(name: string, existing: string[]): string | null {
|
||||
if (!name) return "이름이 비었음";
|
||||
if (!NAME_RE.test(name)) return "글자 · 숫자 · 밑줄만(첫 글자는 글자 · 밑줄)";
|
||||
if (CELL_LIKE.test(name)) return "칸 주소처럼 보이는 이름은 못 씀";
|
||||
if (RESERVED.test(name)) return "TRUE · FALSE 는 못 씀";
|
||||
if (existing.some((n) => n.toLowerCase() === name.toLowerCase())) return "이미 있는 이름";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function listNames(book: Workbook): { 이름: string; 시트: string; 범위: CellRange }[] {
|
||||
return book.시트.flatMap((sheet) =>
|
||||
(sheet.이름정의 ?? []).map((n) => ({ 이름: n.이름, 시트: sheet.id, 범위: n.범위 })),
|
||||
);
|
||||
}
|
||||
|
||||
/** 식이 든 시트의 이름부터 찾고(자기 시트 우선) · 없으면 다른 시트 이름도(느슨한 전역 대체). */
|
||||
export function resolveName(
|
||||
book: Workbook,
|
||||
sheetId: string,
|
||||
name: string,
|
||||
): { 시트: string; 범위: CellRange } | null {
|
||||
const lower = name.toLowerCase();
|
||||
const own = book.시트.find((s) => s.id === sheetId);
|
||||
const local = own?.이름정의?.find((n) => n.이름.toLowerCase() === lower);
|
||||
if (local) return { 시트: sheetId, 범위: local.범위 };
|
||||
for (const sheet of book.시트) {
|
||||
const found = sheet.이름정의?.find((n) => n.이름.toLowerCase() === lower);
|
||||
if (found) return { 시트: sheet.id, 범위: found.범위 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applySheet(ctx: SpreadsheetContext, sheetId: string, next: Sheet): void {
|
||||
ctx.dispatch({ 종류: "시트통째", 시트: { ...next, id: sheetId } });
|
||||
}
|
||||
|
||||
/** 지금 활성 시트에 이름을 더함 — 규칙 어기면 아무 일도 안 하고 까닭을 돌려줌(호출부가 토스트로). */
|
||||
export function defineName(ctx: SpreadsheetContext, name: string, 범위: CellRange): string | null {
|
||||
const reason = invalidNameReason(
|
||||
name,
|
||||
listNames(ctx.book).map((n) => n.이름),
|
||||
);
|
||||
if (reason) return reason;
|
||||
const sheet = ctx.sheet();
|
||||
applySheet(ctx, sheet.id, {
|
||||
...sheet,
|
||||
이름정의: [...(sheet.이름정의 ?? []), { 이름: name, 범위 }],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 이름을 지움 — 그 이름을 쓰던 식은 그대로 남아 다음 풀이 때 `#NAME?`(계약대로). */
|
||||
export function deleteName(ctx: SpreadsheetContext, name: string): void {
|
||||
const lower = name.toLowerCase();
|
||||
for (const sheet of ctx.book.시트) {
|
||||
if (!sheet.이름정의?.some((n) => n.이름.toLowerCase() === lower)) continue;
|
||||
applySheet(ctx, sheet.id, {
|
||||
...sheet,
|
||||
이름정의: sheet.이름정의!.filter((n) => n.이름.toLowerCase() !== lower),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** 이름을 바꿈 — 정의뿐 아니라 모든 시트 식 글 속 이름 글자도 같이 바꿈(시트이름 바꾸기와 같은 규칙). */
|
||||
export function renameName(
|
||||
ctx: SpreadsheetContext,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): string | null {
|
||||
const reason = invalidNameReason(
|
||||
newName,
|
||||
listNames(ctx.book)
|
||||
.map((n) => n.이름)
|
||||
.filter((n) => n.toLowerCase() !== oldName.toLowerCase()),
|
||||
);
|
||||
if (reason) return reason;
|
||||
const commands = ctx.book.시트
|
||||
.map((sheet) => renamedSheet(sheet, oldName, newName))
|
||||
.filter((s): s is Sheet => s !== null)
|
||||
.map((sheet) => ({ 종류: "시트통째" as const, 시트: sheet }));
|
||||
if (!commands.length) return null;
|
||||
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
|
||||
return null;
|
||||
}
|
||||
|
||||
function renamedSheet(sheet: Sheet, oldName: string, newName: string): Sheet | null {
|
||||
// 한글 이름은 `\w` 밖이라 `\b` 가 안 먹음 — 글자 · 숫자 · 밑줄이 아닌 자리를 경계로 직접 잡음.
|
||||
const re = new RegExp(`(?<![\\p{L}\\p{N}_])${escapeReg(oldName)}(?![\\p{L}\\p{N}_])`, "giu");
|
||||
let changed = false;
|
||||
const 칸: Sheet["칸"] = { ...sheet.칸 };
|
||||
for (const [key, cell] of Object.entries(칸)) {
|
||||
if (cell.식 !== undefined && re.test(cell.식)) {
|
||||
re.lastIndex = 0;
|
||||
칸[key] = { ...cell, 식: cell.식.replace(re, newName) };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
const 이름정의 = sheet.이름정의?.map((n) =>
|
||||
n.이름.toLowerCase() === oldName.toLowerCase() ? { ...n, 이름: newName } : n,
|
||||
);
|
||||
if (이름정의 && JSON.stringify(이름정의) !== JSON.stringify(sheet.이름정의)) changed = true;
|
||||
return changed ? { ...sheet, 칸, ...(이름정의 ? { 이름정의 } : {}) } : null;
|
||||
}
|
||||
|
||||
function escapeReg(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** 이름 관리 창 — 통합문서 전체 이름 목록 + 고른 범위에 새 이름. */
|
||||
export function createNameManagerPanel(ctx: SpreadsheetContext): PartHandle {
|
||||
const root = el("div", { className: "ss-names" });
|
||||
const list = el("ul", { className: "ss-names__list" });
|
||||
const nameInput = createInputField({ label: "새 이름", placeholder: "단가" });
|
||||
const addBtn = createButton({ label: "이름 더하기" });
|
||||
let error: HTMLElement | null = null;
|
||||
|
||||
const showError = (msg: string | null): void => {
|
||||
error?.remove();
|
||||
error = null;
|
||||
if (!msg) return;
|
||||
error = el("p", { text: msg });
|
||||
error.style.color = "var(--color-danger, red)";
|
||||
root.append(error);
|
||||
};
|
||||
|
||||
addBtn.addEventListener("click", () => {
|
||||
const 범위 = ctx.selection.범위[0];
|
||||
if (!범위) return;
|
||||
const reason = defineName(ctx, nameInput.input.value.trim(), 범위);
|
||||
showError(reason);
|
||||
if (!reason) {
|
||||
nameInput.input.value = "";
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
function render(): void {
|
||||
list.replaceChildren(
|
||||
...listNames(ctx.book).map(({ 이름, 시트, 범위 }) => {
|
||||
const li = el("li", { className: "ss-names__row" });
|
||||
const label = el("span", { text: `${이름} = ${시트}!${rangeToA1(범위)}` });
|
||||
const rename = createButton({ label: "이름 바꾸기", variant: "ghost" });
|
||||
rename.addEventListener("click", () => {
|
||||
const next = window.prompt("새 이름", 이름);
|
||||
if (!next || next === 이름) return;
|
||||
showError(renameName(ctx, 이름, next));
|
||||
render();
|
||||
});
|
||||
const del = createButton({ label: "지움", variant: "danger" });
|
||||
del.addEventListener("click", () => {
|
||||
deleteName(ctx, 이름);
|
||||
render();
|
||||
});
|
||||
li.append(label, rename, del);
|
||||
return li;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const form = el("div", { className: "ss-names__form", children: [nameInput.root, addBtn] });
|
||||
root.append(el("h4", { text: "이름 관리" }), list, form);
|
||||
render();
|
||||
|
||||
return {
|
||||
root,
|
||||
refresh: render,
|
||||
destroy(): void {
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_numfmt.ts (주인 B)
|
||||
* 숫자 형식 코드 → 보이는 글 — 엑셀 코드 부분 집합: 일반 · `0` `#` `?` `.` `,`(천 단위 · 끝 쉼표 = 천으로 나눔) ·
|
||||
* 구역 `;`(양 · 음 · 0 · 글) · `"글"` · `\x` · `_x`(폭만큼 띄움) · `*x`(채움) · `[Red]` 등 색 · `%` · `@` · `E+00`.
|
||||
* 실무 형식(`0.00` · `_-* #,##0.00_-;\-* #,##0.00_-;_-* "-"_-;_-@_-` · `0.00_);[Red]\(0.00\)` …)은 모두 맞아야 함.
|
||||
* 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`)도 봄
|
||||
* (3차 · 2026-09-27) — 시간(`h:mm:ss`)은 아직 밖(글 그대로).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
ZERO,
|
||||
cmp,
|
||||
div,
|
||||
frac,
|
||||
fracToString,
|
||||
mul,
|
||||
roundAt,
|
||||
sub,
|
||||
toInteger,
|
||||
type Frac,
|
||||
} from "@ui/sheet/ui_template_sheet_frac";
|
||||
import type { ErrorValue, FormattedText, Scalar } from "./spreadsheet_types";
|
||||
|
||||
function isErrorScalar(v: Scalar): v is ErrorValue {
|
||||
return (
|
||||
typeof v === "object" && v !== null && "error" in (v as unknown as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
function groupThousands(digits: string): string {
|
||||
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
// ── 구역 나누기(`;` · 따옴표 · `\` 뒤는 안 나눔) ─────────────────────────────
|
||||
|
||||
function splitSections(code: string): string[] {
|
||||
const sections: string[] = [];
|
||||
let cur = "";
|
||||
let inQuote = false;
|
||||
for (let i = 0; i < code.length; i++) {
|
||||
const ch = code[i];
|
||||
if (ch === '"') {
|
||||
inQuote = !inQuote;
|
||||
cur += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
// 형식 끝에 홀로 남은 역슬래시 · 구역 나눔(`;`) 바로 앞 역슬래시는 깨진 자투리로 보고 버림
|
||||
// (엑셀 붙여넣기가 잘려 남긴 것 — 2026-09-28 실측 rv.json). 그 밖엔 원래대로 다음 글자를 이스케이프.
|
||||
if (i + 1 >= code.length || code[i + 1] === ";") continue;
|
||||
cur += ch + code[i + 1];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ";" && !inQuote) {
|
||||
sections.push(cur);
|
||||
cur = "";
|
||||
continue;
|
||||
}
|
||||
cur += ch;
|
||||
}
|
||||
sections.push(cur);
|
||||
return sections;
|
||||
}
|
||||
|
||||
const COLOR_NAMES = ["black", "white", "red", "green", "blue", "yellow", "magenta", "cyan"];
|
||||
|
||||
function extractColor(section: string): { rest: string; color?: string } {
|
||||
let color: string | undefined;
|
||||
const rest = section.replace(/\[([^\]]+)\]/g, (_m, inner: string) => {
|
||||
const lower = inner.toLowerCase();
|
||||
if (COLOR_NAMES.includes(lower)) {
|
||||
color = lower;
|
||||
return "";
|
||||
}
|
||||
const currency = /^\$([^-\]]*)(-[0-9A-Fa-f]+)?$/.exec(inner);
|
||||
if (currency) return currency[1]; // `[$₩-412]` 등 통화 태그 → 글자만 남김
|
||||
return ""; // 조건 태그(`[>100]` 등)는 1단계에서 무시
|
||||
});
|
||||
return { rest, color };
|
||||
}
|
||||
|
||||
// ── 낱말 나누기 ──────────────────────────────────────────────────────────
|
||||
|
||||
type Tok =
|
||||
| { k: "digit"; ch: "0" | "#" | "?" }
|
||||
| { k: "comma" }
|
||||
| { k: "point" }
|
||||
| { k: "percent" }
|
||||
| { k: "text" }
|
||||
| { k: "general" }
|
||||
| { k: "lit"; ch: string };
|
||||
|
||||
function tokenize(s: string): { toks: Tok[]; fillChar?: string } {
|
||||
const toks: Tok[] = [];
|
||||
let fillChar: string | undefined;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
// 코드 속에 섞인 맨 `General`(따옴표 없이) — 실무 흔한 `General"개"` 같은 단위 접미 버릇.
|
||||
if (s.slice(i, i + 7).toLowerCase() === "general") {
|
||||
toks.push({ k: "general" });
|
||||
i += 6;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
let j = i + 1;
|
||||
while (j < s.length && s[j] !== '"') {
|
||||
toks.push({ k: "lit", ch: s[j] });
|
||||
j++;
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
// 홀로 남은 역슬래시(뒤에 이스케이프할 글자가 없음)는 굴림 등 글꼴에서 ₩ 모양으로 보여 버림.
|
||||
if (i + 1 < s.length) toks.push({ k: "lit", ch: s[i + 1] });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "_" && i + 1 < s.length) {
|
||||
toks.push({ k: "lit", ch: " " });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "*" && i + 1 < s.length) {
|
||||
fillChar = s[i + 1];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "0" || ch === "#" || ch === "?") {
|
||||
toks.push({ k: "digit", ch });
|
||||
continue;
|
||||
}
|
||||
if (ch === ",") {
|
||||
toks.push({ k: "comma" });
|
||||
continue;
|
||||
}
|
||||
if (ch === ".") {
|
||||
toks.push({ k: "point" });
|
||||
continue;
|
||||
}
|
||||
if (ch === "%") {
|
||||
toks.push({ k: "percent" });
|
||||
continue;
|
||||
}
|
||||
if (ch === "@") {
|
||||
toks.push({ k: "text" });
|
||||
continue;
|
||||
}
|
||||
toks.push({ k: "lit", ch });
|
||||
}
|
||||
return { toks, fillChar };
|
||||
}
|
||||
|
||||
// ── 수 구역 그리기 ───────────────────────────────────────────────────────
|
||||
|
||||
function renderNumericSection(toks: Tok[], magnitude: Frac): string {
|
||||
const pointIdx = toks.findIndex((t) => t.k === "point");
|
||||
const hasPercent = toks.some((t) => t.k === "percent");
|
||||
const scaledMag = hasPercent ? mul(magnitude, frac(100n)) : magnitude;
|
||||
|
||||
let lastDigit = -1;
|
||||
toks.forEach((t, i) => {
|
||||
if (t.k === "digit") lastDigit = i;
|
||||
});
|
||||
|
||||
let scale = 0;
|
||||
for (let i = lastDigit + 1; i < toks.length; i++) if (toks[i].k === "comma") scale++;
|
||||
|
||||
let hasGrouping = false;
|
||||
const introEnd = pointIdx >= 0 ? pointIdx : toks.length;
|
||||
for (let i = 0; i < introEnd && i <= lastDigit; i++)
|
||||
if (toks[i].k === "comma") hasGrouping = true;
|
||||
|
||||
const intRun = pointIdx >= 0 ? toks.slice(0, pointIdx) : toks;
|
||||
const fracRun = pointIdx >= 0 ? toks.slice(pointIdx + 1) : [];
|
||||
const intDigitToks = intRun.filter((t): t is Extract<Tok, { k: "digit" }> => t.k === "digit");
|
||||
const fracDigitToks = fracRun.filter((t): t is Extract<Tok, { k: "digit" }> => t.k === "digit");
|
||||
|
||||
const scaleDiv = scale > 0 ? 1000n ** BigInt(scale) : 1n;
|
||||
const adjusted = scaleDiv === 1n ? scaledMag : div(scaledMag, frac(scaleDiv));
|
||||
|
||||
const rounded = roundAt(adjusted, fracDigitToks.length, "round");
|
||||
const scaleUp = frac(10n ** BigInt(fracDigitToks.length));
|
||||
const scaledInt = toInteger(mul(rounded, scaleUp), "round");
|
||||
const allDigits = scaledInt.toString().padStart(fracDigitToks.length + 1, "0");
|
||||
const rawIntStr =
|
||||
fracDigitToks.length > 0 ? allDigits.slice(0, -fracDigitToks.length) : allDigits;
|
||||
const rawFracStr = fracDigitToks.length > 0 ? allDigits.slice(-fracDigitToks.length) : "";
|
||||
|
||||
let intStr = rawIntStr;
|
||||
// 정수부가 온통 `?`(회계 형식 0 구역의 `"-"??` 자리맞춤 버릇)뿐이고 값이 정말 0 이면
|
||||
// 자리마다 빈칸(엑셀은 `?` 를 「있으면 숫자 · 없으면 빈칸」으로 봄 — `0` 처럼 0 을 안 채움).
|
||||
if (intDigitToks.length > 0 && rawIntStr === "0" && intDigitToks.every((t) => t.ch === "?")) {
|
||||
intStr = " ".repeat(intDigitToks.length);
|
||||
} else if (intDigitToks.length > intStr.length) {
|
||||
const need = intDigitToks.length - intStr.length;
|
||||
let pad = "";
|
||||
for (let i = 0; i < need; i++) pad += intDigitToks[i].ch === "0" ? "0" : "";
|
||||
intStr = pad + intStr;
|
||||
}
|
||||
if (hasGrouping) intStr = groupThousands(intStr);
|
||||
|
||||
let keepUntil = fracDigitToks.length;
|
||||
for (let i = fracDigitToks.length - 1; i >= 0; i--) {
|
||||
if (fracDigitToks[i].ch === "0") break;
|
||||
if (rawFracStr[i] !== "0") break;
|
||||
keepUntil = i;
|
||||
}
|
||||
const fracStr = rawFracStr.slice(0, keepUntil);
|
||||
|
||||
let out = "";
|
||||
let intEmitted = false;
|
||||
let fracEmitted = false;
|
||||
for (let i = 0; i < toks.length; i++) {
|
||||
const t = toks[i];
|
||||
if (t.k === "digit" || t.k === "comma") {
|
||||
const inFrac = pointIdx >= 0 && i > pointIdx;
|
||||
if (inFrac) {
|
||||
if (!fracEmitted) {
|
||||
out += fracStr;
|
||||
fracEmitted = true;
|
||||
}
|
||||
} else if (!intEmitted) {
|
||||
out += intStr;
|
||||
intEmitted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (t.k === "point") {
|
||||
if (fracStr.length > 0) out += ".";
|
||||
continue;
|
||||
}
|
||||
if (t.k === "percent") {
|
||||
out += "%";
|
||||
continue;
|
||||
}
|
||||
if (t.k === "text") continue; // 수 구역엔 의미 없음(방어)
|
||||
if (t.k === "general") {
|
||||
out += generalFormat(magnitude);
|
||||
continue;
|
||||
}
|
||||
out += t.ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 분수 형식(`0/0` · `# ?/?` · `# ??/??` · 분모 고정 `# ?/8`) ───────────────
|
||||
|
||||
interface FracPlan {
|
||||
prefix: Tok[];
|
||||
wholeDigits: number; // 0 = 정수부 자리 없음(가분수로 통째)
|
||||
sepToks: Tok[]; // 정수부 · 분자 사이 리터럴(보통 칸 하나)
|
||||
fixedDenom: bigint | null; // null = 분모 자리표시(자동 찾기) · 있으면 그 값 고정
|
||||
denomDigits: number; // 자동일 때 자리표시 개수(최대 분모 = 10^자리 - 1)
|
||||
suffix: Tok[];
|
||||
}
|
||||
|
||||
/** 슬래시 앞뒤 자리표시(0/#/?)·고정 숫자 자리를 찾음 — 없으면 분수 형식이 아님(null). */
|
||||
function detectFraction(toks: Tok[]): FracPlan | null {
|
||||
if (toks.some((t) => t.k === "point")) return null;
|
||||
const slashIdx = toks.findIndex((t) => t.k === "lit" && t.ch === "/");
|
||||
if (slashIdx < 0) return null;
|
||||
|
||||
let i = slashIdx - 1;
|
||||
while (i >= 0 && toks[i].k === "digit") i--;
|
||||
const numeratorStart = i + 1;
|
||||
if (numeratorStart === slashIdx) return null; // 분자 자리표시 없음
|
||||
|
||||
let j = slashIdx + 1;
|
||||
let denomDigits = 0;
|
||||
let fixedDigits = "";
|
||||
let mode: "auto" | "fixed" | null = null;
|
||||
while (j < toks.length) {
|
||||
const t = toks[j];
|
||||
if (t.k === "digit") {
|
||||
if (mode === "fixed") break;
|
||||
mode = "auto";
|
||||
denomDigits++;
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (t.k === "lit" && /[0-9]/.test(t.ch)) {
|
||||
if (mode === "auto") break;
|
||||
mode = "fixed";
|
||||
fixedDigits += t.ch;
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (mode === null) return null;
|
||||
const denomEnd = j;
|
||||
|
||||
const sepToks: Tok[] = [];
|
||||
let k = numeratorStart - 1;
|
||||
while (k >= 0 && toks[k].k !== "digit") {
|
||||
sepToks.unshift(toks[k]);
|
||||
k--;
|
||||
}
|
||||
let wholeDigits = 0;
|
||||
while (k >= 0 && toks[k].k === "digit") {
|
||||
wholeDigits++;
|
||||
k--;
|
||||
}
|
||||
const prefix = wholeDigits > 0 ? toks.slice(0, k + 1) : toks.slice(0, numeratorStart);
|
||||
if (wholeDigits === 0) sepToks.length = 0;
|
||||
|
||||
return {
|
||||
prefix,
|
||||
wholeDigits,
|
||||
sepToks,
|
||||
fixedDenom: mode === "fixed" ? BigInt(fixedDigits) : null,
|
||||
denomDigits,
|
||||
suffix: toks.slice(denomEnd),
|
||||
};
|
||||
}
|
||||
|
||||
/** 분모가 `maxDenom` 이하인 가장 가까운 분수(0 ≤ x) — 다 훑어봐도 999 번 안쪽(자리표시 최대 3 개). */
|
||||
function bestFraction(x: Frac, maxDenom: bigint): { num: bigint; den: bigint } {
|
||||
let bestNum = 0n;
|
||||
let bestDen = 1n;
|
||||
let bestDiff: Frac | null = null;
|
||||
for (let den = 1n; den <= maxDenom; den++) {
|
||||
const num = toInteger(mul(x, frac(den)), "round");
|
||||
const gap = sub(x, frac(num, den));
|
||||
const diff = frac(gap.n < 0n ? -gap.n : gap.n, gap.d);
|
||||
if (bestDiff === null || cmp(diff, bestDiff) < 0) {
|
||||
bestDiff = diff;
|
||||
bestNum = num;
|
||||
bestDen = den;
|
||||
}
|
||||
}
|
||||
return { num: bestNum, den: bestDen };
|
||||
}
|
||||
|
||||
function renderFractionSection(plan: FracPlan, magnitude: Frac): string {
|
||||
let whole = 0n;
|
||||
let remainder = magnitude;
|
||||
if (plan.wholeDigits > 0) {
|
||||
whole = toInteger(magnitude, "trunc");
|
||||
remainder = sub(magnitude, frac(whole));
|
||||
}
|
||||
let num: bigint;
|
||||
let den: bigint;
|
||||
if (plan.fixedDenom !== null) {
|
||||
den = plan.fixedDenom;
|
||||
num = toInteger(mul(remainder, frac(den)), "round");
|
||||
} else {
|
||||
const maxDenom = 10n ** BigInt(plan.denomDigits) - 1n;
|
||||
const best = bestFraction(remainder, maxDenom);
|
||||
num = best.num;
|
||||
den = best.den;
|
||||
}
|
||||
if (plan.wholeDigits > 0 && den > 0n && num >= den) {
|
||||
whole += num / den;
|
||||
num %= den;
|
||||
}
|
||||
const fracText = num !== 0n ? `${num}/${den}` : "";
|
||||
|
||||
let out = "";
|
||||
for (const t of plan.prefix) if (t.k === "lit") out += t.ch;
|
||||
if (plan.wholeDigits > 0) {
|
||||
if (whole !== 0n || fracText === "") out += whole.toString();
|
||||
if (fracText !== "") for (const t of plan.sepToks) if (t.k === "lit") out += t.ch;
|
||||
}
|
||||
out += fracText;
|
||||
for (const t of plan.suffix) if (t.k === "lit") out += t.ch;
|
||||
return out;
|
||||
}
|
||||
|
||||
function generalFormat(value: Frac): string {
|
||||
return fracToString(value);
|
||||
}
|
||||
|
||||
// ── 지수(과학) 형식 `0.00E+00` ───────────────────────────────────────────────
|
||||
|
||||
interface SciPlan {
|
||||
mantissaToks: Tok[]; // `E` 앞 가수 자리(자릿수·소수점은 수 구역과 같은 규칙)
|
||||
expDigits: number; // `E+`·`E-` 뒤 자리표시 개수(모자라면 0 으로 채움)
|
||||
expSign: "+" | "-"; // `E-00` 은 양의 지수에서 부호를 안 보임(엑셀과 같음)
|
||||
}
|
||||
|
||||
/** `E`(부호 있음) 뒤 숫자 자리표시가 끝까지 이어질 때만 지수 형식 — 아니면 null. */
|
||||
function detectScientific(toks: Tok[]): SciPlan | null {
|
||||
for (let i = 0; i < toks.length; i++) {
|
||||
const t = toks[i];
|
||||
if (t.k !== "lit" || (t.ch !== "E" && t.ch !== "e")) continue;
|
||||
const signTok = toks[i + 1];
|
||||
if (!signTok || signTok.k !== "lit" || (signTok.ch !== "+" && signTok.ch !== "-")) continue;
|
||||
let j = i + 2;
|
||||
let digits = 0;
|
||||
while (j < toks.length && toks[j].k === "digit") {
|
||||
digits++;
|
||||
j++;
|
||||
}
|
||||
if (digits === 0 || j !== toks.length) continue;
|
||||
return { mantissaToks: toks.slice(0, i), expDigits: digits, expSign: signTok.ch };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderScientificSection(plan: SciPlan, magnitude: Frac): string {
|
||||
const value = toDouble(magnitude);
|
||||
const pointIdx = plan.mantissaToks.findIndex((t) => t.k === "point");
|
||||
const fracDigits = plan.mantissaToks
|
||||
.slice(pointIdx >= 0 ? pointIdx + 1 : plan.mantissaToks.length)
|
||||
.filter((t) => t.k === "digit").length;
|
||||
let exp = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)));
|
||||
let mantissaText = (value / Math.pow(10, exp)).toFixed(fracDigits);
|
||||
if (Math.abs(Number(mantissaText)) >= 10) {
|
||||
exp += 1;
|
||||
mantissaText = (value / Math.pow(10, exp)).toFixed(fracDigits);
|
||||
}
|
||||
const expSign = exp < 0 ? "-" : plan.expSign === "+" ? "+" : "";
|
||||
const expText = String(Math.abs(exp)).padStart(plan.expDigits, "0");
|
||||
return `${mantissaText}E${expSign}${expText}`;
|
||||
}
|
||||
|
||||
// ── 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`) ───────────────────────
|
||||
// 엑셀 일련번호(1970-01-01 = 25569) → 달력. 1900 윤년 버그는 misc.ts 와 같이 안 다룸.
|
||||
|
||||
const MS_PER_DAY = 86400000;
|
||||
const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569;
|
||||
const WEEKDAY_SHORT = ["일", "월", "화", "수", "목", "금", "토"];
|
||||
|
||||
function toDouble(x: Frac): number {
|
||||
return Number(x.n) / Number(x.d);
|
||||
}
|
||||
|
||||
function serialToDateUTC(serial: number): Date {
|
||||
return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY));
|
||||
}
|
||||
|
||||
type DatePart =
|
||||
{ k: "lit"; text: string } | { k: "y" | "m" | "d"; width: 1 | 2 | 4 } | { k: "a"; width: 3 | 4 };
|
||||
|
||||
/** `y/m/d/a` 낱말 뜀(자리수가 아는 폭이 아니면 그대로 글로) — 없으면 날짜 형식 아님(null). */
|
||||
function detectDateFormat(s: string): DatePart[] | null {
|
||||
const parts: DatePart[] = [];
|
||||
let hasDateToken = false;
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const ch = s[i];
|
||||
if (ch === '"') {
|
||||
let j = i + 1;
|
||||
let text = "";
|
||||
while (j < s.length && s[j] !== '"') {
|
||||
text += s[j];
|
||||
j++;
|
||||
}
|
||||
parts.push({ k: "lit", text });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
if (i + 1 < s.length) parts.push({ k: "lit", text: s[i + 1] });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const lower = ch.toLowerCase();
|
||||
if (lower === "y" || lower === "m" || lower === "d" || lower === "a") {
|
||||
let j = i;
|
||||
while (j < s.length && s[j].toLowerCase() === lower) j++;
|
||||
const width = j - i;
|
||||
// 앞뒤가 영문 알파벳이면 딴 낱말 속에 섞인 우연한 글자(`Fixed` 의 d · `Currency` 의 y 등) —
|
||||
// 자리표시로 안 봄(2026-09-28 — 그 우연 때문에 이름 형식이 날짜로 깨지던 것 고침).
|
||||
const before = i > 0 ? s[i - 1] : "";
|
||||
const after = j < s.length ? s[j] : "";
|
||||
const boundaryOk = !/[A-Za-z]/.test(before) && !/[A-Za-z]/.test(after);
|
||||
if (boundaryOk && lower === "a" && (width === 3 || width === 4)) {
|
||||
parts.push({ k: "a", width });
|
||||
hasDateToken = true;
|
||||
} else if (
|
||||
boundaryOk &&
|
||||
lower !== "a" &&
|
||||
(width === 1 || width === 2 || (lower === "y" && width === 4))
|
||||
) {
|
||||
parts.push({ k: lower as "y" | "m" | "d", width: width as 1 | 2 | 4 });
|
||||
hasDateToken = true;
|
||||
} else {
|
||||
parts.push({ k: "lit", text: s.slice(i, j) });
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
parts.push({ k: "lit", text: ch });
|
||||
i++;
|
||||
}
|
||||
return hasDateToken ? parts : null;
|
||||
}
|
||||
|
||||
function renderDatePart(part: DatePart, date: Date): string {
|
||||
if (part.k === "lit") return part.text;
|
||||
if (part.k === "y") {
|
||||
const y = date.getUTCFullYear();
|
||||
return part.width === 4 ? String(y) : String(y % 100).padStart(2, "0");
|
||||
}
|
||||
if (part.k === "m") {
|
||||
const m = date.getUTCMonth() + 1;
|
||||
return part.width === 1 ? String(m) : String(m).padStart(2, "0");
|
||||
}
|
||||
if (part.k === "d") {
|
||||
const d = date.getUTCDate();
|
||||
return part.width === 1 ? String(d) : String(d).padStart(2, "0");
|
||||
}
|
||||
const wd = WEEKDAY_SHORT[date.getUTCDay()];
|
||||
return part.width === 3 ? wd : `${wd}요일`;
|
||||
}
|
||||
|
||||
function renderDateSection(parts: DatePart[], value: Frac): string {
|
||||
const date = serialToDateUTC(toDouble(value));
|
||||
return parts.map((p) => renderDatePart(p, date)).join("");
|
||||
}
|
||||
|
||||
function chooseSection(sections: string[], value: Frac): { code: string; forceMinus: boolean } {
|
||||
const isNeg = cmp(value, ZERO) < 0;
|
||||
const isZero = cmp(value, ZERO) === 0;
|
||||
if (sections.length === 1) return { code: sections[0], forceMinus: isNeg };
|
||||
if (sections.length === 2) {
|
||||
return isNeg
|
||||
? { code: sections[1], forceMinus: false }
|
||||
: { code: sections[0], forceMinus: false };
|
||||
}
|
||||
if (isZero) return { code: sections[2] ?? sections[0], forceMinus: false };
|
||||
if (isNeg) return { code: sections[1], forceMinus: false };
|
||||
return { code: sections[0], forceMinus: false };
|
||||
}
|
||||
|
||||
// ── 엑셀 「칸 형식 이름」(코드 대신) — `mso-number-format:Fixed` 처럼 클립보드가 이름을 넘김 ─────
|
||||
// 엑셀 붙여넣기 · 일부 자리에서 형식 코드 대신 이름(Fixed·Standard·Currency·Percent·General 등)이
|
||||
// 옴 — 그대로 코드로 읽으면 `y·m·d` 낱자가 날짜 자리표시로 잘못 잡혀 글자가 깨짐(2026-09-28 실측:
|
||||
// `Fixed`→`Fixe11`). 우리 코드로 바꿔치기 · 모르는 이름은 일반(General)으로.
|
||||
|
||||
const EXCEL_NAMED_FORMATS: Record<string, string> = {
|
||||
general: "General",
|
||||
"general number": "General",
|
||||
fixed: "0.00",
|
||||
standard: "#,##0.00",
|
||||
currency: "#,##0.00", // 이 문서 실측 — 통화 기호가 안 보임(사용자 확인)
|
||||
percent: "0.00%",
|
||||
scientific: "0.00E+00",
|
||||
"short date": "yyyy-mm-dd",
|
||||
"long date": "yyyy-mm-dd aaaa",
|
||||
};
|
||||
|
||||
const BARE_WORD_RE = /^[A-Za-z][A-Za-z ]*$/;
|
||||
|
||||
/** 따옴표 한 겹 · 흔한 `\x` 이스케이프까지 방어적으로 풀어(대조용) 이름표와 맞대 봄 —
|
||||
* 맞으면 우리 코드로 · 알파벳 낱말뿐인 모르는 이름이면 일반으로 · 아니면 원래 코드 그대로. */
|
||||
function resolveExcelStyleName(code: string): string {
|
||||
let key = code.trim();
|
||||
if (key.length >= 2 && key.startsWith('"') && key.endsWith('"')) key = key.slice(1, -1);
|
||||
key = key.replace(/\\(.)/g, "$1").trim().toLowerCase().replace(/\s+/g, " ");
|
||||
const mapped = EXCEL_NAMED_FORMATS[key];
|
||||
if (mapped !== undefined) return mapped;
|
||||
// 알파벳 낱말뿐이면서 진짜 날짜 자리표시(`aaa` · `yyyy` 등)도 아니면 모르는 이름 — 일반으로.
|
||||
if (BARE_WORD_RE.test(key) && detectDateFormat(code) === null) return "General";
|
||||
return code;
|
||||
}
|
||||
|
||||
function formatNumber(value: Frac, code: string | undefined): FormattedText {
|
||||
const resolved = code === undefined ? undefined : resolveExcelStyleName(code);
|
||||
if (resolved === undefined || resolved === "" || resolved.trim().toLowerCase() === "general") {
|
||||
return { 글: generalFormat(value), 정렬: "right" };
|
||||
}
|
||||
const sections = splitSections(resolved);
|
||||
const { code: chosen, forceMinus } = chooseSection(sections, value);
|
||||
const { rest, color } = extractColor(chosen);
|
||||
const datePlan = detectDateFormat(rest);
|
||||
if (datePlan) {
|
||||
const extra: Partial<FormattedText> = {};
|
||||
if (color) extra.색 = color;
|
||||
return { 글: renderDateSection(datePlan, value), 정렬: "right", ...extra };
|
||||
}
|
||||
const { toks, fillChar } = tokenize(rest);
|
||||
const extra: Partial<FormattedText> = {};
|
||||
if (color) extra.색 = color;
|
||||
if (fillChar) extra.채움글 = fillChar;
|
||||
const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value;
|
||||
const sci = detectScientific(toks);
|
||||
if (sci) {
|
||||
const body = renderScientificSection(sci, magnitude);
|
||||
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
|
||||
}
|
||||
const fraction = detectFraction(toks);
|
||||
if (fraction) {
|
||||
const body = renderFractionSection(fraction, magnitude);
|
||||
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
|
||||
}
|
||||
if (!toks.some((t) => t.k === "digit" || t.k === "general")) {
|
||||
let out = "";
|
||||
for (const t of toks) if (t.k === "lit") out += t.ch;
|
||||
return { 글: out, 정렬: "right", ...extra };
|
||||
}
|
||||
const body = renderNumericSection(toks, magnitude);
|
||||
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
|
||||
}
|
||||
|
||||
function formatText(value: string, code: string | undefined): FormattedText {
|
||||
if (code === undefined || code === "") return { 글: value, 정렬: "left" };
|
||||
const resolved = resolveExcelStyleName(code);
|
||||
const sections = splitSections(resolved);
|
||||
// 구역 하나뿐(`;` 없음)에 `@` 가 있을 때만 그 구역을 씀(순수 수 형식 하나뿐이면 글엔 안 씀) ·
|
||||
// 넷째 구역은 명시적 글 자리라 `@` 없어도 그대로(고정 라벨 버릇) · 2~3 구역엔 글 자리가 없어 그대로.
|
||||
let textCode: string | undefined;
|
||||
if (sections.length >= 4) textCode = sections[3];
|
||||
else if (sections.length === 1 && sections[0].includes("@")) textCode = sections[0];
|
||||
if (textCode === undefined) return { 글: value, 정렬: "left" };
|
||||
const { rest, color } = extractColor(textCode);
|
||||
const { toks, fillChar } = tokenize(rest);
|
||||
const extra: Partial<FormattedText> = {};
|
||||
if (color) extra.색 = color;
|
||||
if (fillChar) extra.채움글 = fillChar;
|
||||
let out = "";
|
||||
for (const t of toks) {
|
||||
if (t.k === "text") out += value;
|
||||
else if (t.k === "lit") out += t.ch;
|
||||
else if (t.k === "percent") out += "%";
|
||||
}
|
||||
return { 글: out, 정렬: "left", ...extra };
|
||||
}
|
||||
|
||||
/** `code` 없음 = 일반(수는 유효 11 자리 안쪽 · 칸 폭은 격자 몫) · 오류 값은 코드 글 그대로 */
|
||||
export function formatValue(value: Scalar, code: string | undefined): FormattedText {
|
||||
if (isErrorScalar(value)) return { 글: value.error, 정렬: "center" };
|
||||
if (value === null) return { 글: "", 정렬: "right" };
|
||||
if (typeof value === "boolean") return { 글: value ? "TRUE" : "FALSE", 정렬: "center" };
|
||||
if (typeof value === "string") return formatText(value, code);
|
||||
return formatNumber(value, code);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_parser.ts (주인 A)
|
||||
* 수식 글 → 낱말 · 나무 · 나무 → 글. `eval` 금지 — 직접 짠 파서만.
|
||||
* 우선순위는 엑셀과 같음: 범위 `:` > 부호 `-` `+` > `%` > `^` > `* /` > `+ -` > `&` > 비교
|
||||
* (부호가 `^` 보다 먼저 — `-2^2 = 4`). 같은 단은 왼쪽부터(`^` 도 왼쪽부터 · 엑셀과 같음).
|
||||
* 글 `"…"` 안 `""` = 따옴표 하나 · 시트 `'이름'!A1` · `이름!A1` · 오류 값 글자 · TRUE/FALSE.
|
||||
* 참조 낱말 풀기(`parseRef` · `renderRef`)는 참조 옮김(`spreadsheet_refshift.ts`)이 같이 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import { parseDecimal, ZERO } from "@ui/sheet/ui_template_sheet_frac";
|
||||
import { colIndex, colName, quoteSheet, rowIndex } from "./spreadsheet_address";
|
||||
import { getFunction } from "./spreadsheet_functions";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type { ErrorCode, FormulaNode, ParseResult, RefCell, Token } from "./spreadsheet_types";
|
||||
|
||||
// ── 참조 낱말 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const IDC = "[\\p{L}\\p{N}_.]";
|
||||
const END = `(?!${IDC}|\\()`;
|
||||
const CELL = "(\\$?)([A-Za-z]{1,3})(\\$?)(\\d+)";
|
||||
const RE_PREFIX = /^(?:'((?:[^']|'')+)'|([\p{L}\p{N}_.]+))!/u;
|
||||
const RE_AREA = new RegExp(`^${CELL}(?::${CELL})?${END}`, "u");
|
||||
const RE_COLS = new RegExp(`^(\\$?)([A-Za-z]{1,3}):(\\$?)([A-Za-z]{1,3})${END}`, "u");
|
||||
const RE_ROWS = new RegExp(`^(\\$?)(\\d+):(\\$?)(\\d+)${END}`, "u");
|
||||
const RE_ERROR = /^#(NULL!|DIV\/0!|VALUE!|REF!|NAME\?|NUM!|N\/A|CYCLE!)/i;
|
||||
const RE_IDENT = /^[\p{L}_\\][\p{L}\p{N}_.]*/u;
|
||||
|
||||
/** 참조 낱말 하나 — `kind` cell = 칸 · area = `A1:B2` · cols = `A:C` · rows = `3:5`. */
|
||||
export interface RefParts {
|
||||
/** 적힌 시트 머리 글 그대로(`'돌-골막이'!`) · 없으면 "" */
|
||||
prefix: string;
|
||||
sheet: string | null;
|
||||
kind: "cell" | "area" | "cols" | "rows";
|
||||
a: RefCell;
|
||||
b: RefCell;
|
||||
}
|
||||
|
||||
/** 참조 글 → 조각 · 참조가 아니면 null(주소가 격자 밖이어도 null → 이름) */
|
||||
export function parseRef(text: string): RefParts | null {
|
||||
const pm = RE_PREFIX.exec(text);
|
||||
const prefix = pm ? pm[0] : "";
|
||||
const sheet = pm ? (pm[1] !== undefined ? pm[1].replace(/''/g, "'") : pm[2]) : null;
|
||||
const body = text.slice(prefix.length);
|
||||
const pt = (sr: string, col: number, sc: string, row: number): RefCell => ({
|
||||
r: row,
|
||||
c: col,
|
||||
absR: sc === "$",
|
||||
absC: sr === "$",
|
||||
});
|
||||
let m = RE_AREA.exec(body);
|
||||
if (m && m[0].length === body.length) {
|
||||
const a = pt(m[1], colIndex(m[2]), m[3], rowIndex(m[4]));
|
||||
const b = m[5] === undefined ? { ...a } : pt(m[5], colIndex(m[6]), m[7], rowIndex(m[8]));
|
||||
if (a.r < 0 || a.c < 0 || b.r < 0 || b.c < 0) return null;
|
||||
return { prefix, sheet, kind: m[5] === undefined ? "cell" : "area", a, b };
|
||||
}
|
||||
m = RE_COLS.exec(body);
|
||||
if (m && m[0].length === body.length) {
|
||||
const c0 = colIndex(m[2]);
|
||||
const c1 = colIndex(m[4]);
|
||||
if (c0 < 0 || c1 < 0) return null;
|
||||
return {
|
||||
prefix,
|
||||
sheet,
|
||||
kind: "cols",
|
||||
a: pt(m[1], c0, "", 0),
|
||||
b: pt(m[3], c1, "", MAX_ROWS - 1),
|
||||
};
|
||||
}
|
||||
m = RE_ROWS.exec(body);
|
||||
if (m && m[0].length === body.length) {
|
||||
const r0 = rowIndex(m[2]);
|
||||
const r1 = rowIndex(m[4]);
|
||||
if (r0 < 0 || r1 < 0) return null;
|
||||
return {
|
||||
prefix,
|
||||
sheet,
|
||||
kind: "rows",
|
||||
a: pt("", 0, m[1], r0),
|
||||
b: pt("", MAX_COLS - 1, m[3], r1),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const cellText = (p: RefCell): string =>
|
||||
`${p.absC ? "$" : ""}${colName(p.c)}${p.absR ? "$" : ""}${p.r + 1}`;
|
||||
|
||||
/** 조각 → 참조 글(시트 머리는 `prefix` 그대로) */
|
||||
export function renderRef(p: RefParts): string {
|
||||
if (p.kind === "cols")
|
||||
return `${p.prefix}${p.a.absC ? "$" : ""}${colName(p.a.c)}:${p.b.absC ? "$" : ""}${colName(p.b.c)}`;
|
||||
if (p.kind === "rows")
|
||||
return `${p.prefix}${p.a.absR ? "$" : ""}${p.a.r + 1}:${p.b.absR ? "$" : ""}${p.b.r + 1}`;
|
||||
return p.prefix + cellText(p.a) + (p.kind === "area" ? `:${cellText(p.b)}` : "");
|
||||
}
|
||||
|
||||
// ── 낱말 나누기 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** 앞 `=` 뺀 식 글 → 낱말(빈칸 낱말 포함 · 끝까지 · 못 읽는 글은 `unknown`) — 편집기 색칠 · 가리키기용.
|
||||
* 이름(정의 안 된 글자 묶음)도 `unknown` — 파서가 이름 나무로 읽음. */
|
||||
export function tokenize(formula: string): Token[] {
|
||||
const out: Token[] = [];
|
||||
let i = 0;
|
||||
let ref: string | null;
|
||||
const push = (kind: Token["kind"], len: number) => {
|
||||
out.push({ kind, text: formula.slice(i, i + len), start: i, end: i + len });
|
||||
i += len;
|
||||
};
|
||||
while (i < formula.length) {
|
||||
const rest = formula.slice(i);
|
||||
const ch = rest[0];
|
||||
let m: RegExpExecArray | null;
|
||||
if ((m = /^\s+/.exec(rest))) push("space", m[0].length);
|
||||
else if (ch === '"') {
|
||||
const s = /^"(?:[^"]|"")*"?/.exec(rest)!;
|
||||
push("string", s[0].length);
|
||||
} else if ((m = /^([\p{L}_][\p{L}\p{N}_.]*)\(/u.exec(rest)) && !/^(TRUE|FALSE)$/i.test(m[1]))
|
||||
push("func", m[1].length);
|
||||
else if ((ref = refAt(rest))) push(/#REF!$/i.test(ref) ? "error" : "ref", ref.length);
|
||||
else if ((m = /^(TRUE|FALSE)(?![\p{L}\p{N}_.(])/iu.exec(rest))) push("bool", m[0].length);
|
||||
else if ((m = RE_ERROR.exec(rest))) push("error", m[0].length);
|
||||
else if ((m = /^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec(rest))) push("number", m[0].length);
|
||||
else if ((m = RE_IDENT.exec(rest))) push("unknown", m[0].length);
|
||||
else if ((m = /^(<>|<=|>=|[-+*/^&=<>%:])/.exec(rest))) push("op", m[0].length);
|
||||
else if (ch === "(" || ch === ")") push("paren", 1);
|
||||
else if (ch === ",") push("comma", 1);
|
||||
else push("unknown", 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 머리 이 자리에서 시작하는 참조(또는 `시트!#REF!`) 글 */
|
||||
function refAt(rest: string): string | null {
|
||||
const head = RE_PREFIX.exec(rest)?.[0] ?? "";
|
||||
const body = rest.slice(head.length);
|
||||
if (head && /^#REF!/i.test(body)) return head + body.slice(0, 5);
|
||||
for (const re of [RE_AREA, RE_COLS, RE_ROWS]) {
|
||||
const m = re.exec(body);
|
||||
if (m && parseRef(head + m[0])) return head + m[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 나무 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Fail {
|
||||
constructor(
|
||||
readonly message: string,
|
||||
readonly at: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
const COMPARE = ["=", "<>", "<", "<=", ">", ">="];
|
||||
// ponytail: B 함수 표 min 이 엑셀보다 낮은 것만 덧댐 — sub5 가 표를 고치면 지움
|
||||
const EXCEL_MIN: Record<string, number> = {
|
||||
SUM: 1,
|
||||
MIN: 1,
|
||||
MAX: 1,
|
||||
COUNT: 1,
|
||||
COUNTA: 1,
|
||||
PRODUCT: 1,
|
||||
};
|
||||
|
||||
/** 앞 `=` 뺀 식 글 → 나무. 앞에 `=` 가 붙어 와도 됨. 함수 이름은 대문자로. */
|
||||
export function parseFormula(formula: string): ParseResult {
|
||||
const text = formula.startsWith("=") ? formula.slice(1) : formula;
|
||||
const toks = tokenize(text).filter((t) => t.kind !== "space");
|
||||
let p = 0;
|
||||
const peek = (): Token | undefined => toks[p];
|
||||
const isOp = (...ops: string[]) => {
|
||||
const t = toks[p];
|
||||
return !!t && t.kind === "op" && ops.includes(t.text);
|
||||
};
|
||||
const where = () => (toks[p] ? toks[p].start : text.length);
|
||||
|
||||
const binary = (ops: string[], next: () => FormulaNode) => (): FormulaNode => {
|
||||
let left = next();
|
||||
while (isOp(...ops)) {
|
||||
const op = toks[p++].text as "+";
|
||||
left = { type: "binary", op, left, right: next() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const primary = (): FormulaNode => {
|
||||
const t = peek();
|
||||
if (!t) throw new Fail("식이 덜 끝남", text.length);
|
||||
p++;
|
||||
switch (t.kind) {
|
||||
case "number":
|
||||
return { type: "number", value: parseDecimal(t.text), text: t.text };
|
||||
case "string":
|
||||
if (t.text.length < 2 || !t.text.endsWith('"')) throw new Fail("따옴표가 안 닫힘", t.start);
|
||||
return { type: "string", value: t.text.slice(1, -1).replace(/""/g, '"') };
|
||||
case "bool":
|
||||
return { type: "bool", value: t.text.toUpperCase() === "TRUE" };
|
||||
case "error":
|
||||
return {
|
||||
type: "error",
|
||||
code: t.text.slice(t.text.lastIndexOf("#")).toUpperCase() as ErrorCode,
|
||||
};
|
||||
case "ref": {
|
||||
const r = parseRef(t.text)!;
|
||||
return r.kind === "cell"
|
||||
? { type: "cell", sheet: r.sheet, ref: r.a }
|
||||
: { type: "range", sheet: r.sheet, from: r.a, to: r.b };
|
||||
}
|
||||
case "func": {
|
||||
const name = t.text.toUpperCase().replace(/^_XLFN\./, "");
|
||||
p++; // `(`
|
||||
const args: FormulaNode[] = [];
|
||||
// 인자 수는 엑셀처럼 입력 때 막음(편집기가 이 실패로 Enter 를 막음) · 모르는 함수는 풀 때 #NAME?
|
||||
const done = (): FormulaNode => {
|
||||
const fn = getFunction(name);
|
||||
const lo = Math.max(fn?.min ?? 0, EXCEL_MIN[name] ?? 0);
|
||||
if (fn && args.length < lo)
|
||||
throw new Fail(`${name} 인수가 너무 적음(${lo} 개부터)`, t.start);
|
||||
if (fn?.max !== undefined && args.length > fn.max)
|
||||
throw new Fail(`${name} 인수가 너무 많음(${fn.max} 개까지)`, t.start);
|
||||
return { type: "call", name, args };
|
||||
};
|
||||
if (peek()?.text === ")") {
|
||||
p++;
|
||||
return done();
|
||||
}
|
||||
for (;;) {
|
||||
const k = peek();
|
||||
// 빈 인자(`IF(A1,,1)`) = 0 · 글은 비움
|
||||
if (k && (k.kind === "comma" || k.text === ")"))
|
||||
args.push({ type: "number", value: ZERO, text: "" });
|
||||
else args.push(compare());
|
||||
const s = toks[p++];
|
||||
if (s?.kind === "comma") continue;
|
||||
if (s?.text === ")") return done();
|
||||
throw new Fail("함수 괄호가 안 닫힘", s ? s.start : text.length);
|
||||
}
|
||||
}
|
||||
case "paren": {
|
||||
if (t.text !== "(") break;
|
||||
const inner = compare();
|
||||
if (peek()?.text !== ")") throw new Fail("괄호가 안 닫힘", where());
|
||||
p++;
|
||||
return inner;
|
||||
}
|
||||
case "unknown":
|
||||
if (RE_IDENT.exec(t.text)?.[0] === t.text) return { type: "name", name: t.text };
|
||||
}
|
||||
throw new Fail(`읽지 못한 글: ${t.text}`, t.start);
|
||||
};
|
||||
const unary = (): FormulaNode => {
|
||||
if (isOp("-", "+")) {
|
||||
const op = toks[p++].text as "-";
|
||||
return { type: "unary", op, arg: unary() };
|
||||
}
|
||||
return primary();
|
||||
};
|
||||
const percent = (): FormulaNode => {
|
||||
let node = unary();
|
||||
while (isOp("%")) {
|
||||
p++;
|
||||
node = { type: "unary", op: "%", arg: node };
|
||||
}
|
||||
return node;
|
||||
};
|
||||
const power = binary(["^"], percent);
|
||||
const mul = binary(["*", "/"], power);
|
||||
const add = binary(["+", "-"], mul);
|
||||
const concat = binary(["&"], add);
|
||||
const compare = binary(COMPARE, concat);
|
||||
|
||||
try {
|
||||
if (!toks.length) throw new Fail("빈 식", 0);
|
||||
const node = compare();
|
||||
if (p < toks.length) throw new Fail(`남은 글: ${toks[p].text}`, toks[p].start);
|
||||
return { ok: true, node };
|
||||
} catch (e) {
|
||||
if (e instanceof Fail) return { ok: false, message: e.message, at: e.at };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 나무 → 글 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const PREC: Record<string, number> = { "&": 2, "+": 3, "-": 3, "*": 4, "/": 4, "^": 5 };
|
||||
for (const op of COMPARE) PREC[op] = 1;
|
||||
|
||||
function prec(node: FormulaNode): number {
|
||||
if (node.type === "binary") return PREC[node.op];
|
||||
if (node.type === "unary") return node.op === "%" ? 6 : 7;
|
||||
return 8;
|
||||
}
|
||||
|
||||
const sheetHead = (sheet: string | null) => (sheet === null ? "" : `${quoteSheet(sheet)}!`);
|
||||
|
||||
/** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 · 괄호는 필요한 곳만.
|
||||
* 계산 단계 보기용: `sub` 가 글을 주는 마디는 그 글(푼 값)로 갈음 · `mark` 마디는 \u0001 … \u0002 로 감쌈 */
|
||||
export function formulaToText(
|
||||
node: FormulaNode,
|
||||
sub?: (node: FormulaNode) => string | undefined,
|
||||
mark?: FormulaNode,
|
||||
): string {
|
||||
const own = sub?.(node);
|
||||
if (own !== undefined) return own;
|
||||
if (node === mark) return `\u0001${formulaToText(node, sub)}\u0002`;
|
||||
const text = (child: FormulaNode) => formulaToText(child, sub, mark);
|
||||
const wrap = (child: FormulaNode, need: boolean) =>
|
||||
need && sub?.(child) === undefined ? `(${text(child)})` : text(child);
|
||||
switch (node.type) {
|
||||
case "number":
|
||||
return node.text;
|
||||
case "string":
|
||||
return `"${node.value.replace(/"/g, '""')}"`;
|
||||
case "bool":
|
||||
return node.value ? "TRUE" : "FALSE";
|
||||
case "error":
|
||||
return node.code;
|
||||
case "name":
|
||||
return node.name;
|
||||
case "cell":
|
||||
return sheetHead(node.sheet) + cellText(node.ref);
|
||||
case "range": {
|
||||
const { from, to } = node;
|
||||
const kind =
|
||||
from.r === 0 && to.r === MAX_ROWS - 1
|
||||
? "cols"
|
||||
: from.c === 0 && to.c === MAX_COLS - 1
|
||||
? "rows"
|
||||
: "area";
|
||||
return renderRef({ prefix: sheetHead(node.sheet), sheet: node.sheet, kind, a: from, b: to });
|
||||
}
|
||||
case "unary":
|
||||
return node.op === "%"
|
||||
? `${wrap(node.arg, prec(node.arg) < 6)}%`
|
||||
: `${node.op}${wrap(node.arg, prec(node.arg) < 7)}`;
|
||||
case "binary": {
|
||||
const p = PREC[node.op];
|
||||
return `${wrap(node.left, prec(node.left) < p)}${node.op}${wrap(node.right, prec(node.right) <= p)}`;
|
||||
}
|
||||
case "call":
|
||||
return `${node.name}(${node.args.map(text).join(",")})`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_paste_special.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 붙여넣기 골라서 — 값만 · 서식만 · 수식만(Ctrl+Shift+V). `ClipBlock`(0 계약) 을 받아
|
||||
* 대상 왼위(`at`)에 맞게 명령을 만듦 — 서식은 `서식` 명령(칸마다 하나 · A 가 표 번호를 매김) ·
|
||||
* 값 · 식은 `칸` 명령(대상 칸의 지금 서식 번호는 그대로 둠). 참조 옮김은 「원점」 이 있을 때만
|
||||
* `$` 없는 A1 참조를 얕게 옮김(진짜 옮김은 A `spreadsheet_refshift` 몫 — 여긴 메뉴용 편의).
|
||||
*
|
||||
* 잇는 법(D · sub7) — 클립보드(E)가 만든 `ClipBlock` 을 들고:
|
||||
* 1) 우클릭 메뉴 · Ctrl+Shift+V 로 모드 고르는 작은 팝업을 띄움(값만 · 서식만 · 수식만).
|
||||
* 2) 고른 모드로 `pasteSpecial(ctx, block, ctx.selection.활성, mode, sourceOrigin?)` 호출.
|
||||
* 3) 「값만」 은 원본이 아직 화면에 있을 때만 계산값을 살릴 수 있음 — `sourceOrigin` 을 줌(없으면 식 칸은 건너뜀).
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
Cell,
|
||||
CellAddress,
|
||||
CellRange,
|
||||
CellStyle,
|
||||
ClipBlock,
|
||||
ClipCell,
|
||||
Command,
|
||||
Frac,
|
||||
Scalar,
|
||||
} from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import { fracToString } from "@ui/sheet/ui_template_sheet_frac";
|
||||
|
||||
export type PasteSpecialMode = "값" | "서식" | "수식";
|
||||
|
||||
const a1 = (r: number, c: number): string => {
|
||||
let col = c + 1;
|
||||
let letters = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
letters = String.fromCharCode(65 + rem) + letters;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return `${letters}${r + 1}`;
|
||||
};
|
||||
|
||||
/** `$` 없는 A1 참조만 (dr, dc) 만큼 옮김 — 절대참조 · 다른 시트 이름은 손대지 않음(안전한 최소). */
|
||||
export function shiftRelativeRefs(formula: string, dr: number, dc: number): string {
|
||||
return formula.replace(
|
||||
/(\$?)([A-Z]{1,3})(\$?)(\d+)/g,
|
||||
(whole, absC, colLetters, absR, rowDigits) => {
|
||||
if (absC === "$" && absR === "$") return whole;
|
||||
let col = 0;
|
||||
for (const ch of colLetters) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
const nextCol = absC === "$" ? col : col + dc;
|
||||
const nextRow = absR === "$" ? Number(rowDigits) : Number(rowDigits) + dr;
|
||||
if (nextCol < 1 || nextRow < 1) return "#REF!";
|
||||
let letters = "";
|
||||
let n = nextCol;
|
||||
while (n > 0) {
|
||||
const rem = (n - 1) % 26;
|
||||
letters = String.fromCharCode(65 + rem) + letters;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return `${absC}${letters}${absR}${nextRow}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export interface PasteSpecialSource {
|
||||
시트: string;
|
||||
r: number;
|
||||
c: number;
|
||||
}
|
||||
|
||||
/** `block` 을 `at` 에 골라 붙임. `sourceOrigin` 은 「값만」 때 식 칸을 계산값으로 굳히는 데 씀(없으면 식 칸은 건너뜀). */
|
||||
export function pasteSpecial(
|
||||
ctx: SpreadsheetContext,
|
||||
block: ClipBlock,
|
||||
at: CellAddress,
|
||||
mode: PasteSpecialMode,
|
||||
sourceOrigin?: PasteSpecialSource,
|
||||
): void {
|
||||
const sheetId = ctx.selection.시트;
|
||||
const sheet = ctx.sheet();
|
||||
const dr = at.r - (block.원점?.r ?? at.r);
|
||||
const dc = at.c - (block.원점?.c ?? at.c);
|
||||
const commands: Command[] = [];
|
||||
const 칸: Record<string, Cell | null> = {};
|
||||
|
||||
for (const [key, clip] of Object.entries(block.칸)) {
|
||||
const [rowStr, colStr] = key.split(",");
|
||||
const rr = at.r + Number(rowStr);
|
||||
const cc = at.c + Number(colStr);
|
||||
if (rr < 0 || cc < 0) continue;
|
||||
const target = a1(rr, cc);
|
||||
const existing = sheet.칸[target];
|
||||
|
||||
if (mode === "서식") {
|
||||
if (clip.서식) commands.push(styleCommand(sheetId, rr, cc, clip.서식));
|
||||
continue;
|
||||
}
|
||||
if (mode === "수식") {
|
||||
if (clip.식 === undefined) continue;
|
||||
const moved = block.원점 ? shiftRelativeRefs(clip.식, dr, dc) : clip.식;
|
||||
칸[target] = { 식: moved, 서식: existing?.서식 };
|
||||
continue;
|
||||
}
|
||||
// mode === "값"
|
||||
const value = resolveClipValue(clip, sourceOrigin, Number(rowStr), Number(colStr), ctx);
|
||||
if (value === undefined) continue;
|
||||
칸[target] = { 값: value, 서식: existing?.서식 };
|
||||
}
|
||||
|
||||
if (Object.keys(칸).length > 0) commands.push({ 종류: "칸", 시트: sheetId, 칸 });
|
||||
if (commands.length === 0) return;
|
||||
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
|
||||
}
|
||||
|
||||
function styleCommand(sheetId: string, r: number, c: number, style: CellStyle): Command {
|
||||
const 범위: CellRange = { r0: r, c0: c, r1: r, c1: c };
|
||||
return { 종류: "서식", 시트: sheetId, 범위: [범위], 바꿀: { ...style } };
|
||||
}
|
||||
|
||||
function resolveClipValue(
|
||||
clip: ClipCell,
|
||||
origin: PasteSpecialSource | undefined,
|
||||
dr: number,
|
||||
dc: number,
|
||||
ctx: SpreadsheetContext,
|
||||
): Cell["값"] | undefined {
|
||||
if (clip.식 !== undefined) {
|
||||
if (!origin) return undefined;
|
||||
const scalar = ctx.engine.value(origin.시트, origin.r + dr, origin.c + dc);
|
||||
return scalarFrom(scalar);
|
||||
}
|
||||
return clip.값;
|
||||
}
|
||||
|
||||
function scalarFrom(value: Scalar): Cell["값"] | undefined {
|
||||
if (value === null) return undefined;
|
||||
if (typeof value === "string" || typeof value === "boolean") return value;
|
||||
if (typeof value === "object" && "n" in value && "d" in value)
|
||||
return Number(fracToString(value as Frac));
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_recalc.ts (주인 A)
|
||||
* 통합문서 한 벌 풀이 — 서버 Node 진입(`common_util_spreadsheet_node.ts`) · 시험이 부름.
|
||||
* 화면과 같은 엔진(`createCalcEngine`)을 한 번 돌려 `snapshot()` — 계산이 두 벌이 되지 않게.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCalcEngine } from "./spreadsheet_graph";
|
||||
import type { CalcValues, Workbook } from "./spreadsheet_types";
|
||||
|
||||
export function recalcWorkbook(book: Workbook): CalcValues {
|
||||
return createCalcEngine(book).snapshot();
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_refshift.ts (주인 A)
|
||||
* 참조 옮김 — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 바꾸기 · 시트 지우기 때 식 글을 고쳐 적음
|
||||
* (엑셀 규칙) · 복사 · 채우기 때 상대 참조만 옮김 · 구글 R1C1 식 → A1.
|
||||
* 지운 칸 · 격자 밖으로 나간 참조는 `#REF!` · 범위는 남은 만큼 줄어듦(끝까지 지우면 `#REF!`).
|
||||
* 낱말 단위로 참조만 바꿔 끼움 — 빈칸 · 괄호 · 수 글은 적힌 그대로 둠.
|
||||
*
|
||||
* 엑셀 규칙표:
|
||||
* 넣기 — 넣는 자리 이후를 가리키면 밀림 · 범위가 넣는 자리를 품으면 늘어남(첫 행 앞 넣기는 통째 밀림)
|
||||
* 지우기 — 지운 칸 하나 = `#REF!` · 범위는 남은 만큼 · 전부 지우면 `#REF!` · `$` 도 똑같이 옮김
|
||||
* 옮기기 — 옮긴 덩어리 안을 가리키던 참조(범위는 통째 들 때만)는 따라감 · 덮어쓴 자리를 가리키면 `#REF!`
|
||||
* 복사 — 상대만 (dr, dc) · 행 · 열 전체 범위는 그 축을 안 옮김
|
||||
* ========================================================================== */
|
||||
|
||||
import { quoteSheet, rangeInside } from "./spreadsheet_address";
|
||||
import { parseRef, renderRef, tokenize } from "./spreadsheet_parser";
|
||||
import type { RefParts } from "./spreadsheet_parser";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type { CellAddress, CellRange, Command, Workbook } from "./spreadsheet_types";
|
||||
|
||||
const REF_ERR = "#REF!";
|
||||
|
||||
/** 참조 낱말만 `fn` 으로 바꿔 끼움(null = `#REF!`) */
|
||||
function mapRefs(formula: string, fn: (p: RefParts) => RefParts | string | null): string {
|
||||
let out = "";
|
||||
for (const t of tokenize(formula)) {
|
||||
const p = t.kind === "ref" ? parseRef(t.text) : null;
|
||||
if (!p) {
|
||||
out += t.text;
|
||||
continue;
|
||||
}
|
||||
const q = fn(p);
|
||||
out += q === null ? REF_ERR : typeof q === "string" ? q : renderRef(q);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 넣기 · 지우기 한 축 — [lo, hi] → 새 [lo, hi] · 다 지워지면 null */
|
||||
export function shiftSpan(
|
||||
lo: number,
|
||||
hi: number,
|
||||
insert: boolean,
|
||||
at: number,
|
||||
n: number,
|
||||
max: number,
|
||||
): [number, number] | null {
|
||||
if (insert) {
|
||||
if (lo >= at) [lo, hi] = [lo + n, hi + n];
|
||||
else if (hi >= at) hi += n;
|
||||
if (lo >= max) return null;
|
||||
return [lo, Math.min(hi, max - 1)];
|
||||
}
|
||||
const end = at + n - 1;
|
||||
if (lo >= at && hi <= end) return null;
|
||||
return [lo < at ? lo : lo > end ? lo - n : at, hi < at ? hi : hi > end ? hi - n : at - 1];
|
||||
}
|
||||
|
||||
const sheetIdOf = (book: Workbook, name: string) =>
|
||||
book.시트.find((s) => s.이름.toLowerCase() === name.toLowerCase())?.id ?? null;
|
||||
|
||||
const box = (p: RefParts): CellRange => ({
|
||||
r0: Math.min(p.a.r, p.b.r),
|
||||
c0: Math.min(p.a.c, p.b.c),
|
||||
r1: Math.max(p.a.r, p.b.r),
|
||||
c1: Math.max(p.a.c, p.b.c),
|
||||
});
|
||||
|
||||
/** 행열 · 옮기기 · 시트이름 · 시트지우기 명령에 맞춰 식 글 고침(식이 든 시트 id = `host`) — 안 바뀌면 같은 글.
|
||||
* `book` 은 명령 적용 **전** 문서(시트 이름으로 id 를 찾음). `newHost` = 옮기기로 식 칸 자체가 간 시트. */
|
||||
export function shiftForCommand(
|
||||
formula: string,
|
||||
host: string,
|
||||
command: Command,
|
||||
book: Workbook,
|
||||
newHost: string = host,
|
||||
): string {
|
||||
if (command.종류 === "묶음")
|
||||
return command.명령.reduce((f, c) => shiftForCommand(f, host, c, book, newHost), formula);
|
||||
const target = (p: RefParts) => (p.sheet === null ? host : sheetIdOf(book, p.sheet));
|
||||
const nameOf = (id: string) => book.시트.find((s) => s.id === id)?.이름 ?? "";
|
||||
|
||||
switch (command.종류) {
|
||||
case "행넣기":
|
||||
case "행지우기":
|
||||
case "열넣기":
|
||||
case "열지우기": {
|
||||
const rows = command.종류.startsWith("행");
|
||||
const insert = command.종류.endsWith("넣기");
|
||||
return mapRefs(formula, (p) => {
|
||||
if (target(p) !== command.시트) return p;
|
||||
if (rows ? p.kind === "cols" : p.kind === "rows") return p; // 다른 축 전체 범위
|
||||
const [lo, hi] = rows
|
||||
? [p.a.r, p.b.r].sort((x, y) => x - y)
|
||||
: [p.a.c, p.b.c].sort((x, y) => x - y);
|
||||
const span = shiftSpan(lo, hi, insert, command.at, command.수, rows ? MAX_ROWS : MAX_COLS);
|
||||
if (!span) return null;
|
||||
const q = structuredClone(p);
|
||||
// 적힌 차례(뒤집힌 범위)는 바로 세움 — 엑셀도 다시 적을 때 바로 세움
|
||||
if (rows) [q.a.r, q.b.r] = span;
|
||||
else [q.a.c, q.b.c] = span;
|
||||
return q;
|
||||
});
|
||||
}
|
||||
case "옮기기": {
|
||||
const src = command.범위;
|
||||
const dr = command.r - src.r0;
|
||||
const dc = command.c - src.c0;
|
||||
const dst: CellRange = { r0: command.r, c0: command.c, r1: src.r1 + dr, c1: src.c1 + dc };
|
||||
return mapRefs(formula, (p) => {
|
||||
const t = target(p);
|
||||
if (t === null) return p;
|
||||
const b = box(p);
|
||||
let to = t;
|
||||
const q = structuredClone(p);
|
||||
if (t === command.시트 && p.kind !== "cols" && p.kind !== "rows" && rangeInside(b, src)) {
|
||||
to = command.대상시트;
|
||||
for (const pt of [q.a, q.b]) {
|
||||
pt.r += dr;
|
||||
pt.c += dc;
|
||||
}
|
||||
} else if (t === command.대상시트 && rangeInside(b, dst)) return null;
|
||||
if (to === t && newHost === host) return q;
|
||||
q.prefix = to === newHost ? "" : `${quoteSheet(nameOf(to))}!`;
|
||||
return q;
|
||||
});
|
||||
}
|
||||
case "시트이름":
|
||||
return mapRefs(formula, (p) =>
|
||||
p.sheet !== null && target(p) === command.시트
|
||||
? { ...p, prefix: `${quoteSheet(command.이름)}!` }
|
||||
: p,
|
||||
);
|
||||
case "시트지우기":
|
||||
return mapRefs(formula, (p) => (p.sheet !== null && target(p) === command.시트 ? null : p));
|
||||
default:
|
||||
return formula;
|
||||
}
|
||||
}
|
||||
|
||||
/** 복사 · 채우기 — 상대 참조만 (dr, dc) 옮김 · 격자 밖은 `#REF!` */
|
||||
export function moveFormula(formula: string, dr: number, dc: number): string {
|
||||
return mapRefs(formula, (p) => {
|
||||
const q = structuredClone(p);
|
||||
for (const pt of [q.a, q.b]) {
|
||||
if (!pt.absR && p.kind !== "cols") pt.r += dr;
|
||||
if (!pt.absC && p.kind !== "rows") pt.c += dc;
|
||||
if (pt.r < 0 || pt.r >= MAX_ROWS || pt.c < 0 || pt.c >= MAX_COLS) return null;
|
||||
}
|
||||
return q;
|
||||
});
|
||||
}
|
||||
|
||||
/** 구글 `data-sheets-formula`(`=2*R[0]C[-1]`) → 붙일 자리 기준 A1 식(앞 `=` 뺌) */
|
||||
export function r1c1ToA1(formula: string, at: CellAddress): string {
|
||||
const text = formula.startsWith("=") ? formula.slice(1) : formula;
|
||||
const axis = (part: string | undefined, base: number): [number, boolean] =>
|
||||
part === undefined || part === ""
|
||||
? [base, false]
|
||||
: part.startsWith("[")
|
||||
? [base + Number(part.slice(1, -1)), false]
|
||||
: [Number(part) - 1, true];
|
||||
// 글 · 따옴표 시트 이름은 건너뜀 · 이름 한가운데(`ROUND`)는 안 건드림
|
||||
return text.replace(
|
||||
/("(?:[^"]|"")*"|'(?:[^']|'')*')|(?<![\p{L}\p{N}_.$])R(\[-?\d+\]|\d+)?C(\[-?\d+\]|\d+)?(?![\p{L}\p{N}_.(])/gu,
|
||||
(whole, quoted: string | undefined, rp: string | undefined, cp: string | undefined) => {
|
||||
if (quoted) return whole;
|
||||
const [r, absR] = axis(rp, at.r);
|
||||
const [c, absC] = axis(cp, at.c);
|
||||
if (r < 0 || r >= MAX_ROWS || c < 0 || c >= MAX_COLS) return REF_ERR;
|
||||
return renderRef({
|
||||
prefix: "",
|
||||
sheet: null,
|
||||
kind: "cell",
|
||||
a: { r, c, absR, absC },
|
||||
b: { r, c, absR, absC },
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "h_soil_ditch",
|
||||
"서식": [
|
||||
{},
|
||||
{ "크기": 12, "굵게": true },
|
||||
{
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"채움": "#F2F2F2",
|
||||
"테두리": {
|
||||
"위": { "선": "thin" },
|
||||
"아래": { "선": "thin" },
|
||||
"왼": { "선": "thin" },
|
||||
"오른": { "선": "thin" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"가로": "distributed",
|
||||
"세로": "center",
|
||||
"테두리": { "위": { "선": "hair" }, "아래": { "선": "hair" } }
|
||||
},
|
||||
{ "세로": "center", "테두리": { "위": { "선": "hair" }, "아래": { "선": "hair" } } },
|
||||
{
|
||||
"세로": "center",
|
||||
"형식": "0.00",
|
||||
"테두리": { "위": { "선": "hair" }, "아래": { "선": "hair" } }
|
||||
},
|
||||
{ "형식": "0.00" }
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "토사개거",
|
||||
"칸": {
|
||||
"A1": { "값": "□수로형 토사개거 산출근거", "서식": 1 },
|
||||
"A3": { "값": "공종", "서식": 2 },
|
||||
"B3": { "값": "규격", "서식": 2 },
|
||||
"C3": { "값": "산출 내역", "서식": 2 },
|
||||
"D3": { "서식": 2 },
|
||||
"E3": { "서식": 2 },
|
||||
"F3": { "서식": 2 },
|
||||
"G3": { "값": "단위", "서식": 2 },
|
||||
"H3": { "값": "수량", "서식": 2 },
|
||||
"A4": { "값": "터파기", "서식": 3 },
|
||||
"B4": { "값": "토사", "서식": 4 },
|
||||
"C4": { "값": "(윗폭+밑폭)÷2×깊이", "서식": 4 },
|
||||
"G4": { "값": "㎥", "서식": 4 },
|
||||
"H4": { "식": "ROUNDDOWN((J4+J5)/2*J6,2)", "서식": 5 },
|
||||
"A5": { "값": "되메우기", "서식": 3 },
|
||||
"B5": { "값": "토사", "서식": 4 },
|
||||
"C5": { "값": "터파기×0.3", "서식": 4 },
|
||||
"G5": { "값": "㎥", "서식": 4 },
|
||||
"H5": { "식": "ROUNDDOWN(H4*0.3,2)", "서식": 5 },
|
||||
"A6": { "값": "계", "서식": 3 },
|
||||
"H6": { "식": "SUM(H4:H5)", "서식": 5 },
|
||||
"J4": { "식": "치수!B1", "서식": 6 },
|
||||
"J5": { "값": 0.3, "서식": 6 },
|
||||
"J6": { "값": 0.3, "서식": 6 }
|
||||
},
|
||||
"병합": ["A1:H1", "C3:F3", "C4:F4", "C5:F5", "A6:G6"],
|
||||
"열": {
|
||||
"A": { "폭": 10 },
|
||||
"C": { "폭": 6 },
|
||||
"D": { "폭": 6 },
|
||||
"E": { "폭": 6 },
|
||||
"F": { "폭": 6 },
|
||||
"J": { "숨김": true }
|
||||
},
|
||||
"행": { "1": { "높이": 20 } },
|
||||
"틀고정": { "행": 3 },
|
||||
"보기": { "눈금선": false }
|
||||
},
|
||||
{
|
||||
"id": "s2",
|
||||
"이름": "치수",
|
||||
"칸": {
|
||||
"A1": { "값": "윗폭(m)" },
|
||||
"B1": { "값": 0.9, "서식": 6 }
|
||||
}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"계산값": {
|
||||
"s1": {
|
||||
"H4": { "수": "0.18" },
|
||||
"H5": { "수": "0.05" },
|
||||
"H6": { "수": "0.23" },
|
||||
"J4": { "수": "0.9" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_selection.ts (주인 D)
|
||||
* 고름 모델 — 한 칸 · 범위 · 행열 전체 · 전체 · 방향키(Shift 늘림 · Ctrl 끝으로) · 병합으로 늘림 ·
|
||||
* 고름 안 Enter/Tab 돌기. DOM 모름(시험이 그대로 부름). 엑셀 동작이 기준.
|
||||
* ========================================================================== */
|
||||
|
||||
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type { CellAddress, CellRange, Sheet } from "./spreadsheet_types";
|
||||
import type { Selection } from "./spreadsheet_view_types";
|
||||
|
||||
export const LAST_R = MAX_ROWS - 1;
|
||||
export const LAST_C = MAX_COLS - 1;
|
||||
|
||||
export function merges(sheet: Sheet): CellRange[] {
|
||||
const out: CellRange[] = [];
|
||||
for (const t of sheet.병합 ?? []) {
|
||||
const m = parseRange(t);
|
||||
if (m) out.push(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const overlaps = (a: CellRange, b: CellRange): boolean =>
|
||||
a.r0 <= b.r1 && b.r0 <= a.r1 && a.c0 <= b.c1 && b.c0 <= a.c1;
|
||||
|
||||
export const inRange = (g: CellRange, r: number, c: number): boolean =>
|
||||
r >= g.r0 && r <= g.r1 && c >= g.c0 && c <= g.c1;
|
||||
|
||||
/** 병합 일부를 걸친 범위 → 병합 전체를 품게 늘림(늘린 뒤 새로 걸친 것도) */
|
||||
export function expand(range: CellRange, list: CellRange[]): CellRange {
|
||||
const g = { ...range };
|
||||
for (let grew = true; grew;) {
|
||||
grew = false;
|
||||
for (const m of list) {
|
||||
if (!overlaps(g, m)) continue;
|
||||
if (m.r0 < g.r0 || m.r1 > g.r1 || m.c0 < g.c0 || m.c1 > g.c1) {
|
||||
g.r0 = Math.min(g.r0, m.r0);
|
||||
g.r1 = Math.max(g.r1, m.r1);
|
||||
g.c0 = Math.min(g.c0, m.c0);
|
||||
g.c1 = Math.max(g.c1, m.c1);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** 칸이 든 병합(없으면 칸 하나) */
|
||||
export function mergeAt(sheet: Sheet, r: number, c: number): CellRange {
|
||||
return merges(sheet).find((m) => inRange(m, r, c)) ?? { r0: r, c0: c, r1: r, c1: c };
|
||||
}
|
||||
|
||||
/** 숨긴 행 · 필터로 걸러진 행 · 접힌 묶음(방향키 · Enter 가 건너뜀 — 엑셀) */
|
||||
export function rowHidden(sheet: Sheet, r: number): boolean {
|
||||
const row = sheet.행?.[String(r + 1)];
|
||||
return !!(row?.숨김 || row?.걸러짐 || row?.묶음?.접힘);
|
||||
}
|
||||
|
||||
export function colHidden(sheet: Sheet, c: number): boolean {
|
||||
const cols = sheet.열;
|
||||
if (!cols) return false;
|
||||
const col = cols[colName(c)];
|
||||
return !!(col?.숨김 || col?.묶음?.접힘);
|
||||
}
|
||||
|
||||
export function hasValue(sheet: Sheet, r: number, c: number): boolean {
|
||||
const cell = sheet.칸[toA1(r, c)];
|
||||
return !!cell && (cell.식 !== undefined || (cell.값 !== undefined && cell.값 !== ""));
|
||||
}
|
||||
|
||||
export function span(a: CellAddress, b: CellAddress): CellRange {
|
||||
return {
|
||||
r0: Math.min(a.r, b.r),
|
||||
c0: Math.min(a.c, b.c),
|
||||
r1: Math.max(a.r, b.r),
|
||||
c1: Math.max(a.c, b.c),
|
||||
};
|
||||
}
|
||||
|
||||
/** 칸 하나 고름 — 병합 안이면 병합 전체 · 활성은 병합 왼위 */
|
||||
export function selectCell(sheet: Sheet, r: number, c: number): Selection {
|
||||
const m = mergeAt(sheet, r, c);
|
||||
const at = { r: m.r0, c: m.c0 };
|
||||
return { 시트: sheet.id, 범위: [m], 활성: at, 기준: { ...at } };
|
||||
}
|
||||
|
||||
/** 닻(기준)에서 `to` 까지 — 활성은 닻 그대로(엑셀 Shift) */
|
||||
export function selectSpan(sheet: Sheet, anchor: CellAddress, to: CellAddress): Selection {
|
||||
const g = expand(span(anchor, to), merges(sheet));
|
||||
return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } };
|
||||
}
|
||||
|
||||
/** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */
|
||||
export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection {
|
||||
const r0 = Math.min(a, b);
|
||||
const at = { r: active ?? r0, c: firstVisible(sheet, "c") };
|
||||
return {
|
||||
시트: sheet.id,
|
||||
범위: [expand({ r0, r1: Math.max(a, b), c0: 0, c1: LAST_C }, merges(sheet))],
|
||||
활성: at,
|
||||
기준: { ...at },
|
||||
};
|
||||
}
|
||||
|
||||
export function selectCols(sheet: Sheet, a: number, b: number, active?: number): Selection {
|
||||
const c0 = Math.min(a, b);
|
||||
const at = { r: firstVisible(sheet, "r"), c: active ?? c0 };
|
||||
return {
|
||||
시트: sheet.id,
|
||||
범위: [expand({ r0: 0, r1: LAST_R, c0, c1: Math.max(a, b) }, merges(sheet))],
|
||||
활성: at,
|
||||
기준: { ...at },
|
||||
};
|
||||
}
|
||||
|
||||
export function selectAll(sheet: Sheet, active?: CellAddress): Selection {
|
||||
const at = active ?? { r: firstVisible(sheet, "r"), c: firstVisible(sheet, "c") };
|
||||
return {
|
||||
시트: sheet.id,
|
||||
범위: [{ r0: 0, c0: 0, r1: LAST_R, c1: LAST_C }],
|
||||
활성: at,
|
||||
기준: { ...at },
|
||||
};
|
||||
}
|
||||
|
||||
function firstVisible(sheet: Sheet, axis: "r" | "c"): number {
|
||||
let i = 0;
|
||||
while (i < 200 && (axis === "r" ? rowHidden(sheet, i) : colHidden(sheet, i))) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/** 한 칸 옮김 — 숨김 건너뜀 · 끝에서 멈춤 */
|
||||
function step(sheet: Sheet, at: CellAddress, dr: number, dc: number): CellAddress {
|
||||
let { r, c } = at;
|
||||
do {
|
||||
r += dr;
|
||||
c += dc;
|
||||
} while (
|
||||
r >= 0 &&
|
||||
r <= LAST_R &&
|
||||
c >= 0 &&
|
||||
c <= LAST_C &&
|
||||
((dr && rowHidden(sheet, r)) || (dc && colHidden(sheet, c)))
|
||||
);
|
||||
if (r < 0 || r > LAST_R || c < 0 || c > LAST_C) return at;
|
||||
return { r, c };
|
||||
}
|
||||
|
||||
/** 한 줄(행 또는 열)에서 값 든 자리 — 오름차순 */
|
||||
function occupied(sheet: Sheet, at: CellAddress, vertical: boolean): number[] {
|
||||
const out: number[] = [];
|
||||
for (const key of Object.keys(sheet.칸)) {
|
||||
const a = parseA1(key);
|
||||
if (!a || !hasValue(sheet, a.r, a.c)) continue;
|
||||
if (vertical ? a.c === at.c : a.r === at.r) out.push(vertical ? a.r : a.c);
|
||||
}
|
||||
return out.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** Ctrl+방향 — 값 사이를 뛰어 끝으로(엑셀: 값 칸 뭉치의 끝 · 빈 칸이면 다음 값 · 없으면 시트 끝) */
|
||||
function jump(sheet: Sheet, at: CellAddress, dr: number, dc: number): CellAddress {
|
||||
const vertical = dr !== 0;
|
||||
const d = vertical ? dr : dc;
|
||||
const pos = vertical ? at.r : at.c;
|
||||
const edge = d > 0 ? (vertical ? LAST_R : LAST_C) : 0;
|
||||
const set = new Set(occupied(sheet, at, vertical));
|
||||
const has = (i: number): boolean => set.has(i);
|
||||
const put = (i: number): CellAddress => (vertical ? { r: i, c: at.c } : { r: at.r, c: i });
|
||||
if (pos === edge) return at;
|
||||
if (has(pos) && has(pos + d)) {
|
||||
let i = pos;
|
||||
while (i !== edge && has(i + d)) i += d;
|
||||
return put(i);
|
||||
}
|
||||
const sorted = [...set].sort((a, b) => a - b);
|
||||
const next = d > 0 ? sorted.find((i) => i > pos) : sorted.reverse().find((i) => i < pos);
|
||||
return put(next ?? edge);
|
||||
}
|
||||
|
||||
export interface MoveOptions {
|
||||
/** Shift — 닻은 두고 움직이는 끝만 */
|
||||
extend?: boolean;
|
||||
/** Ctrl — 값 끝으로 */
|
||||
jump?: boolean;
|
||||
}
|
||||
|
||||
/** 방향키 · Home 류 공통 — 새 고름 */
|
||||
export function move(
|
||||
sheet: Sheet,
|
||||
sel: Selection,
|
||||
dr: number,
|
||||
dc: number,
|
||||
opts: MoveOptions = {},
|
||||
): Selection {
|
||||
const list = merges(sheet);
|
||||
if (!opts.extend) {
|
||||
const from = sel.활성;
|
||||
const m = mergeAt(sheet, from.r, from.c);
|
||||
// 병합에서 나갈 때는 병합 끝에서 한 칸
|
||||
const edge = { r: dr > 0 ? m.r1 : m.r0, c: dc > 0 ? m.c1 : m.c0 };
|
||||
const to = opts.jump ? jump(sheet, edge, dr, dc) : step(sheet, edge, dr, dc);
|
||||
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.범위[sel.범위.length - 1] ?? span(sel.활성, sel.활성); // 여러 범위면 마지막(활성) 범위
|
||||
const anchor = sel.기준;
|
||||
// 움직이는 끝 = 닻에서 먼 쪽
|
||||
const far = {
|
||||
r: Math.abs(g.r1 - anchor.r) >= Math.abs(anchor.r - g.r0) ? g.r1 : g.r0,
|
||||
c: Math.abs(g.c1 - anchor.c) >= Math.abs(anchor.c - g.c0) ? g.c1 : g.c0,
|
||||
};
|
||||
let to = opts.jump ? jump(sheet, far, dr, dc) : step(sheet, far, dr, dc);
|
||||
// 줄이는 쪽이면 병합 너머까지 한 번에
|
||||
for (let guard = 0; guard < 50; guard++) {
|
||||
const next = expand(span(anchor, to), list);
|
||||
if (!sameRange(next, g) || to === far) break;
|
||||
to = step(sheet, to, dr, dc);
|
||||
}
|
||||
return {
|
||||
시트: sheet.id,
|
||||
범위: [...sel.범위.slice(0, -1), expand(span(anchor, to), list)],
|
||||
활성: { ...sel.활성 },
|
||||
기준: { ...anchor },
|
||||
};
|
||||
}
|
||||
|
||||
export const sameRange = (a: CellRange, b: CellRange): boolean =>
|
||||
a.r0 === b.r0 && a.c0 === b.c0 && a.r1 === b.r1 && a.c1 === b.c1;
|
||||
|
||||
/** 쓰인 칸 끝(Ctrl+End) */
|
||||
export function usedEnd(sheet: Sheet): CellAddress {
|
||||
let r = 0;
|
||||
let c = 0;
|
||||
for (const key of Object.keys(sheet.칸)) {
|
||||
const a = parseA1(key);
|
||||
if (!a) continue;
|
||||
r = Math.max(r, a.r);
|
||||
c = Math.max(c, a.c);
|
||||
}
|
||||
return { r, c };
|
||||
}
|
||||
|
||||
/** 고름 안 Enter/Tab 돌기 — 범위가 한 칸(또는 병합 하나)이면 null(밖으로 옮김) */
|
||||
export function cycle(sheet: Sheet, sel: Selection, dr: number, dc: number): Selection | null {
|
||||
const g = sel.범위[0];
|
||||
if (!g) return null;
|
||||
const list = merges(sheet);
|
||||
if (list.some((m) => sameRange(m, g)) || (g.r0 === g.r1 && g.c0 === g.c1)) return null;
|
||||
// 행 전체 · 열 전체는 쓰인 칸 끝까지만 돎
|
||||
const end = usedEnd(sheet);
|
||||
const box = {
|
||||
...g,
|
||||
r1: g.r1 === LAST_R ? Math.max(end.r, g.r0) : g.r1,
|
||||
c1: g.c1 === LAST_C ? Math.max(end.c, g.c0) : g.c1,
|
||||
};
|
||||
let { r, c } = sel.활성;
|
||||
const cur = mergeAt(sheet, r, c);
|
||||
const rows = box.r1 - box.r0 + 1;
|
||||
const cols = box.c1 - box.c0 + 1;
|
||||
for (let n = 0; n < rows * cols; n++) {
|
||||
if (dr) {
|
||||
r += dr;
|
||||
if (r > box.r1) ((r = box.r0), (c = c + 1 > box.c1 ? box.c0 : c + 1));
|
||||
if (r < box.r0) ((r = box.r1), (c = c - 1 < box.c0 ? box.c1 : c - 1));
|
||||
} else {
|
||||
c += dc;
|
||||
if (c > box.c1) ((c = box.c0), (r = r + 1 > box.r1 ? box.r0 : r + 1));
|
||||
if (c < box.c0) ((c = box.c1), (r = r - 1 < box.r0 ? box.r1 : r - 1));
|
||||
}
|
||||
if (rowHidden(sheet, r) || colHidden(sheet, c)) continue;
|
||||
const m = mergeAt(sheet, r, c);
|
||||
if (m.r0 !== r || m.c0 !== c || sameRange(m, cur)) continue;
|
||||
return { ...sel, 범위: sel.범위.map((x) => ({ ...x })), 활성: { r, c } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 고름 안 칸 전부(행열 전체는 쓰인 끝까지) — Ctrl+Enter · 지우기 대상 */
|
||||
export function cellsOf(sheet: Sheet, sel: Selection): CellAddress[] {
|
||||
const end = usedEnd(sheet);
|
||||
const out: CellAddress[] = [];
|
||||
for (const g of sel.범위) {
|
||||
const r1 = g.r1 === LAST_R ? Math.max(end.r, g.r0) : g.r1;
|
||||
const c1 = g.c1 === LAST_C ? Math.max(end.c, g.c0) : g.c1;
|
||||
for (let r = g.r0; r <= r1; r++) for (let c = g.c0; c <= c1; c++) out.push({ r, c });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_sheet_extra.ts (3단계 · 주인 sub_laptop_4)
|
||||
* 시트 숨기기 · 보이기 · 시트 탭 색. 저장 자리 `Sheet.숨김` · `Sheet.탭색`(0 계약에 방금 더함).
|
||||
* 이 파일은 관리 창(전체 시트 목록 + 숨김 · 색 조작)만 그림 — 실제 탭 줄(`spreadsheet_tabs.ts`,
|
||||
* E 파일)을 안 건드림.
|
||||
*
|
||||
* 잇는 법(E `spreadsheet_tabs.ts` · sub7 — 지금은 하나도 안 이어짐):
|
||||
* 1) `mountTabs` 의 `render()` 가 `ctx.book.시트` 를 돌 때 `숨김` 인 시트는 탭을 안 그림.
|
||||
* 2) 탭 엘리먼트에 `sheet.탭색` 이 있으면 밑줄 · 배경을 그 색으로(엑셀처럼).
|
||||
* 3) 숨은 시트를 다시 보이려면 탭 우클릭 메뉴에 [숨긴 시트 보이기] 항목을 두고 이 파일의
|
||||
* `createSheetExtraPanel(ctx)` 를 열거나, `showSheet(ctx, id)` 를 바로 부름.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { Sheet } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
// css 는 stage3.css 에 얹음(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 더함.
|
||||
|
||||
function applySheet(ctx: SpreadsheetContext, sheetId: string, next: Sheet): void {
|
||||
ctx.dispatch({ 종류: "시트통째", 시트: { ...next, id: sheetId } });
|
||||
}
|
||||
|
||||
/** 시트를 숨김 — 마지막 보이는 시트는 못 숨김(까닭 글 돌려줌 · 되면 null). 활성 시트를 숨기면
|
||||
* 다른 보이는 시트로 활성을 옮김. */
|
||||
export function hideSheet(ctx: SpreadsheetContext, id: string): string | null {
|
||||
const others = ctx.book.시트.filter((s) => s.id !== id && !s.숨김);
|
||||
if (!others.length) return "마지막 보이는 시트는 못 숨김";
|
||||
const sheet = ctx.book.시트.find((s) => s.id === id);
|
||||
if (!sheet || sheet.숨김) return null;
|
||||
applySheet(ctx, id, { ...sheet, 숨김: true });
|
||||
if (ctx.book.활성 === id) ctx.showSheet(others[0].id);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 숨긴 시트를 다시 보임. */
|
||||
export function unhideSheet(ctx: SpreadsheetContext, id: string): void {
|
||||
const sheet = ctx.book.시트.find((s) => s.id === id);
|
||||
if (!sheet || !sheet.숨김) return;
|
||||
applySheet(ctx, id, { ...sheet, 숨김: false });
|
||||
}
|
||||
|
||||
/** 탭 색 — `color` 가 null 이면 색을 지움(기본 탭 색으로). */
|
||||
export function setSheetTabColor(ctx: SpreadsheetContext, id: string, color: string | null): void {
|
||||
const sheet = ctx.book.시트.find((s) => s.id === id);
|
||||
if (!sheet) return;
|
||||
const next: Sheet = { ...sheet };
|
||||
if (color) next.탭색 = color;
|
||||
else delete next.탭색;
|
||||
applySheet(ctx, id, next);
|
||||
}
|
||||
|
||||
/** 시트 관리 창 — 전체 시트 목록 + 숨김 토글 · 탭 색 고르기. */
|
||||
export function createSheetExtraPanel(ctx: SpreadsheetContext): PartHandle {
|
||||
const root = el("div", { className: "ss-sheetextra" });
|
||||
const list = el("ul", { className: "ss-sheetextra__list" });
|
||||
let error: HTMLElement | null = null;
|
||||
|
||||
const showError = (msg: string | null): void => {
|
||||
error?.remove();
|
||||
error = null;
|
||||
if (!msg) return;
|
||||
error = el("p", { text: msg });
|
||||
error.style.color = "var(--color-danger, red)";
|
||||
root.append(error);
|
||||
};
|
||||
|
||||
function render(): void {
|
||||
list.replaceChildren(
|
||||
...ctx.book.시트.map((sheet) => {
|
||||
const li = el("li", { className: "ss-sheetextra__row" });
|
||||
const label = el("span", { text: sheet.이름 + (sheet.숨김 ? "(숨김)" : "") });
|
||||
const colorInput = el("input", {
|
||||
attrs: { type: "color", value: sheet.탭색 ?? "#ffffff" },
|
||||
}) as HTMLInputElement;
|
||||
colorInput.addEventListener("change", () =>
|
||||
setSheetTabColor(ctx, sheet.id, colorInput.value),
|
||||
);
|
||||
const colorClear = createButton({ label: "색 지움", variant: "ghost" });
|
||||
colorClear.addEventListener("click", () => {
|
||||
setSheetTabColor(ctx, sheet.id, null);
|
||||
render();
|
||||
});
|
||||
const toggle = createButton({
|
||||
label: sheet.숨김 ? "보이기" : "숨기기",
|
||||
variant: "ghost",
|
||||
});
|
||||
toggle.addEventListener("click", () => {
|
||||
if (sheet.숨김) {
|
||||
unhideSheet(ctx, sheet.id);
|
||||
showError(null);
|
||||
} else {
|
||||
showError(hideSheet(ctx, sheet.id));
|
||||
}
|
||||
render();
|
||||
});
|
||||
li.append(label, colorInput, colorClear, toggle);
|
||||
return li;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
root.append(el("h4", { text: "시트 관리" }), list);
|
||||
render();
|
||||
|
||||
return {
|
||||
root,
|
||||
refresh: render,
|
||||
destroy(): void {
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_shortcuts.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 엑셀 단축키 전수 표 — 데이터 파일(동작은 안 함). 키마다 뜻 · 이을 동작 이름 · 지금 상태(됨 ·
|
||||
* 안됨 · 범위밖) · 자리(됨이면 어디 있는지 · 안됨이면 어디 만들지 추천)를 적어 둠.
|
||||
* 2026-09-27 `spreadsheet_keys.ts`(D · sub7) 를 읽고 「됨」 을 매김 — keys.ts 가 바뀌면 이 표도 다시 맞출 것.
|
||||
*
|
||||
* 잇는 법(D · sub7) — `spreadsheet_keys.ts` 는 지금 각 키를 switch 문에 직접 심어 둠(표를 돌며 찾지
|
||||
* 않음). 이 표는 ① 빠짐없이 다 있는지 볼 대조표 ② 「안됨」 항목을 새로 심을 때 이름표(동작 이름을
|
||||
* 그대로 함수 이름으로 씀) 로 씀. 새로 심으면 이 파일의 그 항목 `상태` 를 "됨" 으로 바꾸고 `자리` 를 채움.
|
||||
* 개발 중 대조가 필요하면 `findShortcut("Ctrl+B")` 로 찾아 콘솔에 남기는 정도로만 씀(런타임 배선 아님).
|
||||
* ========================================================================== */
|
||||
|
||||
export type ShortcutState = "됨" | "안됨" | "범위밖";
|
||||
|
||||
export interface ShortcutEntry {
|
||||
키: string;
|
||||
뜻: string;
|
||||
/** 이을 함수 이름(식별자) — 새로 심을 때 이 이름을 그대로 씀 */
|
||||
동작: string;
|
||||
상태: ShortcutState;
|
||||
/** 「됨」이면 있는 자리 · 「안됨」이면 만들 자리 추천 · 「범위밖」이면 왜 안 하는지 */
|
||||
자리: string;
|
||||
}
|
||||
|
||||
export const SHORTCUTS: ShortcutEntry[] = [
|
||||
// ── 이동 ──────────────────────────────────────────────────────────────
|
||||
{ 키: "방향키", 뜻: "한 칸 이동", 동작: "move", 상태: "됨", 자리: "keys.ts navKey" },
|
||||
{
|
||||
키: "Shift+방향키",
|
||||
뜻: "고름 늘리기",
|
||||
동작: "extendSelection",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey(move extend)",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+방향키",
|
||||
뜻: "자료 끝으로 건너뜀",
|
||||
동작: "jumpToEdge",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey(move jump)",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+Shift+방향키",
|
||||
뜻: "자료 끝까지 고름",
|
||||
동작: "extendToEdge",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey(move extend+jump)",
|
||||
},
|
||||
{ 키: "Home", 뜻: "그 행 맨 앞 칸", 동작: "moveHome", 상태: "됨", 자리: "keys.ts navKey" },
|
||||
{
|
||||
키: "Ctrl+Home",
|
||||
뜻: "A1 로",
|
||||
동작: "moveToStart",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey(Ctrl+Home)",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+End",
|
||||
뜻: "마지막으로 쓴 칸으로",
|
||||
동작: "moveToUsedEnd",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey(Ctrl+End)",
|
||||
},
|
||||
{
|
||||
키: "PageUp/PageDown",
|
||||
뜻: "화면 높이만큼 위 · 아래",
|
||||
동작: "movePage",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey",
|
||||
},
|
||||
{
|
||||
키: "Tab/Shift+Tab",
|
||||
뜻: "확정 뒤 옆 · 앞 칸",
|
||||
동작: "nextCell",
|
||||
상태: "됨",
|
||||
자리: "keys.ts editingKey · navKey",
|
||||
},
|
||||
{
|
||||
키: "Enter/Shift+Enter",
|
||||
뜻: "확정 뒤 아래 · 위 칸",
|
||||
동작: "nextRow",
|
||||
상태: "됨",
|
||||
자리: "keys.ts editingKey · navKey",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+PageDown/PageUp",
|
||||
뜻: "다음 · 이전 시트 탭",
|
||||
동작: "nextSheet",
|
||||
상태: "안됨",
|
||||
자리: "spreadsheet_tabs.ts 에 만들고 keys.ts navKey 에 연결",
|
||||
},
|
||||
|
||||
// ── 고르기 ────────────────────────────────────────────────────────────
|
||||
{
|
||||
키: "Ctrl+A",
|
||||
뜻: "시트 전체 고름",
|
||||
동작: "selectAllCells",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+Space",
|
||||
뜻: "그 열 전체 고름",
|
||||
동작: "selectColumn",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey",
|
||||
},
|
||||
{
|
||||
키: "Shift+Space",
|
||||
뜻: "그 행 전체 고름",
|
||||
동작: "selectRow",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+클릭",
|
||||
뜻: "범위를 하나 더 고름(여러 범위)",
|
||||
동작: "addRangeToSelection",
|
||||
상태: "안됨",
|
||||
자리: "spreadsheet_selection.ts 확장 — spreadsheet_autofit.ts 머리에도 자리 적어 둠",
|
||||
},
|
||||
|
||||
// ── 편집 ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
키: "F2",
|
||||
뜻: "칸 편집 시작 · 편집 방식 바꿈",
|
||||
동작: "toggleEditMode",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey/editingKey",
|
||||
},
|
||||
{
|
||||
키: "Delete",
|
||||
뜻: "내용만 지움(서식은 남김)",
|
||||
동작: "clearContents",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey",
|
||||
},
|
||||
{
|
||||
키: "Backspace",
|
||||
뜻: "지우고 새로 침",
|
||||
동작: "clearAndType",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey",
|
||||
},
|
||||
{ 키: "Esc", 뜻: "편집 취소", 동작: "cancelEdit", 상태: "됨", 자리: "keys.ts editingKey" },
|
||||
{
|
||||
키: "Alt+Enter",
|
||||
뜻: "칸 안에서 줄바꿈",
|
||||
동작: "insertLineBreak",
|
||||
상태: "됨",
|
||||
자리: "keys.ts editingKey",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+Enter",
|
||||
뜻: "고른 칸 전부에 같은 값",
|
||||
동작: "fillAllSelected",
|
||||
상태: "됨",
|
||||
자리: "keys.ts editingKey(commitAll)",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+D",
|
||||
뜻: "맨 위 칸 값을 아래로 채움",
|
||||
동작: "fillDown",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey(fill)",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+R",
|
||||
뜻: "맨 왼 칸 값을 오른쪽으로 채움",
|
||||
동작: "fillRight",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey(fill)",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+;",
|
||||
뜻: "오늘 날짜를 침",
|
||||
동작: "insertToday",
|
||||
상태: "안됨",
|
||||
자리: "날짜 형식이 1단계 밖이라 보류 — keys.ts navKey 에 새 case",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+Shift+;",
|
||||
뜻: "지금 시각을 침",
|
||||
동작: "insertNow",
|
||||
상태: "안됨",
|
||||
자리: "위와 같음(날짜 보류)",
|
||||
},
|
||||
{ 키: "Ctrl+Z", 뜻: "되돌리기", 동작: "undo", 상태: "됨", 자리: "keys.ts navKey" },
|
||||
{ 키: "Ctrl+Y", 뜻: "다시", 동작: "redo", 상태: "됨", 자리: "keys.ts navKey" },
|
||||
{
|
||||
키: "Ctrl+-",
|
||||
뜻: "칸 · 행 · 열 지우기(당겨 채움)",
|
||||
동작: "deleteCells",
|
||||
상태: "안됨",
|
||||
자리: "대화상자(방향 고르기) 필요 — keys.ts navKey 에 새 case",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+Shift++",
|
||||
뜻: "칸 · 행 · 열 넣기(밀어냄)",
|
||||
동작: "insertCells",
|
||||
상태: "안됨",
|
||||
자리: "위와 같음(대화상자 필요)",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+9",
|
||||
뜻: "고른 행 숨김",
|
||||
동작: "hideRows",
|
||||
상태: "안됨",
|
||||
자리: "「숨김」 명령은 이미 있음 — keys.ts navKey 에 새 case만",
|
||||
},
|
||||
{ 키: "Ctrl+0", 뜻: "고른 열 숨김", 동작: "hideColumns", 상태: "안됨", 자리: "위와 같음" },
|
||||
{ 키: "Ctrl+Shift+9", 뜻: "행 숨김 풀기", 동작: "unhideRows", 상태: "안됨", 자리: "위와 같음" },
|
||||
{
|
||||
키: "Ctrl+Shift+0",
|
||||
뜻: "열 숨김 풀기",
|
||||
동작: "unhideColumns",
|
||||
상태: "안됨",
|
||||
자리: "위와 같음",
|
||||
},
|
||||
{
|
||||
키: "F4",
|
||||
뜻: "식 편집 중 참조를 상대 → 절대 → 혼합으로 돌림",
|
||||
동작: "cycleReferenceAbs",
|
||||
상태: "안됨",
|
||||
자리: "spreadsheet_editor.ts 편집 중 커서 위치 참조를 찾아 고침",
|
||||
},
|
||||
{
|
||||
키: "Alt+=",
|
||||
뜻: "위(왼) 수를 모아 SUM 자동 완성",
|
||||
동작: "autoSum",
|
||||
상태: "안됨",
|
||||
자리: "keys.ts navKey — 위쪽 연속 수 범위를 찾아 `=SUM(...)` 를 침",
|
||||
},
|
||||
|
||||
// ── 복사 · 붙여넣기 ──────────────────────────────────────────────────
|
||||
{
|
||||
키: "Ctrl+C",
|
||||
뜻: "복사",
|
||||
동작: "copySelection",
|
||||
상태: "됨",
|
||||
자리: "spreadsheet_clipboard.ts(E)",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+X",
|
||||
뜻: "잘라내기",
|
||||
동작: "cutSelection",
|
||||
상태: "됨",
|
||||
자리: "spreadsheet_clipboard.ts(E)",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+V",
|
||||
뜻: "붙여넣기",
|
||||
동작: "pasteSelection",
|
||||
상태: "됨",
|
||||
자리: "spreadsheet_clipboard.ts(E)",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+Shift+V",
|
||||
뜻: "붙여넣기 골라서(값만 · 서식만 · 수식만)",
|
||||
동작: "pasteSpecialDialog",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey hook → spreadsheet_paste_special.ts",
|
||||
},
|
||||
|
||||
// ── 찾기 ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
키: "Ctrl+F",
|
||||
뜻: "찾기",
|
||||
동작: "openFind",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey hook → spreadsheet_find_panel.ts",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+H",
|
||||
뜻: "바꾸기",
|
||||
동작: "openReplace",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey hook → spreadsheet_find_panel.ts",
|
||||
},
|
||||
{ 키: "Shift+F5", 뜻: "찾기(엑셀 버릇)", 동작: "openFind", 상태: "됨", 자리: "keys.ts navKey" },
|
||||
|
||||
// ── 메모 ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
키: "Shift+F2",
|
||||
뜻: "메모 넣기 · 고치기",
|
||||
동작: "editComment",
|
||||
상태: "됨",
|
||||
자리: "keys.ts navKey hook → spreadsheet_comments.ts",
|
||||
},
|
||||
|
||||
// ── 서식 ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
키: "Ctrl+1",
|
||||
뜻: "셀 서식 대화상자",
|
||||
동작: "openFormatDialog",
|
||||
상태: "됨",
|
||||
자리: "spreadsheet_format_dialog.ts(오늘 새로 · 2026-09-27) — keys.ts 에 hook 연결만 남음",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+B",
|
||||
뜻: "굵게 토글",
|
||||
동작: "toggleBold",
|
||||
상태: "안됨",
|
||||
자리: "keys.ts navKey 새 case — 서식 명령 { 굵게: !지금값 }",
|
||||
},
|
||||
{ 키: "Ctrl+I", 뜻: "기울임 토글", 동작: "toggleItalic", 상태: "안됨", 자리: "위와 같음" },
|
||||
{ 키: "Ctrl+U", 뜻: "밑줄 토글", 동작: "toggleUnderline", 상태: "안됨", 자리: "위와 같음" },
|
||||
{ 키: "Ctrl+5", 뜻: "취소선 토글", 동작: "toggleStrikethrough", 상태: "안됨", 자리: "위와 같음" },
|
||||
{
|
||||
키: "Ctrl+Shift+&",
|
||||
뜻: "바깥 테두리 넣기",
|
||||
동작: "addOutlineBorder",
|
||||
상태: "안됨",
|
||||
자리: "keys.ts navKey 새 case — 서식 명령 테두리 네 변",
|
||||
},
|
||||
{
|
||||
키: "Ctrl+Shift+_",
|
||||
뜻: "테두리 없앰",
|
||||
동작: "clearBorders",
|
||||
상태: "안됨",
|
||||
자리: "keys.ts navKey 새 case",
|
||||
},
|
||||
|
||||
// ── 시트 ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
키: "Shift+F11",
|
||||
뜻: "새 시트",
|
||||
동작: "newSheet",
|
||||
상태: "안됨",
|
||||
자리: "spreadsheet_tabs.ts 버튼은 있을 수 있음 — 단축키만 keys.ts 에 더함",
|
||||
},
|
||||
|
||||
// ── 범위 밖(Aislo 는 다른 방식이라 안 함) ────────────────────────────
|
||||
{
|
||||
키: "Ctrl+S/O/N",
|
||||
뜻: "저장 · 열기 · 새 파일",
|
||||
동작: "—",
|
||||
상태: "범위밖",
|
||||
자리: "Aislo 는 [저장] 버튼 · 서버 파일이라 브라우저 단축키를 흉내 안 냄",
|
||||
},
|
||||
{
|
||||
키: "F9",
|
||||
뜻: "손으로 다시 계산",
|
||||
동작: "—",
|
||||
상태: "범위밖",
|
||||
자리: "자동 재계산이라 손으로 다시 계산할 일이 없음",
|
||||
},
|
||||
];
|
||||
|
||||
const norm = (key: string): string => key.replace(/\s+/g, "").toLowerCase();
|
||||
|
||||
export function findShortcut(키: string): ShortcutEntry | null {
|
||||
const k = norm(키);
|
||||
return SHORTCUTS.find((s) => norm(s.키) === k) ?? null;
|
||||
}
|
||||
|
||||
export function unimplemented(): ShortcutEntry[] {
|
||||
return SHORTCUTS.filter((s) => s.상태 === "안됨");
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_sort_dialog.ts (주인 A · 화면)
|
||||
* 정렬 대화 상자 · 빠른 정렬(오름 · 내림). 엔진은 `spreadsheet_sortfilter.ts`(명령 `정렬`).
|
||||
* 범위 = 고른 범위 · 칸 하나만 골랐으면 이어진 값 덩어리(엑셀 「현재 영역」) · 머리 행은 짐작해 켬.
|
||||
*
|
||||
* 잇는 법(D · sub7):
|
||||
* · 도구 모음 · 메뉴 「정렬…」 → `openSortDialog(ctx)` — 창을 `ctx.root` 에 띄우고 닫는 함수를 돌려줌.
|
||||
* · 「오름차순」 「내림차순」 단추 → `sortSelection(ctx, 내림)` — 활성 칸 열을 기준으로 바로 정렬.
|
||||
* · 막히면(병합 걸침 등) 토스트로 까닭을 보임 · 되돌리기는 `ctx.dispatch` 가 쌓음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { colName, rangeToA1 } from "./spreadsheet_address";
|
||||
import { CommandError } from "./spreadsheet_commands";
|
||||
import { currentRegion, displayText, sortCommand } from "./spreadsheet_sortfilter";
|
||||
import type { SortKey, SortOptions } from "./spreadsheet_sortfilter";
|
||||
import type { CellRange } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import "./spreadsheet_sortfilter.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["정렬", "Sort"],
|
||||
Range: ["범위", "Range"],
|
||||
Header: ["머리 행 있음", "My data has headers"],
|
||||
Case: ["대소문자 구분", "Case sensitive"],
|
||||
Column: ["열", "Column"],
|
||||
Asc: ["오름차순", "Ascending"],
|
||||
Desc: ["내림차순", "Descending"],
|
||||
AddKey: ["기준 더하기", "Add level"],
|
||||
RemoveKey: ["빼기", "Remove"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Ok: ["정렬", "Sort"],
|
||||
};
|
||||
const t = (key: keyof typeof TEXT) => TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
|
||||
|
||||
/** 정렬 범위 — 칸 하나면 현재 영역 */
|
||||
function targetRange(ctx: SpreadsheetContext): CellRange {
|
||||
const g = ctx.selection.범위[0];
|
||||
if (g.r0 === g.r1 && g.c0 === g.c1)
|
||||
return currentRegion(ctx.book, ctx.selection.시트, g.r0, g.c0);
|
||||
return g;
|
||||
}
|
||||
|
||||
/** 머리 행 짐작 — 첫 행이 모두 글이고 둘째 행에 글 아닌 값이 있으면 머리 */
|
||||
function guessHeader(ctx: SpreadsheetContext, rg: CellRange): boolean {
|
||||
if (rg.r0 === rg.r1) return false;
|
||||
const v = (r: number, c: number) => ctx.engine.value(ctx.selection.시트, r, c);
|
||||
let allText = true;
|
||||
let secondOther = false;
|
||||
for (let c = rg.c0; c <= rg.c1; c++) {
|
||||
const a = v(rg.r0, c);
|
||||
const b = v(rg.r0 + 1, c);
|
||||
if (a !== null && typeof a !== "string") allText = false;
|
||||
if (b !== null && typeof b !== "string") secondOther = true;
|
||||
}
|
||||
return allText && secondOther;
|
||||
}
|
||||
|
||||
function run(ctx: SpreadsheetContext, rg: CellRange, opts: SortOptions): boolean {
|
||||
try {
|
||||
ctx.dispatch(sortCommand(ctx.book, ctx.engine, ctx.selection.시트, rg, opts));
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (!(e instanceof CommandError)) throw e;
|
||||
showToast(e.message, "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 활성 칸 열 기준 바로 정렬 */
|
||||
export function sortSelection(ctx: SpreadsheetContext, desc: boolean): void {
|
||||
if (ctx.readOnly) return;
|
||||
const rg = targetRange(ctx);
|
||||
const c = Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1);
|
||||
run(ctx, rg, { 머리: guessHeader(ctx, rg), 기준: [{ 열: c, 내림: desc }] });
|
||||
}
|
||||
|
||||
/** 정렬 대화 상자 — 닫는 함수를 돌려줌 */
|
||||
export function openSortDialog(ctx: SpreadsheetContext): () => void {
|
||||
const rg = targetRange(ctx);
|
||||
const sheet = ctx.selection.시트;
|
||||
const keys: SortKey[] = [{ 열: Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1) }];
|
||||
|
||||
const header = el("input", { attrs: { type: "checkbox" } });
|
||||
header.checked = guessHeader(ctx, rg);
|
||||
const caseBox = el("input", { attrs: { type: "checkbox" } });
|
||||
const list = el("div", { className: "ss-sort__keys" });
|
||||
const box = el("div", {
|
||||
className: "ss-sort__box",
|
||||
attrs: { role: "dialog", "aria-label": t("Title") },
|
||||
});
|
||||
const shade = el("div", { className: "ss-sort", children: [box] });
|
||||
|
||||
const columnLabel = (c: number) => {
|
||||
const head = header.checked ? displayText(ctx.book, ctx.engine, sheet, rg.r0, c) : "";
|
||||
return head ? `${colName(c)}: ${head}` : `${t("Column")} ${colName(c)}`;
|
||||
};
|
||||
|
||||
function drawKeys() {
|
||||
list.replaceChildren(
|
||||
...keys.map((k, i) => {
|
||||
const col = el("select", { attrs: { "aria-label": t("Column") } });
|
||||
for (let c = rg.c0; c <= rg.c1; c++)
|
||||
col.append(el("option", { text: columnLabel(c), attrs: { value: String(c) } }));
|
||||
col.value = String(k.열);
|
||||
col.addEventListener("change", () => (k.열 = Number(col.value)));
|
||||
const dir = el("select", {
|
||||
children: [
|
||||
el("option", { text: t("Asc"), attrs: { value: "asc" } }),
|
||||
el("option", { text: t("Desc"), attrs: { value: "desc" } }),
|
||||
],
|
||||
});
|
||||
dir.value = k.내림 ? "desc" : "asc";
|
||||
dir.addEventListener("change", () => (k.내림 = dir.value === "desc"));
|
||||
const remove = createButton({
|
||||
label: t("RemoveKey"),
|
||||
variant: "ghost",
|
||||
disabled: keys.length === 1,
|
||||
onClick: () => {
|
||||
keys.splice(i, 1);
|
||||
drawKeys();
|
||||
},
|
||||
});
|
||||
return el("div", { className: "ss-sort__row", children: [col, dir, remove] });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
shade.remove();
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
const ok = () => {
|
||||
if (run(ctx, rg, { 머리: header.checked, 기준: keys, 대소문자: caseBox.checked })) close();
|
||||
};
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Escape") close();
|
||||
else if (ev.key === "Enter" && !(ev.target instanceof HTMLSelectElement)) ok();
|
||||
else return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
header.addEventListener("change", drawKeys);
|
||||
box.append(
|
||||
el("div", { className: "ss-sort__title", text: t("Title") }),
|
||||
el("div", { className: "ss-sort__meta", text: `${t("Range")} ${rangeToA1(rg)}` }),
|
||||
el("label", { className: "ss-sort__meta", children: [header, t("Header")] }),
|
||||
el("label", { className: "ss-sort__meta", children: [caseBox, t("Case")] }),
|
||||
list,
|
||||
el("div", {
|
||||
className: "ss-sort__row",
|
||||
children: [
|
||||
createButton({
|
||||
label: t("AddKey"),
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
keys.push({ 열: rg.c0 });
|
||||
drawKeys();
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ss-sort__foot",
|
||||
children: [
|
||||
createButton({ label: t("Cancel"), variant: "ghost", onClick: close }),
|
||||
createButton({ label: t("Ok"), onClick: ok }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
shade.addEventListener("mousedown", (ev) => {
|
||||
if (ev.target === shade) close();
|
||||
});
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
drawKeys();
|
||||
ctx.root.append(shade);
|
||||
return close;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/* spreadsheet_sortfilter.css (주인 A) — 정렬 대화 상자 · 자동 필터 단추 · 값 목록 창 */
|
||||
|
||||
.ss-sort {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: var(--z-dropdown);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.ss-sort__box,
|
||||
.ss-filter__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-body);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
box-shadow: var(--shadow-lg);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ss-sort__box {
|
||||
min-width: 360px;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.ss-sort__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ss-sort__meta,
|
||||
.ss-sort__row,
|
||||
.ss-sort__foot,
|
||||
.ss-filter__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ss-sort__meta {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ss-sort__row select {
|
||||
flex: 1;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ss-sort__row option {
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.ss-sort__foot,
|
||||
.ss-filter__foot {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 필터 단추 층 — 크기 0(격자 조작을 막지 않음) · 단추만 누름 */
|
||||
.ss-filter__layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ss-filter__btn {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
font-size: 9px;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.ss-filter__btn.is-on {
|
||||
color: var(--color-royal-amethyst);
|
||||
border-color: var(--color-royal-amethyst);
|
||||
}
|
||||
|
||||
.ss-filter__panel {
|
||||
position: absolute;
|
||||
z-index: var(--z-dropdown);
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.ss-filter__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
.ss-filter__list label {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ss-filter__link {
|
||||
padding: 2px var(--spacing-4);
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-filter__link:hover {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_sortfilter.ts (주인 A)
|
||||
* 정렬 · 자동 필터 엔진 — DOM 모름. 값 비교 · 보이는 글은 엔진(`CalcEngine`) · 숫자 형식(B)으로 셈.
|
||||
* 명령은 결과(정렬 차례 · 걸러진 행)를 박아 둠 → 다시 하기가 같은 결과 · 적용은 `applyCommand` 가 여기로 넘김.
|
||||
*
|
||||
* 엑셀 규칙:
|
||||
* 정렬 차례 — 오름: 수 < 글 < 참거짓(FALSE<TRUE) < 오류 · 내림은 거꾸로 · 빈 칸은 늘 맨 뒤 · 같은 값은 원래 차례.
|
||||
* 글은 대소문자 가리지 않음(옵션으로 가림 = 소문자 먼저). 값은 계산값(식 칸은 푼 값).
|
||||
* 정렬 옮김 — 범위 열만 행째 옮김 · 식은 복사처럼 상대 참조만 옮김(범위 밖에서 범위를 가리키던 식은 그대로) ·
|
||||
* 서식 · 메모도 같이 · 행 높이는 제자리 · 걸러진 행은 제자리(보이는 행끼리만 정렬) · 병합이 걸치면 막음.
|
||||
* 필터 — 첫 행 = 머리 · 값 목록 = 보이는 글(숫자 형식 적용) · 빈 칸 = "" · 조건 안 맞는 행은 `걸러짐`
|
||||
* (사용자 숨김과 따로) · 행열을 넣고 지우면 필터 범위 · 조건 열도 따라감 · 끄면 걸러짐 모두 풂.
|
||||
*
|
||||
* 잇는 법(D · sub7 · C · sub6):
|
||||
* · 정렬: 대화 상자 `openSortDialog(ctx)`(spreadsheet_sort_dialog.ts) · 도구 단추 `sortSelection(ctx, 내림)`.
|
||||
* · 필터: `attachFilter(ctx)`(spreadsheet_filter.ts) 를 부품 목록에 붙이고 grid.render() 뒤 `refresh()` ·
|
||||
* 도구 단추 · Ctrl+Shift+L = `toggleFilter(ctx)`.
|
||||
* · 격자(C): 행 `걸러짐` 도 `숨김` 처럼 높이 0 — 머리의 숨김 표시는 사용자 `숨김` 만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { formatValue } from "./spreadsheet_numfmt";
|
||||
import {
|
||||
colIndex,
|
||||
colName,
|
||||
parseRange,
|
||||
rangesOverlap,
|
||||
rangeToA1,
|
||||
toA1,
|
||||
} from "./spreadsheet_address";
|
||||
import { clone, CommandError, sheetOf } from "./spreadsheet_commands";
|
||||
import type { Of } from "./spreadsheet_commands";
|
||||
import { compareValues, isError, isFrac } from "./spreadsheet_eval";
|
||||
import { moveFormula, shiftSpan } from "./spreadsheet_refshift";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type {
|
||||
AutoFilter,
|
||||
CalcEngine,
|
||||
Cell,
|
||||
CellRange,
|
||||
Command,
|
||||
CommandEffect,
|
||||
Scalar,
|
||||
Sheet,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
|
||||
export interface SortKey {
|
||||
/** 기준 열(0 부터 · 범위 안) */
|
||||
열: number;
|
||||
내림?: boolean;
|
||||
}
|
||||
|
||||
export interface SortOptions {
|
||||
/** 첫 행 = 머리(정렬 안 함) */
|
||||
머리: boolean;
|
||||
기준: SortKey[];
|
||||
/** 글 대소문자 가림 */
|
||||
대소문자?: boolean;
|
||||
}
|
||||
|
||||
const filtered = (s: Sheet, r: number) => !!s.행?.[r + 1]?.걸러짐;
|
||||
|
||||
const rank = (v: Scalar) =>
|
||||
isFrac(v) ? 0 : typeof v === "string" ? 1 : typeof v === "boolean" ? 2 : 3;
|
||||
|
||||
const blank = (v: Scalar) => v === null || v === "";
|
||||
|
||||
/** 엑셀 정렬 비교 — 빈 칸은 오름 · 내림 모두 맨 뒤 */
|
||||
export function sortCompare(a: Scalar, b: Scalar, desc = false, caseSensitive = false): number {
|
||||
if (blank(a) || blank(b)) return blank(a) === blank(b) ? 0 : blank(a) ? 1 : -1;
|
||||
let d = rank(a) - rank(b);
|
||||
if (d === 0 && !isError(a)) {
|
||||
if (typeof a === "string" && caseSensitive) d = COLLATE_CASE.compare(a, b as string);
|
||||
else if (typeof a === "string") d = COLLATE.compare(a, b as string);
|
||||
else d = compareValues(a, b);
|
||||
}
|
||||
return desc ? -d : d;
|
||||
}
|
||||
|
||||
const COLLATE = new Intl.Collator("ko", { sensitivity: "accent" });
|
||||
const COLLATE_CASE = new Intl.Collator("ko", { sensitivity: "variant", caseFirst: "lower" });
|
||||
|
||||
/** 칸 하나에서 이어진 값 덩어리(엑셀 「현재 영역」) — 빈 칸만 둘러싼 테두리까지 늘림 */
|
||||
export function currentRegion(book: Workbook, sheetId: string, r: number, c: number): CellRange {
|
||||
const s = sheetOf(book, sheetId);
|
||||
const filled = new Set<string>();
|
||||
for (const [a1, cell] of Object.entries(s.칸))
|
||||
if (cell.값 !== undefined || cell.식 !== undefined) filled.add(a1);
|
||||
const has = (rr: number, cc: number) =>
|
||||
rr >= 0 && cc >= 0 && rr < MAX_ROWS && cc < MAX_COLS && filled.has(toA1(rr, cc));
|
||||
const g = { r0: r, c0: c, r1: r, c1: c };
|
||||
for (let grew = true; grew;) {
|
||||
grew = false;
|
||||
const rowHas = (rr: number) => {
|
||||
for (let cc = g.c0 - 1; cc <= g.c1 + 1; cc++) if (has(rr, cc)) return true;
|
||||
return false;
|
||||
};
|
||||
const colHas = (cc: number) => {
|
||||
for (let rr = g.r0 - 1; rr <= g.r1 + 1; rr++) if (has(rr, cc)) return true;
|
||||
return false;
|
||||
};
|
||||
if (g.r0 > 0 && rowHas(g.r0 - 1)) (g.r0--, (grew = true));
|
||||
if (g.r1 < MAX_ROWS - 1 && rowHas(g.r1 + 1)) (g.r1++, (grew = true));
|
||||
if (g.c0 > 0 && colHas(g.c0 - 1)) (g.c0--, (grew = true));
|
||||
if (g.c1 < MAX_COLS - 1 && colHas(g.c1 + 1)) (g.c1++, (grew = true));
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function checkMerges(s: Sheet, range: CellRange) {
|
||||
const hit = (s.병합 ?? []).some((m) => {
|
||||
const rg = parseRange(m);
|
||||
return rg && rangesOverlap(rg, range);
|
||||
});
|
||||
if (hit) throw new CommandError("병합한 칸이 든 범위는 정렬하지 못함 — 병합을 먼저 풂");
|
||||
}
|
||||
|
||||
// ── 정렬 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 정렬 명령 만들기 — 값은 엔진 계산값으로 견줌 · 걸러진 행은 제자리 */
|
||||
export function sortCommand(
|
||||
book: Workbook,
|
||||
engine: CalcEngine,
|
||||
sheetId: string,
|
||||
range: CellRange,
|
||||
opts: SortOptions,
|
||||
): Command {
|
||||
const s = sheetOf(book, sheetId);
|
||||
const data: CellRange = { ...range, r0: range.r0 + (opts.머리 ? 1 : 0) };
|
||||
if (data.r0 > data.r1) throw new CommandError("정렬할 행이 없음");
|
||||
if (!opts.기준.length) throw new CommandError("정렬 기준이 없음");
|
||||
for (const k of opts.기준)
|
||||
if (k.열 < range.c0 || k.열 > range.c1) throw new CommandError("정렬 기준 열이 범위 밖");
|
||||
checkMerges(s, data);
|
||||
const rows: number[] = [];
|
||||
for (let r = data.r0; r <= data.r1; r++) if (!filtered(s, r)) rows.push(r);
|
||||
const keys = new Map(rows.map((r) => [r, opts.기준.map((k) => engine.value(sheetId, r, k.열))]));
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const va = keys.get(a)!;
|
||||
const vb = keys.get(b)!;
|
||||
for (let i = 0; i < opts.기준.length; i++) {
|
||||
const d = sortCompare(va[i], vb[i], opts.기준[i].내림, opts.대소문자);
|
||||
if (d) return d;
|
||||
}
|
||||
return a - b;
|
||||
});
|
||||
const order: number[] = [];
|
||||
let next = 0;
|
||||
for (let r = data.r0; r <= data.r1; r++) order.push(filtered(s, r) ? r : sorted[next++]);
|
||||
return { 종류: "정렬", 시트: sheetId, 범위: data, 차례: order };
|
||||
}
|
||||
|
||||
export function applySort(book: Workbook, cmd: Of<"정렬">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const { r0, r1, c0, c1 } = cmd.범위;
|
||||
const n = r1 - r0 + 1;
|
||||
if (
|
||||
cmd.차례.length !== n ||
|
||||
new Set(cmd.차례).size !== n ||
|
||||
cmd.차례.some((r) => r < r0 || r > r1)
|
||||
)
|
||||
throw new CommandError("정렬 차례가 범위와 안 맞음");
|
||||
checkMerges(s, cmd.범위);
|
||||
const before = clone(s);
|
||||
const take = <T>(table: Record<string, T> | undefined) => {
|
||||
const out = new Map<string, T>();
|
||||
for (let r = r0; r <= r1; r++)
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
const a1 = toA1(r, c);
|
||||
if (table && a1 in table) {
|
||||
out.set(a1, table[a1]);
|
||||
delete table[a1];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const cells = take<Cell>(s.칸);
|
||||
const notes = take<string>(s.comments);
|
||||
cmd.차례.forEach((src, i) => {
|
||||
const to = r0 + i;
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
const cell = cells.get(toA1(src, c));
|
||||
if (cell) {
|
||||
const moved = { ...cell };
|
||||
if (moved.식 !== undefined) moved.식 = moveFormula(moved.식, to - src, 0);
|
||||
s.칸[toA1(to, c)] = moved;
|
||||
}
|
||||
const note = notes.get(toA1(src, c));
|
||||
if (note !== undefined) (s.comments ??= {})[toA1(to, c)] = note;
|
||||
}
|
||||
});
|
||||
if (s.comments && !Object.keys(s.comments).length) delete s.comments;
|
||||
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
// ── 자동 필터 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** 칸의 보이는 글(숫자 형식 적용) — 빈 칸 "" */
|
||||
export function displayText(
|
||||
book: Workbook,
|
||||
engine: CalcEngine,
|
||||
sheetId: string,
|
||||
r: number,
|
||||
c: number,
|
||||
) {
|
||||
const s = sheetOf(book, sheetId);
|
||||
const cell = s.칸[toA1(r, c)];
|
||||
if (!cell || (cell.값 === undefined && cell.식 === undefined)) return "";
|
||||
const idx = cell.서식 ?? s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? s.기본?.서식 ?? 0;
|
||||
return formatValue(engine.value(sheetId, r, c), book.서식[idx]?.형식).글;
|
||||
}
|
||||
|
||||
/** 필터 단추 목록 — 열의 서로 다른 보이는 글(정렬 차례 · 빈 칸 맨 뒤) · 켬 = 지금 보이는 값 */
|
||||
export function filterItems(
|
||||
book: Workbook,
|
||||
engine: CalcEngine,
|
||||
sheetId: string,
|
||||
c: number,
|
||||
): { 글: string; 켬: boolean }[] {
|
||||
const s = sheetOf(book, sheetId);
|
||||
const f = s.필터 && parseRange(s.필터.범위);
|
||||
if (!f) return [];
|
||||
const allowed = s.필터!.조건?.[colName(c)];
|
||||
const seen = new Map<string, Scalar>();
|
||||
for (let r = f.r0 + 1; r <= f.r1; r++) {
|
||||
const text = displayText(book, engine, sheetId, r, c);
|
||||
if (!seen.has(text)) seen.set(text, text === "" ? null : engine.value(sheetId, r, c));
|
||||
}
|
||||
return [...seen]
|
||||
.sort((a, b) => sortCompare(a[1], b[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
||||
.map(([글]) => ({ 글, 켬: !allowed || allowed.includes(글) }));
|
||||
}
|
||||
|
||||
/** 필터 명령 — 범위 · 조건으로 걸러질 행을 셈(null = 필터 끔) */
|
||||
export function filterCommand(
|
||||
book: Workbook,
|
||||
engine: CalcEngine,
|
||||
sheetId: string,
|
||||
filter: AutoFilter | null,
|
||||
): Command {
|
||||
if (!filter) return { 종류: "필터", 시트: sheetId, 필터: null, 걸러짐: [] };
|
||||
const f = parseRange(filter.범위);
|
||||
if (!f) throw new CommandError(`필터 범위 틀림: ${filter.범위}`);
|
||||
const hidden: number[] = [];
|
||||
const conds = Object.entries(filter.조건 ?? {}).map(
|
||||
([col, list]) => [colIndex(col), new Set(list)] as const,
|
||||
);
|
||||
for (let r = f.r0 + 1; r <= f.r1; r++)
|
||||
if (conds.some(([c, ok]) => !ok.has(displayText(book, engine, sheetId, r, c)))) hidden.push(r);
|
||||
return { 종류: "필터", 시트: sheetId, 필터: filter, 걸러짐: hidden };
|
||||
}
|
||||
|
||||
export function applyFilter(book: Workbook, cmd: Of<"필터">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const before = clone(s);
|
||||
if (cmd.필터) s.필터 = clone(cmd.필터);
|
||||
else delete s.필터;
|
||||
const table = (s.행 ??= {});
|
||||
for (const [k, info] of Object.entries(table)) {
|
||||
delete info.걸러짐;
|
||||
if (!Object.keys(info).length) delete table[k];
|
||||
}
|
||||
for (const r of cmd.걸러짐) (table[String(r + 1)] ??= {}).걸러짐 = true;
|
||||
if (!Object.keys(table).length) delete s.행;
|
||||
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false };
|
||||
}
|
||||
|
||||
/** 행열 넣기 · 지우기 때 필터 범위 · 조건 열 따라감(머리 행 · 끝 열까지 지우면 필터 없어짐) */
|
||||
export function shiftFilter(s: Sheet, rows: boolean, insert: boolean, at: number, n: number) {
|
||||
const f = s.필터 && parseRange(s.필터.범위);
|
||||
if (!s.필터 || !f) return;
|
||||
const sp = rows
|
||||
? shiftSpan(f.r0, f.r1, insert, at, n, MAX_ROWS)
|
||||
: shiftSpan(f.c0, f.c1, insert, at, n, MAX_COLS);
|
||||
const headGone = rows && !insert && f.r0 >= at && f.r0 < at + n;
|
||||
if (!sp || headGone) {
|
||||
delete s.필터;
|
||||
return;
|
||||
}
|
||||
const next = rows ? { ...f, r0: sp[0], r1: sp[1] } : { ...f, c0: sp[0], c1: sp[1] };
|
||||
s.필터.범위 = rangeToA1(next);
|
||||
if (rows || !s.필터.조건) return;
|
||||
const conds: Record<string, string[]> = {};
|
||||
for (const [col, list] of Object.entries(s.필터.조건)) {
|
||||
const c = shiftSpan(colIndex(col), colIndex(col), insert, at, n, MAX_COLS);
|
||||
if (c) conds[colName(c[0])] = list;
|
||||
}
|
||||
s.필터.조건 = conds;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_spill.ts
|
||||
* 긴 글 넘침(엑셀) — 칸보다 긴 글을 옆 빈 칸 위로 보임. 왼쪽 · 일반 맞춤은 오른쪽으로 · 오른쪽 맞춤은
|
||||
* 왼쪽으로 · 가운데는 양쪽으로. 비지 않은 칸 · 병합 칸 · 판 끝(틀 고정 경계 · 그린 범위 밖)에서 멈춤.
|
||||
* 격자(`spreadsheet_grid.ts`)가 판 하나를 그릴 때 줄마다 칸 자리(`slots`)를 모아 부름 — 계산만 여기.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 같은 판 · 같은 줄 · 병합 아닌 칸 하나(병합 칸 · 안 그린 칸은 넣지 않음 → 거기서 멈춤) */
|
||||
export interface SpillSlot {
|
||||
w: number;
|
||||
empty: boolean;
|
||||
}
|
||||
|
||||
/** 넘침 — 왼 · 오른쪽으로 더 쓸 폭(px) · 덮은 칸 열 범위 · 가운데 맞춤이 한쪽만 막혀 잘라 낼 폭 */
|
||||
export interface Spill {
|
||||
left: number;
|
||||
right: number;
|
||||
from: number;
|
||||
to: number;
|
||||
/** 가운데 맞춤 — 글 상자는 양쪽 반씩 늘리고 막힌 쪽은 이만큼 잘라 냄(글은 칸 가운데 그대로) */
|
||||
clipLeft: number;
|
||||
clipRight: number;
|
||||
}
|
||||
|
||||
export type SpillAlign = "left" | "right" | "center";
|
||||
|
||||
/** 칸 c(폭 own)의 글 폭 need 가 넘치면 정렬 쪽 빈 칸으로 늘린 결과 · 안 넘치거나 못 늘리면 null */
|
||||
export function spillOf(
|
||||
slots: Map<number, SpillSlot>,
|
||||
c: number,
|
||||
own: number,
|
||||
need: number,
|
||||
align: SpillAlign,
|
||||
): Spill | null {
|
||||
if (need <= own) return null;
|
||||
let from = c;
|
||||
let to = c;
|
||||
const grow = (dir: -1 | 1, want: number): number => {
|
||||
let got = 0;
|
||||
for (let k = c + dir; got < want; k += dir) {
|
||||
const s = slots.get(k);
|
||||
if (!s || !s.empty) break;
|
||||
got += s.w;
|
||||
if (dir < 0) from = k;
|
||||
else to = k;
|
||||
}
|
||||
return got;
|
||||
};
|
||||
if (align === "center") {
|
||||
const half = (need - own) / 2;
|
||||
const l = Math.min(grow(-1, half), half);
|
||||
const r = Math.min(grow(1, half), half);
|
||||
if (!l && !r) return null;
|
||||
return { left: half, right: half, from, to, clipLeft: half - l, clipRight: half - r };
|
||||
}
|
||||
const got = grow(align === "right" ? -1 : 1, need - own);
|
||||
if (!got) return null;
|
||||
return align === "right"
|
||||
? { left: got, right: 0, from, to, clipLeft: 0, clipRight: 0 }
|
||||
: { left: 0, right: got, from, to, clipLeft: 0, clipRight: 0 };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_stage3.css (주인 sub_laptop_4)
|
||||
* 조건부 서식 · 데이터 유효성 · 이름 정의 · 상태 표시줄 · 시트 관리 창 — 화면 스타일 한 벌.
|
||||
* 색 · 간격은 테마 토큰만.
|
||||
* ========================================================================== */
|
||||
|
||||
.ss-condfmt,
|
||||
.ss-validation,
|
||||
.ss-names,
|
||||
.ss-sheetextra {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.ss-condfmt__list,
|
||||
.ss-validation__list,
|
||||
.ss-names__list,
|
||||
.ss-sheetextra__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.ss-condfmt__row,
|
||||
.ss-validation__row,
|
||||
.ss-names__row,
|
||||
.ss-sheetextra__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ss-condfmt__row > span:first-of-type,
|
||||
.ss-validation__row > span:first-of-type,
|
||||
.ss-names__row > span:first-of-type {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ss-condfmt__swatch {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ss-condfmt__form,
|
||||
.ss-validation__form,
|
||||
.ss-names__form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
align-items: flex-end;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ss-validation__dropdown {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ss-validation__dropdown-item {
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ss-validation__dropdown-item:hover {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.ss-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: 2px var(--spacing-8);
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.ss-statusbar__stats {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
flex: 1;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.ss-statusbar__zoom {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_statusbar.ts (3단계 · 주인 sub_laptop_4)
|
||||
* 아래 상태 표시줄 — 고른 범위의 합계 · 평균 · 개수(COUNTA) · 숫자 개수(COUNT) · 최소 · 최대.
|
||||
* 엑셀처럼 우클릭으로 보일 줄 고름(이 표시줄만의 취향 — 문서에는 안 남음 · 계약 손 안 댐).
|
||||
* 확대 비율 — `ctx.grid.zoom()` 으로 처음 값 · 격자 root 의 `aislo-grid-zoom` 사건(sub6, 2026-09-27)을
|
||||
* 들어 Ctrl+휠로 바뀔 때마다 다시 씀. `setZoom` 은 아직 계약에 없음(격자만 바꿈 · 상태줄은 읽기만).
|
||||
* 화면 붙이기는 `spreadsheet.ts`(D)가 `createStatusBar(ctx)` 로 이미 함(root 를 격자 아래).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import { isFrac, fracToDouble } from "./spreadsheet_functions_math";
|
||||
import type { CellRange, Scalar } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
// css 는 stage3.css 에 얹음(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 더함.
|
||||
|
||||
export type StatKey = "합계" | "평균" | "개수" | "숫자개수" | "최소" | "최대";
|
||||
|
||||
export const STAT_KEYS: StatKey[] = ["합계", "평균", "개수", "숫자개수", "최소", "최대"];
|
||||
|
||||
export interface SelectionStats {
|
||||
합계: number | null;
|
||||
평균: number | null;
|
||||
개수: number;
|
||||
숫자개수: number;
|
||||
최소: number | null;
|
||||
최대: number | null;
|
||||
}
|
||||
|
||||
/** 고른 범위(여럿) 안 칸 값을 모아 통계를 냄 — 엔진 값 기준(식 칸도 풀린 값). */
|
||||
export function computeSelectionStats(ctx: SpreadsheetContext): SelectionStats {
|
||||
const sheet = ctx.selection.시트;
|
||||
const nums: number[] = [];
|
||||
let 개수 = 0;
|
||||
const seen = new Set<number>();
|
||||
|
||||
const visit = (range: CellRange): void => {
|
||||
for (let r = range.r0; r <= range.r1; r++) {
|
||||
for (let c = range.c0; c <= range.c1; c++) {
|
||||
const key = r * 1_000_000 + c;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const value: Scalar = ctx.engine.value(sheet, r, c);
|
||||
if (value === null) continue;
|
||||
개수++;
|
||||
if (isFrac(value)) nums.push(fracToDouble(value));
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const range of ctx.selection.범위) visit(range);
|
||||
|
||||
return {
|
||||
합계: nums.length ? nums.reduce((a, b) => a + b, 0) : null,
|
||||
평균: nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null,
|
||||
개수,
|
||||
숫자개수: nums.length,
|
||||
최소: nums.length ? Math.min(...nums) : null,
|
||||
최대: nums.length ? Math.max(...nums) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function statText(key: StatKey, stats: SelectionStats): string {
|
||||
const value = stats[key];
|
||||
if (key === "개수" || key === "숫자개수") return `${key}: ${value}`;
|
||||
return `${key}: ${value === null ? "—" : formatNumber(value)}`;
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return Number.isInteger(n)
|
||||
? String(n)
|
||||
: n.toLocaleString(undefined, { maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function zoomText(ratio: number): string {
|
||||
return `확대 ${Math.round(ratio * 100)}%`;
|
||||
}
|
||||
|
||||
/** 아래 상태 표시줄 — 우클릭 메뉴로 보일 줄 고름. */
|
||||
export function createStatusBar(ctx: SpreadsheetContext): PartHandle {
|
||||
const root = el("div", { className: "ss-statusbar" });
|
||||
const stats = el("div", { className: "ss-statusbar__stats" });
|
||||
const zoom = el("span", {
|
||||
className: "ss-statusbar__zoom",
|
||||
text: zoomText(ctx.grid.zoom()),
|
||||
});
|
||||
const menu = createMapContextMenu("ss-statusbar");
|
||||
root.append(stats, zoom, menu.element);
|
||||
|
||||
const onZoom = (ev: Event): void => {
|
||||
const ratio = (ev as CustomEvent<{ zoom: number }>).detail?.zoom;
|
||||
if (typeof ratio === "number") zoom.textContent = zoomText(ratio);
|
||||
};
|
||||
ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom);
|
||||
|
||||
let visible = new Set<StatKey>(STAT_KEYS);
|
||||
|
||||
function render(): void {
|
||||
const s = computeSelectionStats(ctx);
|
||||
stats.replaceChildren(
|
||||
...STAT_KEYS.filter((k) => visible.has(k)).map((k) =>
|
||||
el("span", { className: "ss-statusbar__item", text: statText(k, s) }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
root.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault();
|
||||
const box = root.getBoundingClientRect();
|
||||
const items: MapContextMenuItem[] = STAT_KEYS.map((k) => [
|
||||
`${visible.has(k) ? "✓ " : ""}${k}`,
|
||||
() => {
|
||||
if (visible.has(k)) visible.delete(k);
|
||||
else visible.add(k);
|
||||
render();
|
||||
},
|
||||
]);
|
||||
menu.open(ev.clientX - box.left, ev.clientY - box.top, items);
|
||||
});
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
root,
|
||||
refresh: render,
|
||||
destroy(): void {
|
||||
ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom);
|
||||
menu.close();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_tabs.ts (주인 E)
|
||||
* 시트 탭 — 고르기(`ctx.showSheet`) · 더하기 · 이름 바꾸기(두 번 누르기) · 지우기 · 숨기기 ·
|
||||
* 끌어 옮기기 · 복사. 우클릭 메뉴는 `ui_template_context_menu.ts` 재사용.
|
||||
* 이름 바꾸기 · 지우기 · 옮기기는 명령(`시트이름` · `시트지우기` · `시트옮기기` · `시트더하기`).
|
||||
* 숨김 · 탭 색은 `spreadsheet_sheet_extra.ts`(sub4)의 `hideSheet` · `unhideSheet` 를 부름 —
|
||||
* `Sheet.숨김` 인 시트는 탭을 안 그림 · 숨긴 시트 목록은 우클릭 [숨긴 시트 보이기] 하위 메뉴 ·
|
||||
* `Sheet.탭색` 은 탭 밑줄 색.
|
||||
*
|
||||
* 이음(D `spreadsheet.ts` 잇는 자리) — `mountTabs(ctx)` 한 번 부르고 `root` 를 탭 자리(격자 아래)에
|
||||
* 붙임. `ctx.showSheet` 로 활성 시트가 바뀌면(다른 길로도) `refresh()` 를 불러 다시 그림.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { emptySheet } from "./spreadsheet_commands";
|
||||
import { hideSheet, unhideSheet } from "./spreadsheet_sheet_extra";
|
||||
import { st } from "./spreadsheet_text";
|
||||
import type { Sheet, Workbook } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
import "./spreadsheet_toolbar.css";
|
||||
|
||||
/** 없는 `s숫자` 하나 고름. */
|
||||
function nextSheetId(book: Workbook): string {
|
||||
const used = new Set(book.시트.map((s) => s.id));
|
||||
let n = 1;
|
||||
while (used.has(`s${n}`)) n++;
|
||||
return `s${n}`;
|
||||
}
|
||||
|
||||
/** 「시트2」 · 「시트2 (2)」 처럼 안 겹치는 이름. */
|
||||
function nextSheetName(book: Workbook, base: string): string {
|
||||
const used = new Set(book.시트.map((s) => s.이름));
|
||||
if (!used.has(base)) return base;
|
||||
let n = 2;
|
||||
while (used.has(`${base} (${n})`)) n++;
|
||||
return `${base} (${n})`;
|
||||
}
|
||||
|
||||
export function mountTabs(ctx: SpreadsheetContext): PartHandle {
|
||||
const menu = createMapContextMenu("ss");
|
||||
const root = el("div", { className: "ss-tabs", children: [menu.element] });
|
||||
|
||||
let dragging: string | null = null;
|
||||
|
||||
function tabEl(id: string): HTMLElement | null {
|
||||
return root.querySelector<HTMLElement>(`[data-sheet-id="${CSS.escape(id)}"]`);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
root.replaceChildren(
|
||||
menu.element,
|
||||
...ctx.book.시트.filter((s) => !s.숨김).map(renderTab),
|
||||
addButton(),
|
||||
);
|
||||
}
|
||||
|
||||
function renderTab(sheet: Sheet): HTMLElement {
|
||||
const tab = el("div", {
|
||||
className: `ss-tabs__tab${sheet.id === ctx.book.활성 ? " is-active" : ""}`,
|
||||
text: sheet.이름,
|
||||
attrs: { "data-sheet-id": sheet.id },
|
||||
});
|
||||
tab.draggable = true;
|
||||
if (sheet.탭색) tab.style.borderBottom = `3px solid ${sheet.탭색}`;
|
||||
|
||||
tab.addEventListener("click", () => ctx.showSheet(sheet.id));
|
||||
tab.addEventListener("dblclick", () => beginRename(sheet));
|
||||
tab.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault();
|
||||
const box = root.getBoundingClientRect();
|
||||
openMenu(sheet, ev.clientX - box.left, ev.clientY - box.top);
|
||||
});
|
||||
|
||||
tab.addEventListener("dragstart", (ev) => {
|
||||
dragging = sheet.id;
|
||||
ev.dataTransfer?.setData("text/plain", sheet.id);
|
||||
});
|
||||
tab.addEventListener("dragover", (ev) => {
|
||||
if (!dragging || dragging === sheet.id) return;
|
||||
ev.preventDefault();
|
||||
tab.classList.add("is-dragover");
|
||||
});
|
||||
tab.addEventListener("dragleave", () => tab.classList.remove("is-dragover"));
|
||||
tab.addEventListener("drop", (ev) => {
|
||||
ev.preventDefault();
|
||||
tab.classList.remove("is-dragover");
|
||||
if (!dragging || dragging === sheet.id) return;
|
||||
const 자리 = ctx.book.시트.findIndex((s) => s.id === sheet.id);
|
||||
ctx.dispatch({ 종류: "시트옮기기", 시트: dragging, 자리 });
|
||||
dragging = null;
|
||||
});
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
function beginRename(sheet: Sheet): void {
|
||||
const tab = tabEl(sheet.id);
|
||||
if (!tab) return;
|
||||
const input = el("input", { attrs: { value: sheet.이름 } }) as HTMLInputElement;
|
||||
tab.replaceChildren(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
const commit = (): void => {
|
||||
const name = input.value.trim();
|
||||
if (name && name !== sheet.이름)
|
||||
ctx.dispatch({ 종류: "시트이름", 시트: sheet.id, 이름: name });
|
||||
else render();
|
||||
};
|
||||
input.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") input.blur();
|
||||
if (ev.key === "Escape") {
|
||||
input.removeEventListener("blur", commit);
|
||||
render();
|
||||
}
|
||||
});
|
||||
input.addEventListener("blur", commit);
|
||||
}
|
||||
|
||||
function openMenu(sheet: Sheet, x: number, y: number): void {
|
||||
const items: MapContextMenuItem[] = [
|
||||
[st("SheetRename"), () => beginRename(sheet)],
|
||||
[
|
||||
st("SheetDuplicate"),
|
||||
() => {
|
||||
const copy: Sheet = {
|
||||
...structuredClone(sheet),
|
||||
id: nextSheetId(ctx.book),
|
||||
이름: nextSheetName(ctx.book, sheet.이름),
|
||||
};
|
||||
const 자리 = ctx.book.시트.findIndex((s) => s.id === sheet.id) + 1;
|
||||
ctx.dispatch({ 종류: "시트더하기", 시트: copy, 자리 });
|
||||
},
|
||||
],
|
||||
[
|
||||
st("SheetDelete"),
|
||||
async () => {
|
||||
if (ctx.book.시트.length <= 1) {
|
||||
showToast(st("SheetDeleteLastError"), "error");
|
||||
return;
|
||||
}
|
||||
if (await showConfirmDialog(st("SheetDeleteConfirm"))) {
|
||||
ctx.dispatch({ 종류: "시트지우기", 시트: sheet.id });
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
st("SheetHide"),
|
||||
() => {
|
||||
const err = hideSheet(ctx, sheet.id);
|
||||
if (err) showToast(err, "error");
|
||||
},
|
||||
],
|
||||
];
|
||||
const hidden = ctx.book.시트.filter((s) => s.숨김);
|
||||
if (hidden.length) {
|
||||
items.push({
|
||||
label: st("SheetUnhideMenu"),
|
||||
children: hidden.map((s): [string, () => void] => [s.이름, () => unhideSheet(ctx, s.id)]),
|
||||
});
|
||||
}
|
||||
menu.open(x, y, items);
|
||||
}
|
||||
|
||||
function addButton(): HTMLElement {
|
||||
const btn = el("button", {
|
||||
className: "ss-tabs__add",
|
||||
text: "+",
|
||||
attrs: { type: "button", title: st("SheetAdd") },
|
||||
});
|
||||
btn.addEventListener("click", () => {
|
||||
const name = nextSheetName(ctx.book, "Sheet");
|
||||
const sheet = emptySheet(nextSheetId(ctx.book), name);
|
||||
ctx.dispatch({ 종류: "시트더하기", 시트: sheet, 자리: ctx.book.시트.length });
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
root,
|
||||
refresh: render,
|
||||
destroy() {
|
||||
menu.close();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_text.ts (주인 E)
|
||||
* 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 글자 — [한국어, 영어].
|
||||
* ⚠ `@ui/ui_template_locale` 를 안 씀 — `currentLanguageIndex` 하나만 필요해도 그 파일이
|
||||
* 앱 전체 사전(a · b1~4 · common · m1 · m2, 압축 뒤 약 45 KB)을 통째로 끌고 와 화면 예산(28 KB)을
|
||||
* 넘김(2026-09-27 브레인 지시). 자기 언어 상태를 따로 둠 — `setSpreadsheetLanguage` 로만 바꿈.
|
||||
* ========================================================================== */
|
||||
|
||||
let currentLanguageIndex = 0;
|
||||
|
||||
/** 앱 전역 언어 토글과 맞추고 싶으면 D(`spreadsheet.ts`) 가 부름 — 안 부르면 한국어 기본. */
|
||||
export function setSpreadsheetLanguage(index: 0 | 1): void {
|
||||
currentLanguageIndex = index;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
Undo: ["되돌리기", "Undo"],
|
||||
Redo: ["다시", "Redo"],
|
||||
FontName: ["글꼴", "Font"],
|
||||
FontSize: ["크기", "Size"],
|
||||
Bold: ["굵게", "Bold"],
|
||||
Italic: ["기울임", "Italic"],
|
||||
Underline: ["밑줄", "Underline"],
|
||||
Strike: ["취소선", "Strikethrough"],
|
||||
TextColor: ["글자색", "Text color"],
|
||||
FillColor: ["채움색", "Fill color"],
|
||||
ColorClear: ["지움", "Clear"],
|
||||
HAlign: ["가로 정렬", "Horizontal align"],
|
||||
VAlign: ["세로 정렬", "Vertical align"],
|
||||
Align_general: ["일반", "General"],
|
||||
Align_left: ["왼쪽", "Left"],
|
||||
Align_center: ["가운데", "Center"],
|
||||
Align_right: ["오른쪽", "Right"],
|
||||
Align_distributed: ["균등 분할", "Distributed"],
|
||||
Align_centerContinuous: ["선택 영역 가운데", "Center across"],
|
||||
VAlign_top: ["위", "Top"],
|
||||
VAlign_center: ["가운데", "Middle"],
|
||||
VAlign_bottom: ["아래", "Bottom"],
|
||||
Wrap: ["줄 바꿈", "Wrap text"],
|
||||
Merge: ["병합", "Merge"],
|
||||
Unmerge: ["병합 해제", "Unmerge"],
|
||||
Border: ["테두리", "Border"],
|
||||
Border_none: ["없음", "None"],
|
||||
Border_all: ["모두", "All"],
|
||||
Border_outer: ["바깥", "Outer"],
|
||||
Border_top: ["위", "Top"],
|
||||
Border_bottom: ["아래", "Bottom"],
|
||||
Border_left: ["왼", "Left"],
|
||||
Border_right: ["오른", "Right"],
|
||||
BorderLine: ["선", "Line"],
|
||||
BorderColor: ["색", "Color"],
|
||||
NumberFormat: ["숫자 형식", "Number format"],
|
||||
NumberFormat_general: ["일반", "General"],
|
||||
NumberFormat_integer: ["정수 0", "Integer"],
|
||||
NumberFormat_decimal2: ["소수 0.00", "Decimal 0.00"],
|
||||
NumberFormat_thousands: ["천 단위 #,##0", "Thousands"],
|
||||
NumberFormat_thousands2: ["천 단위 #,##0.00", "Thousands 0.00"],
|
||||
NumberFormat_percent: ["백분율 0%", "Percent"],
|
||||
NumberFormat_accounting: ["회계", "Accounting"],
|
||||
NumberFormat_text: ["글자 @", "Text"],
|
||||
NumberFormat_placeholder: ["표시 형식", "Display format"],
|
||||
NumberFormat_hint: [
|
||||
"이름 골라 쓰거나 엑셀 형식 코드를 바로 침",
|
||||
"Pick a preset or type an Excel format code",
|
||||
],
|
||||
SheetAdd: ["시트 더하기", "Add sheet"],
|
||||
SheetRename: ["이름 바꾸기", "Rename"],
|
||||
SheetDelete: ["지우기", "Delete"],
|
||||
SheetDuplicate: ["복사", "Duplicate"],
|
||||
SheetRenamePrompt: ["새 시트 이름", "New sheet name"],
|
||||
SheetDeleteConfirm: [
|
||||
"이 시트를 지울까요? 되돌릴 수 없습니다.",
|
||||
"Delete this sheet? This cannot be undone.",
|
||||
],
|
||||
SheetDeleteLastError: ["마지막 시트는 지울 수 없음", "Cannot delete the last sheet"],
|
||||
SheetHide: ["숨기기", "Hide"],
|
||||
SheetUnhideMenu: ["숨긴 시트 보이기", "Show hidden sheets"],
|
||||
MenuCut: ["잘라내기", "Cut"],
|
||||
MenuCopy: ["복사", "Copy"],
|
||||
MenuPasteHint: ["Ctrl+V 를 쓸 것", "Use Ctrl+V"],
|
||||
MenuInsertRow: ["행 넣기", "Insert row"],
|
||||
MenuDeleteRow: ["행 지우기", "Delete row"],
|
||||
MenuInsertCol: ["열 넣기", "Insert column"],
|
||||
MenuDeleteCol: ["열 지우기", "Delete column"],
|
||||
MenuHide: ["숨기기", "Hide"],
|
||||
MenuUnhide: ["숨김 취소", "Unhide"],
|
||||
MenuClearContent: ["내용 지우기", "Clear content"],
|
||||
MenuClearFormat: ["서식 지우기", "Clear format"],
|
||||
MenuMerge: ["병합", "Merge"],
|
||||
MenuUnmerge: ["병합 해제", "Unmerge"],
|
||||
MenuCommentInsert: ["메모 삽입", "Insert comment"],
|
||||
MenuCommentEdit: ["메모 편집", "Edit comment"],
|
||||
MenuCommentDelete: ["메모 삭제", "Delete comment"],
|
||||
ClipboardPasteFailed: ["붙여넣기를 읽지 못함", "Could not read paste content"],
|
||||
ClipboardMergeBlocked: [
|
||||
"병합된 셀의 일부를 바꿀 수 없습니다",
|
||||
"Can't change part of a merged cell",
|
||||
],
|
||||
Unit: ["단위", "Unit"],
|
||||
UnitGroup_area: ["넓이·부피·길이", "Area · volume · length"],
|
||||
UnitGroup_weight: ["무게·부피(액체)", "Weight · liquid volume"],
|
||||
UnitGroup_count: ["개수", "Count"],
|
||||
UnitGroup_rate: ["속도·각도·비율", "Speed · angle · rate"],
|
||||
UnitGroup_op: ["연산·비교", "Operators"],
|
||||
UnitRecent: ["최근 씀", "Recently used"],
|
||||
UnitAsFormat: ["숫자 형식으로 붙이기", "Attach as number format"],
|
||||
FormulaHint_label: [
|
||||
"엑셀 수식까지: 엑셀에서 Ctrl+` → 복사 → 여기 Ctrl+V · 끝나면 엑셀 Ctrl+` 다시",
|
||||
"Formulas too: in Excel Ctrl+` → copy → paste here (Ctrl+V) · then Ctrl+` again in Excel",
|
||||
],
|
||||
FormulaHint_title: ["엑셀 수식까지 옮기는 법", "How to bring formulas along"],
|
||||
FormulaHint_detail: [
|
||||
"엑셀 Ctrl+` = 「수식 표시」 켜기 · 칸에 계산값 대신 「=...」 글이 그대로 보임. 그 상태로 복사해 " +
|
||||
"여기 붙이면 수식까지 옮겨짐(그냥 복사하면 값만 옴 · 수식 없음). 붙인 뒤 엑셀에서 Ctrl+` 다시 " +
|
||||
"눌러 원래대로.",
|
||||
'Excel Ctrl+` turns on "Show Formulas" — cells show "=..." text instead of the computed ' +
|
||||
"value. Copy in that state and paste here to bring the formulas along (a normal copy only " +
|
||||
"brings values). Press Ctrl+` again in Excel afterward to switch back.",
|
||||
],
|
||||
ClipboardExcelNoFormula: [
|
||||
"값만 들어왔음 — 수식까지는 엑셀 Ctrl+` 뒤 복사",
|
||||
"Only values came in — for formulas too, copy after Ctrl+` in Excel",
|
||||
],
|
||||
} as const;
|
||||
|
||||
export function st(key: keyof typeof TEXT): string {
|
||||
return TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_toolbar.css (주인 E)
|
||||
* 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 안내 토스트 — 화면 스타일 한 벌.
|
||||
* 색 · 간격은 테마 토큰만(`ui_template_theme.css`).
|
||||
* ========================================================================== */
|
||||
|
||||
.ss-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.ss-toolbar__group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding-right: var(--spacing-8);
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ss-toolbar__group:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.ss-toolbar .ui-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ss-toolbar .ui-btn.is-on {
|
||||
background: var(--color-mist-violet);
|
||||
box-shadow: inset 0 0 0 1px var(--color-royal-amethyst);
|
||||
}
|
||||
|
||||
.ss-toolbar__select {
|
||||
height: 28px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.ss-toolbar__select option {
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.ss-toolbar__input {
|
||||
height: 28px;
|
||||
width: 72px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.ss-toolbar__input--font {
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.ss-toolbar__input--size {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.ss-toolbar__input--numfmt {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.ss-toolbar__color {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-toolbar__clear {
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-toolbar__popover {
|
||||
position: absolute;
|
||||
z-index: var(--z-dropdown);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 180px;
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss-toolbar__popover[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ss-toolbar__popover-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.ss-toolbar__popover-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.ss-toolbar__unit-pop {
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ss-toolbar__unit-section + .ss-toolbar__unit-section {
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ss-toolbar__unit-label {
|
||||
margin-bottom: var(--spacing-4);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.ss-toolbar__unit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.ss-toolbar__unit-btn {
|
||||
min-width: 0;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.ss-toolbar__hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.ss-toolbar__formula-hint {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
text-align: right;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-toolbar__formula-hint:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ss-toolbar__formula-hint-detail {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.68rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ss-toolbar__formula-hint-detail[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── 시트 탭 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ss-tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.ss-tabs__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-bottom: 0;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
background: var(--color-paper);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-tabs__tab.is-active {
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ss-tabs__tab.is-dragover {
|
||||
box-shadow: inset 2px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
.ss-tabs__tab input {
|
||||
width: 96px;
|
||||
border: 1px solid var(--color-focus-ring);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.ss-tabs__add {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 우클릭 메뉴(그리드 · 탭 공용, `ui_template_context_menu.ts`) ───────────── */
|
||||
|
||||
.ss__context-menu {
|
||||
position: absolute;
|
||||
z-index: var(--z-dropdown);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 160px;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss__context-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ss__context-menu-item {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-body);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss__context-menu-item:hover {
|
||||
background: var(--color-surface-sunken, var(--color-mist-violet));
|
||||
}
|
||||
|
||||
.ss__context-menu-item.is-danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.ss__context-menu-sep {
|
||||
margin: 2px 4px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ── 메모 편집 창(칸 우클릭 [메모 삽입 · 편집]) ──────────────────────────────── */
|
||||
|
||||
.ss-comment-editor {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(0 0 0 / 35%);
|
||||
}
|
||||
|
||||
.ss-comment-editor__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
width: 280px;
|
||||
padding: var(--spacing-16);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss-comment-editor__input {
|
||||
min-height: 80px;
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.ss-comment-editor__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_toolbar.ts (주인 E)
|
||||
* 서식 도구 모음 — 되돌리기 · 다시 · 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 ·
|
||||
* 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식.
|
||||
* 모두 `ctx.dispatch` 명령으로 — 이 파일은 문서를 직접 안 건드림.
|
||||
* 활성 칸 서식을 `refresh()` 때 단추 상태로. readOnly 면 안 붙임(root null).
|
||||
*
|
||||
* 이음(D `spreadsheet.ts` 잇는 자리) — `mountToolbar(ctx)` 한 번 부르고 `root`(null 이면 안 붙임)를
|
||||
* 도구 모음 자리에 붙임. 고름 · 문서가 바뀔 때마다(자기 dispatch 뒤 포함) `refresh()` 를 부름 —
|
||||
* 스스로 구독하지 않음. `destroy()` 는 부품을 뗄 때.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import { cellToText, textToCell } from "./spreadsheet_editor";
|
||||
import { st } from "./spreadsheet_text";
|
||||
import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
import "./spreadsheet_toolbar.css";
|
||||
|
||||
type Side = "위" | "아래" | "왼" | "오른";
|
||||
type BorderPreset = "none" | "all" | "outer" | "top" | "bottom" | "left" | "right";
|
||||
|
||||
/** 앱 기본 글꼴 · 크기 — `spreadsheet_grid.ts`(C) 의 `fontCss` 가 쓰는 기본과 같은 값(거울) ·
|
||||
* 칸 · 행 · 열 · 시트 기본 어디에도 없을 때 이 값을 흐리게(placeholder) 보임. */
|
||||
const APP_DEFAULT_FONT = "굴림";
|
||||
const APP_DEFAULT_SIZE = 10;
|
||||
|
||||
const FONTS = [
|
||||
"굴림",
|
||||
"굴림체",
|
||||
"맑은 고딕",
|
||||
"나눔고딕",
|
||||
"돋움",
|
||||
"Arial",
|
||||
"Calibri",
|
||||
"Times New Roman",
|
||||
];
|
||||
|
||||
const NUMFMT_PRESETS: [label: string, code: string][] = [
|
||||
[st("NumberFormat_general"), ""],
|
||||
[st("NumberFormat_integer"), "0"],
|
||||
[st("NumberFormat_decimal2"), "0.00"],
|
||||
[st("NumberFormat_thousands"), "#,##0"],
|
||||
[st("NumberFormat_thousands2"), "#,##0.00"],
|
||||
[st("NumberFormat_percent"), "0%"],
|
||||
[st("NumberFormat_accounting"), '_-* #,##0.00_-;-* #,##0.00_-;_-* "-"_-;_-@_-'],
|
||||
[st("NumberFormat_text"), "@"],
|
||||
];
|
||||
|
||||
const BORDER_LINES: BorderLine[] = [
|
||||
"hair",
|
||||
"thin",
|
||||
"medium",
|
||||
"thick",
|
||||
"double",
|
||||
"dotted",
|
||||
"dashed",
|
||||
];
|
||||
|
||||
type TextKey = Parameters<typeof st>[0];
|
||||
|
||||
const UNIT_GROUPS: [label: TextKey, items: string[]][] = [
|
||||
["UnitGroup_area", ["㎡", "㎥", "m", "㎝", "㎜", "㎞"]],
|
||||
["UnitGroup_weight", ["kg", "t", "ℓ", "㎖"]],
|
||||
["UnitGroup_count", ["개", "본", "식", "EA", "매"]],
|
||||
["UnitGroup_rate", ["m/s", "°", "%", "‰"]],
|
||||
["UnitGroup_op", ["×", "÷", "±", "≤", "≥", "Σ"]],
|
||||
];
|
||||
const UNIT_RECENT_MAX = 8;
|
||||
const UNIT_RECENT_KEY = "aislo-ss-recent-units";
|
||||
|
||||
function loadRecentUnits(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(UNIT_RECENT_KEY);
|
||||
return raw ? (JSON.parse(raw) as string[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function pushRecentUnit(unit: string): string[] {
|
||||
const list = [unit, ...loadRecentUnits().filter((u) => u !== unit)].slice(0, UNIT_RECENT_MAX);
|
||||
try {
|
||||
localStorage.setItem(UNIT_RECENT_KEY, JSON.stringify(list));
|
||||
} catch {
|
||||
/* 저장 못 해도 이번 넣기는 그대로 진행 */
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 숫자 형식 코드 끝 따옴표 글자(전에 붙인 단위)를 떼고 새 단위를 붙임 — 없으면 "0" 에서 시작. */
|
||||
function attachUnitToFormat(code: string, unit: string): string {
|
||||
const base = (code || "0").replace(/"[^"]*"\s*$/, "");
|
||||
return base + `"${unit}"`;
|
||||
}
|
||||
|
||||
/** 활성 칸에 지금 걸린 서식 — 칸 자기 것 없으면 열 · 행 · 시트 기본(`Sheet.기본.서식`) · 그다음 0. */
|
||||
function styleAt(ctx: SpreadsheetContext, r: number, c: number): CellStyle {
|
||||
const sheet = ctx.sheet();
|
||||
const cell = sheet.칸[toA1(r, c)];
|
||||
const idx =
|
||||
cell?.서식 ??
|
||||
sheet.열?.[colName(c)]?.서식 ??
|
||||
sheet.행?.[String(r + 1)]?.서식 ??
|
||||
sheet.기본?.서식 ??
|
||||
0;
|
||||
return ctx.book.서식[idx] ?? {};
|
||||
}
|
||||
|
||||
export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
|
||||
if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} };
|
||||
|
||||
const root = el("div", { className: "ss-toolbar" });
|
||||
|
||||
const group = (...children: HTMLElement[]): HTMLElement =>
|
||||
el("div", { className: "ss-toolbar__group", children });
|
||||
|
||||
const patch = (change: { [K in keyof CellStyle]?: CellStyle[K] | null }): void => {
|
||||
ctx.dispatch({
|
||||
종류: "서식",
|
||||
시트: ctx.selection.시트,
|
||||
범위: ctx.selection.범위,
|
||||
바꿀: change,
|
||||
});
|
||||
};
|
||||
|
||||
// ── 되돌리기 · 다시 ────────────────────────────────────────────────────
|
||||
const undoBtn = createButton({ label: "↶", variant: "ghost", onClick: () => ctx.undo() });
|
||||
undoBtn.title = st("Undo");
|
||||
const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() });
|
||||
redoBtn.title = st("Redo");
|
||||
|
||||
// ── 글꼴 · 크기 ────────────────────────────────────────────────────────
|
||||
const fontInput = el("input", {
|
||||
className: "ss-toolbar__input ss-toolbar__input--font",
|
||||
attrs: { list: "ss-toolbar-fonts", "aria-label": st("FontName") },
|
||||
}) as HTMLInputElement;
|
||||
const fontList = el("datalist", {
|
||||
attrs: { id: "ss-toolbar-fonts" },
|
||||
children: FONTS.map((name) => el("option", { attrs: { value: name } })),
|
||||
});
|
||||
fontInput.append(fontList);
|
||||
const commitFont = (): void => patch({ 글꼴: fontInput.value.trim() || null });
|
||||
fontInput.addEventListener("change", commitFont);
|
||||
|
||||
const sizeInput = el("input", {
|
||||
className: "ss-toolbar__input ss-toolbar__input--size",
|
||||
attrs: { type: "number", min: "1", max: "409", step: "0.5", "aria-label": st("FontSize") },
|
||||
}) as HTMLInputElement;
|
||||
sizeInput.addEventListener("change", () => {
|
||||
const n = Number(sizeInput.value);
|
||||
patch({ 크기: sizeInput.value && Number.isFinite(n) && n > 0 ? n : null });
|
||||
});
|
||||
|
||||
// ── 굵게 · 기울임 · 밑줄 · 취소선 ──────────────────────────────────────
|
||||
const boldBtn = createButton({ label: "B", variant: "ghost" });
|
||||
boldBtn.title = st("Bold");
|
||||
const italicBtn = createButton({ label: "I", variant: "ghost" });
|
||||
italicBtn.title = st("Italic");
|
||||
const underlineBtn = createButton({ label: "U", variant: "ghost" });
|
||||
underlineBtn.title = st("Underline");
|
||||
const strikeBtn = createButton({ label: "S", variant: "ghost" });
|
||||
strikeBtn.title = st("Strike");
|
||||
const toggles: [HTMLButtonElement, keyof CellStyle][] = [
|
||||
[boldBtn, "굵게"],
|
||||
[italicBtn, "기울임"],
|
||||
[underlineBtn, "밑줄"],
|
||||
[strikeBtn, "취소선"],
|
||||
];
|
||||
for (const [btn, key] of toggles) {
|
||||
btn.addEventListener("click", () => patch({ [key]: !btn.classList.contains("is-on") }));
|
||||
}
|
||||
|
||||
// ── 글자색 · 채움색 ────────────────────────────────────────────────────
|
||||
const textColor = el("input", {
|
||||
className: "ss-toolbar__color",
|
||||
attrs: { type: "color", "aria-label": st("TextColor"), value: "#000000" },
|
||||
}) as HTMLInputElement;
|
||||
textColor.addEventListener("change", () => patch({ 글자색: textColor.value }));
|
||||
const textColorClear = el("button", {
|
||||
className: "ss-toolbar__clear",
|
||||
text: "✕",
|
||||
attrs: { type: "button", title: st("ColorClear") },
|
||||
});
|
||||
textColorClear.addEventListener("click", () => patch({ 글자색: null }));
|
||||
|
||||
const fillColor = el("input", {
|
||||
className: "ss-toolbar__color",
|
||||
attrs: { type: "color", "aria-label": st("FillColor"), value: "#ffffff" },
|
||||
}) as HTMLInputElement;
|
||||
fillColor.addEventListener("change", () => patch({ 채움: fillColor.value }));
|
||||
const fillColorClear = el("button", {
|
||||
className: "ss-toolbar__clear",
|
||||
text: "✕",
|
||||
attrs: { type: "button", title: st("ColorClear") },
|
||||
});
|
||||
fillColorClear.addEventListener("click", () => patch({ 채움: null }));
|
||||
|
||||
// ── 가로 · 세로 정렬 · 줄 바꿈 ─────────────────────────────────────────
|
||||
const HALIGN_LABEL = {
|
||||
general: st("Align_general"),
|
||||
left: st("Align_left"),
|
||||
center: st("Align_center"),
|
||||
right: st("Align_right"),
|
||||
distributed: st("Align_distributed"),
|
||||
centerContinuous: st("Align_centerContinuous"),
|
||||
} as const;
|
||||
const VALIGN_LABEL = {
|
||||
top: st("VAlign_top"),
|
||||
center: st("VAlign_center"),
|
||||
bottom: st("VAlign_bottom"),
|
||||
} as const;
|
||||
|
||||
const hAlignSelect = el("select", {
|
||||
className: "ss-toolbar__select",
|
||||
attrs: { "aria-label": st("HAlign") },
|
||||
children: (Object.keys(HALIGN_LABEL) as (keyof typeof HALIGN_LABEL)[]).map((v) =>
|
||||
el("option", { attrs: { value: v }, text: HALIGN_LABEL[v] }),
|
||||
),
|
||||
}) as HTMLSelectElement;
|
||||
hAlignSelect.addEventListener("change", () =>
|
||||
patch({ 가로: hAlignSelect.value as CellStyle["가로"] }),
|
||||
);
|
||||
|
||||
const vAlignSelect = el("select", {
|
||||
className: "ss-toolbar__select",
|
||||
attrs: { "aria-label": st("VAlign") },
|
||||
children: (Object.keys(VALIGN_LABEL) as (keyof typeof VALIGN_LABEL)[]).map((v) =>
|
||||
el("option", { attrs: { value: v }, text: VALIGN_LABEL[v] }),
|
||||
),
|
||||
}) as HTMLSelectElement;
|
||||
vAlignSelect.addEventListener("change", () =>
|
||||
patch({ 세로: vAlignSelect.value as CellStyle["세로"] }),
|
||||
);
|
||||
|
||||
const wrapBtn = createButton({ label: st("Wrap"), variant: "ghost" });
|
||||
wrapBtn.addEventListener("click", () => patch({ 줄바꿈: !wrapBtn.classList.contains("is-on") }));
|
||||
|
||||
// ── 병합 ───────────────────────────────────────────────────────────────
|
||||
const mergeBtn = createButton({ label: st("Merge"), variant: "ghost" });
|
||||
mergeBtn.addEventListener("click", () => {
|
||||
const range = ctx.selection.범위[0];
|
||||
if (!range) return;
|
||||
const merged = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
|
||||
ctx.dispatch({ 종류: merged ? "병합풀기" : "병합", 시트: ctx.selection.시트, 범위: range });
|
||||
});
|
||||
|
||||
// ── 테두리 ───────────────────────────────────────────────────────────
|
||||
const borderBtn = createButton({ label: st("Border"), variant: "ghost" });
|
||||
const borderPop = el("div", { className: "ss-toolbar__popover", attrs: { hidden: "true" } });
|
||||
const lineSelect = el("select", {
|
||||
className: "ss-toolbar__select",
|
||||
children: BORDER_LINES.map((v) => el("option", { attrs: { value: v }, text: v })),
|
||||
}) as HTMLSelectElement;
|
||||
const lineColor = el("input", {
|
||||
className: "ss-toolbar__color",
|
||||
attrs: { type: "color", value: "#000000" },
|
||||
}) as HTMLInputElement;
|
||||
const presetsRow = el("div", { className: "ss-toolbar__popover-presets" });
|
||||
const presets: [BorderPreset, string][] = [
|
||||
["none", st("Border_none")],
|
||||
["all", st("Border_all")],
|
||||
["outer", st("Border_outer")],
|
||||
["top", st("Border_top")],
|
||||
["bottom", st("Border_bottom")],
|
||||
["left", st("Border_left")],
|
||||
["right", st("Border_right")],
|
||||
];
|
||||
for (const [preset, label] of presets) {
|
||||
const b = createButton({
|
||||
label,
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
applyBorderPreset(preset);
|
||||
borderPop.hidden = true;
|
||||
},
|
||||
});
|
||||
presetsRow.append(b);
|
||||
}
|
||||
borderPop.append(
|
||||
el("div", {
|
||||
className: "ss-toolbar__popover-row",
|
||||
children: [el("span", { text: st("BorderLine") }), lineSelect],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ss-toolbar__popover-row",
|
||||
children: [el("span", { text: st("BorderColor") }), lineColor],
|
||||
}),
|
||||
presetsRow,
|
||||
);
|
||||
borderBtn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
borderPop.hidden = !borderPop.hidden;
|
||||
});
|
||||
borderPop.addEventListener("click", (ev) => ev.stopPropagation());
|
||||
const closeBorderPop = (): void => {
|
||||
borderPop.hidden = true;
|
||||
};
|
||||
document.addEventListener("click", closeBorderPop);
|
||||
|
||||
function cellsFor(
|
||||
range: CellRange,
|
||||
edge: BorderPreset,
|
||||
): { r: number; c: number; sides: Side[] }[] {
|
||||
const { r0, c0, r1, c1 } = range;
|
||||
const out: { r: number; c: number; sides: Side[] }[] = [];
|
||||
for (let r = r0; r <= r1; r++) {
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
const sides: Side[] = [];
|
||||
if (edge === "all") sides.push("위", "아래", "왼", "오른");
|
||||
else {
|
||||
if ((edge === "outer" || edge === "top") && r === r0) sides.push("위");
|
||||
if ((edge === "outer" || edge === "bottom") && r === r1) sides.push("아래");
|
||||
if ((edge === "outer" || edge === "left") && c === c0) sides.push("왼");
|
||||
if ((edge === "outer" || edge === "right") && c === c1) sides.push("오른");
|
||||
}
|
||||
if (sides.length) out.push({ r, c, sides });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function applyBorderPreset(preset: BorderPreset): void {
|
||||
const range = ctx.selection.범위[0];
|
||||
if (!range) return;
|
||||
if (preset === "none") {
|
||||
ctx.dispatch({
|
||||
종류: "서식",
|
||||
시트: ctx.selection.시트,
|
||||
범위: ctx.selection.범위,
|
||||
바꿀: { 테두리: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const style: BorderSide = { 선: lineSelect.value as BorderLine, 색: lineColor.value };
|
||||
const cmds: Command[] = cellsFor(range, preset).map(({ r, c, sides }) => {
|
||||
const cur = { ...(styleAt(ctx, r, c).테두리 ?? {}) };
|
||||
for (const s of sides) cur[s] = style;
|
||||
return {
|
||||
종류: "서식",
|
||||
시트: ctx.selection.시트,
|
||||
범위: [{ r0: r, c0: c, r1: r, c1: c }],
|
||||
바꿀: { 테두리: cur },
|
||||
};
|
||||
});
|
||||
if (cmds.length === 1) ctx.dispatch(cmds[0]);
|
||||
else if (cmds.length > 1) ctx.dispatch({ 종류: "묶음", 명령: cmds });
|
||||
}
|
||||
|
||||
// ── 숫자 형식 ─────────────────────────────────────────────────────────
|
||||
const numfmtInput = el("input", {
|
||||
className: "ss-toolbar__input ss-toolbar__input--numfmt",
|
||||
attrs: {
|
||||
list: "ss-toolbar-numfmt",
|
||||
"aria-label": st("NumberFormat"),
|
||||
title: st("NumberFormat_hint"),
|
||||
placeholder: st("NumberFormat_placeholder"),
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
const numfmtList = el("datalist", {
|
||||
attrs: { id: "ss-toolbar-numfmt" },
|
||||
children: NUMFMT_PRESETS.map(([label, code]) =>
|
||||
el("option", {
|
||||
attrs: { value: code },
|
||||
text: `${label} (${code || st("NumberFormat_general")})`,
|
||||
}),
|
||||
),
|
||||
});
|
||||
numfmtInput.append(numfmtList);
|
||||
numfmtInput.addEventListener("change", () => patch({ 형식: numfmtInput.value || null }));
|
||||
|
||||
// ── 단위 기호 ─────────────────────────────────────────────────────────
|
||||
const unitBtn = createButton({ label: `${st("Unit")} ▾`, variant: "ghost" });
|
||||
const unitPop = el("div", {
|
||||
className: "ss-toolbar__popover ss-toolbar__unit-pop",
|
||||
attrs: { hidden: "true" },
|
||||
});
|
||||
const unitAsFormat = el("input", { attrs: { type: "checkbox", id: "ss-unit-as-format" } });
|
||||
const unitAsFormatRow = el("label", {
|
||||
className: "ss-toolbar__popover-row",
|
||||
attrs: { for: "ss-unit-as-format" },
|
||||
children: [unitAsFormat, el("span", { text: st("UnitAsFormat") })],
|
||||
});
|
||||
const recentGrid = el("div", { className: "ss-toolbar__unit-grid" });
|
||||
const recentSection = el("div", {
|
||||
className: "ss-toolbar__unit-section",
|
||||
attrs: { hidden: "true" },
|
||||
children: [
|
||||
el("div", { className: "ss-toolbar__unit-label", text: st("UnitRecent") }),
|
||||
recentGrid,
|
||||
],
|
||||
});
|
||||
|
||||
function insertUnit(unit: string): void {
|
||||
if (ctx.editor.editing()) {
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLTextAreaElement && ctx.root.contains(active)) {
|
||||
const s = active.selectionStart ?? active.value.length;
|
||||
const e = active.selectionEnd ?? active.value.length;
|
||||
active.value = active.value.slice(0, s) + unit + active.value.slice(e);
|
||||
active.setSelectionRange(s + unit.length, s + unit.length);
|
||||
active.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
} else if (unitAsFormat.checked) {
|
||||
const { r, c } = ctx.selection.활성;
|
||||
patch({ 형식: attachUnitToFormat(styleAt(ctx, r, c).형식 ?? "", unit) });
|
||||
} else {
|
||||
const { r, c } = ctx.selection.활성;
|
||||
const sheet = ctx.sheet();
|
||||
const key = toA1(r, c);
|
||||
const text = cellToText(sheet.칸[key]) + unit;
|
||||
ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: { [key]: textToCell(text, sheet.칸[key]) } });
|
||||
}
|
||||
renderRecent(pushRecentUnit(unit));
|
||||
unitPop.hidden = true;
|
||||
}
|
||||
|
||||
function renderRecent(list: string[]): void {
|
||||
recentGrid.textContent = "";
|
||||
recentSection.hidden = list.length === 0;
|
||||
for (const u of list) {
|
||||
const b = createButton({ label: u, variant: "ghost", onClick: () => insertUnit(u) });
|
||||
b.className += " ss-toolbar__unit-btn";
|
||||
recentGrid.append(b);
|
||||
}
|
||||
}
|
||||
renderRecent(loadRecentUnits());
|
||||
|
||||
unitPop.append(unitAsFormatRow, recentSection);
|
||||
for (const [labelKey, items] of UNIT_GROUPS) {
|
||||
const grid = el("div", { className: "ss-toolbar__unit-grid" });
|
||||
for (const u of items) {
|
||||
const b = createButton({ label: u, variant: "ghost", onClick: () => insertUnit(u) });
|
||||
b.className += " ss-toolbar__unit-btn";
|
||||
grid.append(b);
|
||||
}
|
||||
unitPop.append(
|
||||
el("div", {
|
||||
className: "ss-toolbar__unit-section",
|
||||
children: [el("div", { className: "ss-toolbar__unit-label", text: st(labelKey) }), grid],
|
||||
}),
|
||||
);
|
||||
}
|
||||
unitBtn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
unitPop.hidden = !unitPop.hidden;
|
||||
});
|
||||
unitPop.addEventListener("click", (ev) => ev.stopPropagation());
|
||||
const closeUnitPop = (): void => {
|
||||
unitPop.hidden = true;
|
||||
};
|
||||
document.addEventListener("click", closeUnitPop);
|
||||
|
||||
// ── 엑셀 수식 복사 안내(오른쪽 위 흐린 글씨 · 누르면 두세 줄 설명) ─────────
|
||||
const formulaHintBtn = el("button", {
|
||||
className: "ss-toolbar__formula-hint",
|
||||
attrs: { type: "button", title: st("FormulaHint_title") },
|
||||
text: st("FormulaHint_label"),
|
||||
});
|
||||
const formulaHintDetail = el("div", {
|
||||
className: "ss-toolbar__formula-hint-detail",
|
||||
attrs: { hidden: "true" },
|
||||
text: st("FormulaHint_detail"),
|
||||
});
|
||||
formulaHintBtn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
formulaHintDetail.hidden = !formulaHintDetail.hidden;
|
||||
});
|
||||
const formulaHint = el("div", {
|
||||
className: "ss-toolbar__hint",
|
||||
children: [formulaHintBtn, formulaHintDetail],
|
||||
});
|
||||
|
||||
root.append(
|
||||
group(undoBtn, redoBtn),
|
||||
group(fontInput, sizeInput),
|
||||
group(boldBtn, italicBtn, underlineBtn, strikeBtn),
|
||||
group(textColor, textColorClear, fillColor, fillColorClear),
|
||||
group(hAlignSelect, vAlignSelect, wrapBtn),
|
||||
group(mergeBtn),
|
||||
group(borderBtn, borderPop),
|
||||
group(numfmtInput),
|
||||
group(unitBtn, unitPop),
|
||||
formulaHint,
|
||||
);
|
||||
|
||||
function refresh(): void {
|
||||
undoBtn.disabled = !ctx.history.canUndo();
|
||||
redoBtn.disabled = !ctx.history.canRedo();
|
||||
const { r, c } = ctx.selection.활성;
|
||||
const style = styleAt(ctx, r, c);
|
||||
fontInput.value = style.글꼴 ?? "";
|
||||
fontInput.placeholder = style.글꼴 ? "" : (ctx.book.기본?.글꼴 ?? APP_DEFAULT_FONT);
|
||||
sizeInput.value = style.크기 != null ? String(style.크기) : "";
|
||||
sizeInput.placeholder =
|
||||
style.크기 != null ? "" : String(ctx.book.기본?.크기 ?? APP_DEFAULT_SIZE);
|
||||
boldBtn.classList.toggle("is-on", !!style.굵게);
|
||||
italicBtn.classList.toggle("is-on", !!style.기울임);
|
||||
underlineBtn.classList.toggle("is-on", !!style.밑줄);
|
||||
strikeBtn.classList.toggle("is-on", !!style.취소선);
|
||||
textColor.value = style.글자색 ?? "#000000";
|
||||
fillColor.value = style.채움 ?? "#ffffff";
|
||||
hAlignSelect.value = style.가로 ?? "general";
|
||||
vAlignSelect.value = style.세로 ?? "top";
|
||||
wrapBtn.classList.toggle("is-on", !!style.줄바꿈);
|
||||
numfmtInput.value = style.형식 ?? "";
|
||||
const range = ctx.selection.범위[0];
|
||||
mergeBtn.textContent = "";
|
||||
mergeBtn.append(
|
||||
el("span", {
|
||||
className: "ui-btn__label",
|
||||
text:
|
||||
range && (ctx.sheet().병합 ?? []).includes(rangeToA1(range))
|
||||
? st("Unmerge")
|
||||
: st("Merge"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
refresh();
|
||||
|
||||
return {
|
||||
root,
|
||||
refresh,
|
||||
destroy() {
|
||||
document.removeEventListener("click", closeBorderPop);
|
||||
document.removeEventListener("click", closeUnitPop);
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_trace.ts (주인 A)
|
||||
* 선행 · 종속 칸 추적 화살표(엑셀 [수식 분석]) — 엔진 의존 그래프(`engine.precedents`)로 한 단계씩 넓힘.
|
||||
* 엑셀 규칙: 누를 때마다 한 단계 더(이미 편 칸 너머만) · 범위는 파란 상자 + 화살 · 오류 칸에서 나온 화살은 빨강 ·
|
||||
* 다른 시트 칸은 점선 화살 + 시트 표 아이콘 · 문서가 바뀌면 화살을 모두 지움.
|
||||
*
|
||||
* 잇는 법(D · sub7):
|
||||
* · 부품 붙이기: `const trace = attachTrace(ctx)` — SVG 겹판을 격자 overlay(editorSlot 층)에 얹음.
|
||||
* grid.render() 뒤마다 `trace.refresh()` · 스크롤은 스스로 따라감 · 시트를 바꾸면 그 시트 화살만 그림.
|
||||
* · 도구 모음 [수식 분석]: 「선행 참조 추적」 → `trace.precedents()` · 「종속 참조 추적」 → `trace.dependents()` ·
|
||||
* 「화살표 지우기」 → `trace.clear()`(종류만 지우려면 `clear("선행")`). 더 펼 칸이 없으면 false(토스트 몫).
|
||||
* ========================================================================== */
|
||||
|
||||
import { cellId, inRange, parseA1 } from "./spreadsheet_address";
|
||||
import { isError } from "./spreadsheet_eval";
|
||||
import type { CalcEngine, CellRange, SheetCellAddress, Workbook } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
export type TraceKind = "선행" | "종속";
|
||||
|
||||
/** 화살 하나 — 늘 가리켜진 쪽(from) → 식 칸(to) */
|
||||
export interface TraceArrow {
|
||||
종류: TraceKind;
|
||||
from: { 시트: string; 범위: CellRange };
|
||||
to: SheetCellAddress;
|
||||
/** 시작 칸 · 범위에 오류 값이 있음 → 빨강 */
|
||||
오류: boolean;
|
||||
}
|
||||
|
||||
export interface TraceState {
|
||||
arrows: TraceArrow[];
|
||||
/** 이미 편 칸(`종류:시트!id`) */
|
||||
opened: Set<string>;
|
||||
}
|
||||
|
||||
const key = (kind: TraceKind, sheet: string, r: number, c: number) =>
|
||||
`${kind}:${sheet}!${cellId(r, c)}`;
|
||||
const one = (r: number, c: number): CellRange => ({ r0: r, c0: c, r1: r, c1: c });
|
||||
|
||||
/** 시트의 식 칸 목록(범위 안만) */
|
||||
function formulaCells(book: Workbook, sheet: string, rg?: CellRange): SheetCellAddress[] {
|
||||
const s = book.시트.find((x) => x.id === sheet);
|
||||
const out: SheetCellAddress[] = [];
|
||||
for (const [a1, cell] of Object.entries(s?.칸 ?? {})) {
|
||||
const at = cell.식 !== undefined ? parseA1(a1) : null;
|
||||
if (at && (!rg || inRange(rg, at.r, at.c))) out.push({ 시트: sheet, ...at });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function hasError(book: Workbook, engine: CalcEngine, sheet: string, rg: CellRange): boolean {
|
||||
if (rg.r0 === rg.r1 && rg.c0 === rg.c1) return isError(engine.value(sheet, rg.r0, rg.c0));
|
||||
const s = book.시트.find((x) => x.id === sheet);
|
||||
for (const a1 of Object.keys(s?.칸 ?? {})) {
|
||||
const at = parseA1(a1);
|
||||
if (at && inRange(rg, at.r, at.c) && isError(engine.value(sheet, at.r, at.c))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 칸을 가리키는 식 칸들(모든 시트) */
|
||||
export function dependentsOf(
|
||||
book: Workbook,
|
||||
engine: CalcEngine,
|
||||
sheet: string,
|
||||
r: number,
|
||||
c: number,
|
||||
): SheetCellAddress[] {
|
||||
const out: SheetCellAddress[] = [];
|
||||
for (const s of book.시트)
|
||||
for (const f of formulaCells(book, s.id))
|
||||
if (
|
||||
engine.precedents(f.시트, f.r, f.c).some((d) => d.시트 === sheet && inRange(d.범위, r, c))
|
||||
)
|
||||
out.push(f);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 한 단계 넓힘 — 처음이면 그 칸 · 아니면 그 칸에서 이어진 화살 끝 중 아직 안 편 칸. 더한 화살이 있으면 true */
|
||||
export function traceStep(
|
||||
book: Workbook,
|
||||
engine: CalcEngine,
|
||||
state: TraceState,
|
||||
kind: TraceKind,
|
||||
at: SheetCellAddress,
|
||||
): boolean {
|
||||
// 이어진 칸을 따라가며 안 편 칸(앞날개)을 모음
|
||||
const frontier: SheetCellAddress[] = [];
|
||||
const seen = new Set<string>();
|
||||
const walk = [at];
|
||||
while (walk.length) {
|
||||
const p = walk.pop()!;
|
||||
const k = key(kind, p.시트, p.r, p.c);
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
if (!state.opened.has(k)) {
|
||||
frontier.push(p);
|
||||
continue;
|
||||
}
|
||||
for (const a of state.arrows) {
|
||||
if (a.종류 !== kind) continue;
|
||||
if (kind === "선행" && a.to.시트 === p.시트 && a.to.r === p.r && a.to.c === p.c)
|
||||
walk.push(...formulaCells(book, a.from.시트, a.from.범위));
|
||||
if (kind === "종속" && a.from.시트 === p.시트 && inRange(a.from.범위, p.r, p.c))
|
||||
walk.push(a.to);
|
||||
}
|
||||
}
|
||||
let added = false;
|
||||
for (const p of frontier) {
|
||||
state.opened.add(key(kind, p.시트, p.r, p.c));
|
||||
const links =
|
||||
kind === "선행"
|
||||
? engine.precedents(p.시트, p.r, p.c).map((d) => ({ from: d, to: p }))
|
||||
: dependentsOf(book, engine, p.시트, p.r, p.c).map((to) => ({
|
||||
from: { 시트: p.시트, 범위: one(p.r, p.c) },
|
||||
to,
|
||||
}));
|
||||
for (const { from, to } of links) {
|
||||
state.arrows.push({
|
||||
종류: kind,
|
||||
from,
|
||||
to,
|
||||
오류: hasError(book, engine, from.시트, from.범위),
|
||||
});
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
// ── 화면 겹판 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const SVG = "http://www.w3.org/2000/svg";
|
||||
const svg = <K extends keyof SVGElementTagNameMap>(
|
||||
tag: K,
|
||||
attrs: Record<string, string | number>,
|
||||
) => {
|
||||
const n = document.createElementNS(SVG, tag);
|
||||
for (const [a, v] of Object.entries(attrs)) n.setAttribute(a, String(v));
|
||||
return n;
|
||||
};
|
||||
|
||||
export interface TraceHandle extends PartHandle {
|
||||
/** 활성 칸 선행 추적 한 단계 — 더한 화살이 없으면 false */
|
||||
precedents(): boolean;
|
||||
dependents(): boolean;
|
||||
clear(kind?: TraceKind): void;
|
||||
/** 지금 화살(시험 · 상태 표시용) */
|
||||
arrows(): readonly TraceArrow[];
|
||||
}
|
||||
|
||||
export function attachTrace(ctx: SpreadsheetContext): TraceHandle {
|
||||
const layer = svg("svg", { class: "ss-trace" });
|
||||
const defs = svg("defs", {});
|
||||
for (const kind of ["plain", "error", "sheet"]) {
|
||||
const m = svg("marker", {
|
||||
id: `ss-trace-head-${kind}`,
|
||||
viewBox: "0 0 10 10",
|
||||
refX: 9,
|
||||
refY: 5,
|
||||
markerWidth: 7,
|
||||
markerHeight: 7,
|
||||
orient: "auto-start-reverse",
|
||||
});
|
||||
m.append(svg("path", { d: "M0,0 L10,5 L0,10 z", class: `ss-trace__head is-${kind}` }));
|
||||
defs.append(m);
|
||||
}
|
||||
let state: TraceState = { arrows: [], opened: new Set() };
|
||||
let book = ctx.book;
|
||||
|
||||
/** 칸 상자 → 겹판 자리(filter 와 같은 맞춤) */
|
||||
function boxIn(r: number, c: number) {
|
||||
const { layer: host, box } = ctx.grid.editorSlot(r, c);
|
||||
const a = host.getBoundingClientRect();
|
||||
const g = ctx.grid.root.getBoundingClientRect();
|
||||
return { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) };
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (ctx.book !== book) {
|
||||
state = { arrows: [], opened: new Set() };
|
||||
book = ctx.book;
|
||||
}
|
||||
const { layer: host } = ctx.grid.editorSlot(0, 0);
|
||||
if (layer.parentElement !== host) host.append(layer);
|
||||
const here = ctx.sheet().id;
|
||||
const parts: SVGElement[] = [defs];
|
||||
const boxes = new Set<string>();
|
||||
for (const a of state.arrows) {
|
||||
const fromHere = a.from.시트 === here;
|
||||
const toHere = a.to.시트 === here;
|
||||
if (!fromHere && !toHere) continue;
|
||||
const tone = a.오류 ? "error" : fromHere && toHere ? "plain" : "sheet";
|
||||
const t = toHere ? boxIn(a.to.r, a.to.c) : null;
|
||||
const rg = a.from.범위;
|
||||
const f = fromHere ? boxIn(rg.r0, rg.c0) : null;
|
||||
const end = t
|
||||
? { x: t.x + t.w / 2, y: t.y + t.h / 2 }
|
||||
: { x: f!.x + f!.w + 60, y: f!.y - 24 };
|
||||
const start = f
|
||||
? { x: f.x + Math.min(f.w, 24) / 2, y: f.y + f.h / 2 }
|
||||
: { x: t!.x - 60, y: t!.y - 24 };
|
||||
if (
|
||||
f &&
|
||||
(rg.r0 !== rg.r1 || rg.c0 !== rg.c1) &&
|
||||
!boxes.has(`${rg.r0},${rg.c0},${rg.r1},${rg.c1}`)
|
||||
) {
|
||||
boxes.add(`${rg.r0},${rg.c0},${rg.r1},${rg.c1}`);
|
||||
const b = boxIn(rg.r1, rg.c1);
|
||||
parts.push(
|
||||
svg("rect", {
|
||||
class: `ss-trace__box is-${a.오류 ? "error" : "plain"}`,
|
||||
x: f.x + 1,
|
||||
y: f.y + 1,
|
||||
width: Math.max(0, b.x + b.w - f.x - 2),
|
||||
height: Math.max(0, b.y + b.h - f.y - 2),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (f)
|
||||
parts.push(
|
||||
svg("circle", { class: `ss-trace__dot is-${tone}`, cx: start.x, cy: start.y, r: 3 }),
|
||||
);
|
||||
else
|
||||
parts.push(
|
||||
svg("rect", {
|
||||
class: "ss-trace__sheet",
|
||||
x: start.x - 9,
|
||||
y: start.y - 7,
|
||||
width: 18,
|
||||
height: 14,
|
||||
}),
|
||||
);
|
||||
parts.push(
|
||||
svg("line", {
|
||||
class: `ss-trace__line is-${tone}`,
|
||||
x1: start.x,
|
||||
y1: start.y,
|
||||
x2: end.x,
|
||||
y2: end.y,
|
||||
"marker-end": `url(#ss-trace-head-${tone})`,
|
||||
"data-from": `${a.from.시트}!${rg.r0},${rg.c0}`,
|
||||
"data-to": `${a.to.시트}!${a.to.r},${a.to.c}`,
|
||||
}),
|
||||
);
|
||||
if (!t)
|
||||
parts.push(
|
||||
svg("rect", { class: "ss-trace__sheet", x: end.x, y: end.y - 7, width: 18, height: 14 }),
|
||||
);
|
||||
}
|
||||
layer.replaceChildren(...parts);
|
||||
}
|
||||
|
||||
const step = (kind: TraceKind) => {
|
||||
if (ctx.book !== book) draw();
|
||||
const { r, c } = ctx.selection.활성;
|
||||
const added = traceStep(ctx.book, ctx.engine, state, kind, { 시트: ctx.sheet().id, r, c });
|
||||
draw();
|
||||
return added;
|
||||
};
|
||||
|
||||
const onScroll = () => draw();
|
||||
ctx.grid.root.addEventListener("scroll", onScroll, true);
|
||||
draw();
|
||||
|
||||
return {
|
||||
root: layer as unknown as HTMLElement,
|
||||
refresh: draw,
|
||||
precedents: () => step("선행"),
|
||||
dependents: () => step("종속"),
|
||||
clear(kind) {
|
||||
if (!kind) state = { arrows: [], opened: new Set() };
|
||||
else {
|
||||
state.arrows = state.arrows.filter((a) => a.종류 !== kind);
|
||||
for (const k of [...state.opened]) if (k.startsWith(`${kind}:`)) state.opened.delete(k);
|
||||
}
|
||||
draw();
|
||||
},
|
||||
arrows: () => state.arrows,
|
||||
destroy() {
|
||||
ctx.grid.root.removeEventListener("scroll", onScroll, true);
|
||||
layer.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
/* ====================================================================== * spreadsheet_types.ts
|
||||
* 산출근거 스프레드시트 계약 — 저장 문서 · 칸 · 서식 · 수식 나무 · 값 · 명령 · 계산 엔진 모양.
|
||||
* 근거 `tmp/M02_분석/9_엑셀검토.md` · PLAN 11-8. 이 파일 · `spreadsheet_view_types.ts` · 견본 JSON 은
|
||||
* 0 계약 주인만 고침 — 다른 창은 브레인에게 고침을 청함. 타입만 둠(서버 Node · 시험이 그대로 씀).
|
||||
*
|
||||
* ── 규칙 ────────────────────────────────────────────────────────────────────
|
||||
* · 외부 라이브러리 0 · 오픈소스 코드 안 베낌(Formula.js · ssf 는 시험 대조 답으로만).
|
||||
* · 가져다 쓰는 것 = `@ui/ui_template_elements`(el · createButton · 이미 앱 번들) ·
|
||||
* `@ui/sheet/ui_template_sheet_frac`(BigInt 분수 · 집계표와 끝수 한 벌) 둘뿐.
|
||||
* · M02 만 `import()` 로 늦게 불러옴 — 다른 페이지 · `A00_Common/main.ts` 가 정적 import 금지.
|
||||
* · 엔진 파일(A · B)은 DOM 을 모름 — 화면 즉시 풀이와 [저장] 때 서버 Node 풀이가 같은 TS(CLAUDE.md 5장 ②).
|
||||
* · 보이는 칸만 그림 · 파일마다 700 줄 안쪽(예산 넘기 전에 쪼갬) · 화면 글자는 그 파일 안에 둠.
|
||||
* · xlsx 가져오기 · 내보내기 · 인쇄 · PDF 는 만들지 않음(단추도 없음). xlsx 는 시험(F)만 읽음.
|
||||
*
|
||||
* ── 크기 예산 (vite build · 압축(gzip) 뒤 · M02 면적산출 · 재료산출을 열 때만 받는 조각) ──
|
||||
* 엔진 A+B 48 KB · 화면 C+D+E 72 KB · css 8 KB · 합 128 KB 안쪽. 재는 곳 = F(빌드 조각 크기 시험).
|
||||
*
|
||||
* ── 파일 · 주인 · 예산 줄 수 ─────────────────────────────────────────────────
|
||||
* 0 spreadsheet_types.ts 이 계약(타입) ~450
|
||||
* 0 spreadsheet_view_types.ts 화면 부품 사이 계약(DOM 타입) ~150
|
||||
* 0 spreadsheet_sample.json 저장 견본 = `basis/<열 id>.json` 한 벌
|
||||
* A spreadsheet_address.ts A1 ↔ 행열 · 범위 · 열 글자 · 시트 이름 따옴표 ~150
|
||||
* A spreadsheet_parser.ts 낱말 나누기 · 수식 나무 · 나무 → 글 ~500
|
||||
* A spreadsheet_eval.ts 나무 풀기 · 오류 전파 · 형 바꾸기 ~350
|
||||
* A spreadsheet_graph.ts 계산 엔진 — 의존 그래프 · 딸린 칸만 · 순환 ~350
|
||||
* A spreadsheet_refshift.ts 넣기 · 지우기 · 옮기기 · 복사 때 참조 옮김 ~300
|
||||
* A spreadsheet_commands.ts 명령 적용 → 되돌림 짝 · 빈 통합문서 ~550
|
||||
* A spreadsheet_history.ts 되돌리기 · 다시 ~100
|
||||
* A spreadsheet_commands_sheet.ts 시트 명령(더하기 · 지우기 · 이름 · 옮기기 · 통째) ~100
|
||||
* A spreadsheet_sortfilter.ts 정렬 · 자동 필터 엔진(차례 · 걸러짐 셈 · 적용) ~300
|
||||
* A spreadsheet_sort_dialog.ts 정렬 대화 상자 · 빠른 정렬(화면) ~250
|
||||
* A spreadsheet_filter.ts 자동 필터 단추 · 값 목록 창(화면) ~300
|
||||
* A spreadsheet_sortfilter.css ~150
|
||||
* A spreadsheet_trace.ts 선행 · 종속 추적 화살표(한 단계씩 · SVG 겹판) ~270
|
||||
* A spreadsheet_eval_steps.ts 수식 계산 단계 보기 창 ~230
|
||||
* A spreadsheet_errcheck.ts 오류 검사 까닭 · ⚠ 단추 · 풍선(세모는 C 가 그림) ~200
|
||||
* A spreadsheet_analysis.css 위 셋 화면 스타일 ~200
|
||||
* A spreadsheet_recalc.ts 통합문서 한 벌 풀이(서버 Node · 시험 진입) ~80
|
||||
* B spreadsheet_functions.ts 함수 표(이름 → 뜻) · 인자 수 검사 ~120
|
||||
* B spreadsheet_functions_math.ts 수학 · 삼각 · 끝수 ~400
|
||||
* B spreadsheet_functions_misc.ts 논리 · 글 · 찾기 · 세기 ~500
|
||||
* B spreadsheet_numfmt.ts 숫자 형식 코드 → 보이는 글 ~400
|
||||
* B spreadsheet_fill.ts 채우기 핸들 연속값 ~200
|
||||
* C spreadsheet_grid.ts 격자 — 보이는 칸만 · 틀고정 4 판 · 병합 · 고름 그림 ~550
|
||||
* C spreadsheet_headers.ts 열 글자 · 행 번호 · 폭 · 높이 끌기 · 숨김 표시 ~350
|
||||
* C spreadsheet_cellstyle.ts 서식 → CSS(hair · 균등 분할 · 겹친 테두리) ~250
|
||||
* C spreadsheet_grid.css ~200
|
||||
* D spreadsheet.ts 진입 createSpreadsheet · 부품 잇기 · 문맥 ~350
|
||||
* D spreadsheet_selection.ts 고름 모델 · Shift/Ctrl 방향 · 병합으로 늘림 ~300
|
||||
* D spreadsheet_editor.ts 칸 편집기 · 수식 입력줄 · 주소 상자 · 한글 조합 · 참조 가리키기 ~600
|
||||
* D spreadsheet_keys.ts 엑셀 단축키 지도 ~300
|
||||
* D spreadsheet_mouse.ts 끌기 고르기 · 채우기 끌기 · 두 번 누르기 ~300
|
||||
* D spreadsheet.css ~150
|
||||
* D spreadsheet_links.ts 위 알약 막대 — 입력값 · 자재를 칸에 연결(늦게 불러옴) ~350
|
||||
* E spreadsheet_toolbar.ts 서식 도구 모음 ~550
|
||||
* E spreadsheet_tabs.ts 시트 탭 ~300
|
||||
* E spreadsheet_menu.ts 우클릭 메뉴 ~300
|
||||
* E spreadsheet_clipboard.ts 복사 · 잘라내기 · 붙여넣기(TSV · HTML) ~550
|
||||
* E spreadsheet_toolbar.css ~150
|
||||
* 3 spreadsheet_condfmt.ts 조건부 서식 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~260
|
||||
* 3 spreadsheet_validation.ts 데이터 유효성 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~230
|
||||
* 3 spreadsheet_names.ts 이름 정의 · 이름 관리 창(주인 sub_laptop_4 · 안 이어짐) ~220
|
||||
* 3 spreadsheet_statusbar.ts 아래 상태 표시줄 — 합계 · 평균 · 개수 등(주인 sub_laptop_4 · 안 이어짐) ~150
|
||||
* 3 spreadsheet_autosum.ts 자동 합계(Alt+= · Σ, 주인 sub_laptop_4 · 안 이어짐) ~90
|
||||
* 3 spreadsheet_sheet_extra.ts 시트 숨김 · 보이기 · 탭 색(주인 sub_laptop_4 · 계약 더함 · 안 이어짐) ~150
|
||||
* 3 spreadsheet_stage3.css 위 여섯 화면 스타일 한 벌 ~140
|
||||
* F common_util/common_util_spreadsheet_node.ts Node 진입(재계산 · 폴더 밖) ~40
|
||||
* F common_util/common_util_spreadsheet.py 파이썬 껍데기(common_util_node_bundle) ~60
|
||||
* F package.json `build:spreadsheet` · M02 `basis` 종류(Store · Layers) · 옛 산출근거 옮김 ·
|
||||
* M02_MasterTemplete_Structure.ts 끼우기 · resources/tester/test_spreadsheet_*.py(실무 2,699 칸 대조 · 크기)
|
||||
*
|
||||
* ── 좌표 ────────────────────────────────────────────────────────────────────
|
||||
* 안쪽은 0 부터(r = 행 · c = 열) · 저장 · 식 글은 엑셀 A1(1 부터). 행 최대 1,048,576 · 열 최대 16,384(XFD).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Frac } from "@ui/sheet/ui_template_sheet_frac";
|
||||
|
||||
export type { Frac };
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 1. 저장 문서 — 구조물집계표 열마다 한 파일 `resources/master_template/basis/<열 id>.json`
|
||||
// (층 규칙은 M02 와 같음 · 프로젝트 `templates/basis/` · `_initial/` 사본). 견본 = spreadsheet_sample.json.
|
||||
// 키는 한글 · 열거 값(정렬 · 선 · 오류)은 엑셀 이름 그대로.
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const MAX_ROWS = 1_048_576;
|
||||
export const MAX_COLS = 16_384;
|
||||
|
||||
/** 칸에 넣은 값 — 수는 JSON 수(엑셀처럼 유효 15 자리) · 글 · 참거짓. 식 칸에는 없음. */
|
||||
export type CellInput = number | string | boolean;
|
||||
|
||||
export interface Cell {
|
||||
/** 입력 값 — `식` 이 있으면 없음. `'` 로 시작해 친 글은 `'` 를 떼고 글로 둠. */
|
||||
값?: CellInput;
|
||||
/** A1 식 — 앞 `=` 뺌(`ROUNDDOWN((G31+J31/2)*P31,2)`). 행열을 넣고 지우면 글을 고쳐 적음. */
|
||||
식?: string;
|
||||
/** `서식` 표 번호 — 없으면 행 · 열 서식 · 시트 `기본.서식` · 그다음 0(기본). */
|
||||
서식?: number;
|
||||
}
|
||||
|
||||
export type HAlign = "general" | "left" | "center" | "right" | "distributed" | "centerContinuous";
|
||||
export type VAlign = "top" | "center" | "bottom";
|
||||
export type BorderLine = "hair" | "thin" | "medium" | "thick" | "double" | "dotted" | "dashed";
|
||||
|
||||
export interface BorderSide {
|
||||
선: BorderLine;
|
||||
/** `#RRGGBB` · 없으면 검정. */
|
||||
색?: string;
|
||||
}
|
||||
|
||||
/** 칸 서식 한 벌 — 없는 칸은 기본값. 같은 서식은 표에 한 번만(칸은 번호). */
|
||||
export interface CellStyle {
|
||||
글꼴?: string;
|
||||
/** pt */
|
||||
크기?: number;
|
||||
굵게?: boolean;
|
||||
기울임?: boolean;
|
||||
밑줄?: boolean;
|
||||
취소선?: boolean;
|
||||
/** `#RRGGBB` */
|
||||
글자색?: string;
|
||||
/** `#RRGGBB` — 없으면 채움 없음. */
|
||||
채움?: string;
|
||||
가로?: HAlign;
|
||||
세로?: VAlign;
|
||||
줄바꿈?: boolean;
|
||||
테두리?: { 위?: BorderSide; 아래?: BorderSide; 왼?: BorderSide; 오른?: BorderSide };
|
||||
/** 엑셀 숫자 형식 코드 그대로(`0.00` · `#,##0` · `_-* #,##0.00_-;…` · `@`) · 없으면 일반. */
|
||||
형식?: string;
|
||||
}
|
||||
|
||||
/** 묶음(윤곽) — 층 1 부터(중첩 단계) · 접힘 이면 격자가 숨김처럼 그림(숨김 · 걸러짐과는 화면 표시가 다름 — 윤곽 +/- 단추). */
|
||||
export interface Outline {
|
||||
층: number;
|
||||
접힘?: boolean;
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
/** 엑셀 글자 폭 단위(기본 글꼴 숫자 폭 기준) — 화면이 px 로 바꿈. */
|
||||
폭?: number;
|
||||
숨김?: boolean;
|
||||
서식?: number;
|
||||
묶음?: Outline;
|
||||
}
|
||||
|
||||
export interface RowInfo {
|
||||
/** pt */
|
||||
높이?: number;
|
||||
숨김?: boolean;
|
||||
서식?: number;
|
||||
/** 자동 필터가 숨긴 행 — 사용자 `숨김` 과 따로(필터 풀면 보임) · 격자는 숨김처럼 그림. */
|
||||
걸러짐?: boolean;
|
||||
묶음?: Outline;
|
||||
}
|
||||
|
||||
/** 자동 필터 — `범위` 첫 행 = 머리(단추) · `조건` = 열 글자 → 보일 값(보이는 글 · 빈 칸은 ""). 조건 없는 열은 다 보임. */
|
||||
export interface AutoFilter {
|
||||
범위: string;
|
||||
조건?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface Sheet {
|
||||
/** 안 바뀌는 id(`s1` …) — 이름을 바꿔도 그대로. 식 글은 이름으로 가리킴. */
|
||||
id: string;
|
||||
이름: string;
|
||||
/** A1 열쇠 · 빈 칸은 안 적음. */
|
||||
칸: Record<string, Cell>;
|
||||
/** `C3:AD3` — 겹치지 않음 · 왼위 칸만 값 · 서식은 칸마다(테두리 때문). */
|
||||
병합?: string[];
|
||||
/** 열 글자 열쇠(`A` · `AB`). */
|
||||
열?: Record<string, ColumnInfo>;
|
||||
/** 행 번호 열쇠(1 부터 · `"31"`). */
|
||||
행?: Record<string, RowInfo>;
|
||||
/** 위에서 고정할 행 수 · 왼쪽에서 고정할 열 수. */
|
||||
틀고정?: { 행?: number; 열?: number };
|
||||
보기?: { 눈금선?: boolean };
|
||||
/** 시트 통째(모두 고르기) — 서식 번호 · 열 폭(글자 단위) · 행 높이(pt). 칸 · 행 · 열 값이 없을 때 씀 ·
|
||||
* 없으면 통합문서 `기본`. */
|
||||
기본?: { 서식?: number; 열폭?: number; 행높이?: number };
|
||||
/** 칸 메모 — A1 열쇠 → 메모 글. 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 메모는 지움). */
|
||||
comments?: Record<string, string>;
|
||||
/** 자동 필터(시트에 하나) */
|
||||
필터?: AutoFilter;
|
||||
/** 조건부 서식 — 배열 앞쪽이 우선순위 위(`spreadsheet_condfmt.ts`). */
|
||||
조건부서식?: CondFmtRule[];
|
||||
/** 데이터 유효성(`spreadsheet_validation.ts`). */
|
||||
유효성?: DataValidationRule[];
|
||||
/** 이 시트에서 정의한 이름 — 범위는 이 시트 안(`spreadsheet_names.ts`). 식 나무의 `{type:"name"}` 는
|
||||
* A 의 eval 이 `resolveName` 으로 풀어야 함(지금 안 이어짐 — 8-3 절 참고). */
|
||||
이름정의?: NamedRange[];
|
||||
/** 시트 숨김(`spreadsheet_sheet_extra.ts`) — 탭은 `spreadsheet_tabs.ts`(E)가 이 값으로 걸러야 함(안 이어짐). */
|
||||
숨김?: boolean;
|
||||
/** 시트 탭 색 `#RRGGBB`(`spreadsheet_sheet_extra.ts`) — 탭 칠은 E 몫(안 이어짐). */
|
||||
탭색?: string;
|
||||
/** 알약 연결(`spreadsheet_links.ts` · sub7 계약 더함) — 위 알약(입력값 · 자재)이 가리키는 이 시트 칸.
|
||||
* 행열 넣기 · 지우기에 따라감(지운 칸이면 빠짐) · 바꿈은 `시트통째` 로(되돌리기 짝). */
|
||||
연결?: CellLink[];
|
||||
}
|
||||
|
||||
/** 자재DB 한 줄 — 재료산출 알약. */
|
||||
export interface LinkMaterial {
|
||||
코드: string;
|
||||
이름: string;
|
||||
규격: string;
|
||||
단위: string;
|
||||
}
|
||||
|
||||
/** 알약 ↔ 칸 한 줄 — `키` = 입력값 키 또는 자재 코드 · 키 하나에 칸 하나 · 칸 하나에 키 하나. */
|
||||
export interface CellLink {
|
||||
키: string;
|
||||
이름?: string;
|
||||
단위?: string;
|
||||
/** A1 */
|
||||
칸: string;
|
||||
자재?: LinkMaterial;
|
||||
}
|
||||
|
||||
/** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */
|
||||
export interface WorkbookDefaults {
|
||||
글꼴?: string;
|
||||
크기?: number;
|
||||
열폭?: number;
|
||||
행높이?: number;
|
||||
}
|
||||
|
||||
export interface Workbook {
|
||||
종류: "통합문서";
|
||||
판: 1;
|
||||
/** 구조물집계표 열 id(파일 이름과 같음). */
|
||||
열: string;
|
||||
기본?: WorkbookDefaults;
|
||||
/** 서식 표 — [0] = 기본(빈 `{}`). */
|
||||
서식: CellStyle[];
|
||||
시트: Sheet[];
|
||||
/** 열 때 보일 시트 id. */
|
||||
활성?: string;
|
||||
/** 서버만 씀 — [저장] 때 Node 풀이 결과(시트 id → A1 → 값). 브라우저 값은 안 적음 · 화면은 안 읽음. */
|
||||
계산값?: CalcValues;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 2. 주소
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface CellAddress {
|
||||
r: number;
|
||||
c: number;
|
||||
}
|
||||
|
||||
/** 닫힌 범위 — r0 ≤ r1 · c0 ≤ c1. 행 전체 = c0 0 · c1 MAX_COLS-1(열 전체도 같은 꼴). */
|
||||
export interface CellRange {
|
||||
r0: number;
|
||||
c0: number;
|
||||
r1: number;
|
||||
c1: number;
|
||||
}
|
||||
|
||||
/** 시트까지 붙은 칸 — 엔진 · 명령이 씀. */
|
||||
export interface SheetCellAddress extends CellAddress {
|
||||
시트: string;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 3. 수식 — 낱말 · 나무 (A 가 만들고 B · D · E 가 읽음)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type ErrorCode =
|
||||
"#NULL!" | "#DIV/0!" | "#VALUE!" | "#REF!" | "#NAME?" | "#NUM!" | "#N/A" | "#CYCLE!"; // 순환 참조 — 엑셀엔 없음 · 칸에 보임(9_엑셀검토 3-3)
|
||||
|
||||
export type TokenKind =
|
||||
| "number"
|
||||
| "string"
|
||||
| "bool"
|
||||
| "error"
|
||||
| "ref" // 칸 · 범위 · 행 · 열 참조(시트 붙은 것 포함) — 편집기가 색 테두리로 씀
|
||||
| "func" // 함수 이름(뒤 `(` 앞까지)
|
||||
| "op"
|
||||
| "paren"
|
||||
| "comma"
|
||||
| "space"
|
||||
| "unknown";
|
||||
|
||||
/** 낱말 — `start` · `end` 는 식 글(앞 `=` 뺀) 안 글자 자리. 편집기 참조 가리키기 · 색칠용. */
|
||||
export interface Token {
|
||||
kind: TokenKind;
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** 나무 안 칸 참조 — 시트 없으면 식이 든 시트. `$` 는 abs. */
|
||||
export interface RefCell {
|
||||
r: number;
|
||||
c: number;
|
||||
absR: boolean;
|
||||
absC: boolean;
|
||||
}
|
||||
|
||||
export type FormulaNode =
|
||||
| { type: "number"; value: Frac; text: string }
|
||||
| { type: "string"; value: string }
|
||||
| { type: "bool"; value: boolean }
|
||||
| { type: "error"; code: ErrorCode }
|
||||
/** 칸 하나 */
|
||||
| { type: "cell"; sheet: string | null; ref: RefCell }
|
||||
/** 범위(`A1:B5` · `A:A` · `3:3`) — 행 · 열 전체는 끝을 MAX 로. 한쪽이라도 `#REF!` 면 error 나무 */
|
||||
| { type: "range"; sheet: string | null; from: RefCell; to: RefCell }
|
||||
/** 글자 그대로 남은 이름(정의 없음) → 풀면 `#NAME?` */
|
||||
| { type: "name"; name: string }
|
||||
| { type: "unary"; op: "-" | "+" | "%"; arg: FormulaNode }
|
||||
| {
|
||||
type: "binary";
|
||||
op: "+" | "-" | "*" | "/" | "^" | "&" | "=" | "<>" | "<" | "<=" | ">" | ">=";
|
||||
left: FormulaNode;
|
||||
right: FormulaNode;
|
||||
}
|
||||
| { type: "call"; name: string; args: FormulaNode[] };
|
||||
|
||||
/** 파싱 실패 — 칸에는 식 글을 그대로 두고 값은 `#NAME?`(엑셀은 입력을 막지만 우리는 까닭과 함께 받음). */
|
||||
export interface ParseFailure {
|
||||
ok: false;
|
||||
message: string;
|
||||
/** 식 글 안 자리 */
|
||||
at: number;
|
||||
}
|
||||
|
||||
export type ParseResult = { ok: true; node: FormulaNode } | ParseFailure;
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 4. 값 — 풀이 중(분수) · 저장(십진 글)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface ErrorValue {
|
||||
error: ErrorCode;
|
||||
/** 풍선 까닭(한글) */
|
||||
why?: string;
|
||||
}
|
||||
|
||||
/** 칸 하나 값 — 수는 BigInt 분수(무리수 함수만 double → 유효 15 자리 십진 → 분수).
|
||||
* null = 빈 칸 — 수에서 0 · 글에서 "" · 범위 함수는 건너뜀(엑셀 규칙). */
|
||||
export type Scalar = Frac | string | boolean | ErrorValue | null;
|
||||
|
||||
/** 범위 값 — 함수가 칸마다 읽음(빈 칸 null). */
|
||||
export interface RangeValue {
|
||||
kind: "range";
|
||||
rows: number;
|
||||
cols: number;
|
||||
at(r: number, c: number): Scalar;
|
||||
}
|
||||
|
||||
export type EvalResult = Scalar | RangeValue;
|
||||
|
||||
/** 저장 · 서버 결과 한 칸 — 수는 십진 글(끝수 뒤 · 30 자리까지). */
|
||||
export type CalcValue =
|
||||
{ 수: string } | { 글: string } | { 참: boolean } | { 오류: ErrorCode; 까닭?: string };
|
||||
|
||||
/** 시트 id → A1 → 값 (식 칸만). */
|
||||
export type CalcValues = Record<string, Record<string, CalcValue>>;
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 5. 풀이 문맥 · 함수 (A 의 eval ↔ B 의 함수 표)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface EvalContext {
|
||||
/** 식이 든 칸 */
|
||||
sheet: string;
|
||||
r: number;
|
||||
c: number;
|
||||
/** 칸 값 — 식 칸이면 엔진이 먼저 풀어 줌 · 순환이면 `#CYCLE!` */
|
||||
cell(sheet: string, r: number, c: number): Scalar;
|
||||
range(sheet: string, range: CellRange): RangeValue;
|
||||
/** 시트 이름 → id (없으면 null → `#REF!`) */
|
||||
sheetId(name: string): string | null;
|
||||
}
|
||||
|
||||
/** 인자는 늦게 풂(IF · IFERROR · AND 가 고른 것만 풀게). 범위 인자는 RangeValue 로 옴. */
|
||||
export type LazyArg = () => EvalResult;
|
||||
|
||||
export interface SheetFunction {
|
||||
/** 인자 수 — 최대 없으면 끝없음 */
|
||||
min: number;
|
||||
max?: number;
|
||||
call(args: LazyArg[], ctx: EvalContext): EvalResult;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 6. 명령 — 문서를 바꾸는 길은 이것뿐(되돌리기 · 서버 재계산 · 캐시가 한 길로 감)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type Command =
|
||||
/** 칸 여럿 한 번에 — null = 칸 지움(값 · 식 · 서식 모두). 붙여넣기 · 채우기 · 지우기도 이것. */
|
||||
| { 종류: "칸"; 시트: string; 칸: Record<string, Cell | null> }
|
||||
/** 내용만 지움(Delete) — 서식은 남김 */
|
||||
| { 종류: "내용지움"; 시트: string; 범위: CellRange[] }
|
||||
/** 서식 바꿈 — 칸마다 지금 서식에 `바꿀` 을 덮어 새 서식 번호(표에 없으면 더함). null 항목 = 그 속성 지움 */
|
||||
| {
|
||||
종류: "서식";
|
||||
시트: string;
|
||||
범위: CellRange[];
|
||||
바꿀: { [K in keyof CellStyle]?: CellStyle[K] | null };
|
||||
}
|
||||
| { 종류: "서식지움"; 시트: string; 범위: CellRange[] }
|
||||
/** 행 넣기 · 지우기 — 참조 옮김(지운 칸 참조는 #REF!) · 병합 늘고 줆 */
|
||||
| { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기"; 시트: string; at: number; 수: number }
|
||||
/** 잘라 붙이기 — 옮긴 칸을 가리키던 식도 따라감 */
|
||||
| { 종류: "옮기기"; 시트: string; 범위: CellRange; 대상시트: string; r: number; c: number }
|
||||
| { 종류: "병합"; 시트: string; 범위: CellRange }
|
||||
| { 종류: "병합풀기"; 시트: string; 범위: CellRange }
|
||||
/** 폭(글자 단위) · 높이(pt) — null = 기본으로 */
|
||||
| { 종류: "열폭"; 시트: string; 열: number[]; 폭: number | null }
|
||||
| { 종류: "행높이"; 시트: string; 행: number[]; 높이: number | null }
|
||||
| { 종류: "숨김"; 시트: string; 축: "행" | "열"; 번호: number[]; 숨김: boolean }
|
||||
| { 종류: "틀고정"; 시트: string; 행: number; 열: number }
|
||||
| { 종류: "눈금선"; 시트: string; 보임: boolean }
|
||||
| { 종류: "시트더하기"; 시트: Sheet; 자리: number }
|
||||
| { 종류: "시트지우기"; 시트: string }
|
||||
/** 이름 바꾸면 다른 시트 식 글의 이름도 바꿈 */
|
||||
| { 종류: "시트이름"; 시트: string; 이름: string }
|
||||
| { 종류: "시트옮기기"; 시트: string; 자리: number }
|
||||
/** 되돌림 짝 전용 — 시트 통째 바꿔 넣기(행열 지우기 되돌림 등) */
|
||||
| { 종류: "시트통째"; 시트: Sheet; 서식?: CellStyle[] }
|
||||
/** 정렬 — `차례[i]` = 범위 r0+i 자리에 올 원래 행(범위 열만 옮김 · 식은 복사처럼 상대 참조만 옮김).
|
||||
* 차례는 `sortCommand`(값 비교는 엔진)가 만듦 — 다시 하기가 같은 결과가 되게 명령에 박음. */
|
||||
| { 종류: "정렬"; 시트: string; 범위: CellRange; 차례: number[] }
|
||||
/** 자동 필터 켜기 · 조건 바꾸기 · 끄기(null) — `걸러짐` = 숨길 행(`filterCommand` 가 값으로 셈) */
|
||||
| { 종류: "필터"; 시트: string; 필터: AutoFilter | null; 걸러짐: number[] }
|
||||
/** 행 · 열 묶기(윤곽) · 접기 · 펴기 · 풀기(`묶음` null) — 격자 윤곽 +/- 단추가 씀 */
|
||||
| { 종류: "묶음설정"; 시트: string; 축: "행" | "열"; 번호: number[]; 묶음: Outline | null }
|
||||
/** 여러 명령 = 되돌리기 한 번 */
|
||||
| { 종류: "묶음"; 명령: Command[] };
|
||||
|
||||
/** 명령을 적용한 뒤 계산 엔진에 알릴 것 — 칸만 바뀌었으면 칸 목록 · 모양이 바뀌었으면 rebuild. */
|
||||
export interface CommandEffect {
|
||||
/** 되돌림 짝 */
|
||||
undo: Command;
|
||||
/** 값 · 식이 바뀐 칸 — 딸린 칸만 다시 풂 */
|
||||
cells: SheetCellAddress[];
|
||||
/** 행열 · 옮기기 · 시트 명령 — 엔진을 통째로 다시 */
|
||||
rebuild: boolean;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 7. 계산 엔진 — 통합문서를 참조로 쥠(명령이 문서를 바꾸면 알려 줌)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface CalcEngine {
|
||||
/** 바뀐 칸 → 딸린 칸만 다시 풂 · 다시 푼 칸(바뀐 칸 포함) 목록 */
|
||||
update(changed: SheetCellAddress[]): SheetCellAddress[];
|
||||
/** 문서를 통째로 다시 읽고 전부 풂(행열 · 시트 명령 · setDoc 뒤) */
|
||||
rebuild(book: Workbook): void;
|
||||
/** 칸 값 — 식 칸이면 푼 값 · 아니면 입력 값(수는 분수) */
|
||||
value(sheet: string, r: number, c: number): Scalar;
|
||||
/** 식 칸이 가리키는 칸 · 범위(편집기 색 테두리 · 오류 추적) */
|
||||
precedents(sheet: string, r: number, c: number): { 시트: string; 범위: CellRange }[];
|
||||
/** 식 칸 전부 → 저장 모양(서버 `계산값` · 시험 대조) */
|
||||
snapshot(): CalcValues;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 8. 되돌리기 · 채우기 · 숫자 형식 · 클립보드 (A · B · E 공개 모양)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface History {
|
||||
/** 적용이 끝난 명령과 그 되돌림 짝을 쌓음 · 다시 스택은 비움 */
|
||||
push(done: Command, undo: Command): void;
|
||||
undo(): Command | null;
|
||||
redo(): Command | null;
|
||||
canUndo(): boolean;
|
||||
canRedo(): boolean;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export type FillDirection = "down" | "up" | "right" | "left";
|
||||
|
||||
/** 숫자 형식 결과 — `색` 은 `[Red]` 같은 형식 색 · `채움글` 은 `*x` 반복 글(격자가 폭만큼 늘림). */
|
||||
export interface FormattedText {
|
||||
글: string;
|
||||
색?: string;
|
||||
채움글?: string;
|
||||
/** 형식이 정한 정렬(일반 정렬일 때 수 = 오른쪽 · 글 = 왼쪽) */
|
||||
정렬: "left" | "right" | "center";
|
||||
}
|
||||
|
||||
/** 붙여넣기로 읽은 칸 뭉치 — 왼위 기준 상대 자리. */
|
||||
export interface ClipCell {
|
||||
값?: CellInput;
|
||||
/** 앞 `=` 뺀 A1 식 — `원점` 기준으로 적힌 글 */
|
||||
식?: string;
|
||||
서식?: CellStyle;
|
||||
}
|
||||
|
||||
/** 클립보드 형 — 브라우저가 읽는 것은 `text/plain`(TSV) · `text/html` 둘.
|
||||
* 우리 → 밖: 둘 다 씀(보이는 값 + 인라인 서식 표) · 우리 → 우리: HTML 속 data 속성에 뭉치 JSON.
|
||||
* 밖 → 우리 수식: ① 구글 `data-sheets-formula`(R1C1 상대) ② 엑셀 「수식 표시」(Ctrl+`) 로 복사한 글 칸이
|
||||
* `=` 뒤에 글이 있고 식으로 읽히면 식(「=」 한 글자 칸은 글) — 실제 되는지는 E 가 이 PC 엑셀로 시험. */
|
||||
export interface ClipBlock {
|
||||
rows: number;
|
||||
cols: number;
|
||||
/** `r,c` 열쇠(0 부터 · 뭉치 왼위 기준) */
|
||||
칸: Record<string, ClipCell>;
|
||||
/** 뭉치 안 병합(0 부터) */
|
||||
병합: CellRange[];
|
||||
/** 식 글이 적힌 기준 왼위 — 붙일 때 (붙일 자리 − 원점) 만큼 상대 참조를 옮김.
|
||||
* null = 옮기지 않음(엑셀 수식 표시 글 — 적힌 주소 그대로 · 구글 R1C1 은 붙일 자리로 풀어 null). */
|
||||
원점: CellAddress | null;
|
||||
출처: "aislo" | "google" | "excel" | "text";
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 9. 3단계 — 조건부 서식 · 데이터 유효성 · 이름 정의 (Sheet 확장 · sub_laptop_4 계약 더함 · 안 이어짐)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type CondFmtCompareOp =
|
||||
"보다큼" | "보다작음" | "크거나같음" | "작거나같음" | "같음" | "다름";
|
||||
|
||||
export type CondFmtCondition =
|
||||
| { 종류: "비교"; 연산: CondFmtCompareOp; 값: number }
|
||||
| { 종류: "사이"; 아래: number; 위: number }
|
||||
| { 종류: "글포함"; 글: string }
|
||||
/** 임의 수식 글 — A 의 파서 · eval 이 있어야 풀림(`spreadsheet_condfmt.ts` 머리 「잇는 법」). */
|
||||
| { 종류: "수식"; 식: string };
|
||||
|
||||
/** 조건부 서식이 먹이는 스타일 — `서식` 표 번호를 안 씀(규칙마다 직접 가짐). */
|
||||
export interface CondFmtStyle {
|
||||
채움?: string;
|
||||
글자색?: string;
|
||||
굵게?: boolean;
|
||||
}
|
||||
|
||||
export interface CondFmtRule {
|
||||
id: string;
|
||||
범위: CellRange[];
|
||||
조건: CondFmtCondition;
|
||||
스타일: CondFmtStyle;
|
||||
}
|
||||
|
||||
export type DataValidationKind =
|
||||
{ 종류: "목록"; 값들: string[] } | { 종류: "수범위"; 아래?: number; 위?: number };
|
||||
|
||||
export interface DataValidationRule {
|
||||
id: string;
|
||||
범위: CellRange[];
|
||||
규칙: DataValidationKind;
|
||||
/** 어긴 값을 넣으려 하면 막을지 · 경고만 하고 넣을지(둘 다 D 편집기 잇기 몫 · 지금 안 이어짐). */
|
||||
어기면: "막음" | "경고";
|
||||
메시지?: string;
|
||||
}
|
||||
|
||||
/** 이름 정의 — 이 이름을 정의한 시트 안 범위만 가리킴(다른 시트를 가리키는 이름은 3단계 밖). */
|
||||
export interface NamedRange {
|
||||
이름: string;
|
||||
범위: CellRange;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_validation.ts (3단계 · 주인 sub_laptop_4)
|
||||
* 데이터 유효성 — 목록(드롭다운) · 수 범위. 어기면 막거나(칸에 못 들어감) 경고만(그래도 들어감).
|
||||
* 저장 자리 `Sheet.유효성`(0 계약에 방금 더함). 규칙은 범위 하나에 하나만 먹임(엑셀과 같이 — 겹치면
|
||||
* 배열 앞쪽이 이김).
|
||||
*
|
||||
* 잇는 법(D · sub7 — 지금은 하나도 안 이어짐):
|
||||
* 1) 편집기(`spreadsheet_editor.ts`)가 칸 편집을 커밋하기 전에 `checkValue(rule, candidate)` 를
|
||||
* 불러 `어기면 === "막음"` 이면 `dispatch` 를 안 부르고(입력 취소) 메시지를 토스트로 · `"경고"` 면
|
||||
* 토스트만 띄우고 그대로 진행.
|
||||
* 2) 칸 편집이 시작될 때(`editor.begin`) `findValidation(sheet, r, c)` 가 「목록」 규칙이면
|
||||
* `createValidationDropdown(ctx, r, c, rule)` 을 칸 옆에 띄우고 고르면 그 값을 칸에 넣음.
|
||||
* 3) 행 · 열 넣기 · 지우기 뒤 `remapValidationRanges` 를 그 시트 `유효성` 에 적용(조건부 서식과 같은
|
||||
* 자리 · A 몫 · 지금 안 이어짐).
|
||||
* 4) 도구 모음 · 메뉴에 [데이터 유효성] 항목을 두고 `createValidationPanel(ctx)` 를 띄움.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { inRange, rangeToA1 } from "./spreadsheet_address";
|
||||
import type {
|
||||
CellInput,
|
||||
CellRange,
|
||||
DataValidationKind,
|
||||
DataValidationRule,
|
||||
Sheet,
|
||||
} from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
|
||||
|
||||
export type { DataValidationKind, DataValidationRule };
|
||||
|
||||
/** 칸이 든 범위를 덮는 첫 규칙(배열 앞쪽 우선) — 없으면 undefined. */
|
||||
export function findValidation(sheet: Sheet, r: number, c: number): DataValidationRule | undefined {
|
||||
return (sheet.유효성 ?? []).find((rule) => rule.범위.some((range) => inRange(range, r, c)));
|
||||
}
|
||||
|
||||
export interface ValidationCheck {
|
||||
ok: boolean;
|
||||
어기면?: "막음" | "경고";
|
||||
메시지: string;
|
||||
}
|
||||
|
||||
function defaultMessage(kind: DataValidationKind): string {
|
||||
if (kind.종류 === "목록") return `목록 안 값만 됨(${kind.값들.join(", ")})`;
|
||||
const 아래 = kind.아래 ?? -Infinity;
|
||||
const 위 = kind.위 ?? Infinity;
|
||||
return `${아래 === -Infinity ? "" : 아래}${위 === Infinity ? " 이상" : ` ~ ${위}`} 수만 됨`;
|
||||
}
|
||||
|
||||
function matchesKind(kind: DataValidationKind, candidate: CellInput): boolean {
|
||||
if (kind.종류 === "목록")
|
||||
return typeof candidate !== "boolean" && kind.값들.includes(String(candidate));
|
||||
if (typeof candidate !== "number") return false;
|
||||
if (kind.아래 !== undefined && candidate < kind.아래) return false;
|
||||
if (kind.위 !== undefined && candidate > kind.위) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 후보 값이 규칙을 지키는지 — 어기면 `어기면`(막음 · 경고)과 안내 글을 돌려줌. */
|
||||
export function checkValue(rule: DataValidationRule, candidate: CellInput): ValidationCheck {
|
||||
if (matchesKind(rule.규칙, candidate)) return { ok: true, 메시지: "" };
|
||||
return { ok: false, 어기면: rule.어기면, 메시지: rule.메시지 || defaultMessage(rule.규칙) };
|
||||
}
|
||||
|
||||
/** 행 · 열 넣기 · 지우기 뒤 범위 옮김 — `shift` 가 null 이면 그 범위는 버림(규칙이 범위를 다 잃으면 규칙째 버림). */
|
||||
export function remapValidationRanges(
|
||||
rules: DataValidationRule[],
|
||||
shift: (range: CellRange) => CellRange | null,
|
||||
): DataValidationRule[] {
|
||||
const out: DataValidationRule[] = [];
|
||||
for (const rule of rules) {
|
||||
const moved = rule.범위.map(shift).filter((r): r is CellRange => r !== null);
|
||||
if (moved.length) out.push({ ...rule, 범위: moved });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function applySheet(ctx: SpreadsheetContext, next: Sheet): void {
|
||||
ctx.dispatch({ 종류: "시트통째", 시트: next });
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
const newId = (): string => `dv${Date.now().toString(36)}${seq++}`;
|
||||
|
||||
export function addValidationRule(
|
||||
ctx: SpreadsheetContext,
|
||||
rule: Omit<DataValidationRule, "id">,
|
||||
): void {
|
||||
const sheet = ctx.sheet();
|
||||
applySheet(ctx, { ...sheet, 유효성: [...(sheet.유효성 ?? []), { ...rule, id: newId() }] });
|
||||
}
|
||||
|
||||
export function removeValidationRule(ctx: SpreadsheetContext, id: string): void {
|
||||
const sheet = ctx.sheet();
|
||||
applySheet(ctx, { ...sheet, 유효성: (sheet.유효성 ?? []).filter((r) => r.id !== id) });
|
||||
}
|
||||
|
||||
/** 목록 규칙일 때 칸 옆에 띄울 작은 드롭다운(고르면 `onPick` 이 불림 — 실제 `dispatch` 는 부른 쪽 몫). */
|
||||
export function createValidationDropdown(
|
||||
ctx: SpreadsheetContext,
|
||||
r: number,
|
||||
c: number,
|
||||
rule: DataValidationRule,
|
||||
onPick: (value: string) => void,
|
||||
): HTMLElement | null {
|
||||
if (rule.규칙.종류 !== "목록") return null;
|
||||
const box = ctx.grid.cellBox(r, c);
|
||||
const dropdown = el("div", { className: "ss-validation__dropdown" });
|
||||
dropdown.style.left = `${box.x}px`;
|
||||
dropdown.style.top = `${box.y + box.h}px`;
|
||||
dropdown.style.minWidth = `${box.w}px`;
|
||||
dropdown.replaceChildren(
|
||||
...rule.규칙.값들.map((value) => {
|
||||
const item = el("div", { className: "ss-validation__dropdown-item", text: value });
|
||||
item.addEventListener("click", () => onPick(value));
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
return dropdown;
|
||||
}
|
||||
|
||||
const kindText = (k: DataValidationKind): string =>
|
||||
k.종류 === "목록" ? `목록: ${k.값들.join(", ")}` : `수범위: ${k.아래 ?? "−∞"} ~ ${k.위 ?? "+∞"}`;
|
||||
|
||||
/** 유효성 관리 창 — 지금 시트 규칙 목록 + 고른 범위에 규칙 더하기. */
|
||||
export function createValidationPanel(ctx: SpreadsheetContext): PartHandle {
|
||||
const root = el("div", { className: "ss-validation" });
|
||||
const list = el("ul", { className: "ss-validation__list" });
|
||||
|
||||
const kindSelect = createSelectField({
|
||||
label: "규칙",
|
||||
options: [
|
||||
{ value: "목록", text: "목록(드롭다운)" },
|
||||
{ value: "수범위", text: "수 범위" },
|
||||
],
|
||||
});
|
||||
const listValues = createInputField({ label: "목록 값(쉼표로 나눔)" });
|
||||
const min = createInputField({ label: "아래(수범위)" });
|
||||
const max = createInputField({ label: "위(수범위)" });
|
||||
const onBreak = createSelectField({
|
||||
label: "어기면",
|
||||
options: [
|
||||
{ value: "막음", text: "막음" },
|
||||
{ value: "경고", text: "경고" },
|
||||
],
|
||||
});
|
||||
const message = createInputField({ label: "안내 글(생략 가능)" });
|
||||
const addBtn = createButton({ label: "규칙 더하기" });
|
||||
|
||||
addBtn.addEventListener("click", () => {
|
||||
const 범위 = ctx.selection.범위;
|
||||
if (!범위.length) return;
|
||||
const 규칙 = build규칙();
|
||||
if (!규칙) return;
|
||||
addValidationRule(ctx, {
|
||||
범위: [...범위],
|
||||
규칙,
|
||||
어기면: onBreak.select.value as "막음" | "경고",
|
||||
메시지: message.input.value || undefined,
|
||||
});
|
||||
render();
|
||||
});
|
||||
|
||||
function build규칙(): DataValidationKind | null {
|
||||
if (kindSelect.select.value === "목록") {
|
||||
const 값들 = listValues.input.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return 값들.length ? { 종류: "목록", 값들 } : null;
|
||||
}
|
||||
const 아래 = min.input.value ? Number(min.input.value) : undefined;
|
||||
const 위 = max.input.value ? Number(max.input.value) : undefined;
|
||||
return 아래 === undefined && 위 === undefined ? null : { 종류: "수범위", 아래, 위 };
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const rules = ctx.sheet().유효성 ?? [];
|
||||
list.replaceChildren(
|
||||
...rules.map((rule) => {
|
||||
const li = el("li", { className: "ss-validation__row" });
|
||||
const label = el("span", {
|
||||
text: `${rule.범위.map(rangeToA1).join(", ")} — ${kindText(rule.규칙)}(${rule.어기면})`,
|
||||
});
|
||||
const del = createButton({ label: "지움", variant: "danger" });
|
||||
del.addEventListener("click", () => {
|
||||
removeValidationRule(ctx, rule.id);
|
||||
render();
|
||||
});
|
||||
li.append(label, del);
|
||||
return li;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const form = el("div", {
|
||||
className: "ss-validation__form",
|
||||
children: [
|
||||
kindSelect.root,
|
||||
listValues.root,
|
||||
min.root,
|
||||
max.root,
|
||||
onBreak.root,
|
||||
message.root,
|
||||
addBtn,
|
||||
],
|
||||
});
|
||||
root.append(el("h4", { text: "데이터 유효성 규칙" }), list, form);
|
||||
render();
|
||||
|
||||
return {
|
||||
root,
|
||||
refresh: render,
|
||||
destroy(): void {
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_view_menu.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 이동 대화상자(F5 · 주소 · 정의된 이름) · 틀 고정 메뉴(첫 행 · 첫 열 · 현재 칸 기준 · 풀기).
|
||||
*
|
||||
* 정의된 이름 목록은 `Sheet.이름정의`(3단계 · sub_laptop_4 계약 · 안 이어짐) 가 있으면
|
||||
* 그대로 보여 줌 — 없으면 빈 목록(주소 입력만 됨). 이름으로 다른 시트를 못 가리키는
|
||||
* 지금 계약대로 활성 시트 안에서만 찾음.
|
||||
*
|
||||
* 잇는 법(D · sub7) — F5 → `openGoToDialog(ctx)` 를 `spreadsheet_keys.ts` navKey 에 더함.
|
||||
* 틀 고정 단추(E 도구 모음)는 눌렀을 때 `openFreezePanesMenu(ctx, button)` 를 부르면 그
|
||||
* 단추 아래 작은 메뉴가 뜸.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el } from "@ui/ui_template_elements";
|
||||
import { parseRange, rangeToA1 } from "./spreadsheet_address";
|
||||
import type { CellRange } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const TEXT = {
|
||||
goToTitle: "이동",
|
||||
addressLabel: "참조",
|
||||
namedRanges: "정의된 이름",
|
||||
ok: "확인",
|
||||
cancel: "취소",
|
||||
notFound: "주소를 찾을 수 없음",
|
||||
freezeFirstRow: "첫 행 고정",
|
||||
freezeFirstColumn: "첫 열 고정",
|
||||
freezeAtActive: "틀 고정(현재 칸 기준)",
|
||||
unfreeze: "틀 고정 풀기",
|
||||
};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 이동(F5)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** 범위(또는 칸) 글을 읽어 그 범위로 고름을 옮기고 보이게 스크롤함. */
|
||||
export function goTo(ctx: SpreadsheetContext, target: CellRange): void {
|
||||
const top = { r: target.r0, c: target.c0 };
|
||||
ctx.select({ 시트: ctx.selection.시트, 범위: [target], 활성: top, 기준: top }, true);
|
||||
}
|
||||
|
||||
export function openGoToDialog(ctx: SpreadsheetContext): void {
|
||||
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 errorMsg = el("p", { text: "" });
|
||||
errorMsg.style.cssText = "color:var(--color-danger, red);font-size:0.8rem;min-height:1em;";
|
||||
|
||||
const addressField = createInputField({
|
||||
label: TEXT.addressLabel,
|
||||
value: rangeToA1(
|
||||
ctx.selection.범위[0] ?? {
|
||||
r0: ctx.selection.활성.r,
|
||||
c0: ctx.selection.활성.c,
|
||||
r1: ctx.selection.활성.r,
|
||||
c1: ctx.selection.활성.c,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
const goToAddress = (): void => {
|
||||
const target = parseRange(addressField.input.value);
|
||||
if (!target) {
|
||||
errorMsg.textContent = TEXT.notFound;
|
||||
return;
|
||||
}
|
||||
goTo(ctx, target);
|
||||
close();
|
||||
};
|
||||
addressField.input.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") goToAddress();
|
||||
});
|
||||
|
||||
const names = ctx.sheet().이름정의 ?? [];
|
||||
const namesList = el("div", {
|
||||
children: names.map((n) =>
|
||||
createButton({
|
||||
label: `${n.이름} (${rangeToA1(n.범위)})`,
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
goTo(ctx, n.범위);
|
||||
close();
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
namesList.style.cssText =
|
||||
"display:flex;flex-direction:column;gap:2px;max-height:160px;overflow:auto;";
|
||||
|
||||
panel.append(
|
||||
el("h3", { text: TEXT.goToTitle }),
|
||||
addressField.root,
|
||||
errorMsg,
|
||||
...(names.length > 0 ? [el("p", { text: TEXT.namedRanges }), namesList] : []),
|
||||
el("div", {
|
||||
className: "ui-confirm__actions",
|
||||
children: [
|
||||
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
|
||||
createButton({ label: TEXT.ok, variant: "filled", onClick: goToAddress }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
overlay.append(panel);
|
||||
document.body.append(overlay);
|
||||
addressField.input.focus();
|
||||
addressField.input.select();
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 틀 고정
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function freezeFirstRow(ctx: SpreadsheetContext): void {
|
||||
ctx.dispatch({
|
||||
종류: "틀고정",
|
||||
시트: ctx.selection.시트,
|
||||
행: 1,
|
||||
열: ctx.sheet().틀고정?.열 ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function freezeFirstColumn(ctx: SpreadsheetContext): void {
|
||||
ctx.dispatch({
|
||||
종류: "틀고정",
|
||||
시트: ctx.selection.시트,
|
||||
행: ctx.sheet().틀고정?.행 ?? 0,
|
||||
열: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/** 활성 칸의 왼위 자리를 기준으로 그 위 · 왼쪽을 고정(활성 칸이 A1 이면 사실상 풀기와 같음). */
|
||||
export function freezeAtActiveCell(ctx: SpreadsheetContext): void {
|
||||
const at = ctx.selection.활성;
|
||||
ctx.dispatch({ 종류: "틀고정", 시트: ctx.selection.시트, 행: at.r, 열: at.c });
|
||||
}
|
||||
|
||||
export function unfreezePanes(ctx: SpreadsheetContext): void {
|
||||
ctx.dispatch({ 종류: "틀고정", 시트: ctx.selection.시트, 행: 0, 열: 0 });
|
||||
}
|
||||
|
||||
/** 도구 모음 단추 아래(또는 화면 가운데, `anchor` 없으면) 뜨는 작은 메뉴 — 누르면 바로 적용하고 닫힘. */
|
||||
export function openFreezePanesMenu(ctx: SpreadsheetContext, anchor?: HTMLElement): void {
|
||||
const menu = el("div", { className: "ui-confirm__panel" });
|
||||
menu.style.cssText =
|
||||
"position:fixed;display:flex;flex-direction:column;gap:2px;min-width:180px;z-index:1000;padding:6px;";
|
||||
|
||||
const items: [string, () => void][] = [
|
||||
[TEXT.freezeFirstRow, () => freezeFirstRow(ctx)],
|
||||
[TEXT.freezeFirstColumn, () => freezeFirstColumn(ctx)],
|
||||
[TEXT.freezeAtActive, () => freezeAtActiveCell(ctx)],
|
||||
[TEXT.unfreeze, () => unfreezePanes(ctx)],
|
||||
];
|
||||
|
||||
const close = (): void => {
|
||||
menu.remove();
|
||||
document.removeEventListener("mousedown", onOutside, true);
|
||||
document.removeEventListener("keydown", onEscape);
|
||||
};
|
||||
const onOutside = (ev: MouseEvent): void => {
|
||||
if (!menu.contains(ev.target as Node)) close();
|
||||
};
|
||||
const onEscape = (ev: KeyboardEvent): void => {
|
||||
if (ev.key === "Escape") close();
|
||||
};
|
||||
|
||||
menu.append(
|
||||
...items.map(([label, action]) =>
|
||||
createButton({
|
||||
label,
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
action();
|
||||
close();
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
document.body.append(menu);
|
||||
if (anchor) {
|
||||
const box = anchor.getBoundingClientRect();
|
||||
menu.style.top = `${box.bottom + 4}px`;
|
||||
menu.style.left = `${box.left}px`;
|
||||
} else {
|
||||
menu.style.top = "40%";
|
||||
menu.style.left = "50%";
|
||||
menu.style.transform = "translate(-50%, -50%)";
|
||||
}
|
||||
setTimeout(() => {
|
||||
document.addEventListener("mousedown", onOutside, true);
|
||||
document.addEventListener("keydown", onEscape);
|
||||
}, 0);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_view_types.ts
|
||||
* 화면 부품(C 격자 · D 입력 · E 도구) 사이 계약 — 문맥 하나를 D 가 만들어 C · E 에 넘김.
|
||||
* 0 계약 주인만 고침. 파일 목록 · 주인 · 규칙은 `spreadsheet_types.ts` 머리.
|
||||
*
|
||||
* 흐름: 사용자 조작 → (D · E) `ctx.dispatch(명령)` → A 적용 · 되돌림 쌓기 · 엔진 update/rebuild →
|
||||
* C `grid.invalidate(칸)`(보이는 칸만 다시) → `onChange(문서)`. 서버 왕복 없음 · 자동저장 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
CalcEngine,
|
||||
CellAddress,
|
||||
CellRange,
|
||||
Command,
|
||||
History,
|
||||
Sheet,
|
||||
SheetCellAddress,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
|
||||
/** 고름 — 범위 여럿(Ctrl+누르기) · 활성 칸은 편집 · 수식 입력줄이 보이는 칸 · 기준은 Shift 늘림의 닻. */
|
||||
export interface Selection {
|
||||
시트: string;
|
||||
범위: CellRange[];
|
||||
활성: CellAddress;
|
||||
기준: CellAddress;
|
||||
}
|
||||
|
||||
/** 격자 안 한 점이 가리키는 것 — 머리 끝(`colEdge` · `rowEdge`)은 폭 · 높이 끌기 자리. */
|
||||
export interface HitResult {
|
||||
kind: "cell" | "colHead" | "rowHead" | "corner" | "colEdge" | "rowEdge" | "fillHandle";
|
||||
r: number;
|
||||
c: number;
|
||||
}
|
||||
|
||||
/** 칸 자리 — 격자 내용 좌표(px · 스크롤 전) · 병합이면 병합 전체. */
|
||||
export interface CellBox {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** 편집 중 식 참조 색 테두리(D 가 채우고 C 가 그림). */
|
||||
export interface RefHighlight {
|
||||
시트: string;
|
||||
범위: CellRange;
|
||||
/** 0 부터 차례 — 색은 C 가 차례로 고름 */
|
||||
색번호: number;
|
||||
}
|
||||
|
||||
/** C 격자 — `createGrid(ctx)` 가 돌려줌. */
|
||||
export interface GridHandle {
|
||||
/** 격자 뿌리(스크롤 판 · 틀고정 판 포함) — D 가 창 안에 붙임 */
|
||||
root: HTMLElement;
|
||||
/** 보이는 칸 통째로 다시(시트 바꿈 · 행열 · 폭 · 틀고정 뒤) */
|
||||
render(): void;
|
||||
/** 값 · 서식이 바뀐 칸만 다시(보이는 것만) */
|
||||
invalidate(cells: SheetCellAddress[]): void;
|
||||
/** 고름 · 참조 색 테두리 · 채우기 손잡이 다시 그림 */
|
||||
renderSelection(): void;
|
||||
setRefHighlights(list: RefHighlight[]): void;
|
||||
cellBox(r: number, c: number): CellBox;
|
||||
/** 화면 좌표 → 가리킨 것(격자 밖 null) */
|
||||
hitTest(clientX: number, clientY: number): HitResult | null;
|
||||
/** 칸이 보이게 스크롤(틀고정 판이면 그대로) */
|
||||
reveal(r: number, c: number): void;
|
||||
/** 지금 보이는 행열(틀고정 판 뺀 본문 판) */
|
||||
visibleRange(): CellRange;
|
||||
/** 편집기를 얹을 층과 칸 자리(화면 좌표 · 병합이면 전체) — D 편집기가 씀 */
|
||||
editorSlot(r: number, c: number): { layer: HTMLElement; box: CellBox };
|
||||
/** 지금 확대(1 = 100%) — 바뀌면 `root` 에서 CustomEvent("aislo-grid-zoom", { detail: { zoom } }) 를 쏨(E 상태줄이 들음) */
|
||||
zoom(): number;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/** D 편집기 — E 도구 모음 · C 가 부름. */
|
||||
export interface EditorHandle {
|
||||
/** 편집 중인가 — 방향키 · 붙여넣기 뜻이 달라짐 */
|
||||
editing(): boolean;
|
||||
/** 활성 칸 편집 시작(`initial` = 친 글자로 시작) */
|
||||
begin(initial?: string): void;
|
||||
/** 확정 · 취소 */
|
||||
commit(): void;
|
||||
cancel(): void;
|
||||
/** 수식 입력줄 · 주소 상자를 활성 칸으로 다시 */
|
||||
sync(): void;
|
||||
}
|
||||
|
||||
/** D 가 만들어 C · E 에 넘기는 문맥 — 문서는 이것 하나를 여럿이 봄. */
|
||||
export interface SpreadsheetContext {
|
||||
/** 부품 뿌리 */
|
||||
root: HTMLElement;
|
||||
/** 지금 문서 — 명령만이 바꿈(직접 고치지 말 것) */
|
||||
book: Workbook;
|
||||
readOnly: boolean;
|
||||
engine: CalcEngine;
|
||||
history: History;
|
||||
selection: Selection;
|
||||
/** 활성 시트 */
|
||||
sheet(): Sheet;
|
||||
/** 명령 하나 적용 → 되돌림 쌓기 → 엔진 → 다시 그림 → onChange. readOnly 면 안 함 */
|
||||
dispatch(command: Command): void;
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
/** 고름 바꿈 → 다시 그림 · 수식 입력줄 · onSelect */
|
||||
select(selection: Selection, reveal?: boolean): void;
|
||||
/** 활성 시트 바꿈(탭) */
|
||||
showSheet(sheetId: string): void;
|
||||
grid: GridHandle;
|
||||
editor: EditorHandle;
|
||||
}
|
||||
|
||||
/** E 부품 — D 가 `mountToolbar(ctx)` 등으로 붙이고 고름 · 문서가 바뀌면 `refresh()`. */
|
||||
export interface PartHandle {
|
||||
root: HTMLElement | null;
|
||||
refresh(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ DXF 로 저장해 달라」는 안내로 떨어진다.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
@@ -194,7 +195,39 @@ def _expand(entity: Any) -> list[Any]:
|
||||
|
||||
def dxf_to_entities(path: Path) -> list[dict[str, Any]]:
|
||||
"""DXF 파일을 도각 엔티티 목록으로. 지원 밖 도형(해치·솔리드 등)은 버린다."""
|
||||
document = ezdxf.readfile(str(path))
|
||||
return _document_entities(ezdxf.readfile(str(path)))
|
||||
|
||||
|
||||
def _layer_color(document: Any, name: str) -> str | None:
|
||||
"""도면층 색(ACI) → 화면 색. 꺼진 층은 음수로 적히므로 절댓값."""
|
||||
try:
|
||||
aci = abs(int(document.layers.get(name).dxf.color))
|
||||
red, green, blue = ezdxf_colors.aci2rgb(aci)
|
||||
return f"#{red:02x}{green:02x}{blue:02x}"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def dxf_text_to_entities(text: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""클립보드 DXF 글 → (엔티티 목록, 쓰인 도면층 [{name, color}])."""
|
||||
return _entities_with_layers(ezdxf.read(io.StringIO(text)))
|
||||
|
||||
|
||||
def drawing_file_to_entities(
|
||||
filename: str, data: bytes
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""끌어다 놓은 DXF·DWG 파일 → (엔티티 목록, 쓰인 도면층 [{name, color}])."""
|
||||
return _entities_with_layers(_read_drawing_file(filename, data))
|
||||
|
||||
|
||||
def _entities_with_layers(document: Any) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""엔티티 `layerId` 는 DXF 도면층 이름 그대로 — 웹 CAD 가 이름으로 자기 도면층에 잇는다."""
|
||||
entities = _document_entities(document)
|
||||
names = sorted({str(entity["layerId"]) for entity in entities})
|
||||
return entities, [{"name": name, "color": _layer_color(document, name)} for name in names]
|
||||
|
||||
|
||||
def _document_entities(document: Any) -> list[dict[str, Any]]:
|
||||
entities: list[dict[str, Any]] = []
|
||||
index = 0
|
||||
for source in document.modelspace():
|
||||
@@ -266,6 +299,11 @@ def _dwg_to_dxf(source: Path, work_dir: Path) -> Path:
|
||||
|
||||
def import_frame_file(filename: str, data: bytes) -> list[dict[str, Any]]:
|
||||
"""올린 도각 파일(DXF·DWG)을 도각 엔티티 목록으로 바꾼다."""
|
||||
return _document_entities(_read_drawing_file(filename, data))
|
||||
|
||||
|
||||
def _read_drawing_file(filename: str, data: bytes) -> Any:
|
||||
"""DXF·DWG 파일 → ezdxf 문서(메모리에 다 읽음 · 임시 폴더는 바로 지움)."""
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in (".dxf", ".dwg"):
|
||||
raise ValueError("DXF 또는 DWG 파일만 올릴 수 있습니다.")
|
||||
@@ -275,6 +313,6 @@ def import_frame_file(filename: str, data: bytes) -> list[dict[str, Any]]:
|
||||
source.write_bytes(data)
|
||||
target = _dwg_to_dxf(source, work_dir) if suffix == ".dwg" else source
|
||||
try:
|
||||
return dxf_to_entities(target)
|
||||
return ezdxf.readfile(str(target))
|
||||
except ezdxf.DXFError as exc:
|
||||
raise ValueError(f"DXF 를 읽지 못했습니다: {exc}") from exc
|
||||
|
||||
@@ -11,12 +11,18 @@ from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from uuid import UUID
|
||||
|
||||
import ezdxf
|
||||
from fastapi import APIRouter, File, Response, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import export_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import (
|
||||
drawing_file_to_entities,
|
||||
dxf_text_to_entities,
|
||||
import_frame_file,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
frame_document,
|
||||
frame_template_document,
|
||||
@@ -145,6 +151,74 @@ async def import_frame_template(
|
||||
)
|
||||
|
||||
|
||||
class DxfClipRequest(BaseModel):
|
||||
"""클립보드에 담긴 DXF 글."""
|
||||
|
||||
text: str
|
||||
|
||||
|
||||
@router.post("/dxf-clip")
|
||||
async def paste_dxf_clip(request: DxfClipRequest) -> JSONResponse:
|
||||
"""클립보드 DXF 글 → 붙일 엔티티 · 도면층. 프로젝트 무관(M02 양식도 씀) · 저장 안 함."""
|
||||
try:
|
||||
if len(request.text) > _FRAME_IMPORT_MAX_BYTES:
|
||||
raise ValueError("붙여넣을 도형이 너무 많습니다(20MB 넘음).")
|
||||
entities, layers = await asyncio.to_thread(dxf_text_to_entities, request.text)
|
||||
validate_template_entities(entities)
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"entities": entities,
|
||||
"layers": layers,
|
||||
"entity_count": len(entities),
|
||||
}
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except (UnicodeError, ezdxf.DXFError):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "클립보드 DXF 를 읽지 못했습니다 — 깨진 글입니다.",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B07 DXF 붙여넣기 실패")
|
||||
return JSONResponse(
|
||||
status_code=500, content={"status": "error", "message": "DXF 를 붙여넣지 못했습니다."}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/dxf-clip/file")
|
||||
async def paste_drawing_file(file: UploadFile = File(...)) -> JSONResponse:
|
||||
"""끌어다 놓은 DXF·DWG 파일(오토캐드 WBLOCK·DXFOUT) → 붙일 엔티티 · 도면층 · 저장 안 함."""
|
||||
try:
|
||||
data = await file.read()
|
||||
if len(data) > _FRAME_IMPORT_MAX_BYTES:
|
||||
raise ValueError("도면 파일이 너무 큽니다(20MB 넘음).")
|
||||
entities, layers = await asyncio.to_thread(
|
||||
drawing_file_to_entities, file.filename or "", data
|
||||
)
|
||||
validate_template_entities(entities)
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"entities": entities,
|
||||
"layers": layers,
|
||||
"entity_count": len(entities),
|
||||
}
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B07 도면 파일 붙여넣기 실패: %s", file.filename)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "도면 파일을 붙여넣지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
|
||||
async def put_frame_template(
|
||||
project_id: UUID, request: FrameTemplateSaveRequest
|
||||
|
||||
@@ -176,8 +176,9 @@ export const CANVAS_INPUT_FIELD_HEIGHT = 20;
|
||||
|
||||
/**
|
||||
* Canvas input field background color
|
||||
* 자동색(검정) — 어두운 바탕 검정 칸에 흰 글 · 밝은 바탕 흰 칸에 검정 글(`theme.ts` paintColor)
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_BACKGROUND_COLOR = '#161616';
|
||||
export const CANVAS_INPUT_FIELD_BACKGROUND_COLOR = '#000000';
|
||||
|
||||
/**
|
||||
* Canvas input field text color
|
||||
|
||||
@@ -55,6 +55,9 @@ button {
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cad-host-save {
|
||||
--cad-title-height: 0px;
|
||||
}
|
||||
.controls {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -146,6 +149,13 @@ body > canvas[data-id="canvas"] {
|
||||
flex-direction: column;
|
||||
background: var(--cad-chrome-raised);
|
||||
border-bottom: 1px solid var(--cad-line);
|
||||
transition: height 120ms ease;
|
||||
}
|
||||
/* 접힘 모드에서 탭으로 잠깐 펼침 — 도면 자리는 그대로 두고 위에 겹쳐 뜸(122px = 펼친 리본 높이) */
|
||||
.cad-ribbon[data-peek="true"] {
|
||||
z-index: 10;
|
||||
height: 122px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.cad-ribbon-tabs {
|
||||
display: flex;
|
||||
@@ -154,6 +164,21 @@ body > canvas[data-id="canvas"] {
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-ribbon-tabs__collapse {
|
||||
margin-left: auto;
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
align-self: center;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-ribbon-tabs__collapse:hover {
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-ribbon-tabs button {
|
||||
padding: 0 14px;
|
||||
border: 1px solid transparent;
|
||||
@@ -501,6 +526,48 @@ body > canvas[data-id="canvas"] {
|
||||
background: var(--cad-chrome-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
/* 오토캐드 파일 끌어다 놓기 안내 — 캔버스 오른쪽 위 흐린 글씨(빠른 특성 창이 뜨면 그 아래로 가려짐) */
|
||||
.cad-drop-hint {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 8px);
|
||||
right: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
max-width: 360px;
|
||||
padding: 2px 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
opacity: 0.55;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cad-drop-hint:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.cad-drop-hint__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 10px;
|
||||
}
|
||||
/* 파일을 끄는 동안 — 화면 가장자리 흐린 테두리(`dxf-clip-paste.ts`) */
|
||||
body.cad-file-dragging::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
inset: 6px;
|
||||
border: 2px dashed var(--cad-accent);
|
||||
border-radius: 6px;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cad-view-controls button {
|
||||
width: 34px;
|
||||
height: 32px;
|
||||
@@ -966,11 +1033,24 @@ body > canvas[data-id="canvas"] {
|
||||
}
|
||||
|
||||
.cad-frame-tokens__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__fold {
|
||||
flex: none;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--cad-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -3,10 +3,10 @@ import { toast } from 'react-toastify';
|
||||
import { clearRecovery, restoreRecovery } from '../helpers/autosave';
|
||||
import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json';
|
||||
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage';
|
||||
import { requestDrawingExport } from '../integration/aislo-drawing-bridge';
|
||||
import { requestDrawingExport, requestHostSave } from '../integration/aislo-drawing-bridge';
|
||||
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
|
||||
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
|
||||
import { redo, undo } from '../state';
|
||||
import { isHostSave, redo, undo } from '../state';
|
||||
import type { CadCommand } from './command.types';
|
||||
|
||||
export const FILE_COMMANDS: CadCommand[] = [
|
||||
@@ -17,6 +17,10 @@ export const FILE_COMMANDS: CadCommand[] = [
|
||||
glyph: '💾',
|
||||
hint: '현재 도면을 브라우저에 저장한다',
|
||||
run: () => {
|
||||
if (isHostSave()) {
|
||||
requestHostSave();
|
||||
return '양식 저장 요청';
|
||||
}
|
||||
void exportEntitiesToLocalStorage().then(() => {
|
||||
clearRecovery();
|
||||
toast.success('도면을 저장했습니다.');
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/** 화면 오른쪽 위 흐린 안내 — 오토캐드에서 저장한 파일을 끌어다 놓는 법 · 누르면 순서 (B07 · M02 양식 둘 다) */
|
||||
import { type FC, useState } from 'react';
|
||||
import { isDrawingReadOnly } from '../state';
|
||||
|
||||
export const DropHint: FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (isDrawingReadOnly()) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="cad-drop-hint controls"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
title="오토캐드 도형을 이 화면에 붙이는 법"
|
||||
>
|
||||
오토캐드에서 WBLOCK(또는 DXFOUT)으로 저장 → 파일을 여기로 끌어다 놓기
|
||||
{open && (
|
||||
<span className="cad-drop-hint__steps">
|
||||
<span>① 오토캐드에서 도형을 고르고 WBLOCK(또는 DXFOUT) 입력 → 파일 이름 정해 저장</span>
|
||||
<span>② 저장한 파일(도면 파일 통째도 됨)을 이 화면으로 끌어다 놓기</span>
|
||||
<span>③ 원래 좌표 · 도면층 그대로 붙고 골라진 상태 — 되돌리기 한 번에 빠짐</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -113,6 +113,8 @@ function boxSizeOf(entity: TextEntity | ImageEntity): { width: number; height: n
|
||||
|
||||
export const FramePlaceholderPanel: FC = () => {
|
||||
const [visible, setVisible] = useState(isFrameEditMode());
|
||||
// 접으면 그림 오른쪽 아래를 덮지 않는다 — 머리 줄만 남는다.
|
||||
const [folded, setFolded] = useState(false);
|
||||
const [picked, setPicked] = useState<TextEntity | ImageEntity | null>(null);
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
@@ -139,8 +141,21 @@ export const FramePlaceholderPanel: FC = () => {
|
||||
return (
|
||||
<section className="cad-frame-tokens controls">
|
||||
<header className="cad-frame-tokens__header">
|
||||
자리표 놓기 — 누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다
|
||||
<span>
|
||||
{folded ? '자리표 놓기' : '자리표 놓기 — 누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="cad-frame-tokens__fold"
|
||||
aria-expanded={!folded}
|
||||
title={folded ? '자리표 패널 펴기' : '자리표 패널 접기'}
|
||||
onClick={() => setFolded(!folded)}
|
||||
>
|
||||
{folded ? '▴' : '▾'}
|
||||
</button>
|
||||
</header>
|
||||
{!folded && (
|
||||
<>
|
||||
<div className="cad-frame-tokens__group">
|
||||
<span className="cad-frame-tokens__title">글자</span>
|
||||
{TEXT_TOKENS.map((token) => (
|
||||
@@ -190,6 +205,8 @@ export const FramePlaceholderPanel: FC = () => {
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HtmlEvent } from '../App.types';
|
||||
import { getCommandById } from '../commands/registry';
|
||||
import { runCommand } from '../commands/run-command';
|
||||
import { QUICK_ACCESS_COMMANDS } from '../ribbon/ribbon.config';
|
||||
import { getHostTitle } from '../state';
|
||||
import { getHostTitle, isHostSave } from '../state';
|
||||
|
||||
export const QuickAccessBar: FC = () => {
|
||||
const [hostTitle, setTitle] = useState(getHostTitle());
|
||||
@@ -13,6 +13,8 @@ export const QuickAccessBar: FC = () => {
|
||||
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
}, []);
|
||||
// 부모 페이지가 저장·이름을 맡는 M02 에선 제목 줄을 숨겨 그림 칸을 넓힌다.
|
||||
if (isHostSave()) return null;
|
||||
return (
|
||||
<header className="cad-titlebar controls">
|
||||
<div className="cad-brand">
|
||||
|
||||
@@ -12,16 +12,20 @@ import { QuickAccessBar } from './QuickAccessBar';
|
||||
import { Ribbon } from '../ribbon/Ribbon';
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { useCadRefresh } from './use-cad-refresh';
|
||||
import { isInspectorCollapsed, isRibbonCollapsed, setInspectorCollapsed } from './ui-state';
|
||||
import { getActiveToolActor } from '../state';
|
||||
import type { Tool } from '../tools';
|
||||
import { ViewControls } from './ViewControls';
|
||||
import { DropHint } from './DropHint';
|
||||
|
||||
export const Toolbar: FC = () => {
|
||||
useCadRefresh();
|
||||
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
||||
const [commandLineVisible, setCommandLineVisible] = useState(false);
|
||||
|
||||
const activeTool = (getActiveToolActor()?.getSnapshot()?.context?.type ?? null) as Tool | null;
|
||||
const ribbonCollapsed = isRibbonCollapsed();
|
||||
// 좌측 패널 접힘 — 호스트(M02 미리보기)가 접힌 채로 실을 수 있게 ui-state 에 둔다
|
||||
const panelCollapsed = isInspectorCollapsed();
|
||||
|
||||
useEffect(() => {
|
||||
// 편 상태의 폭은 App.css의 `--cad-panel-width` 기본값을 쓴다. 여기 숫자를 박아
|
||||
@@ -32,8 +36,11 @@ export const Toolbar: FC = () => {
|
||||
'--cad-command-height',
|
||||
commandLineVisible ? '86px' : '0px'
|
||||
);
|
||||
// 리본 접힘 — 탭 줄(26px)만 남기고 패널 줄 자리를 걷는다(`Ribbon.tsx` 가 안 그림).
|
||||
if (ribbonCollapsed) document.documentElement.style.setProperty('--cad-ribbon-height', '26px');
|
||||
else document.documentElement.style.removeProperty('--cad-ribbon-height');
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}, [panelCollapsed, commandLineVisible]);
|
||||
}, [panelCollapsed, commandLineVisible, ribbonCollapsed]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -48,9 +55,10 @@ export const Toolbar: FC = () => {
|
||||
/>
|
||||
<InspectorPanel
|
||||
collapsed={panelCollapsed}
|
||||
onToggleCollapsed={() => setPanelCollapsed((value) => !value)}
|
||||
onToggleCollapsed={() => setInspectorCollapsed(!panelCollapsed)}
|
||||
/>
|
||||
<ViewControls />
|
||||
<DropHint />
|
||||
<QuickProperties />
|
||||
<BlockLibraryPanel />
|
||||
{commandLineVisible && <CommandLine />}
|
||||
|
||||
@@ -12,6 +12,8 @@ let inspectorCollapsed = false;
|
||||
let quickPropertiesVisible = false;
|
||||
let activeRibbonTab = 'home';
|
||||
let blockLibraryVisible = false;
|
||||
/** 호스트(M02)가 처음부터 접어 실을 때 true — 탭을 누르면 펼침(`setActiveRibbonTab`). */
|
||||
let ribbonCollapsed = false;
|
||||
|
||||
const notify = () => notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
|
||||
@@ -20,12 +22,18 @@ export const isInspectorCollapsed = () => inspectorCollapsed;
|
||||
export const isQuickPropertiesVisible = () => quickPropertiesVisible;
|
||||
export const getActiveRibbonTab = () => activeRibbonTab;
|
||||
export const isBlockLibraryVisible = () => blockLibraryVisible;
|
||||
export const isRibbonCollapsed = () => ribbonCollapsed;
|
||||
|
||||
export function setActiveRibbonTab(tabId: string): void {
|
||||
activeRibbonTab = tabId;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function setRibbonCollapsed(collapsed: boolean): void {
|
||||
ribbonCollapsed = collapsed;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function openInspector(tab: InspectorTab): void {
|
||||
inspectorTab = tab;
|
||||
inspectorCollapsed = false;
|
||||
|
||||
@@ -41,6 +41,8 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
private screenScale = 1;
|
||||
private screenMouseLocation: Point;
|
||||
private canvasSize: Point = new Point(100, 100);
|
||||
/** 맞춤 뒤 사용자가 화면을 안 옮겼음 — 캔버스 크기가 바뀌면 다시 맞춘다(첫 표시 때 iframe 이 늦게 커짐). */
|
||||
private viewFitted = false;
|
||||
|
||||
// Style-run batching (static scene rendering): consecutive stroke calls
|
||||
// with the same style are collected into one Path2D and stroked once.
|
||||
@@ -61,7 +63,9 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
}
|
||||
|
||||
public setCanvasSize(newCanvasSize: Point) {
|
||||
const changed = newCanvasSize.x !== this.canvasSize.x || newCanvasSize.y !== this.canvasSize.y;
|
||||
this.canvasSize = newCanvasSize;
|
||||
if (changed && this.viewFitted) this.zoomToFitScreen();
|
||||
}
|
||||
|
||||
public getScreenScale() {
|
||||
@@ -70,6 +74,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
|
||||
public setScreenScale(newScreenScale: number) {
|
||||
this.screenScale = newScreenScale;
|
||||
this.viewFitted = false;
|
||||
triggerReactUpdate(StateVariable.screenZoom);
|
||||
}
|
||||
|
||||
@@ -79,6 +84,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
|
||||
public setScreenOffset(newScreenOffset: Point) {
|
||||
this.screenOffset = newScreenOffset;
|
||||
this.viewFitted = false;
|
||||
triggerReactUpdate(StateVariable.screenOffset);
|
||||
}
|
||||
|
||||
@@ -96,6 +102,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
}
|
||||
|
||||
public panScreen(screenOffsetX: number, screenOffsetY: number) {
|
||||
this.viewFitted = false;
|
||||
this.screenOffset = new Point(
|
||||
this.screenOffset.x - screenOffsetX / this.screenScale,
|
||||
this.screenOffset.y - screenOffsetY / this.screenScale
|
||||
@@ -145,7 +152,14 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
// 확정). 도각까지 넣으면 A1 한 장 전체가 잡혀 화면의 40%가 여백으로 간다.
|
||||
const pickable = getPickableEntities();
|
||||
const entities = pickable.length ? pickable : getEntities();
|
||||
if (!entities.length) return;
|
||||
if (!entities.length) {
|
||||
// 빈 도면 — 원점(0,0)을 화면 가운데로. 안 옮기면 기준점 십자가 왼쪽 아래 귀퉁이에 잘린다.
|
||||
const canvasSize = this.getCanvasSize();
|
||||
const zoom = this.getScreenScale();
|
||||
this.setScreenOffset(new Point(-canvasSize.x / 2 / zoom, -canvasSize.y / 2 / zoom));
|
||||
this.viewFitted = true;
|
||||
return;
|
||||
}
|
||||
const boundingBox = getBoundingBoxOfMultipleEntities(entities);
|
||||
const boundingWidth = boundingBox.maxX - boundingBox.minX;
|
||||
const boundingHeight = boundingBox.maxY - boundingBox.minY;
|
||||
@@ -171,6 +185,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
worldCenterY - canvasSize.y / 2 / zoomLevel
|
||||
)
|
||||
);
|
||||
this.viewFitted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,7 +257,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
lineWidth: number,
|
||||
dash: number[] = []
|
||||
) {
|
||||
// 저장된 도면 색은 다크 배경 기준 — 라이트 테마에서는 그릴 때만 대비를 맞춘다.
|
||||
// 자동색(검정·흰색·기본색)은 바탕 따라 흰/검정 · 라이트 테마는 밝은 색 대비도 맞춤(그릴 때만).
|
||||
const color = paintColor(rawColor);
|
||||
if (this.batching) {
|
||||
const effectiveWidth = isHighlighted ? lineWidth + 1 : lineWidth;
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
getSnapPoint,
|
||||
isDrawingDirty,
|
||||
isDrawingReadOnly,
|
||||
isOriginMarker,
|
||||
isViewOnly,
|
||||
} from '../state';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { isEntityHidden } from './visibility';
|
||||
@@ -47,6 +49,9 @@ export function registerCadDebugHook(): void {
|
||||
readOnly: () => isDrawingReadOnly(),
|
||||
dirty: () => isDrawingDirty(),
|
||||
meta: () => getDesignMeta(),
|
||||
// 기준점 십자 · 미리보기(보기 전용) 켜짐 — 그림이 안 도는 탭에서도 수치로 본다.
|
||||
originMarker: () => isOriginMarker(),
|
||||
viewOnly: () => isViewOnly(),
|
||||
// 세계 좌표 → 화면 좌표. 그림·글자가 "제 자리에 그려졌나"를 픽셀로 판정할 때 쓴다.
|
||||
screen: (x: number, y: number) => {
|
||||
const point = getScreenCanvasDrawController().worldToTarget(new Point(x, y));
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
SNAP_POINT_SIZE,
|
||||
} from '../App.consts';
|
||||
import { type SnapPoint, SnapPointType } from '../App.types';
|
||||
|
||||
/** 기준점 십자 반길이(px) — 가로세로 20px. */
|
||||
const ORIGIN_MARKER_SIZE = 10;
|
||||
import type { DrawController } from '../drawControllers/DrawController';
|
||||
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
@@ -229,6 +232,23 @@ export function drawCursor(drawController: ScreenCanvasDrawController) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 좌표 0,0 기준점 — 화면 크기 고정 십자 + 작은 원. 도면 개체가 아니라 그리기 겹판(저장 · 선택 · 스냅 대상 아님). */
|
||||
export function drawOriginMarker(drawController: ScreenCanvasDrawController) {
|
||||
const origin = drawController.worldToTarget(new Point(0, 0));
|
||||
drawController.setOpacity(0.6);
|
||||
drawController.setLineStyles(false, false, '#ffffff', 1, []); // 자동색 — 바탕 따라 흰/검정
|
||||
drawController.drawLineScreen(
|
||||
new Point(origin.x - ORIGIN_MARKER_SIZE, origin.y),
|
||||
new Point(origin.x + ORIGIN_MARKER_SIZE, origin.y)
|
||||
);
|
||||
drawController.drawLineScreen(
|
||||
new Point(origin.x, origin.y - ORIGIN_MARKER_SIZE),
|
||||
new Point(origin.x, origin.y + ORIGIN_MARKER_SIZE)
|
||||
);
|
||||
drawController.drawArcScreen(origin, ORIGIN_MARKER_SIZE / 3, 0, 2 * Math.PI, true);
|
||||
drawController.setOpacity(1);
|
||||
}
|
||||
|
||||
/** 선택한 객체의 그립(편집점)을 화면 크기 고정 사각형으로 그린다 */
|
||||
export function drawGrips(drawController: ScreenCanvasDrawController, entities: Entity[]) {
|
||||
for (const entity of entities) {
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
getShouldDrawCursor,
|
||||
getSnapPoint,
|
||||
getSnapPointOnAngleGuide,
|
||||
isOriginMarker,
|
||||
isViewOnly,
|
||||
} from '../state';
|
||||
import {
|
||||
drawCursor,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
drawEntities,
|
||||
drawGrips,
|
||||
drawHelpers,
|
||||
drawOriginMarker,
|
||||
drawSnapPoint,
|
||||
} from './draw-functions';
|
||||
import { getClosestSnapPoint } from './get-closest-snap-point';
|
||||
@@ -47,6 +50,7 @@ export function draw(drawController: ScreenCanvasDrawController) {
|
||||
drawScene(drawController, performance.now());
|
||||
drawHighlightedEntities(drawController);
|
||||
|
||||
if (isOriginMarker()) drawOriginMarker(drawController);
|
||||
drawGrips(drawController, getSelectedEntities());
|
||||
drawHelpers(drawController, getAngleGuideEntities());
|
||||
drawEntities(drawController, getGhostHelperEntities());
|
||||
@@ -65,7 +69,8 @@ export function draw(drawController: ScreenCanvasDrawController) {
|
||||
);
|
||||
drawSnapPoint(drawController, closestSnapPoint, isMarked);
|
||||
|
||||
if (getShouldDrawCursor()) {
|
||||
// 미리보기는 십자 커서 · 명령 안내 · 좌표 풍선을 그리지 않는다(손 모양 커서).
|
||||
if (getShouldDrawCursor() && !isViewOnly()) {
|
||||
drawCursor(drawController);
|
||||
getInputController().draw(drawController);
|
||||
}
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ export async function getEntitiesAndLayersFromJsonObject(
|
||||
if (groupId) entity.groupId = groupId;
|
||||
}
|
||||
let layers = data.layers;
|
||||
if (data.layers.length === 0) {
|
||||
if (!layers || layers.length === 0) {
|
||||
layers = [getNewLayer()];
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
resolveCommandInput,
|
||||
} from '../commands/registry.ts';
|
||||
import { runCommand } from '../commands/run-command.ts';
|
||||
import { requestHostSave } from '../integration/aislo-drawing-bridge.ts';
|
||||
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts';
|
||||
import { calculateAngleGuidesAndSnapPoints } from '../helpers/calculate-angle-guides-and-snap-points.ts';
|
||||
import { findClosestEntity } from '../helpers/find-closest-entity.ts';
|
||||
@@ -40,6 +41,8 @@ import {
|
||||
getSnapEnabled,
|
||||
getSnapPoint,
|
||||
getSnapPointOnAngleGuide,
|
||||
isHostSave,
|
||||
isViewOnly,
|
||||
redo,
|
||||
setActiveToolActor,
|
||||
setAngleStep,
|
||||
@@ -109,6 +112,11 @@ export class InputController {
|
||||
canvas?.addEventListener('contextmenu', (evt) => {
|
||||
evt.preventDefault();
|
||||
});
|
||||
// 가운데 단추 — 브라우저 자동 스크롤(페이지가 같이 움직임) · 붙여넣기를 막는다.
|
||||
// 막는 자리는 mousedown(아래 handleMouseDown) — pointerdown 을 막으면 mousedown 이 안 와 팬이 끊긴다.
|
||||
canvas?.addEventListener('auxclick', (evt: MouseEvent) => {
|
||||
if (evt.button === MouseButton.Middle) evt.preventDefault();
|
||||
});
|
||||
// 캔버스 밖(리본·패널) 위에서의 트랙패드 핀치도 브라우저를 확대시키지 않는다.
|
||||
// ctrl이 없는 휠은 그대로 둬서 패널 스크롤은 살린다.
|
||||
document.addEventListener(
|
||||
@@ -189,6 +197,10 @@ export class InputController {
|
||||
if (evt.button === MouseButton.Middle) {
|
||||
setPanStartLocation(null);
|
||||
}
|
||||
if (evt.button === MouseButton.Left && isViewOnly()) {
|
||||
setPanStartLocation(null);
|
||||
return;
|
||||
}
|
||||
if (evt.button === MouseButton.Left) {
|
||||
if (this.isSelectToolActive()) {
|
||||
// 선택 도구는 누를 때 첫 점을 이미 보냈다. 끌었으면 놓는 자리로 사각형을 닫는다.
|
||||
@@ -262,6 +274,9 @@ export class InputController {
|
||||
setPanStartLocation(newScreenMouseLocation);
|
||||
}
|
||||
|
||||
// 미리보기는 보기만 — 스냅 · 가까운 개체 강조를 하지 않는다.
|
||||
if (isViewOnly()) return;
|
||||
|
||||
// Calculate angle guides and snap points
|
||||
if (getSnapEnabled()) {
|
||||
calculateAngleGuidesAndSnapPoints();
|
||||
@@ -320,11 +335,18 @@ export class InputController {
|
||||
|
||||
public handleMouseDown(evt: MouseEvent) {
|
||||
if (evt.button === MouseButton.Middle) {
|
||||
evt.preventDefault(); // 자동 스크롤로 부모 페이지가 같이 굴러가던 것
|
||||
setPanStartLocation(this.getCanvasPoint(evt));
|
||||
return;
|
||||
}
|
||||
if (evt.button !== MouseButton.Left) return;
|
||||
if ((evt.target as HTMLElement | null)?.closest('.controls')) return;
|
||||
if (isViewOnly()) {
|
||||
// 미리보기 — 왼쪽 끌기는 선택 상자 대신 화면 옮기기.
|
||||
evt.preventDefault();
|
||||
setPanStartLocation(this.getCanvasPoint(evt));
|
||||
return;
|
||||
}
|
||||
// AutoCAD처럼 누른 자리에서 사각형이 시작되도록 선택 도구에만 첫 점을 미리 보낸다.
|
||||
// 다른 도구는 예전처럼 놓을 때 한 점을 받는다 (끌다가 점이 두 개 찍히지 않게).
|
||||
if (!this.isSelectToolActive()) return;
|
||||
@@ -413,11 +435,13 @@ export class InputController {
|
||||
// Tab => move keyboard focus
|
||||
return;
|
||||
}
|
||||
if (evt.ctrlKey && evt.code === 'KeyV') {
|
||||
// 막지 않음 — 브라우저가 `paste` 를 쏘고 `integration/dxf-clip-paste.ts` 가 받음(한글 자판도 code 로)
|
||||
return;
|
||||
}
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
if (evt.ctrlKey && evt.key === 'v') {
|
||||
// User wants to paste the clipboard
|
||||
} else if (evt.ctrlKey && !evt.shiftKey && evt.key === 'z') {
|
||||
if (evt.ctrlKey && !evt.shiftKey && evt.key === 'z') {
|
||||
// User wants to undo the last action
|
||||
this.handleUndo(evt);
|
||||
} else if (evt.ctrlKey && evt.shiftKey && evt.key === 'z') {
|
||||
@@ -426,6 +450,9 @@ export class InputController {
|
||||
} else if (evt.ctrlKey && evt.key === 'y') {
|
||||
// User wants to redo the last action
|
||||
this.handleRedo(evt);
|
||||
} else if (evt.ctrlKey && evt.key === 's') {
|
||||
// 부모 페이지가 저장을 맡은 화면(M02 양식)만 — B07 은 예전처럼 아무 일 없음
|
||||
if (isHostSave()) requestHostSave();
|
||||
} else if (evt.ctrlKey && evt.key === 'a') {
|
||||
// User wants to select everything
|
||||
setSelectedEntityIds(getPickableEntities().map((entity) => entity.id));
|
||||
|
||||
@@ -15,15 +15,20 @@ import {
|
||||
getScreenCanvasDrawController,
|
||||
isDrawingDirty,
|
||||
isDrawingReadOnly,
|
||||
isViewOnly,
|
||||
resetUndoBaseline,
|
||||
setActiveLayerId,
|
||||
setDesignMeta,
|
||||
setEntities,
|
||||
setFrameEditMode,
|
||||
setHostReadOnly,
|
||||
setHostSave,
|
||||
setHostTitle,
|
||||
setLayers,
|
||||
setOriginMarker,
|
||||
setViewOnly,
|
||||
} from '../state.ts';
|
||||
import { setInspectorCollapsed, setRibbonCollapsed } from '../components/ui-state.ts';
|
||||
import { toast } from 'react-toastify';
|
||||
import { runCommandInput } from '../commands/run-command.ts';
|
||||
import { setRecoveryScope } from '../helpers/autosave.ts';
|
||||
@@ -38,6 +43,7 @@ export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b08:save-request';
|
||||
export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b08:save-response';
|
||||
export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b08:navigate';
|
||||
export const AISLO_DRAWING_EXPORT_MESSAGE = 'aislo:b08:export-file';
|
||||
export const AISLO_DRAWING_HOST_SAVE_MESSAGE = 'aislo:b08:host-save';
|
||||
|
||||
interface DrawingLoadMessage {
|
||||
type: typeof AISLO_DRAWING_LOAD_MESSAGE;
|
||||
@@ -53,6 +59,15 @@ interface DrawingLoadMessage {
|
||||
readOnly?: boolean;
|
||||
/** 제목표시줄 부제 — 없으면 「B07 상세 설계」. */
|
||||
hostTitle?: string;
|
||||
/** 저장(💾 · Ctrl+S)을 부모 [저장]에 맡기는가. */
|
||||
hostSave?: boolean;
|
||||
/** 리본을 접힌 채로 여는가 — 없으면(B07) 펼침 그대로. */
|
||||
ribbonCollapsed?: boolean;
|
||||
/** 좌측 패널(특성 · 도면층)을 접힌 채로 여는가 — 없으면(B07) 지금 상태 그대로. */
|
||||
panelsCollapsed?: boolean;
|
||||
/** 미리보기 — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */
|
||||
viewOnly?: boolean;
|
||||
originMarker?: boolean;
|
||||
}
|
||||
|
||||
interface DrawingSaveRequestMessage {
|
||||
@@ -75,6 +90,11 @@ export function requestDrawingNavigation(direction: 'prev' | 'next') {
|
||||
notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction });
|
||||
}
|
||||
|
||||
/** 저장 요청을 부모 페이지 [저장]에 넘긴다 — 캐드는 양식 파일을 모른다. */
|
||||
export function requestHostSave() {
|
||||
notifyParent(AISLO_DRAWING_HOST_SAVE_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금 도면을 DXF·DWG 파일로 내려받도록 부모에게 요청한다 (2026-09-06 사용자 지시).
|
||||
* 캐드는 프로젝트를 모르므로 파일 만들기는 부모가 서버에 맡긴다.
|
||||
@@ -98,6 +118,10 @@ function registerTextDoubleClickEdit() {
|
||||
const canvas = getCanvas();
|
||||
if (!canvas) return;
|
||||
canvas.addEventListener('dblclick', (event: MouseEvent) => {
|
||||
if (isViewOnly()) {
|
||||
getScreenCanvasDrawController().zoomToFitScreen();
|
||||
return;
|
||||
}
|
||||
if (isDrawingReadOnly()) {
|
||||
toast.info('확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요.');
|
||||
return;
|
||||
@@ -197,6 +221,11 @@ export function registerAisloDrawingBridge() {
|
||||
setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {});
|
||||
setHostReadOnly(event.data.readOnly === true);
|
||||
setHostTitle(event.data.hostTitle ?? 'B07 상세 설계');
|
||||
setHostSave(event.data.hostSave === true);
|
||||
setRibbonCollapsed(event.data.ribbonCollapsed === true);
|
||||
if (event.data.panelsCollapsed) setInspectorCollapsed(true);
|
||||
setViewOnly(event.data.viewOnly === true);
|
||||
setOriginMarker(event.data.originMarker === true);
|
||||
if (event.data.frameEdit) applyFramePreview();
|
||||
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
|
||||
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 오토캐드 도형 붙이기 — 두 길 모두 서버가 DXF 를 도형으로 바꿔 현재 도면에 원좌표 그대로 붙이고
|
||||
* 고른 상태로 둔다(되돌리기 한 번에 빠짐).
|
||||
* - 끌어다 놓기: 오토캐드 WBLOCK(DWG) · DXFOUT(DXF) 로 저장한 파일, 또는 도면 파일 통째.
|
||||
* 끄는 동안 `body.cad-file-dragging` 으로 흐린 테두리.
|
||||
* - Ctrl+V: 시스템 클립보드 글이 DXF 면 같은 길 · 아니면 앱 안 붙여넣기(PASTECLIP).
|
||||
* 키 처리(`input-controller.ts`)는 Ctrl+V 의 기본 동작을 막지 않아 브라우저가 `paste` 를 쏜다 —
|
||||
* `paste` 이벤트의 글은 권한 없이 읽힘(iframe 안에서도).
|
||||
*/
|
||||
import { toast } from 'react-toastify';
|
||||
import { runCommandInput } from '../commands/run-command';
|
||||
import { hasClipboardContent } from '../helpers/cad-clipboard';
|
||||
import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json';
|
||||
import {
|
||||
addEntities,
|
||||
getLayers,
|
||||
isDrawingReadOnly,
|
||||
setLayers,
|
||||
setSelectedEntityIds,
|
||||
} from '../state';
|
||||
import type { JsonEntity } from '../entities/Entity';
|
||||
import { type ClipLayer, isDxfText, prepareClip } from './dxf-clip';
|
||||
|
||||
const DXF_CLIP_URL = '/api/projects/dxf-clip';
|
||||
|
||||
export async function pasteDxfText(text: string): Promise<number> {
|
||||
return pasteServerClip(
|
||||
await fetch(DXF_CLIP_URL, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function pasteDrawingFile(file: File): Promise<number> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return pasteServerClip(
|
||||
await fetch(`${DXF_CLIP_URL}/file`, { method: 'POST', credentials: 'include', body: form })
|
||||
);
|
||||
}
|
||||
|
||||
async function pasteServerClip(response: Response): Promise<number> {
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
entities?: JsonEntity[];
|
||||
layers?: ClipLayer[];
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok || !payload.entities) {
|
||||
throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const { entities, newLayers } = prepareClip(payload.entities, payload.layers ?? [], getLayers());
|
||||
const drawing = await getEntitiesAndLayersFromJsonObject({ entities, layers: getLayers() });
|
||||
if (newLayers.length) setLayers([...getLayers(), ...newLayers]);
|
||||
addEntities(drawing.entities, true);
|
||||
setSelectedEntityIds(drawing.entities.map((entity) => entity.id));
|
||||
return drawing.entities.length;
|
||||
}
|
||||
|
||||
function reportPaste(job: Promise<number>): void {
|
||||
job
|
||||
.then((count) => toast.success(`오토캐드 도형 ${count}개를 붙여넣었습니다.`))
|
||||
.catch((error: unknown) =>
|
||||
toast.error(error instanceof Error ? error.message : '도형을 붙여넣지 못했습니다.')
|
||||
);
|
||||
}
|
||||
|
||||
function refuseReadOnly(): boolean {
|
||||
if (!isDrawingReadOnly()) return false;
|
||||
toast.info('볼 수만 있는 도면입니다 — 붙여넣을 수 없습니다.');
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerDxfClipPaste(): void {
|
||||
document.addEventListener('paste', (event: ClipboardEvent) => {
|
||||
if ((event.target as HTMLElement | null)?.closest?.('input, textarea, select')) return;
|
||||
const text = event.clipboardData?.getData('text/plain') ?? '';
|
||||
event.preventDefault();
|
||||
if (!isDxfText(text)) {
|
||||
if (hasClipboardContent()) runCommandInput('PASTECLIP');
|
||||
return;
|
||||
}
|
||||
if (!refuseReadOnly()) reportPaste(pasteDxfText(text));
|
||||
});
|
||||
|
||||
// 파일을 끌 때만 — 앱 안 끌기(글 · 패널)는 건드리지 않음. 기본 동작(파일 열기로 화면 이동)은 막음.
|
||||
const dragging = (event: DragEvent) => event.dataTransfer?.types.includes('Files') ?? false;
|
||||
const setDragging = (on: boolean) => document.body.classList.toggle('cad-file-dragging', on);
|
||||
document.addEventListener('dragover', (event) => {
|
||||
if (!dragging(event)) return;
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
});
|
||||
document.addEventListener('dragleave', (event) => {
|
||||
if (!event.relatedTarget) setDragging(false);
|
||||
});
|
||||
document.addEventListener('drop', (event) => {
|
||||
if (!dragging(event)) return;
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
const file = event.dataTransfer?.files[0];
|
||||
if (file && !refuseReadOnly()) reportPaste(pasteDrawingFile(file));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 오토캐드 → 웹 CAD 붙여넣기 — 순수 부분.
|
||||
* 오토캐드 기본 명령(WBLOCK · DXFOUT)으로 저장한 파일을 끌어다 놓거나 DXF 글을 Ctrl+V 하면,
|
||||
* 서버(`/api/projects/dxf-clip` · `/file`)가 돌려준 JSON 을 여기서 현재 도면에 맞춘다.
|
||||
* DOM · 상태를 안 만지는 것만 둔다(시험이 바로 부름) — 잇기는 `dxf-clip-paste.ts`.
|
||||
*/
|
||||
import type { Layer } from '../App.types.ts';
|
||||
import type { JsonEntity } from '../entities/Entity';
|
||||
|
||||
/** DXF 글인가 — 그룹 코드 0 다음 줄이 SECTION 으로 시작(앞 공백 · CRLF 허용). */
|
||||
export function isDxfText(text: string): boolean {
|
||||
return /^\s*0\s*\r?\n\s*SECTION\b/.test(text);
|
||||
}
|
||||
|
||||
export interface ClipLayer {
|
||||
name: string;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 엔티티(layerId = DXF 도면층 이름)를 현재 도면에 붙일 모양으로 —
|
||||
* 같은 이름 도면층이 있으면 그 층 · 없으면 새 층(DXF 층 색) · id 는 모두 새로(두 번 붙여도 안 겹침).
|
||||
*/
|
||||
export function prepareClip(
|
||||
entities: JsonEntity[],
|
||||
clipLayers: ClipLayer[],
|
||||
existing: Layer[]
|
||||
): { entities: JsonEntity[]; newLayers: Layer[] } {
|
||||
const idByName = new Map(existing.map((layer) => [layer.name, layer.id]));
|
||||
const newLayers: Layer[] = [];
|
||||
const colorByName = new Map(clipLayers.map((layer) => [layer.name, layer.color]));
|
||||
const layerIdOf = (name: string): string => {
|
||||
let id = idByName.get(name);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
idByName.set(name, id);
|
||||
const color = colorByName.get(name);
|
||||
newLayers.push({ id, name, isVisible: true, isLocked: false, ...(color ? { color } : {}) });
|
||||
}
|
||||
return id;
|
||||
};
|
||||
const renew = (entity: JsonEntity): JsonEntity => ({
|
||||
...entity,
|
||||
id: crypto.randomUUID(),
|
||||
layerId: layerIdOf(String(entity.layerId ?? '0')),
|
||||
...(entity.children ? { children: entity.children.map(renew) } : {}),
|
||||
});
|
||||
return { entities: entities.map(renew), newLayers };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import './random-uuid-polyfill';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
@@ -17,6 +18,7 @@ import { queryEntitiesNearPoint } from './helpers/spatial-index';
|
||||
import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points';
|
||||
import { InputController } from './inputController/input-controller.ts';
|
||||
import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.ts';
|
||||
import { registerDxfClipPaste } from './integration/dxf-clip-paste.ts';
|
||||
import {
|
||||
getActiveToolActor,
|
||||
getCanvas,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
getLastDrawTimestamp,
|
||||
getScreenCanvasDrawController,
|
||||
getSnapPoint,
|
||||
isViewOnly,
|
||||
setActiveLayerId,
|
||||
setActiveToolActor,
|
||||
setCanvas,
|
||||
@@ -81,7 +84,7 @@ function startDrawLoop(
|
||||
/**
|
||||
* Highlight the entity closest to the mouse when the select tool is active
|
||||
*/
|
||||
if (getActiveToolActor()?.getSnapshot()?.context?.type === Tool.SELECT) {
|
||||
if (getActiveToolActor()?.getSnapshot()?.context?.type === Tool.SELECT && !isViewOnly()) {
|
||||
const screenCanvasDrawController = getScreenCanvasDrawController();
|
||||
if (!screenCanvasDrawController) {
|
||||
throw new Error('getScreenCanvasDrawController() returned null');
|
||||
@@ -152,6 +155,7 @@ function initApplication() {
|
||||
setLayers(layers);
|
||||
setActiveLayerId(layers[0].id);
|
||||
registerAisloDrawingBridge();
|
||||
registerDxfClipPaste();
|
||||
registerCadDebugHook();
|
||||
registerAutoSave();
|
||||
const screenCanvasDrawController = new ScreenCanvasDrawController(context);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* http + IP 주소(예: Tailscale)는 보안 문맥이 아니라 `crypto.randomUUID` 가 없음 — 개체 만들기에서 죽음.
|
||||
* getRandomValues(보안 문맥 아니어도 됨)로 v4 UUID 를 채움 · 이미 있으면 그대로.
|
||||
* `main.tsx` 가 맨 먼저 부름. 앱 본체 짝 = `A00_Common/random_uuid_polyfill.ts`.
|
||||
*/
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
|
||||
crypto.randomUUID = () => {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` as `${string}-${string}-${string}-${string}-${string}`;
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -3,7 +3,12 @@ import { type FC, type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import type { CadCommand } from '../commands/command.types';
|
||||
import { getCommandById } from '../commands/registry';
|
||||
import { runCommand } from '../commands/run-command';
|
||||
import { getActiveRibbonTab, setActiveRibbonTab } from '../components/ui-state';
|
||||
import {
|
||||
getActiveRibbonTab,
|
||||
isRibbonCollapsed,
|
||||
setActiveRibbonTab,
|
||||
setRibbonCollapsed,
|
||||
} from '../components/ui-state';
|
||||
import type { Tool } from '../tools';
|
||||
import { RIBBON_TABS, type RibbonPanel, type RibbonWidget } from './ribbon.config';
|
||||
|
||||
@@ -147,6 +152,21 @@ const Panel: FC<{
|
||||
export const Ribbon: FC<RibbonProps> = ({ activeTool, widgets, onCommandRun }) => {
|
||||
const activeTabId = getActiveRibbonTab();
|
||||
const activeTab = RIBBON_TABS.find((tab) => tab.id === activeTabId) ?? RIBBON_TABS[0];
|
||||
const collapsed = isRibbonCollapsed();
|
||||
// 접힘 모드에서 탭을 누르면 잠깐 펼침(도면 위에 겹쳐 뜸) — 리본 밖을 누르면 다시 접힘
|
||||
// (오피스 「리본 자동 숨기기」). 접기 단추로 펼치면 고정 펼침.
|
||||
const [peek, setPeek] = useState(false);
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
const peeking = collapsed && peek;
|
||||
|
||||
useEffect(() => {
|
||||
if (!peeking) return;
|
||||
const close = (event: PointerEvent) => {
|
||||
if (!navRef.current?.contains(event.target as Node)) setPeek(false);
|
||||
};
|
||||
window.addEventListener('pointerdown', close, true);
|
||||
return () => window.removeEventListener('pointerdown', close, true);
|
||||
}, [peeking]);
|
||||
|
||||
const handleRun = (command: CadCommand) => {
|
||||
runCommand(command);
|
||||
@@ -154,30 +174,46 @@ export const Ribbon: FC<RibbonProps> = ({ activeTool, widgets, onCommandRun }) =
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="cad-ribbon controls" aria-label="CAD 리본">
|
||||
<nav ref={navRef} className="cad-ribbon controls" aria-label="CAD 리본" data-peek={peeking}>
|
||||
<div className="cad-ribbon-tabs">
|
||||
{RIBBON_TABS.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
data-active={tab.id === activeTab.id}
|
||||
onClick={() => setActiveRibbonTab(tab.id)}
|
||||
onClick={() => {
|
||||
setActiveRibbonTab(tab.id);
|
||||
if (collapsed) setPeek(true);
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="cad-ribbon-tabs__collapse"
|
||||
onClick={() => {
|
||||
setPeek(false);
|
||||
setRibbonCollapsed(!collapsed);
|
||||
}}
|
||||
title={collapsed ? '리본 펼치기' : '리본 접기'}
|
||||
>
|
||||
{collapsed ? '⌄' : '⌃'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="cad-ribbon-panels">
|
||||
{activeTab.panels.map((panel) => (
|
||||
<Panel
|
||||
key={panel.label}
|
||||
panel={panel}
|
||||
activeTool={activeTool}
|
||||
widgets={widgets}
|
||||
onRun={handleRun}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{(!collapsed || peeking) && (
|
||||
<div className="cad-ribbon-panels">
|
||||
{activeTab.panels.map((panel) => (
|
||||
<Panel
|
||||
key={panel.label}
|
||||
panel={panel}
|
||||
activeTool={activeTool}
|
||||
widgets={widgets}
|
||||
onRun={handleRun}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -192,7 +192,11 @@ let frameEditMode = false;
|
||||
let frameFields: Record<string, string> = {};
|
||||
/** 부모가 보기 전용으로 실었는가 — M02 에서 고칠 권한이 없는 양식을 볼 때. */
|
||||
let hostReadOnly = false;
|
||||
/** 미리보기(M02 집계표 카드) — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */
|
||||
let viewOnly = false;
|
||||
let originMarker = false;
|
||||
let hostTitle = 'B07 상세 설계';
|
||||
let hostSave = false;
|
||||
|
||||
/**
|
||||
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
|
||||
@@ -271,6 +275,9 @@ export const isDrawingDirty = () => drawingDirty;
|
||||
* 확정). 보기(확대·이동·도면층 켜기끄기)는 그대로 두고, 푸는 길은 부모의 [수정]뿐이다.
|
||||
*/
|
||||
export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true || hostReadOnly;
|
||||
export const isViewOnly = (): boolean => viewOnly;
|
||||
/** 좌표 0,0 기준점 십자를 그리는가 — 그리기 겹판일 뿐 도면 개체가 아니다. */
|
||||
export const isOriginMarker = (): boolean => originMarker;
|
||||
|
||||
// setters
|
||||
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
|
||||
@@ -498,6 +505,13 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => {
|
||||
/** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */
|
||||
/** 부모가 실은 도면을 보기 전용으로 둔다 — 확정본과 같은 막힘(도면을 실을 때마다 새로 정함). */
|
||||
/** 제목표시줄 부제 — 부모가 도면을 실을 때 정함(없으면 B07 기본 글). */
|
||||
/** 저장(💾 · Ctrl+S)을 부모 페이지 [저장]에 맡기는가 — M02 양식만 켠다. */
|
||||
export const isHostSave = (): boolean => hostSave;
|
||||
export const setHostSave = (enabled: boolean) => {
|
||||
hostSave = enabled;
|
||||
// 제목 줄이 없어지면 그 높이만큼 위로 당긴다(App.css `.cad-host-save`).
|
||||
document.documentElement.classList.toggle('cad-host-save', enabled);
|
||||
};
|
||||
export const getHostTitle = (): string => hostTitle;
|
||||
export const setHostTitle = (title: string) => {
|
||||
hostTitle = title;
|
||||
@@ -507,6 +521,17 @@ export const setHostReadOnly = (readOnly: boolean) => {
|
||||
hostReadOnly = readOnly;
|
||||
notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
};
|
||||
export const setOriginMarker = (enabled: boolean) => {
|
||||
originMarker = enabled;
|
||||
notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
};
|
||||
export const setViewOnly = (enabled: boolean) => {
|
||||
viewOnly = enabled;
|
||||
// 그리는 십자 커서 대신 손 모양 — 캔버스는 `cursor-none` 이라 안 두면 커서가 안 보임.
|
||||
const canvas = getCanvas();
|
||||
if (canvas) canvas.style.cursor = enabled ? 'grab' : '';
|
||||
notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
};
|
||||
export const setFrameEditMode = (enabled: boolean, fields: Record<string, string> = {}) => {
|
||||
frameEditMode = enabled;
|
||||
frameFields = enabled ? fields : {};
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
*
|
||||
* iframe은 호스트와 **같은 오리진**이라 `localStorage["frd_theme"]`를 공유한다.
|
||||
* 부모가 토글하며 값을 쓰면 이 문서에는 `storage` 이벤트가 오므로, 부모 쪽에
|
||||
* 별도 전달 코드(postMessage)를 넣지 않는다. 값이 없으면 미지정 상태로 두어
|
||||
* `ui_template_theme.css`의 `prefers-color-scheme` 폴백이 그대로 먹는다.
|
||||
* 별도 전달 코드(postMessage)를 넣지 않는다. 값이 없으면 부모 문서의 `data-theme`
|
||||
* (호스트 index.html 기본값 light)를 따른다 — 시스템 설정을 따로 따르면 호스트와 어긋난다.
|
||||
* 부모가 없을 때(단독 실행)만 `prefers-color-scheme` 폴백이 먹는다.
|
||||
*/
|
||||
|
||||
import { bumpSceneVersion } from './helpers/scene-version.ts';
|
||||
@@ -27,11 +28,22 @@ function applyTheme(value: string | null): void {
|
||||
bumpSceneVersion();
|
||||
}
|
||||
|
||||
/** 부모(호스트) 문서의 테마 — 부모가 없거나 못 읽으면 null. */
|
||||
function parentTheme(): string | null {
|
||||
try {
|
||||
return window.parent === window
|
||||
? null
|
||||
: window.parent.document.documentElement.getAttribute('data-theme');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 부팅 시 1회 호출 — 현재 테마 반영 + 이후 변경 구독. */
|
||||
export function syncThemeFromHost(): void {
|
||||
applyTheme(localStorage.getItem(THEME_STORAGE_KEY));
|
||||
applyTheme(localStorage.getItem(THEME_STORAGE_KEY) ?? parentTheme());
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === THEME_STORAGE_KEY) applyTheme(event.newValue);
|
||||
if (event.key === THEME_STORAGE_KEY) applyTheme(event.newValue ?? parentTheme());
|
||||
});
|
||||
// 시스템 설정 폴백으로 도는 중이면 OS 전환도 따라간다.
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
@@ -61,26 +73,38 @@ function relativeLuminance(r: number, g: number, b: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* 도면 색 보정 — 저장된 엔티티 색은 다크 배경 기준(밝은 회색·흰색)이라
|
||||
* 라이트 모드 흰 종이 위에서는 안 보인다. **그릴 때만** 휘도를 낮춰 색상(색조)은
|
||||
* 유지한 채 대비를 살린다. 저장 데이터는 건드리지 않는다.
|
||||
* 자동색 — 오토캐드 색 번호 7 처럼 바탕을 따라 흰/검정으로 그리는 색.
|
||||
* 검정 · 흰색(DXF 색 7) · 기본색(`#f5f7fa` = 새 개체 · DXF ByLayer 가져온 색).
|
||||
*/
|
||||
const AUTO_COLORS = new Set(['#000000', '#ffffff', '#f5f7fa', 'black', 'white']);
|
||||
|
||||
function normalizeHex(color: string): string {
|
||||
const value = color.trim().toLowerCase();
|
||||
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(value);
|
||||
return short ? `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}` : value;
|
||||
}
|
||||
|
||||
export function isAutoColor(color: string): boolean {
|
||||
return AUTO_COLORS.has(normalizeHex(color));
|
||||
}
|
||||
|
||||
/**
|
||||
* 도면 색 보정 — **그릴 때만** 바꾼다(저장 데이터는 그대로).
|
||||
* - 자동색: 어두운 바탕 흰색 · 밝은 바탕 검정.
|
||||
* - 밝은 바탕: 흰 종이에 묻히는 밝은 색은 휘도를 낮춰 색조를 살린 채 대비를 맞춘다.
|
||||
* - 어두운 바탕: 자동색 밖은 그대로(어두운 색을 밝히지 않음).
|
||||
*/
|
||||
export function paintColor(color: string): string {
|
||||
if (!isLightTheme()) return color;
|
||||
const light = isLightTheme();
|
||||
if (isAutoColor(color)) return light ? '#000000' : '#ffffff';
|
||||
if (!light) return color;
|
||||
const cached = paintCache.get(color);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color);
|
||||
let result = color;
|
||||
if (hex) {
|
||||
const raw = hex[1];
|
||||
const full =
|
||||
raw.length === 3
|
||||
? raw
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
: raw;
|
||||
const full = normalizeHex(color).slice(1);
|
||||
const r = Number.parseInt(full.slice(0, 2), 16);
|
||||
const g = Number.parseInt(full.slice(2, 4), 16);
|
||||
const b = Number.parseInt(full.slice(4, 6), 16);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user