Compare commits
676
Commits
c0eb280c32
..
CODEX
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcb1c725c4 | ||
|
|
2f7acff79f | ||
|
|
8206bf6bb4 | ||
|
|
fedc0b5b15 | ||
|
|
63c6fa360c | ||
|
|
8b2473af6e | ||
|
|
99a514d9b8 | ||
|
|
7147ca1a40 | ||
|
|
88d0b8c5ca | ||
|
|
13c6b581ca | ||
|
|
fb56d7c294 | ||
|
|
f1b464e64b | ||
|
|
6ad43ed795 | ||
|
|
b981911755 | ||
|
|
f7c91f2f12 | ||
|
|
3913258df9 | ||
|
|
25cf716a0d | ||
|
|
c56d4254f5 | ||
|
|
1b908d3c7e | ||
|
|
71666a8633 | ||
|
|
74ae298b9c | ||
|
|
3ec501ab47 | ||
|
|
dc725012ed | ||
|
|
4fa49e307a | ||
|
|
c53eabf517 | ||
|
|
14068c0931 | ||
|
|
62073aac1b | ||
|
|
d1675e6218 | ||
|
|
0f510eb53b | ||
|
|
9f69ebe225 | ||
|
|
0541830750 | ||
|
|
c0a76b844d | ||
|
|
7ea8b75162 | ||
|
|
4a1fb2128b | ||
|
|
7459f31170 | ||
|
|
f685b4e3b3 | ||
|
|
c1ad11e869 | ||
|
|
9ed36efe08 | ||
|
|
95849aefbd | ||
|
|
d17ede799d | ||
|
|
b315181246 | ||
|
|
04b9524b54 | ||
|
|
61caa8e27d | ||
|
|
6a5c5382b3 | ||
|
|
647b474446 | ||
|
|
5a206fc2fe | ||
|
|
cd2ee5664d | ||
|
|
5d97102f35 | ||
|
|
e7d8ea8002 | ||
|
|
f7f4572f3c | ||
|
|
59a4dd4eba | ||
|
|
a01cf96769 | ||
|
|
e55494b379 | ||
|
|
4a5b00d72d | ||
|
|
657d82b9ce | ||
|
|
6096e065af | ||
|
|
4accd1e641 | ||
|
|
9c3528f063 | ||
|
|
f9fd05809b | ||
|
|
08f1fc56a6 | ||
|
|
05a4943bcc | ||
|
|
b2b6435778 | ||
|
|
5f2b8ae906 | ||
|
|
2847fec327 | ||
|
|
5b92de9510 | ||
|
|
6d4b309e0d | ||
|
|
6e108f010a | ||
|
|
5f14c4b89a | ||
|
|
9785556ec0 | ||
|
|
4d128335cf | ||
|
|
58aaaf5bcf | ||
|
|
8c9e51936d | ||
|
|
8e0aeccd8b | ||
|
|
7cc8428aa5 | ||
|
|
242db61ee4 | ||
|
|
ccd738fff8 | ||
|
|
316bf7a958 | ||
|
|
1ff64f4d92 | ||
|
|
cb280c1de9 | ||
|
|
8fa828f038 | ||
|
|
13dd39325d | ||
|
|
c8686ee3ad | ||
|
|
ec613b8dc6 | ||
|
|
aa1edd65dc | ||
|
|
2afb8879a0 | ||
|
|
c8144a97b1 | ||
|
|
0b0106f527 | ||
|
|
dc1399244d | ||
|
|
e63cadfde4 | ||
|
|
f7743c95e8 | ||
|
|
31fd33f12d | ||
|
|
341ae31877 | ||
|
|
296337327e | ||
|
|
8f1dfa0605 | ||
|
|
22bf0ad76f | ||
|
|
1428a25919 | ||
|
|
bf5f4001c3 | ||
|
|
ae1b3682e8 | ||
|
|
ca946ebbf1 | ||
|
|
7ffafabb77 | ||
|
|
5ada61bf88 | ||
|
|
07b01f963f | ||
|
|
8ba38e47b6 | ||
|
|
a9a4631160 | ||
|
|
0c5d42fb7b | ||
|
|
3f0c1d774f | ||
|
|
b20c702c54 | ||
|
|
f8302149a9 | ||
|
|
ac0b55e819 | ||
|
|
6c0210ebc5 | ||
|
|
209f05f802 | ||
|
|
203179f93e | ||
|
|
59cb28ce58 | ||
|
|
fd3800857d | ||
|
|
ae51bed23c | ||
|
|
d8e5723c78 | ||
|
|
16babf0515 | ||
|
|
354e6cd029 | ||
|
|
effde345f7 | ||
|
|
161d0f214b | ||
|
|
7502315026 | ||
|
|
21184fa104 | ||
|
|
417fcb2b29 | ||
|
|
c105d7ff2e | ||
|
|
f4d383369a | ||
|
|
515e09631a | ||
|
|
af1e694c83 | ||
|
|
7716f4dd5b | ||
|
|
de9b3958f3 | ||
|
|
8bed79a277 | ||
|
|
be0317ef74 | ||
|
|
be37e29093 | ||
|
|
434e65ad94 | ||
|
|
5b7bf3d789 | ||
|
|
b18ac9a581 | ||
|
|
158d978434 | ||
|
|
50abcb894d | ||
|
|
6c09be87f8 | ||
|
|
dffc42381a | ||
|
|
a3407f56b1 | ||
|
|
3cd3ef8869 | ||
|
|
9a43a924a7 | ||
|
|
6f778cd900 | ||
|
|
36f57cf722 | ||
|
|
689f232ab9 | ||
|
|
01785fbcd9 | ||
|
|
6406927f09 | ||
|
|
e31baa7eea | ||
|
|
2e921ee1e7 | ||
|
|
733f70cfa3 | ||
|
|
1b5a6ab79a | ||
|
|
9463338bdd | ||
|
|
5d0f0fd1d7 | ||
|
|
dc59f8afd8 | ||
|
|
e916c81e38 | ||
|
|
8b82df0d54 | ||
|
|
d1a565b8f1 | ||
|
|
38a7e95992 | ||
|
|
61d82cc060 | ||
|
|
2c87f91f36 | ||
|
|
238ec02859 | ||
|
|
1982821e56 | ||
|
|
cee8893e64 | ||
|
|
7a776cb028 | ||
|
|
6af6e87560 | ||
|
|
544d012808 | ||
|
|
32cafa6d2f | ||
|
|
41a30e2b89 | ||
|
|
ce4ea7069e | ||
|
|
b4100acc6d | ||
|
|
92e2784038 | ||
|
|
551593d040 | ||
|
|
2a3051fbca | ||
|
|
58ba4545f2 | ||
|
|
4d7a978d0b | ||
|
|
a718c2d67c | ||
|
|
760e43c8df | ||
|
|
d9ae3a1a7a | ||
|
|
b67a3be753 | ||
|
|
8f55366949 | ||
|
|
542c5cc260 | ||
|
|
397f5d51a6 | ||
|
|
fcd7191a93 | ||
|
|
0c79b4e209 | ||
|
|
26eac086a8 | ||
|
|
2deef5a5da | ||
|
|
a66df6d887 | ||
|
|
53d08087c9 | ||
|
|
d6c85a5f28 | ||
|
|
312f4ab8b6 | ||
|
|
c14717d0c2 | ||
|
|
015921c906 | ||
|
|
b3b4363120 | ||
|
|
dd2f51b521 | ||
|
|
b5114ec17f | ||
|
|
41cf1cd195 | ||
|
|
fbb214cd94 | ||
|
|
559d445e4e | ||
|
|
bf21676ef5 | ||
|
|
818470e25f | ||
|
|
3a59f715f6 | ||
|
|
4a1a71fd3a | ||
|
|
7c62d90d9a | ||
|
|
767adc4a42 | ||
|
|
365ff5a13d | ||
|
|
7f04bf961c | ||
|
|
d94f3f78c1 | ||
|
|
45a22c5d72 | ||
|
|
b038e99d8d | ||
|
|
083c12ad3a | ||
|
|
98b1adac34 | ||
|
|
0d8f967262 | ||
|
|
470d00a18c | ||
|
|
db1bb9aa87 | ||
|
|
057d3976f9 | ||
|
|
fe30cdbbd8 | ||
|
|
978ccde8b1 | ||
|
|
473d959d60 | ||
|
|
7ad36c122f | ||
|
|
d74eecc9f6 | ||
|
|
19056e63a9 | ||
|
|
a2340c4d6d | ||
|
|
90a2cb9a11 | ||
|
|
76676b78fc | ||
|
|
5167c0ae6d | ||
|
|
8a9456579a | ||
|
|
dd80662750 | ||
|
|
ac660e12c7 | ||
|
|
acd30ba32c | ||
|
|
308c20fd9c | ||
|
|
507a216373 | ||
|
|
a8ab28ca17 | ||
|
|
6ad33cd105 | ||
|
|
692a2903ef | ||
|
|
dbcc19da51 | ||
|
|
7d92298b03 | ||
|
|
adb822e0b2 | ||
|
|
38aae89b2d | ||
|
|
c471133cd0 | ||
|
|
a8bae4e693 | ||
|
|
8d3050c5ac | ||
|
|
17212254b9 | ||
|
|
0a04660932 | ||
|
|
789eddf460 | ||
|
|
df0240eb2f | ||
|
|
2c520537b6 | ||
|
|
3b674dc1b2 | ||
|
|
7760dcd777 | ||
|
|
40e70c38e4 | ||
|
|
79f1a52a3e | ||
|
|
01062fcdb6 | ||
|
|
5717d3cabd | ||
|
|
3068e03dc1 | ||
|
|
7beeb089d1 | ||
|
|
0ff37c9b97 | ||
|
|
34b0ec6e2a | ||
|
|
439f605ba6 | ||
|
|
cd26699d21 | ||
|
|
812d969999 | ||
|
|
b492a9195a | ||
|
|
77d3ffeee6 | ||
|
|
ea4cb043cd | ||
|
|
be93410299 | ||
|
|
77754ad5f0 | ||
|
|
3b00f19dc3 | ||
|
|
399c3a11ea | ||
|
|
22eaed6509 | ||
|
|
998660c342 | ||
|
|
90194b2849 | ||
|
|
e7ee049548 | ||
|
|
074d9aec13 | ||
|
|
5b41cfaf01 | ||
|
|
eb387d5f84 | ||
|
|
8cd4a78efc | ||
|
|
5f43ef8541 | ||
|
|
5b04f453a6 | ||
|
|
22f3df9476 | ||
|
|
90e478fe0b | ||
|
|
5cb65dff16 | ||
|
|
64e0759a25 | ||
|
|
9f22a6b467 | ||
|
|
78dc1075b6 | ||
|
|
10cc5dd373 | ||
|
|
e65592a76d | ||
|
|
1973eba8b2 | ||
|
|
4e0fd3106d | ||
|
|
eb3ede5350 | ||
|
|
f94d7c13a4 | ||
|
|
35dbc4254f | ||
|
|
9aa07fda75 | ||
|
|
6c4b059837 | ||
|
|
2be64ab732 | ||
|
|
03b8ef42ce | ||
|
|
6837feaa2a | ||
|
|
a1bae17e4d | ||
|
|
1a0ba6c6dc | ||
|
|
cc6889e4f3 | ||
|
|
4a25b99f88 | ||
|
|
0941a02eb4 | ||
|
|
71fac35dab | ||
|
|
154d693599 | ||
|
|
2d1719039e | ||
|
|
b5eea31357 | ||
|
|
b8195aff2e | ||
|
|
5892232890 | ||
|
|
927e85d110 | ||
|
|
51b380b64d | ||
|
|
b3764299a3 | ||
|
|
08e493f132 | ||
|
|
b7449f857c | ||
|
|
41c975c7bf | ||
|
|
9d85ce8b4a | ||
|
|
76c450b948 | ||
|
|
38b3459efb | ||
|
|
4417628a1c | ||
|
|
6ad10d92d3 | ||
|
|
ef31f057f8 | ||
|
|
4e7e9a6ac2 | ||
|
|
2cbfc1f53f | ||
|
|
1758ba0fb2 | ||
|
|
258a3512a4 | ||
|
|
927b7aa727 | ||
|
|
548daa6f4f | ||
|
|
f25cd17ab5 | ||
|
|
867c4650c7 | ||
|
|
43572ccccc | ||
|
|
2615bf78d2 | ||
|
|
aafd38d0c7 | ||
|
|
73c5733e22 | ||
|
|
aecf04acad | ||
|
|
4366cd3f03 | ||
|
|
3b0c5480f2 | ||
|
|
7dbae140c1 | ||
|
|
0494079111 | ||
|
|
b777b36a1a | ||
|
|
751a5da02c | ||
|
|
a24dd43eec | ||
|
|
af1b419d5d | ||
|
|
55f1eeeb7d | ||
|
|
2082cd98a5 | ||
|
|
cc3a8a7109 | ||
|
|
2a9b343e2e | ||
|
|
f72252a5ca | ||
|
|
b2cbd852a0 | ||
|
|
c48ed4c309 | ||
|
|
207c2e40eb | ||
|
|
071676e0b8 | ||
|
|
1bba901834 | ||
|
|
acc4408028 | ||
|
|
5e92e37b77 | ||
|
|
fc87859511 | ||
|
|
afcb59d11b | ||
|
|
155d1eefbe | ||
|
|
7cac596afb | ||
|
|
2de2c23c9a | ||
|
|
c96d173f58 | ||
|
|
5c0f12a59a | ||
|
|
8e6815bfd5 | ||
|
|
8eceee987d | ||
|
|
4425bd4fea | ||
|
|
5982748ef6 | ||
|
|
85e7e7eaf7 | ||
|
|
7389335ca9 | ||
|
|
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/
|
||||
~$*
|
||||
|
||||
@@ -57,6 +57,15 @@ export const STATE_REGISTRY = {
|
||||
legacy: () => "b05-route-drainage-collapsed",
|
||||
},
|
||||
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
|
||||
/** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */
|
||||
"drainage-visible": { bucket: "pref", scope: "global" },
|
||||
/** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */
|
||||
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
|
||||
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
|
||||
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
|
||||
"routeedit-view": { bucket: "pref", scope: "project" },
|
||||
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
|
||||
"routeedit-locks": { bucket: "pref", scope: "project" },
|
||||
"masshaul-open": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
@@ -103,6 +112,8 @@ export const STATE_REGISTRY = {
|
||||
scope: "project",
|
||||
legacy: (p) => `aislo:structure-pick:${p}`,
|
||||
},
|
||||
/** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */
|
||||
selection: { bucket: "draft", scope: "project" },
|
||||
/** 배수관·암거 조정창에서 예약한 옵션 값. */
|
||||
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
|
||||
/** 배수관 이동(측점 옮김) 예약. */
|
||||
@@ -147,10 +158,19 @@ export const STATE_REGISTRY = {
|
||||
* 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고
|
||||
* [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */
|
||||
crossdesign: { bucket: "draft", scope: "route" },
|
||||
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
|
||||
* 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */
|
||||
"alignment-draft": {
|
||||
bucket: "draft",
|
||||
scope: "route",
|
||||
legacy: (_p, r) => `b05-profile-alignment-draft:${r}`,
|
||||
},
|
||||
|
||||
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
|
||||
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
|
||||
latest: { bucket: "result", scope: "project", legacy: (p) => `b05:latest:${p}` },
|
||||
/** 종단도 최소 흙덮기 강제 여부 — 종단 보기 「페이지 설정」 값을 두 보기가 같이 본다(24-2c). */
|
||||
"profile-enforce-min-cover": { bucket: "result", scope: "project" },
|
||||
/** 종횡단 설정값(config 상수 + 프로젝트 임도 종류 + 노선 id). B05·B06 이 같은 값을
|
||||
* 받아 쓰므로 화면을 오갈 때마다 다시 묻지 않는다(2026-09-06 호출 정리).
|
||||
* 노선이 바뀌면 `latest` 와 함께 버린다. */
|
||||
@@ -169,6 +189,9 @@ export const STATE_REGISTRY = {
|
||||
scope: "project",
|
||||
legacy: (p) => `b06:rock-boundary-default:${p}`,
|
||||
},
|
||||
/** B05 계획노선 편집 창 — 마지막에 적용한 초기노선 갈래(로직 · 비중 · 노드 · 비교값).
|
||||
* 노선을 다시 계산해 결과를 버릴 때도 이 값은 새 노선의 출발점이라 되살린다(`_Apply`). */
|
||||
"routeedit-initial": { bucket: "result", scope: "project" },
|
||||
} as const satisfies Record<string, StateEntry>;
|
||||
|
||||
export type StateName = keyof typeof STATE_REGISTRY;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/* 좌측 패널 아래 고정 영역의 단추 넷([초기화][저장][확정][보기 전환]) — 좁은 패널에서도 글자가
|
||||
두 줄로 꺾이지 않게 안쪽 여백을 줄이고 줄바꿈을 막는다(계획서 24-2b). */
|
||||
.b05-route__dock .b05-route__actions .ui-btn,
|
||||
.b05-route__dock .b06-profile__actions .ui-btn {
|
||||
padding-inline: var(--spacing-8);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* =============================================================================
|
||||
* design_dock.ts
|
||||
* B05 · B06 한 페이지 두 보기의 **좌측 패널 맨 아래 고정 영역**(계획서 24-1 · 24-2b).
|
||||
*
|
||||
* 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 넷은 두 보기가 **같다**:
|
||||
* [초기화] [저장] [확정] [보기 전환](종단 보기에서는 「횡단 보기」 · 횡단 보기에서는
|
||||
* 「종단 보기」). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은
|
||||
* 단추를 만들고 켜고 끄기만 한다(저장 흐름은 `design_save.ts` 몫).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import "./design_dock.css";
|
||||
|
||||
/** 보기 이름 — 종단 보기(3D + 종단도) · 횡단 보기(종단도 + 횡단 카드). */
|
||||
export type DesignView = "profile" | "cross";
|
||||
|
||||
/** 단추 하나의 동작 — `enabled` 가 거짓이면 눌리지 않고 `title` 이 까닭을 알린다. */
|
||||
export interface DesignDockAction {
|
||||
run: () => void | Promise<void>;
|
||||
enabled?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface DesignDockActions {
|
||||
reset: DesignDockAction;
|
||||
save: DesignDockAction;
|
||||
confirm: DesignDockAction;
|
||||
/** [보기 전환] — 지금은 주소 이동이다(2단계에서 같은 페이지 안 전환으로 바뀜). */
|
||||
switchView: DesignDockAction;
|
||||
}
|
||||
|
||||
export interface DesignDock {
|
||||
root: HTMLElement;
|
||||
/** 단추 넷의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */
|
||||
setActions: (actions: Partial<DesignDockActions>) => void;
|
||||
/** 보기가 바뀌면 [보기 전환] 글자가 따라 바뀐다. */
|
||||
setView: (view: DesignView) => void;
|
||||
}
|
||||
|
||||
function label(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 보기 전환 단추 글자 — 가려는 **쪽** 이름이다. */
|
||||
export function switchViewLabel(view: DesignView): string {
|
||||
return label(view === "profile" ? "Design_Btn_ToCross" : "Design_Btn_ToProfile");
|
||||
}
|
||||
|
||||
/**
|
||||
* 아래 고정 영역을 만든다. `above` 는 단추 행 위에 놓을 것(구조물 목록) — 목록을 「구조물 배치」
|
||||
* 본문에 두면 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시).
|
||||
*/
|
||||
export function createDesignDock(
|
||||
view: DesignView,
|
||||
above: ReadonlyArray<HTMLElement>,
|
||||
initial: DesignDockActions,
|
||||
): DesignDock {
|
||||
let current = view;
|
||||
let actions = { ...initial };
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-route__dock ui-sidebar-actions";
|
||||
const divider = document.createElement("hr");
|
||||
divider.className = "b05-structure__divider";
|
||||
const row = document.createElement("div");
|
||||
row.className = view === "profile" ? "b05-route__actions" : "b06-profile__actions";
|
||||
|
||||
const press = (name: keyof DesignDockActions) => (): void => {
|
||||
void Promise.resolve(actions[name].run());
|
||||
};
|
||||
const resetButton = createButton({
|
||||
label: label("Common_Btn_Reset"),
|
||||
variant: "danger",
|
||||
onClick: press("reset"),
|
||||
});
|
||||
const saveButton = createButton({
|
||||
label: label("B06_Profile_Btn_Save"),
|
||||
variant: "ghost",
|
||||
onClick: press("save"),
|
||||
});
|
||||
const confirmButton = createButton({
|
||||
label: label("B06_Profile_Btn_Confirm"),
|
||||
variant: "filled",
|
||||
onClick: press("confirm"),
|
||||
});
|
||||
const switchButton = createButton({
|
||||
label: switchViewLabel(view),
|
||||
variant: "ghost",
|
||||
onClick: press("switchView"),
|
||||
});
|
||||
resetButton.dataset.dockAction = "reset";
|
||||
saveButton.dataset.dockAction = "save";
|
||||
confirmButton.dataset.dockAction = "confirm";
|
||||
switchButton.dataset.dockAction = "switchView";
|
||||
const buttons = {
|
||||
reset: resetButton,
|
||||
save: saveButton,
|
||||
confirm: confirmButton,
|
||||
switchView: switchButton,
|
||||
};
|
||||
|
||||
const sync = (): void => {
|
||||
(Object.keys(buttons) as Array<keyof DesignDockActions>).forEach((name) => {
|
||||
const action = actions[name];
|
||||
buttons[name].disabled = action.enabled === false;
|
||||
if (action.title) buttons[name].title = action.title;
|
||||
else buttons[name].removeAttribute("title");
|
||||
});
|
||||
(switchButton.querySelector(".ui-btn__label") ?? switchButton).textContent =
|
||||
switchViewLabel(current);
|
||||
row.className = current === "profile" ? "b05-route__actions" : "b06-profile__actions";
|
||||
};
|
||||
|
||||
row.append(resetButton, saveButton, confirmButton, switchButton);
|
||||
root.append(...above, divider, row);
|
||||
sync();
|
||||
|
||||
return {
|
||||
root,
|
||||
setActions: (next) => {
|
||||
actions = { ...actions, ...next };
|
||||
sync();
|
||||
},
|
||||
setView: (next) => {
|
||||
current = next;
|
||||
sync();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/* 한 페이지 두 보기(계획서 24-3a · 24-3b) — 감싸는 칸은 자리를 차지하지 않아(display: contents)
|
||||
진행 단계 틀이 예전처럼 아울렛에 바로 붙은 것과 같게 놓인다. 숨긴 보기만 빠진다. */
|
||||
.design-page,
|
||||
.design-page__view,
|
||||
.design-page__profile {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.design-page__view[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단 보기에서 종단도는 3D 위에 떠 있는
|
||||
판(absolute · 3D 크기가 안 바뀜)이고, 횡단 보기에서는 카드 칸 아래 흐름에 선다. */
|
||||
.design-page__main {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.design-page__center {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.design-page__center > .design-page__view > * {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.design-page__main[data-view="cross"] > .design-page__profile > .b05-route-profile {
|
||||
position: relative;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* 횡단 조정(카드 조정창 사본 · 폼 칸에 옮겨 붙은 행)은 횡단 보기 몫 — 종단 보기에서는 한 벌 폼
|
||||
안에서 가린다(패널 뿌리 클래스가 보기를 따라 바뀐다). */
|
||||
.b05-route__panel .b06-adjust-dock,
|
||||
.b05-route__panel [class*="b06-structure-panel__struct--"] {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/* =============================================================================
|
||||
* design_page.ts
|
||||
* B05 · B06 **한 페이지 두 보기**(계획서 24-3a · 24-3b).
|
||||
*
|
||||
* 주소 둘(`#/b05-profile` · `#/b06-section`)이 이 페이지 하나를 연다. 같은 페이지 안에서 주소만
|
||||
* 바뀌면 라우터가 `show(route)` 만 부른다 — 보기를 다시 만들지 않고 숨김 · 보임만 바꾼다.
|
||||
* 다른 페이지로 떠나면 `dispose` 로 3D · 종단도 · 횡단 · 창 듣기를 정리한다.
|
||||
*
|
||||
* 틀은 하나 — 진행 단계 · 좌측 패널(칸 · 아래 단추) · 가운데 아래 종단도는 한 벌(한 노드)이고
|
||||
* (`design_shared`), 보기는 가운데 위 칸만 채운다:
|
||||
* mountCenter(center, host, shared) 종단 보기 = 3D 화면 · 횡단 보기 = 횡단 카드(처음 보일 때)
|
||||
* 보기가 바뀌면 좌측 칸 · 아래 단추 글자 · 종단도 판 · 진행 단계 켜진 칸 · 제목이 따라간다.
|
||||
*
|
||||
* 보기는 `host.onShow` · `onHide` · `onDispose` 로 숨김 · 보임 · 떠남 때 할 일을 건다 —
|
||||
* 숨으면 3D 그리기 루프 · 포인터 듣기를 떼고, 보이면 가게 값으로 다시 그린다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createWorkflowLayout, type WorkflowLayoutHandle } from "@ui/ui_template_workflow_layout";
|
||||
import { workflowSteps } from "../b_page_scaffold";
|
||||
import {
|
||||
fetchWorkflowState,
|
||||
goToWorkflowStage,
|
||||
WORKFLOW_STEP_ROUTES,
|
||||
type WorkflowState,
|
||||
} from "../b_workflow_nav";
|
||||
import { currentRoute, navigateTo, type MountedPage } from "../router";
|
||||
import type { DesignView } from "./design_dock";
|
||||
import type { DesignPageShared } from "./design_shared";
|
||||
import "./design_page.css";
|
||||
|
||||
export type { DesignPageShared } from "./design_shared";
|
||||
|
||||
/** 보기 ↔ 주소. */
|
||||
export const DESIGN_VIEW_ROUTES: Record<DesignView, RoutePath> = {
|
||||
profile: ROUTES.B05_PROFILE,
|
||||
cross: ROUTES.B06_SECTION,
|
||||
};
|
||||
|
||||
export function designViewOf(route: RoutePath): DesignView | null {
|
||||
if (route === ROUTES.B05_PROFILE) return "profile";
|
||||
if (route === ROUTES.B06_SECTION) return "cross";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 보기마다 진행 단계 칸 · 제목. */
|
||||
const VIEW_STEP: Record<DesignView, number> = { profile: 2, cross: 3 };
|
||||
const VIEW_TITLE: Record<DesignView, keyof typeof ui_locales> = {
|
||||
profile: "B05_Route_Title",
|
||||
cross: "B06_Profile_Title",
|
||||
};
|
||||
|
||||
/** 보기가 페이지에서 받는 것 — 숨김 · 보임 · 떠남 때 할 일을 건다. */
|
||||
export interface DesignPageHost {
|
||||
readonly view: DesignView;
|
||||
/** 지금 보이는가(떠났으면 false). */
|
||||
visible: () => boolean;
|
||||
/** 떠났는가 — 늦게 온 자료가 떠난 화면을 채우지 않게 본다. */
|
||||
disposed: () => boolean;
|
||||
/** 다시 보일 때(처음 마운트는 빼고) — 다른 보기에서 바뀐 값을 가게에서 다시 읽는다. */
|
||||
onShow: (fn: () => void) => void;
|
||||
/** 숨을 때 — 그리기 루프 · 창 듣기를 뗀다. */
|
||||
onHide: (fn: () => void) => void;
|
||||
/** 떠날 때 — 건 차례의 거꾸로 부른다. */
|
||||
onDispose: (fn: () => void) => void;
|
||||
}
|
||||
|
||||
export interface DesignPageView {
|
||||
/** 가운데 위 칸만 채운다 — 좌측 패널 · 단추 · 종단도는 `shared` 한 벌. 자료가 서면 끝난다. */
|
||||
mountCenter: (
|
||||
center: HTMLElement,
|
||||
host: DesignPageHost,
|
||||
shared: DesignPageShared,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** 보기 정의 — 처음 보일 때 읽는다(번들을 보기별로 나눠 둔다). */
|
||||
const VIEWS: Record<DesignView, () => Promise<DesignPageView>> = {
|
||||
profile: async () => ({ mountCenter: (await import("./design_view_profile")).mountProfileView }),
|
||||
cross: async () => ({ mountCenter: (await import("./design_view_cross")).mountCrossView }),
|
||||
};
|
||||
|
||||
interface ViewSlot {
|
||||
box: HTMLElement;
|
||||
/** 자료가 섰다(실패해도 끝남). */
|
||||
ready: Promise<void>;
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
const run = (fn: () => void): void => {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
console.error("[design_page]", error);
|
||||
}
|
||||
};
|
||||
|
||||
/** 한 페이지를 세운다 — 라우터가 `MountedPage` 로 받아 같은 페이지 안 이동에 쓴다. */
|
||||
export function renderDesignPage(root: HTMLElement): MountedPage {
|
||||
const page = document.createElement("div");
|
||||
page.className = "design-page";
|
||||
root.replaceChildren(page);
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
|
||||
// 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다.
|
||||
const left = document.createElement("div");
|
||||
left.className = "design-page__left";
|
||||
const main = document.createElement("div");
|
||||
main.className = "design-page__main";
|
||||
const center = document.createElement("div");
|
||||
center.className = "design-page__center";
|
||||
const profileSlot = document.createElement("div");
|
||||
profileSlot.className = "design-page__profile";
|
||||
main.append(center, profileSlot);
|
||||
|
||||
const slots = new Map<DesignView, ViewSlot>();
|
||||
let current: DesignView = designViewOf(currentRoute()) ?? "profile";
|
||||
let layout: WorkflowLayoutHandle | null = null;
|
||||
let disposedPage = false;
|
||||
|
||||
const shared: DesignPageShared = {
|
||||
projectId,
|
||||
left,
|
||||
profileSlot,
|
||||
view: () => current,
|
||||
parts: {},
|
||||
bindings: {},
|
||||
panel: null,
|
||||
profile: null,
|
||||
structures: null,
|
||||
pipeOptionQueue: [],
|
||||
revealLeft: () => layout?.setOptionsOpen(true),
|
||||
ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready,
|
||||
};
|
||||
|
||||
/** 한 벌이 보기를 따라간다 — 좌측 칸 · 단추 글자 · 종단도 판 · 진행 단계 · 제목. */
|
||||
const applyView = (view: DesignView): void => {
|
||||
main.dataset.view = view;
|
||||
shared.panel?.setView(view);
|
||||
shared.profile?.setView(view);
|
||||
layout?.setTitle(ui_locales[VIEW_TITLE[view]][currentLanguageIndex]);
|
||||
layout?.setActiveStep(VIEW_STEP[view]);
|
||||
layout?.root.classList.toggle("b05-route-layout", view === "profile");
|
||||
layout?.root.classList.toggle("b06-profile-layout", view === "cross");
|
||||
};
|
||||
|
||||
const buildLayout = (state: WorkflowState | null): void => {
|
||||
layout = createWorkflowLayout({
|
||||
title: ui_locales[VIEW_TITLE[current]][currentLanguageIndex],
|
||||
steps: workflowSteps(),
|
||||
activeStep: VIEW_STEP[current],
|
||||
leftPanel: left,
|
||||
mainContent: main,
|
||||
stages: state?.stages,
|
||||
currentStage: state?.current_stage,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
// 종단설계 · 횡단설계 두 칸은 같은 페이지 — 주소만 바꿔 보기만 바꾼다.
|
||||
onStepClick: (stepIndex) => {
|
||||
const route = WORKFLOW_STEP_ROUTES[stepIndex];
|
||||
if (designViewOf(route)) navigateTo(route);
|
||||
else goToWorkflowStage(projectId, route);
|
||||
},
|
||||
});
|
||||
layout.root.classList.add("design-page__layout");
|
||||
page.append(layout.root);
|
||||
applyView(current);
|
||||
};
|
||||
if (!projectId) {
|
||||
showToast("프로젝트를 먼저 선택하세요.", "error");
|
||||
buildLayout(null);
|
||||
} else {
|
||||
void fetchWorkflowState(projectId)
|
||||
.catch(() => null)
|
||||
.then((state) => {
|
||||
if (!disposedPage) buildLayout(state);
|
||||
});
|
||||
}
|
||||
|
||||
function mountView(view: DesignView, visibleAtStart: boolean): ViewSlot {
|
||||
const life = { show: [] as Array<() => void>, hide: [] as Array<() => void> };
|
||||
const cleanups: Array<() => void> = [];
|
||||
let visible = visibleAtStart;
|
||||
let disposed = false;
|
||||
const box = document.createElement("div");
|
||||
box.className = "design-page__view";
|
||||
box.dataset.view = view;
|
||||
box.hidden = !visibleAtStart;
|
||||
center.append(box);
|
||||
const host: DesignPageHost = {
|
||||
view,
|
||||
visible: () => visible && !disposed,
|
||||
disposed: () => disposed,
|
||||
onShow: (fn) => life.show.push(fn),
|
||||
onHide: (fn) => life.hide.push(fn),
|
||||
onDispose: (fn) => cleanups.push(fn),
|
||||
};
|
||||
const ready = VIEWS[view]()
|
||||
.then((def) => {
|
||||
if (disposed) return;
|
||||
const mounting = def.mountCenter(box, host, shared);
|
||||
// 부품 · 동작을 건 뒤(마운트의 동기 앞부분) 한 벌을 지금 보기로 맞춘다.
|
||||
if (view === current) applyView(view);
|
||||
return mounting;
|
||||
})
|
||||
.catch((error) => console.error(`[design_page] 보기 마운트 실패: ${view}`, error));
|
||||
const slot: ViewSlot = {
|
||||
box,
|
||||
ready,
|
||||
// 숨긴 채 세운 보기(종단 보기 [확정])도 처음 보일 때 이 알림으로 가게 값을 다시 읽는다.
|
||||
show() {
|
||||
visible = true;
|
||||
box.hidden = false;
|
||||
life.show.forEach(run);
|
||||
},
|
||||
hide() {
|
||||
visible = false;
|
||||
box.hidden = true;
|
||||
life.hide.forEach(run);
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
cleanups.reverse().forEach(run);
|
||||
},
|
||||
};
|
||||
slots.set(view, slot);
|
||||
return slot;
|
||||
}
|
||||
|
||||
const show = (view: DesignView): void => {
|
||||
const started = performance.now();
|
||||
if (slots.has(current) && current !== view) slots.get(current)?.hide();
|
||||
current = view;
|
||||
const slot = slots.get(view);
|
||||
if (slot) {
|
||||
slot.show();
|
||||
applyView(view);
|
||||
} else mountView(view, true);
|
||||
// 검증용 — 보기 전환에 든 시간(ms)과 횟수를 화면 밖에서 수치로 본다.
|
||||
const probe = window as unknown as { __designPage?: { view: string; switchMs: number[] } };
|
||||
probe.__designPage ??= { view, switchMs: [] };
|
||||
probe.__designPage.view = view;
|
||||
probe.__designPage.switchMs.push(Math.round(performance.now() - started));
|
||||
};
|
||||
|
||||
show(current);
|
||||
|
||||
return {
|
||||
covers: (route) => designViewOf(route) !== null,
|
||||
show: (route) => {
|
||||
const view = designViewOf(route);
|
||||
if (view && view !== current) show(view);
|
||||
},
|
||||
dispose() {
|
||||
disposedPage = true;
|
||||
slots.forEach((slot) => slot.dispose());
|
||||
slots.clear();
|
||||
shared.profile?.dispose();
|
||||
delete (window as unknown as { __designPage?: unknown }).__designPage;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/* =============================================================================
|
||||
* design_panel.ts
|
||||
* B05 · B06 한 페이지 두 보기의 **좌측 패널 칸 등록부**(계획서 24-1 · 24-2b).
|
||||
*
|
||||
* 칸(구조물 배치 · 페이지 설정 · 표준 횡단면 설정 …)은 **한 번만 정의**하고 보일 보기만 적는다.
|
||||
* 페이지 파일은 칸도 단추도 직접 만들지 않고 `createDesignPanel(ctx, view)` 한 줄만 부른다 —
|
||||
* 시험(`test_design_view_parity.py`)이 이를 지킨다.
|
||||
*
|
||||
* registerPanelSection({ id, title, views, order, build }) ← 칸 정의(모듈이 읽힐 때 한 번)
|
||||
* createDesignPanel(ctx, view) → { root, setView } ← 페이지가 부름
|
||||
*
|
||||
* 칸이 쓰는 페이지 쪽 부품(구조물 패널 · 입력 칸 묶음 …)은 `ctx.parts` 에서 이름으로 꺼낸다 —
|
||||
* 칸 정의가 B05 · B06 의 구체 타입을 몰라도 되게 하려는 것이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import {
|
||||
createDesignDock,
|
||||
type DesignDock,
|
||||
type DesignDockActions,
|
||||
type DesignView,
|
||||
} from "./design_dock";
|
||||
|
||||
export type { DesignView } from "./design_dock";
|
||||
|
||||
/** 좌측 패널 하나가 페이지에서 받는 것 — 값은 바뀌므로 부품은 이름으로, 단추 동작은 함수로. */
|
||||
export interface DesignPanelContext {
|
||||
projectId: string | null;
|
||||
/** 이름 → 페이지가 만든 부품. 칸 정의가 `part()` 로 꺼낸다. */
|
||||
parts: Record<string, unknown>;
|
||||
/** 아래 단추 넷의 동작 — 페이지가 채운다(저장 흐름은 `design_save`). */
|
||||
dock: DesignDockActions;
|
||||
}
|
||||
|
||||
/** 칸 정의가 돌려주는 것 — 칸 뿌리 하나, 그리고 (있으면) 아래 고정 영역 위에 얹을 것. */
|
||||
export type PanelSectionBuild = HTMLElement | { root: HTMLElement; dock?: HTMLElement };
|
||||
|
||||
export interface PanelSectionDef {
|
||||
id: string;
|
||||
title: string;
|
||||
/** 이 칸이 보일 보기 — 둘 다 적으면 한 정의로 두 보기(구조물 배치). */
|
||||
views: ReadonlyArray<DesignView>;
|
||||
/** 칸 차례 — 작을수록 위. */
|
||||
order: number;
|
||||
build: (ctx: DesignPanelContext, view: DesignView) => PanelSectionBuild;
|
||||
}
|
||||
|
||||
const registry = new Map<string, PanelSectionDef>();
|
||||
|
||||
/** 칸 정의를 등록한다. 같은 id 는 덮어쓴다(같은 모듈이 다시 읽혀도 한 벌). */
|
||||
export function registerPanelSection(def: PanelSectionDef): void {
|
||||
registry.set(def.id, def);
|
||||
}
|
||||
|
||||
/** 그 보기에 보일 칸 정의 — 차례대로. 시험이 「두 보기가 같은 칸을 쓰는지」 볼 때도 쓴다. */
|
||||
export function panelSectionsFor(view: DesignView): PanelSectionDef[] {
|
||||
return [...registry.values()]
|
||||
.filter((def) => def.views.includes(view))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/** 칸이 부품을 이름으로 꺼낸다 — 페이지가 안 건넸으면 바로 알리도록 던진다. */
|
||||
export function part<T>(ctx: DesignPanelContext, name: string): T {
|
||||
const found = ctx.parts[name];
|
||||
if (found === undefined)
|
||||
throw new Error(`좌측 패널 부품 「${name}」 을 페이지가 건네지 않았습니다.`);
|
||||
return found as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 칸 틀 — 접는 제목 + 본문. 두 보기가 **다른 클래스**를 쓰던 것(`b05-route__panel-section` ·
|
||||
* `b06-profile__group`)을 그대로 살린다(2단계에서 한 벌로 합침). 종단 보기 본문은 별도 div,
|
||||
* 횡단 보기는 칸 뿌리에 바로 붙인다.
|
||||
*/
|
||||
export function createSectionFrame(
|
||||
view: DesignView,
|
||||
title: string,
|
||||
collapsed = false,
|
||||
): { root: HTMLElement; body: HTMLElement } {
|
||||
const root = document.createElement("section");
|
||||
const heading = document.createElement("h3");
|
||||
heading.textContent = title;
|
||||
if (view === "profile") {
|
||||
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
|
||||
// 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫.
|
||||
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
|
||||
heading.className = "ui-collapsible__title";
|
||||
const body = document.createElement("div");
|
||||
body.className = "b05-route__panel-body";
|
||||
root.append(heading, body);
|
||||
return { root, body };
|
||||
}
|
||||
root.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
|
||||
heading.className = "b06-profile__group-legend ui-collapsible__title";
|
||||
root.append(heading);
|
||||
return { root, body: root };
|
||||
}
|
||||
|
||||
export interface DesignPanel {
|
||||
root: HTMLElement;
|
||||
/** 아래 고정 영역 — 단추 켬·끔을 페이지가 다시 줄 때 쓴다. */
|
||||
dock: DesignDock;
|
||||
/** 보기가 바뀌면 그 보기에 없는 칸은 숨기고 있는 칸은 (처음이면 만들어) 보인다. */
|
||||
setView: (view: DesignView) => void;
|
||||
}
|
||||
|
||||
/** 좌측 패널 — 그 보기의 칸을 차례대로 세우고 아래 고정 영역을 붙인다. */
|
||||
export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): DesignPanel {
|
||||
const root = document.createElement("div");
|
||||
root.className = view === "profile" ? "b05-route__panel" : "b06-profile__form";
|
||||
const built = new Map<string, HTMLElement>();
|
||||
const dockParts: HTMLElement[] = [];
|
||||
const dock = createDesignDock(view, dockParts, ctx.dock);
|
||||
|
||||
const show = (target: DesignView): void => {
|
||||
const wanted = new Set(panelSectionsFor(target).map((def) => def.id));
|
||||
for (const def of panelSectionsFor(target)) {
|
||||
if (built.has(def.id)) continue;
|
||||
const made = def.build(ctx, target);
|
||||
const sectionRoot = "root" in made ? made.root : made;
|
||||
if ("root" in made && made.dock) dock.root.prepend(made.dock);
|
||||
built.set(def.id, sectionRoot);
|
||||
root.insertBefore(sectionRoot, dock.root);
|
||||
}
|
||||
// 차례를 정의 순서로 다시 맞춘다 — 늦게 만들어진 칸이 맨 아래에 남지 않게.
|
||||
for (const def of panelSectionsFor(target)) {
|
||||
const element = built.get(def.id);
|
||||
if (element) root.insertBefore(element, dock.root);
|
||||
}
|
||||
built.forEach((element, id) => {
|
||||
// 칸이 스스로 숨긴 것(선택 포인트)은 건드리지 않고, 보기 밖 칸만 가리고 되살린다.
|
||||
element.style.display = wanted.has(id) ? "" : "none";
|
||||
});
|
||||
};
|
||||
|
||||
root.append(dock.root);
|
||||
show(view);
|
||||
// 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도
|
||||
// 접힘 항목은 손대지 않는다.
|
||||
attachCollapsible(root);
|
||||
|
||||
return {
|
||||
root,
|
||||
dock,
|
||||
setView: (next) => {
|
||||
dock.setView(next);
|
||||
root.className = next === "profile" ? "b05-route__panel" : "b06-profile__form";
|
||||
show(next);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_profile.ts
|
||||
* 종단도 한 벌(계획서 24-1 · 24-2c) — 종단 보기 · 횡단 보기가 **같은 종단도**를 가운데
|
||||
* 아래쪽에 둔다. 도구(▲▼ 고치기 · 되돌리기 · 직선화 · 옮기기 · 확대 · 표 · 유토곡선 · 범례 ·
|
||||
* 뱃지)와 막기 규칙(최소 흙덮기 · 기울기 상한)은 보기와 무관하게 하나.
|
||||
*
|
||||
* 그래프와 도면 테이블이 **하나의 가로 스크롤러** 안에 같은 폭으로 쌓여 X축이 자동으로
|
||||
* 맞물린다. 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [저장]·[확정]
|
||||
* 시점에 `saveProfileAlignment()`로 편집 델타만 보낸다.
|
||||
*
|
||||
* 판 자리 약속(side · overlay)은 `design_profile_slots.ts` 에 있고 여기서 다시 내보낸다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drainage_Spans";
|
||||
import { readState, writeState } from "../b_page_state";
|
||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
||||
import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
|
||||
import type {
|
||||
StructureInstance,
|
||||
StructureType,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay";
|
||||
import {
|
||||
hasLegacyAlignment,
|
||||
readAlignment,
|
||||
toDesignProfile,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Data";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { hasStaleDesigns } from "../../B06_Section/B06_Section_UI_Section_Common";
|
||||
import {
|
||||
blocksMinCover,
|
||||
findMinCoverViolations,
|
||||
minCoverPoints,
|
||||
type MinCoverPipe,
|
||||
type MinCoverPoint,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_MinCover";
|
||||
import type {
|
||||
AlignmentBase,
|
||||
AlignmentEdits,
|
||||
ProfileAlignment,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import {
|
||||
buildAlignment,
|
||||
chainageKey,
|
||||
emptyEdits,
|
||||
toAlignmentBase,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import {
|
||||
structureGroupMenuItems,
|
||||
stationIdAtStructure as stationIdAtStructureOf,
|
||||
structureIdAtStation as structureIdAtStationOf,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Structures";
|
||||
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||||
import {
|
||||
createRouteMassHaulPanel,
|
||||
type MassHaulSummaryValues,
|
||||
type RouteMassHaulContext,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
|
||||
import { renderBalanceBar } from "../../B05_Profile/B05_Profile_UI_Profile_Balance";
|
||||
import { createHeightCascade } from "../../B05_Profile/B05_Profile_UI_Profile_Heights";
|
||||
import { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render";
|
||||
import {
|
||||
irregularStationId,
|
||||
type IrregularStation,
|
||||
} from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import { createCrossPreview } from "../../B05_Profile/B05_Profile_UI_Profile_Preview";
|
||||
import { createPanelTools } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Tools";
|
||||
import { createProfileZoom } from "../../B05_Profile/B05_Profile_UI_Profile_Zoom";
|
||||
import {
|
||||
adoptLegacyProfileKeys,
|
||||
bindProfileCollapse,
|
||||
createElevationHold,
|
||||
createProfileDrag,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
|
||||
import {
|
||||
planCurveWarning,
|
||||
sampleAt,
|
||||
snapToMasterStations,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
|
||||
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
||||
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel";
|
||||
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||||
import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
|
||||
import "../../B06_Section/B06_Section_UI_Style.css";
|
||||
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
|
||||
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
|
||||
|
||||
export type {
|
||||
DesignOverlayBoard,
|
||||
DesignSideBoard,
|
||||
DesignSideFactory,
|
||||
DesignSideHost,
|
||||
DesignView,
|
||||
} from "./design_profile_slots";
|
||||
|
||||
/** 종단도를 만들 때 넘기는 것. */
|
||||
export interface DesignProfileContext {
|
||||
projectId: string;
|
||||
/** 처음 보기. */
|
||||
view: DesignView;
|
||||
/** 오른쪽 판 — 없으면 판 없이 종단 열만. */
|
||||
side?: DesignSideFactory;
|
||||
/** 오른쪽 판을 켜고 끄는 단추 — 도구줄 오른쪽에 붙는다(판이 있을 때만). */
|
||||
sideTool?: (board: DesignSideBoard) => HTMLElement;
|
||||
/** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */
|
||||
onSelectStation: (stationId: string | null) => void;
|
||||
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */
|
||||
onResetAll?: () => void;
|
||||
callbacks?: RouteProfilePanelCallbacks;
|
||||
}
|
||||
|
||||
/** 계획고 편집 후 횡단을 다시 계산하기까지 기다리는 시간(ms).
|
||||
* ▲/▼ 길게 누르기(초당 10회)·끌기에서 매 프레임 다시 돌지 않게 마지막 값만 계산한다.
|
||||
* 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 —
|
||||
* 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다.
|
||||
* 60→16ms(2026-09-06) — ▲ 한 번에 라벨이 서기까지 중앙 130ms 를 쟀는데 그중 60ms 가
|
||||
* 이 대기였다. 한 프레임(16ms)이면 연속 입력은 여전히 마지막 값만 계산한다. */
|
||||
const CROSS_PREVIEW_DEBOUNCE_MS = 16;
|
||||
|
||||
export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
const { projectId, onSelectStation, onResetAll, callbacks } = ctx;
|
||||
adoptLegacyProfileKeys();
|
||||
const root = document.createElement("section");
|
||||
root.className = "b05-route-profile";
|
||||
root.dataset.view = ctx.view;
|
||||
// 이름표 "종단" — 무엇의 손잡이인지 화살표 옆에 밝힌다(2026-08-04 사용자 지시).
|
||||
const panelHandle = createWorkflowPanelHandle("bottom", "down", "종단");
|
||||
const balanceBar = document.createElement("div");
|
||||
balanceBar.className = "b05-route-profile__balance";
|
||||
const body = document.createElement("div");
|
||||
body.className = "b05-route-profile__body";
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b05-route-profile__empty";
|
||||
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
|
||||
body.append(empty);
|
||||
// 종단면 본문 + 우측 판(배수유역도)을 나란히 놓는 2단 구성.
|
||||
// 우측 판이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시).
|
||||
// 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다.
|
||||
// body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다.
|
||||
const progress = createProgressCircle({ overlay: true });
|
||||
progress.root.hidden = true;
|
||||
const bodyWrap = document.createElement("div");
|
||||
bodyWrap.className = "b05-route-profile__body-wrap";
|
||||
// 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도
|
||||
// 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고).
|
||||
bodyWrap.append(balanceBar, body, progress.root);
|
||||
// 유토곡선 — 패널 바닥에 붙는 오버레이 서브패널(2026-08-04 사용자 확정).
|
||||
// 종단도·테이블 배치는 건드리지 않고 그 위를 덮으며, 위 경계 리사이저로 높이를 조절한다.
|
||||
// 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고
|
||||
// (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은
|
||||
// 손을 뗀 뒤(clearDragFlags) 1회만 한다. 펼침·범례 토글 등 드래그 밖 알림은 전체 draw.
|
||||
const subPanelChanged = (): void => {
|
||||
if (drag.isSubDragging()) scheduleLightSync();
|
||||
else draw();
|
||||
};
|
||||
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시).
|
||||
* 유토곡선을 접어도 이 값은 남아야 한다. */
|
||||
const massBadge = createMassHaulBadge();
|
||||
// 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다.
|
||||
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
|
||||
bodyWrap.append(massBadge.root);
|
||||
let massHaulSummary: MassHaulSummaryValues | null = null;
|
||||
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
|
||||
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
|
||||
const haulPrefetch = createHaulPlanPrefetch(
|
||||
projectId,
|
||||
() => routeId ?? undefined,
|
||||
subPanelChanged,
|
||||
);
|
||||
const massHaul = createRouteMassHaulPanel(
|
||||
subPanelChanged,
|
||||
(summary) => {
|
||||
const before = massHaulSummary;
|
||||
massHaulSummary = summary;
|
||||
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
|
||||
},
|
||||
haulPrefetch,
|
||||
);
|
||||
bodyWrap.append(massHaul.overlay, massHaul.handle);
|
||||
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
|
||||
massHaul.attachScrollSync(body);
|
||||
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
|
||||
const tableOverlay = createProfileTableOverlay(subPanelChanged);
|
||||
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
|
||||
tableOverlay.attachScrollSync(body);
|
||||
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
|
||||
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
|
||||
body.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
const content = document.createElement("div");
|
||||
content.className = "b05-route-profile__content";
|
||||
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
|
||||
let minCoverTargets: MinCoverPoint[] = [];
|
||||
/** 최소고를 편집에서 강제할지 — 종단 보기 「페이지 설정」 체크박스를 따라온다(기본 해제).
|
||||
* 두 보기가 같은 값을 보도록 세션 한 칸에 비춰 둔다. */
|
||||
let enforceMinCover = readState<boolean>("profile-enforce-min-cover", projectId) === true;
|
||||
/** 관 목록이 바뀌었다 — 최소 흙덮기 판정 대상을 다시 잡는다(판 · 보기 어느 쪽에서 와도). */
|
||||
const takePipes = (pipes: ReadonlyArray<MinCoverPipe>): void => {
|
||||
// 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다.
|
||||
minCoverTargets = minCoverPoints([...pipes]);
|
||||
renderBalance();
|
||||
};
|
||||
// 오른쪽 판(배수유역도) — 판 쪽이 만들어 넘긴다(판 자리 약속 `DesignSideFactory`).
|
||||
const drainagePanel: DesignSideBoard | null =
|
||||
ctx.side?.({
|
||||
projectId,
|
||||
view: ctx.view,
|
||||
onPipesChanged: (pipes) => {
|
||||
takePipes(pipes);
|
||||
callbacks?.onPipesChanged?.(pipes);
|
||||
},
|
||||
onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM),
|
||||
onPipeSelected: (chainageM) => callbacks?.onPipeSelected?.(chainageM),
|
||||
// 배수유역도 우클릭 빈 자리 메뉴 = 종단그래프와 같은 구조물군 → 종류 2단 목록
|
||||
// (2026-08-18 일원화). 선택하면 사이드 폼 자동 지정(addAt 경로).
|
||||
structureMenuItems: (chainage) =>
|
||||
structureGroupMenuItems(structureTypes, (typeId) =>
|
||||
callbacks?.onStructureTypeAdd?.(chainage, typeId),
|
||||
),
|
||||
}) ?? null;
|
||||
content.append(bodyWrap);
|
||||
if (drainagePanel) content.append(drainagePanel.root);
|
||||
const drag = createProfileDrag({
|
||||
root,
|
||||
body,
|
||||
massHaul,
|
||||
tableOverlay,
|
||||
draw: () => draw(),
|
||||
});
|
||||
root.append(panelHandle.root, drag.resizerRoot, content);
|
||||
drainagePanel?.load(projectId);
|
||||
|
||||
let detail: SectionDetailResponse | null = null;
|
||||
let selectedStationId: string | null = null;
|
||||
let stationInterval: number | undefined;
|
||||
let routeId: number | null = null;
|
||||
let irregularStations: IrregularStation[] = [];
|
||||
// 구조물 정본(structures.json) 목록과 타입 레지스트리 — 그래프 서클마크·벌룬에 쓴다.
|
||||
let structures: StructureInstance[] = [];
|
||||
let structureTypes: StructureType[] = [];
|
||||
let selectedStructureId: string | null = null;
|
||||
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
|
||||
let stationDisplay = { station: 0, cumulative: 0 };
|
||||
let base: AlignmentBase | null = null;
|
||||
let alignment: ProfileAlignment | null = null;
|
||||
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
|
||||
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
|
||||
let savedEdits: AlignmentEdits = emptyEdits();
|
||||
let redrawPending = false;
|
||||
let lastWidth = 0;
|
||||
let lastHeight = 0;
|
||||
|
||||
/** 측점선 ↔ 알약(구조물) 짝짓기는 `_Profile_Structures` 몫(700줄 한계). */
|
||||
/** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로
|
||||
* 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */
|
||||
const structureIdAtStation = (stationId: string | null): string | null =>
|
||||
stationId !== null && stationIdAtStructure(selectedStructureId) === stationId
|
||||
? selectedStructureId
|
||||
: structureIdAtStationOf(stationId, irregularStations, structures);
|
||||
const stationIdAtStructure = (structureId: string | null): string | null =>
|
||||
stationIdAtStructureOf(structureId, irregularStations, structures);
|
||||
|
||||
/**
|
||||
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
|
||||
* 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 페이지에 알린다.
|
||||
* 같은 자리 알약도 함께 켜고 끈다(2026-08-17 사용자 지시 — 둘은 한 구조물이다).
|
||||
*/
|
||||
function selectStation(stationId: string | null): void {
|
||||
const next = stationId !== null && stationId === selectedStationId ? null : stationId;
|
||||
selectedStationId = next;
|
||||
selectedStructureId = structureIdAtStation(next);
|
||||
draw();
|
||||
onSelectStation(next);
|
||||
}
|
||||
|
||||
function renderBalance(): void {
|
||||
const minCoverViolations = findMinCoverViolations(
|
||||
minCoverTargets,
|
||||
(chainageM) => sampleAt(alignment, chainageM, "ground_elevation_m"),
|
||||
(chainageM) => sampleAt(alignment, chainageM, "elevation_m"),
|
||||
);
|
||||
renderBalanceBar({
|
||||
planCurve: planCurveWarning(alignment, detail),
|
||||
minCoverViolations,
|
||||
balanceBar,
|
||||
tools: tools.render(),
|
||||
trailing,
|
||||
alignment,
|
||||
legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal),
|
||||
edited: store.edited(),
|
||||
hasIrregularStations: irregularStations.length > 0,
|
||||
dirty: store.dirty(),
|
||||
onResetAll: () => {
|
||||
store.resetAll();
|
||||
onResetAll?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* 줌 조작구(가로 배율) — 상태를 종단도가 들고 있어 편집·재계산으로 다시 그려도 유지된다.
|
||||
세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */
|
||||
const profileZoom = createProfileZoom(() => draw());
|
||||
|
||||
/** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */
|
||||
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
|
||||
/** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */
|
||||
const elevationHold = createElevationHold(body, () => draw());
|
||||
// 오른쪽 판 켬 · 끔 단추 — 가로 배율 조작구 옆, 두 보기 같은 자리.
|
||||
const sideTool = drainagePanel && ctx.sideTool ? ctx.sideTool(drainagePanel) : null;
|
||||
const trailing = document.createElement("div");
|
||||
trailing.className = "b05-route-profile__trailing";
|
||||
trailing.append(profileZoom.bar, ...(sideTool ? [sideTool] : []));
|
||||
|
||||
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */
|
||||
const { tools, history, handleToolPick, setListening } = createPanelTools({
|
||||
root,
|
||||
base: () => base,
|
||||
alignment: () => alignment,
|
||||
edits: () => store.edits(),
|
||||
applyEdits: (next) => applyEdits(next),
|
||||
selectedStationId: () => selectedStationId,
|
||||
irregularStations: () => irregularStations,
|
||||
stationIdOf: (station) => irregularStationId(station.id),
|
||||
moveStation: (station, toChainageM) =>
|
||||
callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station),
|
||||
// 판이 없는 보기는 관 자리를 되돌리기에 태우지 않는다.
|
||||
drainage: drainagePanel ?? { pipeChainages: () => [], setPipeChainages: () => undefined },
|
||||
restore: () => {
|
||||
// 세션이 정본이므로 편집 초안을 다시 읽어 그린다.
|
||||
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
|
||||
rebuild();
|
||||
},
|
||||
restoreSaved: () => store.restoreSaved(),
|
||||
canRestoreSaved: () => store.canRestoreSaved(),
|
||||
refresh: () => renderBalance(),
|
||||
});
|
||||
|
||||
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */
|
||||
function applyEdits(next: AlignmentEdits): void {
|
||||
if (!base || !alignment) return;
|
||||
const candidate = buildAlignment(base, next);
|
||||
if (
|
||||
base.policy.grade_violation_policy === "block" &&
|
||||
candidate.violations.length > alignment.violations.length
|
||||
) {
|
||||
showToast(
|
||||
`종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정).
|
||||
if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return;
|
||||
store.replace(next);
|
||||
history.record();
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
|
||||
* 실제 렌더링하도록 모은다.
|
||||
*/
|
||||
function rebuild(): void {
|
||||
if (base) alignment = buildAlignment(base, store.edits());
|
||||
// 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유
|
||||
// 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다
|
||||
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음).
|
||||
scheduleCrossPreview();
|
||||
if (redrawPending) return;
|
||||
redrawPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
redrawPending = false;
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
/** 횡단 설계 프리뷰는 `_Profile_Preview` 몫(700줄 한계). */
|
||||
const crossPreview = createCrossPreview({
|
||||
projectId,
|
||||
detail: () => detail,
|
||||
routeId: () => routeId,
|
||||
edits: () => store.edits(),
|
||||
debounceMs: CROSS_PREVIEW_DEBOUNCE_MS,
|
||||
onApplied: (updated) => {
|
||||
draw();
|
||||
callbacks?.onCrossDesignsUpdated?.(updated);
|
||||
},
|
||||
});
|
||||
const scheduleCrossPreview = (): void => crossPreview.schedule();
|
||||
|
||||
/**
|
||||
* 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지
|
||||
* 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면
|
||||
* 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기
|
||||
* 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리).
|
||||
*
|
||||
* draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는
|
||||
* 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다.
|
||||
*/
|
||||
const heights = createHeightCascade({
|
||||
root,
|
||||
body,
|
||||
massHaul,
|
||||
tableOverlay,
|
||||
isMainDragging: drag.isMainDragging,
|
||||
isSubDragging: drag.isSubDragging,
|
||||
mainDragRef: drag.mainDragRef,
|
||||
isMainDragCooldown: drag.isMainDragCooldown,
|
||||
hasDetail: () => detail !== null,
|
||||
});
|
||||
const applyHeightCascade = (allowGrow: boolean): { chartHeight: number } =>
|
||||
heights.apply(allowGrow);
|
||||
const scheduleLightSync = (): void => heights.scheduleLightSync();
|
||||
|
||||
function draw(): void {
|
||||
renderProfile({
|
||||
body,
|
||||
callbacks,
|
||||
massHaul,
|
||||
tableOverlay,
|
||||
store,
|
||||
detail: () => detail,
|
||||
alignment: () => alignment,
|
||||
base: () => base,
|
||||
stationInterval: () => stationInterval,
|
||||
irregularStations: () => irregularStations,
|
||||
minCoverTargets: () => minCoverTargets,
|
||||
structures: () => structures,
|
||||
structureTypes: () => structureTypes,
|
||||
selectedStationId: () => selectedStationId,
|
||||
setSelectedStationId: (value) => {
|
||||
selectedStationId = value;
|
||||
},
|
||||
selectedStructureId: () => selectedStructureId,
|
||||
setSelectedStructureId: (value) => {
|
||||
selectedStructureId = value;
|
||||
},
|
||||
stationDisplay: () => stationDisplay,
|
||||
setLastSize: (width, height) => {
|
||||
lastWidth = width;
|
||||
lastHeight = height;
|
||||
},
|
||||
renderBalance,
|
||||
applyHeightCascade,
|
||||
clearMainDragCooldown: drag.clearMainDragCooldown,
|
||||
selectStation,
|
||||
applyEdits,
|
||||
handleToolPick,
|
||||
zoom: profileZoom.state,
|
||||
holdElevationRange: elevationHold.hold,
|
||||
toolActive: () => tools.mode() !== "none",
|
||||
selectedRuns: () => tools.selectedRuns(),
|
||||
stationIdAtStructure,
|
||||
redraw: draw,
|
||||
setElevationWindowUpdater: (update) => {
|
||||
updateElevationWindow = update;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
|
||||
const SCROLL_SETTLE_MS = 80;
|
||||
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
|
||||
let settledScrollLeft = 0;
|
||||
let scrollSettleTimer = 0;
|
||||
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의
|
||||
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
|
||||
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
|
||||
body.addEventListener("scroll", () => {
|
||||
if (elevationHold.editing()) return;
|
||||
const fitted = updateElevationWindow?.() ?? null;
|
||||
window.clearTimeout(scrollSettleTimer);
|
||||
if (!needsFullRedraw(fitted)) return;
|
||||
scrollSettleTimer = window.setTimeout(() => {
|
||||
if (elevationHold.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
|
||||
settledScrollLeft = body.scrollLeft;
|
||||
draw();
|
||||
}, SCROLL_SETTLE_MS);
|
||||
});
|
||||
|
||||
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
|
||||
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
|
||||
attachWheelHorizontalScroll(body);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (
|
||||
body.clientWidth <= 0 ||
|
||||
body.clientHeight <= 0 ||
|
||||
(Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1)
|
||||
)
|
||||
return;
|
||||
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
|
||||
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
|
||||
scheduleLightSync();
|
||||
drag.scheduleDraw();
|
||||
});
|
||||
resizeObserver.observe(body);
|
||||
|
||||
// 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫).
|
||||
bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(draw));
|
||||
|
||||
/** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */
|
||||
function syncDrainageSpans(): void {
|
||||
drainagePanel?.setIntervalSpans(routeSpansFromStructures(structures, structureTypes));
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
/** 보기가 바뀌었다 — 종단도는 그대로 두고 판에만 알린다(다시 만들지 않음). */
|
||||
setView(next: DesignView) {
|
||||
root.dataset.view = next;
|
||||
drainagePanel?.setView?.(next);
|
||||
},
|
||||
render(nextDetail: SectionDetailResponse, nextStationInterval?: number, nextRouteId?: number) {
|
||||
detail = nextDetail;
|
||||
stationInterval = nextStationInterval;
|
||||
const stored = readAlignment(nextDetail.longitudinal);
|
||||
// 재탐색으로 경로(routeId)가 바뀔 때, 사용자가 조작한 편집이 있으면 **새 경로에 이월**한다.
|
||||
// 편집은 chainage 키라 새 base에 그대로 재적용된다(범위 밖·미매칭 변화점은 best-effort로 드롭).
|
||||
const routeChanged = nextRouteId !== routeId;
|
||||
const carried = routeChanged && store.edited() ? store.edits() : null;
|
||||
// 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다.
|
||||
if (routeChanged || !store.dirty()) {
|
||||
routeId = nextRouteId ?? routeId;
|
||||
savedEdits = stored?.edits ?? emptyEdits();
|
||||
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
|
||||
}
|
||||
base = stored ? toAlignmentBase(stored) : null;
|
||||
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
|
||||
if (carried) store.replace(carried);
|
||||
alignment = base ? buildAlignment(base, store.edits()) : null;
|
||||
// 유토곡선 balloon 위치 캐시 — 두 보기가 **같은 scope**를 써서 같은 자리를 공유한다.
|
||||
// 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다.
|
||||
configureBalloonOffsets(
|
||||
`${projectId}:${routeId ?? "-"}`,
|
||||
nextDetail.balloon_offsets ?? undefined,
|
||||
);
|
||||
draw();
|
||||
requestAnimationFrame(draw);
|
||||
// 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 —
|
||||
// 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D
|
||||
// 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인).
|
||||
//
|
||||
// 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이
|
||||
// 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기
|
||||
// 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다
|
||||
// (2026-09-03 사용자 보고 — 편집 측점 2개가 세션에 남은 상태에서 재현).
|
||||
if (
|
||||
hasStaleDesigns({
|
||||
longitudinal: {
|
||||
design_profiles: alignment
|
||||
? [toDesignProfile(alignment, nextDetail.longitudinal.design_profiles?.[0])]
|
||||
: nextDetail.longitudinal.design_profiles,
|
||||
},
|
||||
cross_sections: nextDetail.cross_sections,
|
||||
})
|
||||
) {
|
||||
scheduleCrossPreview();
|
||||
}
|
||||
},
|
||||
/** 라이브 계획선 샘플(편집 반영분) — 3D 측점 바·코리도가 이걸 본다(2026-08-23).
|
||||
* 편집 전·base 없음이면 null — 호출부는 정본 design_profiles로 폴백한다. */
|
||||
alignmentSamples: () => alignment?.samples ?? null,
|
||||
/**
|
||||
* 계획 유토곡선 계산에 필요한 프로젝트 설정(토량환산계수·운반장비 경계·노반폭).
|
||||
* 페이지가 `fetchSectionContext()` 응답에서 뽑아 넘긴다 — 프론트에 사본을 두지 않는다.
|
||||
*/
|
||||
setEarthworkContext(next: RouteMassHaulContext | null) {
|
||||
massHaul.setContext(next);
|
||||
draw();
|
||||
},
|
||||
setSelectedStation(stationId: string | null) {
|
||||
selectedStationId = stationId;
|
||||
// 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17).
|
||||
selectedStructureId = structureIdAtStation(stationId);
|
||||
draw();
|
||||
},
|
||||
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
|
||||
setEnforceMinCover(enforce: boolean) {
|
||||
enforceMinCover = enforce;
|
||||
writeState("profile-enforce-min-cover", enforce, projectId);
|
||||
},
|
||||
/** 판 없이 관 목록을 넘긴다 — 판이 없는 보기에서 최소 흙덮기를 판정하는 길. */
|
||||
setMinCoverPipes(pipes: ReadonlyArray<MinCoverPipe>) {
|
||||
takePipes(pipes);
|
||||
},
|
||||
/** 종단기울기 상한(%)을 즉시 갈아 끼운다 — 페이지 설정의 지형 구분·등급·세부
|
||||
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
|
||||
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
|
||||
setGradeLimit(maxGradePct: number) {
|
||||
if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
|
||||
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
|
||||
base = { ...base, policy: { ...base.policy, max_grade_pct: maxGradePct } };
|
||||
alignment = buildAlignment(base, store.edits());
|
||||
draw();
|
||||
},
|
||||
/** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */
|
||||
setRoutePolyline(points: ReadonlyArray<{ x: number; y: number }>) {
|
||||
drainagePanel?.setRoute(points);
|
||||
},
|
||||
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
|
||||
setIrregularStations(stations: IrregularStation[]) {
|
||||
irregularStations = snapToMasterStations(alignment, stations);
|
||||
draw();
|
||||
},
|
||||
/** 구조물 타입 레지스트리를 받아 마크 색·약호·우클릭 메뉴에 쓴다(최초 1회). */
|
||||
setStructureTypes(types: StructureType[]) {
|
||||
structureTypes = types;
|
||||
syncDrainageSpans();
|
||||
draw();
|
||||
},
|
||||
/** 구조물 정본 목록을 반영해 그래프 서클마크를 다시 그린다. */
|
||||
setStructures(next: StructureInstance[]) {
|
||||
structures = next;
|
||||
if (selectedStructureId && !next.some((s) => s.structure_id === selectedStructureId)) {
|
||||
selectedStructureId = null;
|
||||
}
|
||||
syncDrainageSpans();
|
||||
draw();
|
||||
},
|
||||
/** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */
|
||||
setSelectedStructure(structureId: string | null) {
|
||||
selectedStructureId = structureId;
|
||||
selectedStationId = stationIdAtStructure(structureId);
|
||||
draw();
|
||||
},
|
||||
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
|
||||
setStationDisplay(next: { station: number; cumulative: number }) {
|
||||
stationDisplay = next;
|
||||
draw();
|
||||
},
|
||||
/**
|
||||
* 특정 chainage의 계획고 편집(station_offset·curve_radii)을 지운다.
|
||||
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
|
||||
*/
|
||||
resetStationEdit(chainageM: number, moveTo?: number) {
|
||||
const key = chainageKey(chainageM);
|
||||
const edits = store.edits();
|
||||
if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return;
|
||||
store.resetStation(chainageM, moveTo);
|
||||
},
|
||||
isDirty: () => store.dirty(),
|
||||
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
|
||||
async save(): Promise<void> {
|
||||
if (!routeId || !store.dirty()) return;
|
||||
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
|
||||
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
||||
if (next?.base_pvi?.length) {
|
||||
base = toAlignmentBase(next);
|
||||
alignment = next;
|
||||
if (detail) detail.longitudinal.profile_alignment = next;
|
||||
}
|
||||
store.markSaved();
|
||||
draw();
|
||||
},
|
||||
clear() {
|
||||
detail = null;
|
||||
base = null;
|
||||
alignment = null;
|
||||
selectedStationId = null;
|
||||
balanceBar.replaceChildren();
|
||||
body.replaceChildren(empty);
|
||||
},
|
||||
/** 밖에서 바뀐 횡단 면적(카드 갱신)을 유토곡선 · 뱃지에 다시 싣는다. */
|
||||
redraw: () => draw(),
|
||||
/** 오른쪽 판(배수유역도) — 판 없이 만들었으면 null. */
|
||||
side: drainagePanel,
|
||||
/** 그래프 영역 로딩 서클. 문구를 주면 켜고 null이면 끈다. */
|
||||
setLoading(label: string | null) {
|
||||
progress.root.hidden = label === null;
|
||||
if (label !== null) progress.set(null, label);
|
||||
},
|
||||
/** 보기가 숨으면 false — 창 키 듣기를 뗀다. 다시 보이면 잇고, 다른 보기가 고친 계획고 초안
|
||||
* (같은 가게) · 관(판)을 다시 읽어 그린다(계획서 24-3a). */
|
||||
setActive(active: boolean) {
|
||||
setListening(active);
|
||||
if (!active) return;
|
||||
if (base) alignment = buildAlignment(base, store.edits());
|
||||
draw();
|
||||
drainagePanel?.load(projectId);
|
||||
},
|
||||
dispose() {
|
||||
setListening(false);
|
||||
drag.dispose();
|
||||
crossPreview.dispose();
|
||||
resizeObserver.disconnect();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 종단도 한 벌 — `createDesignProfile` 이 돌려주는 것. */
|
||||
export type DesignProfile = ReturnType<typeof createDesignProfile>;
|
||||
@@ -0,0 +1,47 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_profile_slots.ts
|
||||
* 종단도 한 벌의 **판 자리 약속**(계획서 24-1 · 24-2c) — `design_profile.ts` 가 다시
|
||||
* 내보낸다(부르는 쪽은 `design_profile.ts` 에서 가져간다).
|
||||
*
|
||||
* 판 자리 둘 — 종단도는 자리만 내주고 판은 밖에서 만들어 끼운다(나중에 떼기 쉽게).
|
||||
* · side 종단도 오른쪽 판(배수유역도 · sub7). `DesignSideFactory` 로 받는다.
|
||||
* · overlay 종단도 위 겹침 판(유토곡선 · 표). 종단도가 직접 만든다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
DrainagePanel,
|
||||
DrainagePanelCallbacks,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Drainage_Panel_Types";
|
||||
|
||||
/** 가운데 보기 — 종단 보기(3D + 종단도) · 횡단 보기(종단도 + 횡단 카드). */
|
||||
export type DesignView = "profile" | "cross";
|
||||
|
||||
/**
|
||||
* 오른쪽 판을 만들 때 종단도가 넘겨 주는 것 — 판이 알릴 일(관 목록 · 유역 · 관 고름 ·
|
||||
* 우클릭 메뉴 항목)과 처음 보기. 판은 `onPipesChanged` 를 **반드시** 불러야 종단도가
|
||||
* 최소 흙덮기를 판정한다.
|
||||
*/
|
||||
export interface DesignSideHost extends DrainagePanelCallbacks {
|
||||
projectId: string;
|
||||
view: DesignView;
|
||||
}
|
||||
|
||||
/**
|
||||
* 오른쪽 판(배수유역도). 종단도는 만든 직후 `load(projectId)` · 노선이 오면 `setRoute` ·
|
||||
* 구조물이 바뀌면 `setIntervalSpans` · 되돌리기에 `pipeChainages`/`setPipeChainages` ·
|
||||
* 보기가 바뀌면 `setView` 를 부른다. 뿌리(`root`)는 종단 열 오른쪽에 붙는다.
|
||||
*/
|
||||
export type DesignSideBoard = DrainagePanel & {
|
||||
/** 보기가 바뀌었다 — 횡단 보기에서도 켜고 끈다(판이 정함). */
|
||||
setView?: (view: DesignView) => void;
|
||||
};
|
||||
|
||||
/** 오른쪽 판을 끼우는 함수 모양 — 종단도를 만들 때 한 번 부른다. */
|
||||
export type DesignSideFactory = (host: DesignSideHost) => DesignSideBoard;
|
||||
|
||||
/** 위 겹침 판(유토곡선 · 표) — 종단 스크롤러와 가로 스크롤을 맞추는 떼었다 붙이는 판. */
|
||||
export interface DesignOverlayBoard {
|
||||
overlay: HTMLElement;
|
||||
handle: HTMLElement;
|
||||
attachScrollSync: (scroller: HTMLElement) => void;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* =============================================================================
|
||||
* design_reset.ts
|
||||
* [초기화] 한 줄기 — 두 보기의 아래 단추가 **같은 함수**를 부른다(계획서 24-2b).
|
||||
*
|
||||
* 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. 네이티브 confirm 은
|
||||
* 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다(2026-08-19 진단) — 화면 안 모달로
|
||||
* 확인받는다. (`B05_Profile_UI_Page_Actions.resetDesignAction` 에서 옮겨 왔다 — 동작 그대로.)
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import {
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { clearDrafts } from "./../b_page_state";
|
||||
import { remountRoute } from "./../router";
|
||||
import { clearRouteLatestCache, resetRouteDesign } from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { clearAlignmentDrafts } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* [초기화] — 확인을 받고, 서버 초기값 복원 뒤 옛 경로 기준 캐시를 전부 비우고 종단 보기로 다시 들어간다.
|
||||
* `onCleared` 는 페이지만 아는 조작 캐시(상단측 변경분 등)를 비울 자리다.
|
||||
*/
|
||||
export async function resetDesign(
|
||||
projectId: string,
|
||||
routeId: number | null,
|
||||
onCleared?: () => void,
|
||||
): Promise<void> {
|
||||
if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await resetRouteDesign(projectId);
|
||||
// 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다.
|
||||
clearRouteLatestCache(projectId);
|
||||
invalidateSectionDetail(projectId);
|
||||
// 사용자 조작(② 초안)은 **등록표 한 곳**에서 통째로 버린다(2026-09-06 일원화).
|
||||
// 예전에는 파일마다 따로 지워 새 값이 늘 때 빠뜨리기 쉬웠다. 노선 범위 초안은
|
||||
// 노선이 바뀌면 키가 달라져 자연히 딸려 오지 않는다.
|
||||
clearDrafts(projectId, routeId);
|
||||
onCleared?.();
|
||||
// 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이
|
||||
// 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: 새로고침 후 최대 6.0m 어긋남).
|
||||
clearAlignmentDrafts();
|
||||
showToast(L("B05_Route_Reset_Success"), "success");
|
||||
remountRoute(ROUTES.B05_PROFILE); // 같은 페이지라도 다시 세운다 — 보기만 바꾸면 옛 화면이 남음
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B05_Route_Reset_Failed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_save.ts
|
||||
* B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례.
|
||||
*
|
||||
* 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
|
||||
*
|
||||
* 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일**
|
||||
* (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라,
|
||||
* 그 몫만 `steps` · `cross` 로 받는다. 서버 라우트는 그대로다.
|
||||
*
|
||||
* ⚠ 차례를 바꾸면 결과가 갈린다 — 관 목록은 스냅샷이라 구간값(암거 옵션)보다 먼저, 상단측은
|
||||
* 횡단 patch 보다 먼저 나가야 사용자 수정이 맨 위에 얹힌다(2026-09-06 · 09-12).
|
||||
* 영구저장은 여기서만 한다(CLAUDE.md 5장 — 자동저장 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import { flushPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { flushPendingStructures } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import {
|
||||
flushUphillOverrides,
|
||||
saveProfileAlignment,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import {
|
||||
markAlignmentSaved,
|
||||
pendingAlignment,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
import { stateKey } from "../b_page_state";
|
||||
|
||||
/** 저장 차례 — 이 순서 그대로 돈다. */
|
||||
export const DESIGN_SAVE_ORDER = [
|
||||
"pipes",
|
||||
"structures",
|
||||
"alignment",
|
||||
"culvertOptions",
|
||||
"uphill",
|
||||
"cross",
|
||||
] as const;
|
||||
|
||||
export type DesignSaveStep = (typeof DESIGN_SAVE_ORDER)[number];
|
||||
|
||||
export interface DesignSaveContext {
|
||||
projectId: string;
|
||||
routeId: () => number | null;
|
||||
/** 저장 앞 준비 — 면적을 모으기 전 전 측점 재계산(B06). */
|
||||
prepare?: () => Promise<void>;
|
||||
/**
|
||||
* 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다.
|
||||
* `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(화면 목록으로 한 번 더 맞춤).
|
||||
*/
|
||||
steps?: Partial<Record<Exclude<DesignSaveStep, "cross">, () => Promise<void>>>;
|
||||
/** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */
|
||||
cross: (confirm: boolean) => Promise<void>;
|
||||
/** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */
|
||||
done?: (confirm: boolean) => Promise<void> | void;
|
||||
/** 멈춘 저장을 알린다. */
|
||||
failed: (error: unknown, confirm: boolean) => void;
|
||||
}
|
||||
|
||||
/** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */
|
||||
function reportOnly(label: string): (error: unknown) => void {
|
||||
return (error) => {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${label} 저장에 실패했습니다.${detail}`, "error");
|
||||
};
|
||||
}
|
||||
|
||||
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */
|
||||
async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> {
|
||||
const draft = pendingAlignment(routeId);
|
||||
if (!draft) return;
|
||||
await saveProfileAlignment(projectId, routeId, draft);
|
||||
markAlignmentSaved(routeId);
|
||||
}
|
||||
|
||||
/** 실패해도 다음 차례로 넘어가는 창구와 그 이름(보기가 준 일에도 같이 씌운다). */
|
||||
const TOLERANT: Partial<Record<DesignSaveStep, string>> = {
|
||||
pipes: "배수관",
|
||||
structures: "구조물",
|
||||
alignment: "계획선",
|
||||
};
|
||||
|
||||
/** 차례마다 기본 창구. 암거 옵션 · 상단측은 조용히 넘어간다(다음 [저장]에서 다시 나감). */
|
||||
function defaultStep(
|
||||
step: Exclude<DesignSaveStep, "cross">,
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): () => Promise<void> {
|
||||
switch (step) {
|
||||
case "pipes":
|
||||
return () => flushPendingPipes(projectId);
|
||||
case "structures":
|
||||
return () => flushPendingStructures(projectId);
|
||||
case "alignment":
|
||||
return () => flushAlignmentDraft(projectId, routeId);
|
||||
case "culvertOptions":
|
||||
return () =>
|
||||
flushCulvertOptions(projectId, stateKey("culvertopt", projectId, routeId)).catch(
|
||||
() => undefined,
|
||||
);
|
||||
case "uphill":
|
||||
return () => flushUphillOverrides(projectId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [저장] · [확정] — 세션에 쌓인 조작을 정해진 차례로 정본에 보낸다.
|
||||
* 노선이 없으면 아무 일도 하지 않고 false. 끝까지 나가면 true.
|
||||
*/
|
||||
export async function saveDesign(
|
||||
ctx: DesignSaveContext,
|
||||
options: { confirm?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const confirm = options.confirm ?? false;
|
||||
const routeId = ctx.routeId();
|
||||
if (routeId === null) return false;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await ctx.prepare?.();
|
||||
for (const step of DESIGN_SAVE_ORDER) {
|
||||
if (step === "cross") {
|
||||
await ctx.cross(confirm);
|
||||
continue;
|
||||
}
|
||||
const own = ctx.steps?.[step];
|
||||
const base = defaultStep(step, ctx.projectId, routeId);
|
||||
const run =
|
||||
step === "pipes"
|
||||
? async () => {
|
||||
await base();
|
||||
await own?.();
|
||||
}
|
||||
: (own ?? base);
|
||||
const label = TOLERANT[step];
|
||||
await (label ? run().catch(reportOnly(label)) : run());
|
||||
}
|
||||
await ctx.done?.(confirm);
|
||||
return true;
|
||||
} catch (error) {
|
||||
ctx.failed(error, confirm);
|
||||
return false;
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* =============================================================================
|
||||
* design_sections.ts
|
||||
* 두 보기가 **함께 쓰는 좌측 칸 정의** — 「구조물 배치」(계획서 24-2b).
|
||||
*
|
||||
* 한 정의에 `views: ["profile", "cross"]` 를 적어 두 보기가 같은 칸을 같은 자리(맨 위)에 세운다.
|
||||
* 칸 알맹이는 페이지가 건네는 부품 `structures`(뿌리 · 아래 고정 영역에 얹을 목록 뿌리)다 —
|
||||
* 종단 보기는 배치 콜백, 횡단 보기는 거기에 횡단 조정 콜백을 더해 만들지만 **칸 정의는 하나**다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { part, registerPanelSection } from "./design_panel";
|
||||
|
||||
/** 페이지가 건네는 「구조물 배치」 부품의 모양 — 뿌리와 목록 뿌리만 안다. */
|
||||
export interface StructuresPart {
|
||||
root: HTMLElement;
|
||||
listRoot: HTMLElement;
|
||||
}
|
||||
|
||||
export const STRUCTURES_SECTION_ID = "structures";
|
||||
|
||||
registerPanelSection({
|
||||
id: STRUCTURES_SECTION_ID,
|
||||
title: "구조물 배치",
|
||||
views: ["profile", "cross"],
|
||||
order: 10,
|
||||
build: (ctx) => {
|
||||
const structures = part<StructuresPart>(ctx, "structures");
|
||||
// 구조물 목록은 「구조물 배치」 본문이 아니라 아래 고정 영역에 놓는다 — 본문에 두면
|
||||
// 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시).
|
||||
return { root: structures.root, dock: structures.listRoot };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_selection.ts
|
||||
* 측점 · 구조물 고름 **한 곳** (PLAN 24-2d).
|
||||
*
|
||||
* 24-1 약속의 `design_store` 「selection({chainageM, structureId})」 키 자리다. 그 모듈이
|
||||
* 서면 이 파일의 `readSelection` · `selectDesign` · `subscribeSelection` 세 함수를 그쪽
|
||||
* `get` · `set` · `subscribe("selection")` 으로 잇는다 — 부르는 쪽 코드는 바뀌지 않는다.
|
||||
*
|
||||
* 값은 등록표 키 `structure-pick`(② 설계 초안 · 페이지를 오가도 남음)에 실린다.
|
||||
* 어느 화면(3D · 종단 알약 · 목록 · 횡단 카드)에서 골라도 같은 값이 적히고, 다른 쪽은
|
||||
* 이 값을 읽어 따라간다. 관 · 구조물을 찾는 거리 `PIPE_MATCH_M` 도 여기 한 값뿐이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../b_page_state";
|
||||
|
||||
/** 관 · 구조물 찾는 거리(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다. 이 값이 유일하다. */
|
||||
export const PIPE_MATCH_M = 0.51;
|
||||
|
||||
export interface DesignSelection {
|
||||
chainageM: number | null;
|
||||
structureId: string | null;
|
||||
}
|
||||
|
||||
export const NO_SELECTION: DesignSelection = { chainageM: null, structureId: null };
|
||||
|
||||
/** 두 누가거리가 같은 자리인가. */
|
||||
export function isNear(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) < PIPE_MATCH_M;
|
||||
}
|
||||
|
||||
/** 그 누가거리에서 찾는 거리 안에 있는 항목 중 **가장 가까운 것** — 이웃한 항목이 둘 걸려도
|
||||
* 자리가 맞는 쪽을 고른다. */
|
||||
export function nearestWithin<T>(
|
||||
items: ReadonlyArray<T>,
|
||||
chainageM: number,
|
||||
chainageOf: (item: T) => number,
|
||||
): T | undefined {
|
||||
let best: T | undefined;
|
||||
let bestGap = PIPE_MATCH_M;
|
||||
for (const item of items) {
|
||||
const gap = Math.abs(chainageOf(item) - chainageM);
|
||||
if (gap < bestGap) {
|
||||
best = item;
|
||||
bestGap = gap;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 선택을 풀 때 필요한 구조물의 최소 모양 — B05 `StructureInstance` 가 그대로 맞는다. */
|
||||
export interface SelectableStructure {
|
||||
structure_id?: string | null;
|
||||
chainage_m?: number | null;
|
||||
start_m?: number | null;
|
||||
end_m?: number | null;
|
||||
}
|
||||
|
||||
/** 풀린 선택 — 관이면 그 관의 누가거리, 아니면 구조물 id. 둘 다 없으면 고를 항목이 없다. */
|
||||
export interface ResolvedSelection {
|
||||
pipeChainageM: number | null;
|
||||
structureId: string | null;
|
||||
}
|
||||
|
||||
/** 이 구조물이 그 측점을 덮는가 — 점형은 기준점 근처, 구간형은 시작~종료 안(양끝 여유 포함). */
|
||||
export function coversChainage(structure: SelectableStructure, chainageM: number): boolean {
|
||||
const anchor = structure.chainage_m ?? structure.start_m;
|
||||
const start = structure.start_m ?? anchor;
|
||||
const end = structure.end_m ?? anchor;
|
||||
if (start == null || end == null) return false;
|
||||
return (
|
||||
chainageM >= Math.min(start, end) - PIPE_MATCH_M &&
|
||||
chainageM <= Math.max(start, end) + PIPE_MATCH_M
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 값을 폼이 열 항목으로 푼다 — 어느 화면에서 골랐든 **같은 순서 · 같은 거리**다.
|
||||
* ① 구조물 id 가 있고 목록에 있으면 그 구조물 ② 측점 근처 관(계곡 통과 시설이 먼저 —
|
||||
* 같은 측점에 구조물이 겹쳐도 횡단도에 그려지는 것은 관이다) ③ 측점을 덮는 구조물.
|
||||
*/
|
||||
export function resolveSelection(
|
||||
selection: DesignSelection,
|
||||
pipes: ReadonlyArray<{ chainage_m: number }>,
|
||||
structures: ReadonlyArray<SelectableStructure>,
|
||||
): ResolvedSelection {
|
||||
if (selection.structureId) {
|
||||
const byId = structures.find((entry) => entry.structure_id === selection.structureId);
|
||||
if (byId) return { pipeChainageM: null, structureId: byId.structure_id ?? null };
|
||||
}
|
||||
const chainageM = selection.chainageM;
|
||||
if (chainageM === null) return { pipeChainageM: null, structureId: null };
|
||||
const pipe = nearestWithin(pipes, chainageM, (entry) => entry.chainage_m);
|
||||
if (pipe) return { pipeChainageM: pipe.chainage_m, structureId: null };
|
||||
const hit = structures.find((entry) => coversChainage(entry, chainageM));
|
||||
return { pipeChainageM: null, structureId: hit?.structure_id ?? null };
|
||||
}
|
||||
|
||||
/* ── 세션 값 + 알림 ─────────────────────────────────────────────────────── */
|
||||
|
||||
interface Stored {
|
||||
at?: unknown;
|
||||
key?: unknown;
|
||||
sid?: unknown;
|
||||
}
|
||||
|
||||
/** 세션에 남은 고름 — 없으면 null. `key` 는 횡단도가 부재를 부르는 이름(조정창 여는 데 씀). */
|
||||
export function readSelection(
|
||||
projectId: string | null,
|
||||
): (DesignSelection & { partKey?: string }) | null {
|
||||
if (!projectId) return null;
|
||||
try {
|
||||
const value = readState<Stored>("structure-pick", projectId);
|
||||
if (!value || typeof value.at !== "number" || !Number.isFinite(value.at)) return null;
|
||||
return {
|
||||
chainageM: value.at,
|
||||
structureId: typeof value.sid === "string" ? value.sid : null,
|
||||
partKey: typeof value.key === "string" ? value.key : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type SelectionListener = (selection: DesignSelection, from: string | undefined) => void;
|
||||
|
||||
const listeners = new Map<string, Set<SelectionListener>>();
|
||||
let dispatching = false;
|
||||
|
||||
/** 고름이 바뀔 때 부른다. 돌려주는 함수로 구독을 끊는다. */
|
||||
export function subscribeSelection(projectId: string | null, fn: SelectionListener): () => void {
|
||||
if (!projectId) return () => undefined;
|
||||
let set = listeners.get(projectId);
|
||||
if (!set) listeners.set(projectId, (set = new Set()));
|
||||
set.add(fn);
|
||||
return () => set.delete(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* 고름을 적고 구독자에게 알린다. `partKey` 를 안 주면 이미 적힌 부재키를 지킨다 — 3D 로
|
||||
* 부재를 집은 직후 선택 동기화가 측점만 다시 적어도 조정창이 그대로 열려야 한다.
|
||||
* `from` 은 낸 쪽 이름 — 구독자가 제 것을 되받아 다시 그리지 않게 거른다.
|
||||
* 값이 그대로면 알리지 않고, 알림 도중 다시 낸 값은 적기만 한다(되울림 방지).
|
||||
*/
|
||||
export function selectDesign(
|
||||
projectId: string | null,
|
||||
selection: DesignSelection,
|
||||
options: { partKey?: string; from?: string } = {},
|
||||
): void {
|
||||
if (!projectId) return;
|
||||
if (selection.chainageM === null) {
|
||||
writeState("structure-pick", null, projectId);
|
||||
} else {
|
||||
const before = readSelection(projectId);
|
||||
const same =
|
||||
before !== null &&
|
||||
before.chainageM === selection.chainageM &&
|
||||
before.structureId === selection.structureId &&
|
||||
(options.partKey === undefined || options.partKey === before.partKey);
|
||||
if (same) return;
|
||||
const key = options.partKey ?? before?.partKey;
|
||||
writeState(
|
||||
"structure-pick",
|
||||
{ at: selection.chainageM, key, sid: selection.structureId ?? undefined },
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
if (dispatching) return;
|
||||
dispatching = true;
|
||||
try {
|
||||
for (const fn of [...(listeners.get(projectId) ?? [])]) fn(selection, options.from);
|
||||
} finally {
|
||||
dispatching = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/* =============================================================================
|
||||
* design_shared.ts
|
||||
* B05 · B06 한 페이지의 **한 벌 자리**(계획서 24-3b) — 좌측 패널(칸 · 아래 단추 넷) · 종단도 ·
|
||||
* 「구조물 배치」 폼을 페이지에 **한 번만** 만들고 두 보기가 같은 노드를 함께 쓴다.
|
||||
*
|
||||
* 한 벌이 받은 사용자 조작(종단도 측점 고름 · 구조물 폼 · 우클릭)은 **지금 보이는 보기**가
|
||||
* `bindDesignView` 로 건 동작으로 간다. 값이 바뀐 알림(관 목록 · 횡단 재계산)은 숨은 보기도
|
||||
* 받는다 — 다시 보일 때 같은 값을 들고 있게.
|
||||
* [저장] · [확정]은 두 보기 몫을 모아 저장 한 줄기(`saveDesign`) 한 번으로 보낸다. 종단 보기의
|
||||
* [확정]은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav";
|
||||
import { navigateTo } from "../router";
|
||||
import { createStructuresSection, type StructuresSection } from "../b_structures_section";
|
||||
import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
|
||||
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
||||
import { drainageSide, drainageSideTool } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool";
|
||||
import { createDesignPanel, type DesignPanel } from "./design_panel";
|
||||
import { createDesignProfile, type DesignProfile } from "./design_profile";
|
||||
import { saveDesign, type DesignSaveStep } from "./design_save";
|
||||
import { resetDesign } from "./design_reset";
|
||||
import type { DesignView } from "./design_dock";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 종단도가 보기에게 알리는 것 — 측점 고름 · [초기선 복원] · 우클릭 · 끌기 · 판(배수유역도). */
|
||||
export interface DesignProfileBinding {
|
||||
onSelectStation: (stationId: string | null) => void;
|
||||
onResetAll?: () => void;
|
||||
callbacks?: RouteProfilePanelCallbacks;
|
||||
}
|
||||
|
||||
/** 저장 한 줄기에 보기가 보태는 몫(계획고 · 관은 한 벌 종단도가 낸다). */
|
||||
export interface DesignViewSave {
|
||||
routeId: () => number | null;
|
||||
prepare?: () => Promise<void>;
|
||||
steps?: Partial<
|
||||
Record<Exclude<DesignSaveStep, "cross" | "pipes" | "alignment">, () => Promise<void>>
|
||||
>;
|
||||
cross?: (confirm: boolean) => Promise<void>;
|
||||
/** 저장 뒤 이 보기만의 캐시 비우기 · 다시 읽기(알림 · 다음 단계는 여기서 한다). */
|
||||
done?: (confirm: boolean) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface DesignViewBinding {
|
||||
profile?: DesignProfileBinding;
|
||||
/** 「구조물 배치」 폼 알림 — 그 보기가 보일 때만 받는다. */
|
||||
structures?: Partial<StructuresCallbacks>;
|
||||
save?: DesignViewSave;
|
||||
/** [초기화] 때 이 보기만 아는 조작 캐시를 비운다. */
|
||||
onReset?: () => void;
|
||||
/** 다른 보기가 고친 관 옵션을 이 보기 캐시(횡단 스펙)에 얹는다. */
|
||||
onPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
|
||||
}
|
||||
|
||||
/** 한 벌 자리 — 페이지(`design_page`)가 만들어 두 보기에 같은 객체로 넘긴다. */
|
||||
export interface DesignPageShared {
|
||||
projectId: string;
|
||||
/** 좌측 패널 자리(아래 단추 넷은 좌측 패널 안). */
|
||||
left: HTMLElement;
|
||||
/** 가운데 아래 종단도 자리. */
|
||||
profileSlot: HTMLElement;
|
||||
/** 지금 보이는 보기. */
|
||||
view: () => DesignView;
|
||||
/** 좌측 칸 부품 — 두 보기가 제 몫을 넣고, 칸 정의가 이름으로 꺼낸다. */
|
||||
parts: Record<string, unknown>;
|
||||
bindings: Partial<Record<DesignView, DesignViewBinding>>;
|
||||
/** 먼저 쓰는 쪽이 만든다 — 뒤 보기는 있는 것을 쓴다. */
|
||||
panel: DesignPanel | null;
|
||||
profile: DesignProfile | null;
|
||||
structures: StructuresSection | null;
|
||||
/** 배수유역도가 마지막으로 알린 관 목록 — 뒤늦게 서는 보기가 처음부터 같은 관 목록을 쥔다(24-7a). */
|
||||
lastPipes?: Parameters<NonNullable<RouteProfilePanelCallbacks["onPipesChanged"]>>[0];
|
||||
/** 횡단 보기가 서기 전에 종단 보기가 고친 관 옵션 — 횡단 보기가 서면 캐시에 얹는다. */
|
||||
pipeOptionQueue: Array<[number, Record<string, number | string>]>;
|
||||
/** 접힌 좌측 패널을 편다(바깥에서 고른 것이 폼에 실릴 때). */
|
||||
revealLeft: () => void;
|
||||
/** 그 보기를 (숨긴 채라도) 세우고 자료가 설 때까지 기다린다. */
|
||||
ensureView: (view: DesignView) => Promise<void>;
|
||||
}
|
||||
|
||||
const bound = (shared: DesignPageShared): DesignViewBinding => shared.bindings[shared.view()] ?? {};
|
||||
|
||||
/** 보기가 제 몫을 건다 — 같은 보기가 다시 걸면 덮는다. */
|
||||
export function bindDesignView(
|
||||
shared: DesignPageShared,
|
||||
view: DesignView,
|
||||
binding: DesignViewBinding,
|
||||
): void {
|
||||
shared.bindings[view] = { ...shared.bindings[view], ...binding };
|
||||
}
|
||||
|
||||
/**
|
||||
* 종단 보기 폼에서 고친 관 옵션을 횡단 캐시에도 얹는다 — 세월교 · BOX 폼 값은 횡단 스펙(캐시)이
|
||||
* 정본이라, 안 얹으면 한 벌 폼이 횡단 보기로 가는 순간 옛 값으로 돌아간다. 횡단 보기가 아직
|
||||
* 안 섰으면 담아 두었다가 설 때 얹는다.
|
||||
*/
|
||||
export function forwardPipeOptions(
|
||||
shared: DesignPageShared,
|
||||
chainageM: number,
|
||||
patch: Record<string, number | string> | undefined,
|
||||
): void {
|
||||
if (!patch || !Object.keys(patch).length) return;
|
||||
const apply = shared.bindings.cross?.onPipeOptions;
|
||||
if (apply) apply(chainageM, patch);
|
||||
else shared.pipeOptionQueue.push([chainageM, patch]);
|
||||
}
|
||||
|
||||
/** 횡단 보기가 캐시 창구를 건다 — 담아 둔 옵션을 먼저 얹는다. */
|
||||
export function bindPipeOptions(
|
||||
shared: DesignPageShared,
|
||||
apply: (chainageM: number, patch: Record<string, number | string>) => void,
|
||||
): void {
|
||||
bindDesignView(shared, "cross", { onPipeOptions: apply });
|
||||
shared.pipeOptionQueue.splice(0).forEach(([chainageM, patch]) => apply(chainageM, patch));
|
||||
}
|
||||
|
||||
/** 지금 보기의 종단도 알림으로 보낸다. */
|
||||
function toView<K extends keyof RouteProfilePanelCallbacks>(
|
||||
shared: DesignPageShared,
|
||||
name: K,
|
||||
): NonNullable<RouteProfilePanelCallbacks[K]> {
|
||||
return ((...args: unknown[]) =>
|
||||
(bound(shared).profile?.callbacks?.[name] as ((...a: unknown[]) => void) | undefined)?.(
|
||||
...args,
|
||||
)) as NonNullable<RouteProfilePanelCallbacks[K]>;
|
||||
}
|
||||
|
||||
/** 두 보기 모두에 보낸다 — 값이 바뀐 알림. */
|
||||
function toAll<K extends keyof RouteProfilePanelCallbacks>(
|
||||
shared: DesignPageShared,
|
||||
name: K,
|
||||
): NonNullable<RouteProfilePanelCallbacks[K]> {
|
||||
return ((...args: unknown[]) =>
|
||||
Object.values(shared.bindings).forEach((binding) =>
|
||||
(binding?.profile?.callbacks?.[name] as ((...a: unknown[]) => void) | undefined)?.(...args),
|
||||
)) as NonNullable<RouteProfilePanelCallbacks[K]>;
|
||||
}
|
||||
|
||||
/** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */
|
||||
export function sharedProfile(shared: DesignPageShared): DesignProfile {
|
||||
if (shared.profile) return shared.profile;
|
||||
const profile = createDesignProfile({
|
||||
projectId: shared.projectId,
|
||||
view: shared.view(),
|
||||
side: drainageSide,
|
||||
sideTool: drainageSideTool,
|
||||
onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId),
|
||||
onResetAll: () => bound(shared).profile?.onResetAll?.(),
|
||||
callbacks: {
|
||||
onPipesChanged: (pipes) => {
|
||||
shared.lastPipes = pipes;
|
||||
toAll(shared, "onPipesChanged")(pipes);
|
||||
},
|
||||
onCrossDesignsUpdated: toAll(shared, "onCrossDesignsUpdated"),
|
||||
onStructureMove: toView(shared, "onStructureMove"),
|
||||
onStructureRemove: toView(shared, "onStructureRemove"),
|
||||
onPipeAdd: toView(shared, "onPipeAdd"),
|
||||
onStructureAdd: toView(shared, "onStructureAdd"),
|
||||
onIrregularSelect: toView(shared, "onIrregularSelect"),
|
||||
onStructureSelect: toView(shared, "onStructureSelect"),
|
||||
onStructureMarkMove: toView(shared, "onStructureMarkMove"),
|
||||
onStructureTypeAdd: toView(shared, "onStructureTypeAdd"),
|
||||
onBasinSelected: toView(shared, "onBasinSelected"),
|
||||
onPipeSelected: toView(shared, "onPipeSelected"),
|
||||
},
|
||||
});
|
||||
shared.profileSlot.append(profile.root);
|
||||
shared.profile = profile;
|
||||
return profile;
|
||||
}
|
||||
|
||||
/** 「구조물 배치」 폼 한 벌 — 알림은 지금 보기로, 상세 옵션은 횡단 보기에서만 그린다. */
|
||||
export function sharedStructures(shared: DesignPageShared): StructuresSection {
|
||||
if (shared.structures) return shared.structures;
|
||||
const on = (): Partial<StructuresCallbacks> => bound(shared).structures ?? {};
|
||||
shared.structures = createStructuresSection(
|
||||
{
|
||||
onChange: (next) => on().onChange?.(next),
|
||||
onSelect: (structure) => on().onSelect?.(structure),
|
||||
getInterval: () => on().getInterval?.() ?? 20,
|
||||
onPipeAdd: (chainageM, attributes) => on().onPipeAdd?.(chainageM, attributes),
|
||||
onPipeUpdate: (from, to, attributes) => on().onPipeUpdate?.(from, to, attributes),
|
||||
onPipeRemove: (chainageM) => on().onPipeRemove?.(chainageM),
|
||||
onPipeSelect: (chainageM) => on().onPipeSelect?.(chainageM),
|
||||
onReveal: () => shared.revealLeft(),
|
||||
},
|
||||
{ includeDetail: () => shared.view() === "cross", projectId: shared.projectId },
|
||||
);
|
||||
shared.parts.structures = shared.structures;
|
||||
return shared.structures;
|
||||
}
|
||||
|
||||
/** 지금 두 보기가 아는 노선 — 횡단 보기가 먼저(확정 본문이 그쪽이다). */
|
||||
function routeIdOf(shared: DesignPageShared): number | null {
|
||||
return shared.bindings.cross?.save?.routeId() ?? shared.bindings.profile?.save?.routeId() ?? null;
|
||||
}
|
||||
|
||||
/** 좌측 패널 한 벌 — 부품은 두 보기가 `shared.parts` 에 넣고, 칸은 보기가 처음 보일 때 선다. */
|
||||
export function sharedPanel(shared: DesignPageShared): DesignPanel {
|
||||
if (shared.panel) return shared.panel;
|
||||
const other = (): DesignView => (shared.view() === "profile" ? "cross" : "profile");
|
||||
const panel = createDesignPanel(
|
||||
{
|
||||
projectId: shared.projectId,
|
||||
parts: shared.parts,
|
||||
dock: {
|
||||
reset: {
|
||||
run: () =>
|
||||
resetDesign(shared.projectId, routeIdOf(shared), () =>
|
||||
Object.values(shared.bindings).forEach((binding) => binding?.onReset?.()),
|
||||
),
|
||||
},
|
||||
// 자료가 서기 전에는 잠근다 — 보기가 `designReady` 로 연다.
|
||||
save: { run: () => saveSharedDesign(shared, false), enabled: false },
|
||||
confirm: { run: () => saveSharedDesign(shared, true), enabled: false },
|
||||
// [보기 전환] = 주소만 바꾼다 — 같은 페이지라 라우터가 보기만 바꾼다(다시 안 만듦).
|
||||
switchView: {
|
||||
run: () => navigateTo(other() === "profile" ? ROUTES.B05_PROFILE : ROUTES.B06_SECTION),
|
||||
},
|
||||
},
|
||||
},
|
||||
shared.view(),
|
||||
);
|
||||
shared.left.append(panel.root);
|
||||
shared.panel = panel;
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** 자료가 섰다 — [저장] · [확정]을 연다(두 보기 어느 쪽이 먼저든 한 번이면 된다). */
|
||||
export function designReady(shared: DesignPageShared): void {
|
||||
shared.panel?.dock.setActions({
|
||||
save: {
|
||||
run: () => saveSharedDesign(shared, false),
|
||||
enabled: true,
|
||||
title: L("B06_Profile_Btn_Save_Tip"),
|
||||
},
|
||||
confirm: { run: () => saveSharedDesign(shared, true), enabled: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* [저장] · [확정] 한 번 — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가,
|
||||
* 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳.
|
||||
*/
|
||||
export async function saveSharedDesign(shared: DesignPageShared, confirm: boolean): Promise<void> {
|
||||
// [확정]은 횡단 본문(표준 횡단면 · 전 측점 면적)이 있어야 한다 — 숨긴 채라도 횡단 보기를 세운다.
|
||||
if (confirm) await shared.ensureView("cross");
|
||||
const profileSave = shared.bindings.profile?.save;
|
||||
const crossSave = shared.bindings.cross?.save;
|
||||
if (confirm && !crossSave?.cross) {
|
||||
showToast(L("B06_Profile_Confirm_Failed"), "error");
|
||||
return;
|
||||
}
|
||||
const profile = shared.profile;
|
||||
const side = profile?.side;
|
||||
await saveDesign(
|
||||
{
|
||||
projectId: shared.projectId,
|
||||
routeId: () => routeIdOf(shared),
|
||||
prepare: crossSave?.prepare,
|
||||
steps: {
|
||||
// 세션 초안이 나간 뒤 배수유역도 목록으로 한 번 더 맞춘다(시설 되붙이기 · 응답으로 화면 갱신).
|
||||
...(side
|
||||
? {
|
||||
pipes: async () => {
|
||||
await side.savePipes();
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// 계획고 초안 — 한 벌 종단도가 저장하고 다시 그린다.
|
||||
...(profile ? { alignment: () => profile.save() } : {}),
|
||||
...profileSave?.steps,
|
||||
...crossSave?.steps,
|
||||
},
|
||||
cross: async (isConfirm) => {
|
||||
if (crossSave?.cross) await crossSave.cross(isConfirm);
|
||||
else await profileSave?.cross?.(isConfirm);
|
||||
},
|
||||
done: async (isConfirm) => {
|
||||
if (isConfirm) {
|
||||
showToast(L("B06_Profile_Confirm_Success"), "success");
|
||||
goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]);
|
||||
return;
|
||||
}
|
||||
await profileSave?.done?.(false);
|
||||
await crossSave?.done?.(false);
|
||||
showToast(L("B06_Profile_Save_Success"), "success");
|
||||
},
|
||||
failed: (error, isConfirm) => {
|
||||
const key = isConfirm ? "B06_Profile_Confirm_Failed" : "B06_Profile_Save_Failed";
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${L(key)}${detail}`, "error");
|
||||
},
|
||||
},
|
||||
{ confirm },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_store.ts
|
||||
* B05 · B06 설계 상태 **한 곳**(계획서 24-1) — 두 보기가 같은 값을 같은 창구로 읽고 쓴다.
|
||||
*
|
||||
* const store = useDesignStore(projectId, routeId);
|
||||
* store.get("pipes") · store.set("pipes", next) · store.subscribe("pipes", fn)
|
||||
*
|
||||
* 밑은 지금 캐시 그대로다 — 값마다 `b_page_state` 등록 키 하나(새 키는 등록부에 먼저).
|
||||
* 가게는 값을 따로 들고 있지 않는다: 읽을 때마다 캐시를 읽고, 쓸 때 캐시에 쓴 뒤 듣는 쪽을
|
||||
* 부른다. 그래서 가게를 거치지 않고 캐시를 고친 옛 자리가 남아 있어도 읽는 값은 갈리지 않는다
|
||||
* (다만 그 자리는 듣는 쪽을 못 부른다 — 쓰는 길은 가게로 옮길 것).
|
||||
*
|
||||
* `alignmentDraft` 는 등록부 `alignment-draft`(노선 칸)다 — 옛 키(`b05-profile-alignment-draft:{노선}`)
|
||||
* 값은 처음 읽을 때 옮겨 받는다. B05 되돌리기(`_Profile_History`)도 이 칸을 사진 찍는다(24-2c).
|
||||
* 부르는 쪽이 프로젝트를 모르면(`null`) 지금 연 프로젝트 칸을 쓴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../b_page_state";
|
||||
import { CURRENT_PROJECT_ID_KEY } from "../../config/config_frontend";
|
||||
import type { StructureInstance } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import type { DetailPipeInput } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { AlignmentEdits } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
/** 한 페이지의 두 보기(계획서 24-1). */
|
||||
export type DesignView = "profile" | "cross";
|
||||
|
||||
/** 고른 자리 — 측점 · 구조물. 둘 다 비면 고른 것이 없다. */
|
||||
export interface DesignSelection {
|
||||
chainageM: number | null;
|
||||
structureId: string | null;
|
||||
}
|
||||
|
||||
/** 가게가 드는 값 — 없으면 null(= 만진 적 없음 · 저장분을 쓸 것). */
|
||||
export interface DesignStoreValues {
|
||||
/** 미저장 구조물 목록(정본 `structures.json`). */
|
||||
structures: StructureInstance[] | null;
|
||||
/** 미저장 관 목록(정본 `pipe_points.json`) — 주인은 이 가게 하나다. */
|
||||
pipes: DetailPipeInput[] | null;
|
||||
/** 미저장 계획고 편집(▲▼ · 곡선반경). 노선이 있어야 한다. */
|
||||
alignmentDraft: AlignmentEdits | null;
|
||||
/** 종횡단 상세 — 세션 사본. 화면이 함께 쥐는 객체는 `B06_Section_Section_Store`. */
|
||||
sectionDetail: SectionDetailResponse | null;
|
||||
/** 고른 측점 · 구조물. */
|
||||
selection: DesignSelection | null;
|
||||
}
|
||||
|
||||
export type DesignStoreKey = keyof DesignStoreValues;
|
||||
|
||||
export interface DesignStore {
|
||||
get<K extends DesignStoreKey>(key: K): DesignStoreValues[K];
|
||||
set<K extends DesignStoreKey>(key: K, value: DesignStoreValues[K]): void;
|
||||
/** 가게로 쓸 때마다 부른다. 돌려받은 함수로 끊는다. */
|
||||
subscribe<K extends DesignStoreKey>(
|
||||
key: K,
|
||||
fn: (value: DesignStoreValues[K]) => void,
|
||||
): () => void;
|
||||
}
|
||||
|
||||
/** 옛 계획고 초안 키 — 위 머리 주석 참조(남은 옛 값을 쓸어 낼 때만 쓴다). */
|
||||
export const ALIGNMENT_DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
|
||||
|
||||
/** 계획고 초안 칸의 프로젝트 — 부르는 쪽이 모르면 지금 연 프로젝트, 그것도 없으면 `-`
|
||||
* (노선 번호는 프로젝트를 건너 겹치지 않는다). */
|
||||
export function draftProjectId(projectId: string | null = null): string {
|
||||
if (projectId) return projectId;
|
||||
try {
|
||||
return window.localStorage.getItem(CURRENT_PROJECT_ID_KEY) || "-";
|
||||
} catch {
|
||||
return "-";
|
||||
}
|
||||
}
|
||||
|
||||
interface Slot<T> {
|
||||
/** 듣는 쪽을 묶는 이름 — 같은 캐시 칸이면 같은 이름. 칸을 못 정하면 null. */
|
||||
id: string | null;
|
||||
read(): T | null;
|
||||
write(value: T | null): void;
|
||||
}
|
||||
|
||||
function slotOf<K extends DesignStoreKey>(
|
||||
key: K,
|
||||
projectId: string | null,
|
||||
routeId: number | null,
|
||||
): Slot<DesignStoreValues[K]> {
|
||||
type V = DesignStoreValues[K];
|
||||
if (key === "alignmentDraft") {
|
||||
const project = draftProjectId(projectId);
|
||||
if (routeId === null) return { id: null, read: () => null, write: () => undefined };
|
||||
return {
|
||||
id: `alignment-draft:${project}:${routeId}`,
|
||||
read: () => {
|
||||
const parsed = readState<Partial<AlignmentEdits>>("alignment-draft", project, routeId);
|
||||
if (!parsed) return null;
|
||||
return {
|
||||
station_offsets: parsed.station_offsets ?? {},
|
||||
curve_radii: parsed.curve_radii ?? {},
|
||||
} as V;
|
||||
},
|
||||
write: (value) => writeState("alignment-draft", value, project, routeId),
|
||||
};
|
||||
}
|
||||
// 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다.
|
||||
const name = (
|
||||
{
|
||||
structures: "structures",
|
||||
pipes: "pipes",
|
||||
sectionDetail: "section-detail",
|
||||
selection: "selection",
|
||||
} as const
|
||||
)[key as Exclude<DesignStoreKey, "alignmentDraft">];
|
||||
const routeScoped = name === "section-detail";
|
||||
if (!projectId || (routeScoped && routeId === null)) {
|
||||
return { id: null, read: () => null, write: () => undefined };
|
||||
}
|
||||
const scopeRoute = routeScoped ? routeId : undefined;
|
||||
return {
|
||||
id: `${name}:${projectId}${routeScoped ? `:${routeId}` : ""}`,
|
||||
read: () => readState<V>(name, projectId, scopeRoute),
|
||||
write: (value) => writeState(name, value, projectId, scopeRoute),
|
||||
};
|
||||
}
|
||||
|
||||
const listeners = new Map<string, Set<(value: unknown) => void>>();
|
||||
|
||||
/**
|
||||
* 그 프로젝트 · 노선의 가게. 부를 때마다 새로 만들어도 된다 — 값도 듣는 쪽도 가게 밖
|
||||
* (캐시 · 모듈 표)에 있어서 같은 칸을 가리키면 같은 가게다.
|
||||
*
|
||||
* 노선을 모르면 `routeId = null` — 노선 칸(`alignmentDraft` · `sectionDetail`)은 비어 보이고
|
||||
* 쓰기는 버려진다. 프로젝트를 모르면 프로젝트 칸이 그렇다.
|
||||
*/
|
||||
export function useDesignStore(projectId: string | null, routeId: number | null): DesignStore {
|
||||
return {
|
||||
get(key) {
|
||||
return slotOf(key, projectId, routeId).read() as DesignStoreValues[typeof key];
|
||||
},
|
||||
set(key, value) {
|
||||
const slot = slotOf(key, projectId, routeId);
|
||||
slot.write(value);
|
||||
if (slot.id) listeners.get(`${key}|${slot.id}`)?.forEach((fn) => fn(value));
|
||||
},
|
||||
subscribe(key, fn) {
|
||||
const id = slotOf(key, projectId, routeId).id;
|
||||
if (!id) return () => undefined;
|
||||
const bucket = `${key}|${id}`;
|
||||
const set = listeners.get(bucket) ?? new Set();
|
||||
listeners.set(bucket, set);
|
||||
const listener = fn as (value: unknown) => void;
|
||||
set.add(listener);
|
||||
return () => set.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,133 +1,108 @@
|
||||
import { createDesignSync } from "./B06_Section_UI_Page_Design_Sync";
|
||||
import { createGradeEdit } from "./B06_Section_UI_Page_Grade_Edit";
|
||||
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
||||
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
|
||||
import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { mountMissingStationNotice } from "./B06_Section_UI_Missing_Stations";
|
||||
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
fetchWorkflowState,
|
||||
goToWorkflowStage,
|
||||
WORKFLOW_STEP_ROUTES,
|
||||
type WorkflowState,
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
/* =============================================================================
|
||||
* design_view_cross.ts
|
||||
* 한 페이지의 **횡단 보기**(계획서 24-3b) — 가운데 위 칸 = 횡단 카드(처음 보일 때 그린다 ·
|
||||
* 그 안에서 스크롤). 좌측 패널 · 아래 단추 · 종단도는 한 벌(`design_shared`)을 종단 보기와
|
||||
* 같은 노드로 쓴다.
|
||||
*
|
||||
* 여기서는 횡단 보기 몫만 건다 — 좌측 횡단 칸 재료(횡단 보기 설정 · 표준 횡단면) · 구조물 폼
|
||||
* 알림 · 카드 조정 제어 · 저장 몫. (옛 `B06_Section_UI_Page.ts` 의 본체다. 저장 흐름은
|
||||
* `_Page_Persist`, 설계 반영 · 재계산은 `_Page_Design_Sync` 가 맡는다.)
|
||||
*
|
||||
* 종단 보기의 [확정]이 횡단 자료를 얻으려고 이 보기를 **숨긴 채** 세우기도 한다 — 숨긴 칸은
|
||||
* 폭이 0 이라 카드는 그리지 않고, 처음 보일 때 보임 알림이 그린다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createDesignSync } from "../../B06_Section/B06_Section_UI_Page_Design_Sync";
|
||||
import { leaveForDashboard } from "../b_missing_data_guard";
|
||||
import { readByKey, stateKey, writeByKey } from "../b_page_state";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations";
|
||||
import type {
|
||||
StructureInstance,
|
||||
StructureType,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import {
|
||||
fetchSectionContext,
|
||||
getSections,
|
||||
type SectionContextResponse,
|
||||
type SectionDetailResponse,
|
||||
type StandardCrossSection,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
} from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls";
|
||||
import {
|
||||
confirmCurrentSections,
|
||||
createCutSlopeStore,
|
||||
createRockBoundaryStore,
|
||||
saveCurrentSections,
|
||||
crossSaveParts,
|
||||
type SectionPersistContext,
|
||||
} from "./B06_Section_UI_Page_Persist";
|
||||
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
|
||||
import {
|
||||
readStructurePick,
|
||||
writeStructurePick,
|
||||
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import { applyStructurePick } from "./B06_Section_UI_Page_Structure_Pick";
|
||||
import { createSectionView } from "./B06_Section_UI_Section_View";
|
||||
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Persist";
|
||||
import { maxToeFitHalfWidth } from "../../B06_Section/B06_Section_UI_Cross_Fit";
|
||||
import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
|
||||
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick";
|
||||
import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View";
|
||||
import { revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
|
||||
import {
|
||||
applyPipeOptionsToCache,
|
||||
replayDraftPipeOptions,
|
||||
type PipeOptionsContext,
|
||||
} from "./B06_Section_UI_Page_Pipe_Options";
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Pipe_Options";
|
||||
import {
|
||||
createStandardPanel,
|
||||
effectiveStandardCross,
|
||||
rememberRockBoundaryDefault,
|
||||
type StandardPanelController,
|
||||
} from "./B06_Section_UI_Standard_Panel";
|
||||
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
createB06StructuresPanel,
|
||||
isWallSelecting,
|
||||
wireStructureSelection,
|
||||
} from "./B06_Section_UI_Page_Structures_Panel";
|
||||
import "./B06_Section_UI_Style.css";
|
||||
import "./B06_Section_UI_Style_Cross.css";
|
||||
import "./B06_Section_UI_Style_Cross_Controls.css";
|
||||
import "./B06_Section_UI_Style_Cross_Areas.css";
|
||||
import { loadSectionDetail } from "./B06_Section_Section_Store";
|
||||
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
|
||||
import { buildGroup, createSampleWidener, L } from "./B06_Section_UI_Page_Common";
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Structures_Panel";
|
||||
import "../../B06_Section/B06_Section_UI_Style.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross_Controls.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
|
||||
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
|
||||
import {
|
||||
applyStructureAreaRows,
|
||||
structureAreaRows,
|
||||
} from "../../B06_Section/B06_Section_Structure_Layouts";
|
||||
import { createSampleWidener, L } from "../../B06_Section/B06_Section_UI_Page_Common";
|
||||
import {
|
||||
createCrossViewPart,
|
||||
createStandardSlot,
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Panel";
|
||||
import type { DesignPageHost } from "./design_page";
|
||||
import {
|
||||
bindDesignView,
|
||||
bindPipeOptions,
|
||||
designReady,
|
||||
sharedPanel,
|
||||
type DesignPageShared,
|
||||
} from "./design_shared";
|
||||
import "@util/common_util_mass_haul.css";
|
||||
|
||||
export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
/** 횡단면도 **표시** 반폭 기본값(m) — 2026-08-19 사용자 지시로 20 → 12. 계산·보유 샘플 폭은
|
||||
* 백엔드 기본(SECTION_CROSS_HALF_WIDTH_M = 20m) 그대로라 12는 표시 범위만 좁힌다. */
|
||||
const DISPLAY_HALF_WIDTH_DEFAULT_M = 12;
|
||||
|
||||
export async function mountCrossView(
|
||||
center: HTMLElement,
|
||||
host: DesignPageHost,
|
||||
shared: DesignPageShared,
|
||||
): Promise<void> {
|
||||
const projectId = shared.projectId || null;
|
||||
let currentRouteId: number | null = null;
|
||||
let sectionDetail: SectionDetailResponse | null = null;
|
||||
let stationInterval: number | undefined;
|
||||
|
||||
// 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다.
|
||||
// 표준 횡단면 설정은 기본 접힘(2026-08-23 사용자 지시) — 자주 여는 칸이 아니다.
|
||||
const standardGroup = buildGroup(L("B06_Std_Title"), true);
|
||||
const standardPanelSlot = document.createElement("div");
|
||||
standardGroup.append(standardPanelSlot);
|
||||
const standardPanelSlot = createStandardSlot();
|
||||
let standardPanel: StandardPanelController | null = null;
|
||||
|
||||
// 높이 배율 옵션은 폐지 — 항상 1배율(2026-08-06 사용자 지시).
|
||||
// 횡단 반폭은 **자체 컨테이너 + 자체 [보기 반폭 적용] 버튼**이다(2026-08-23 사용자 지시)
|
||||
// — 표준 횡단면 설정의 [전체 측점 반영]에 물려 있어 반폭만 바꿔도 전 측점 설계
|
||||
// 재계산을 기다려야 했다. 요청 반폭이 보유 샘플 폭(기본 20m, config
|
||||
// SECTION_CROSS_HALF_WIDTH_M) 이하면 표시만 바꾸므로 즉시 끝나고, 넘을 때만 재생성한다.
|
||||
const crossHalfWidthField = createInputField({
|
||||
label: L("B05_Route_Field_CrossHalfWidth"),
|
||||
type: "number",
|
||||
});
|
||||
crossHalfWidthField.input.min = "0.1";
|
||||
crossHalfWidthField.input.step = "0.1";
|
||||
const applyWidthButton = createButton({
|
||||
label: L("B06_View_Apply"),
|
||||
variant: "filled",
|
||||
onClick: () => void applyCrossHalfWidth(),
|
||||
});
|
||||
applyWidthButton.title = L("B06_View_Apply_Tip");
|
||||
const viewGroup = buildGroup(L("B06_View_Title"));
|
||||
// 반폭 입력 + [적용]은 한 줄(2026-08-23 사용자 지시).
|
||||
const viewRow = document.createElement("div");
|
||||
viewRow.className = "b06-profile__field-row";
|
||||
viewRow.append(crossHalfWidthField.root, applyWidthButton);
|
||||
viewGroup.append(viewRow);
|
||||
/** 횡단면도 **표시** 반폭 기본값(m) — 2026-08-19 사용자 지시로 20 → 12. 계산·보유
|
||||
* 샘플 폭은 백엔드 기본(SECTION_CROSS_HALF_WIDTH_M = 20m) 그대로라 12는 표시
|
||||
* 범위만 좁힌다(12 ≤ 20이므로 재계산 없음). 계산 범위와 혼동 금지. */
|
||||
const DISPLAY_HALF_WIDTH_DEFAULT_M = 12;
|
||||
|
||||
// 하단 액션 [종단 이동][임시저장][확정] — 종단 이동은 저장 없이 B05로 페이지 이동만,
|
||||
// 확정은 임시저장과 같은 내용 저장 후 stage 2·3을 닫고 B07 상세설계로 넘어간다
|
||||
// (2026-08-08 워크플로우 재정의).
|
||||
const goProfileButton = createButton({
|
||||
label: L("B06_Profile_Btn_GoProfile"),
|
||||
variant: "ghost",
|
||||
onClick: () => navigateTo(ROUTES.B05_PROFILE),
|
||||
});
|
||||
const saveButton = createButton({
|
||||
label: L("B06_Profile_Btn_Save"),
|
||||
variant: "ghost",
|
||||
onClick: () => void saveCurrentSections(persistContext),
|
||||
});
|
||||
saveButton.title = L("B06_Profile_Btn_Save_Tip");
|
||||
saveButton.disabled = true;
|
||||
const confirmButton = createButton({
|
||||
label: L("B06_Profile_Btn_Confirm"),
|
||||
variant: "filled",
|
||||
onClick: () => void confirmCurrentSections(persistContext),
|
||||
});
|
||||
confirmButton.disabled = true;
|
||||
const actionRow = document.createElement("div");
|
||||
actionRow.className = "b06-profile__actions";
|
||||
actionRow.append(goProfileButton, saveButton, confirmButton);
|
||||
// 「횡단 보기 설정」 칸 재료(반폭 입력 + [보기 반폭 적용])는 `_Page_Panel` 이 만든다.
|
||||
const crossView = createCrossViewPart(() => void applyCrossHalfWidth());
|
||||
const crossHalfWidthField = crossView.field;
|
||||
|
||||
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */
|
||||
const focusStationAt = (chainageM: number): void => {
|
||||
@@ -138,30 +113,35 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
sectionView.focusStation(nearest.station_id);
|
||||
};
|
||||
/** 그 누가거리를 가진 측점 카드(관 찾는 거리 한 값). */
|
||||
const ownerAt = (chainageM: number) =>
|
||||
sectionDetail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
|
||||
// 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화).
|
||||
// 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행].
|
||||
// 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b).
|
||||
let structureMarksSink:
|
||||
((structures: StructureInstance[], types: StructureType[]) => void) | null = null;
|
||||
/** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */
|
||||
let pipeReloadTimer = 0;
|
||||
const structuresPanel = createB06StructuresPanel({
|
||||
projectId,
|
||||
// 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자).
|
||||
reveal: () => layout.setOptionsOpen(true),
|
||||
// 종단 알약 레인에 같은 목록을 넘긴다 — B05 와 같은 표기(2026-09-07 사용자 지시 4).
|
||||
// 뷰는 이 패널보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다.
|
||||
shared,
|
||||
// 종단 알약 레인에 같은 목록을 넘긴다 — 종단 보기와 같은 표기(2026-09-07 사용자 지시 4).
|
||||
onMarks: (structures, types) => {
|
||||
structureMarksSink?.(structures, types);
|
||||
syncBermSpans(structures);
|
||||
},
|
||||
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시
|
||||
// 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정).
|
||||
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06).
|
||||
onStructuresChanged: () => void refreshDetailForStructures(),
|
||||
onPipesWritten: () => {
|
||||
window.clearTimeout(pipeReloadTimer);
|
||||
pipeReloadTimer = window.setTimeout(() => shared.profile?.side?.reload(), 200);
|
||||
},
|
||||
detail: () => sectionDetail,
|
||||
// 폼 기본 높이 = 지금 도면에 그려진 순수 높이(조정창이 보여주던 값과 같은 계산).
|
||||
wallHeight: (chainageM, role) => {
|
||||
const owner = sectionDetail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
|
||||
);
|
||||
const owner = ownerAt(chainageM);
|
||||
const culvert = owner?.culvert;
|
||||
if (!owner || !culvert) return null;
|
||||
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
|
||||
@@ -172,13 +152,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m)
|
||||
.pureHeight;
|
||||
},
|
||||
// 폼 형태 = 지금 도면이 쓰는 형태. 조작(조정값 m)이 있으면 그것이 정본이라
|
||||
// 저장된 옵션보다 앞선다 — 안 맞추면 폼은 저장값, 그림은 조작값이 되어
|
||||
// "유출구 형태를 바꿔도 안 바뀐다"로 보인다(2026-08-30 사용자 보고).
|
||||
// 폼 형태 = 지금 도면이 쓰는 형태. 조작(조정값 m)이 있으면 그것이 정본이라 저장된 옵션보다
|
||||
// 앞선다(2026-08-30 사용자 보고: 유출구 형태를 바꿔도 안 바뀐다).
|
||||
wallForm: (chainageM, role) => {
|
||||
const owner = sectionDetail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
|
||||
);
|
||||
const owner = ownerAt(chainageM);
|
||||
const culvert = owner?.culvert;
|
||||
if (!owner || !culvert) return null;
|
||||
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
|
||||
@@ -188,32 +165,25 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
},
|
||||
// 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다.
|
||||
wallSideLabels: (chainageM) => {
|
||||
const owner = sectionDetail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
|
||||
);
|
||||
const owner = ownerAt(chainageM);
|
||||
if (!owner) return null;
|
||||
const inletLeft = (owner.uphill_side ?? "left") === "left";
|
||||
return inletLeft ? { inlet: "좌", outlet: "우" } : { inlet: "우", outlet: "좌" };
|
||||
},
|
||||
stationInterval: () => stationInterval ?? 20,
|
||||
focusChainage: focusStationAt,
|
||||
queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch),
|
||||
// 벽·구체를 누르는 중이면 그 누름이 카드를 고른다 — 폼이 구조물을 열며 카드를 먼저
|
||||
// 고르면 뒤이은 카드 선택이 「같은 측점 재클릭 = 해제」로 먹힌다.
|
||||
focusChainage: (chainageM) => {
|
||||
if (!isWallSelecting()) focusStationAt(chainageM);
|
||||
},
|
||||
applyPipeOptions: (chainageM, patch) =>
|
||||
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
|
||||
});
|
||||
|
||||
const dockDivider = document.createElement("hr");
|
||||
dockDivider.className = "b05-structure__divider";
|
||||
const actionDock = document.createElement("div");
|
||||
actionDock.className = "b05-route__dock ui-sidebar-actions";
|
||||
actionDock.append(structuresPanel.listRoot, dockDivider, actionRow);
|
||||
|
||||
const leftForm = document.createElement("div");
|
||||
leftForm.className = "b06-profile__form";
|
||||
// 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준.
|
||||
leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock);
|
||||
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
|
||||
attachCollapsible(leftForm);
|
||||
// 좌측 패널 = 한 벌 등록부 — 횡단 보기 칸 재료만 넣는다. 칸 차례: 구조물 배치(최상단 —
|
||||
// 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준. 아래 단추 넷 · 저장은 한 벌이 낸다.
|
||||
Object.assign(shared.parts, { crossView, standardSlot: standardPanelSlot });
|
||||
sharedPanel(shared);
|
||||
|
||||
// 설계 선택 반영·재계산·소단 동기화는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
|
||||
const {
|
||||
@@ -233,12 +203,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
async function refreshDetailForStructures(): Promise<void> {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
try {
|
||||
if (projectId && currentRouteId !== null) {
|
||||
sectionDetail = await loadSectionDetail(projectId, currentRouteId);
|
||||
}
|
||||
sectionDetail = await loadSectionDetail(projectId, currentRouteId);
|
||||
// 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가
|
||||
// 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측: 카드는 바뀌는데
|
||||
// 최종 누가토량이 그대로였음).
|
||||
// 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측).
|
||||
if (sectionDetail) {
|
||||
applyStructureAreaRows(
|
||||
sectionDetail.cross_sections,
|
||||
@@ -291,23 +258,17 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
||||
recompute: (chainageM) => recomputeIfRock(chainageM),
|
||||
});
|
||||
const rockOffsets = rockStore.offsets;
|
||||
const rockBoundaryControl = rockStore.control;
|
||||
|
||||
// 측점별 암 절토 경사(2026-09-07 사용자 지시) — 암 경계선과 같은 꼴의 세션 저장소다.
|
||||
// 전체를 바꾸는 자리는 좌측 [표준 횡단면 설정]이고, 여기 값은 그 측점 하나만 덮는다.
|
||||
const cutSlopeStore = createCutSlopeStore({
|
||||
sessionKey: () => stateKey("cutslope", projectId, currentRouteId),
|
||||
standard: () => (projectId ? effectiveStandardCross(projectId) : null),
|
||||
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
||||
recompute: (chainageM) => recomputeIfRock(chainageM),
|
||||
});
|
||||
const cutSlopeRatios = cutSlopeStore.ratios;
|
||||
|
||||
const stationControls = createStationControls({
|
||||
// 키는 등록표(`b_page_state`)가 만든다 — 이름만 넘기면 통·범위·옛 키 이관이 따라온다.
|
||||
// 형변환을 두지 않는다: 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다
|
||||
// (2026-09-06 `extraspan` 누락으로 B06 이 안 뜬 뒤 막음).
|
||||
// 키는 등록표(`b_page_state`)가 만든다 — 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다.
|
||||
sessionKey: (kind) => stateKey(kind, projectId, currentRouteId),
|
||||
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
||||
detail: () => sectionDetail,
|
||||
@@ -317,16 +278,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
projectId: () => projectId,
|
||||
onSaveError: (message) => showToast(`배수관 구간값 저장 실패 — ${message}`, "error"),
|
||||
});
|
||||
const { stationWidth: stationWidthControl, revetOffset: revetOffsetControl } = stationControls;
|
||||
const { widths: stationWidths, inletStructures, basinAdjustments } = stationControls;
|
||||
|
||||
const sectionView = createSectionView(
|
||||
(chainageM, change) => {
|
||||
void handleDesignChange(chainageM, change);
|
||||
},
|
||||
rockBoundaryControl,
|
||||
stationWidthControl,
|
||||
revetOffsetControl,
|
||||
rockStore.control,
|
||||
stationControls.stationWidth,
|
||||
stationControls.revetOffset,
|
||||
stationControls.inletStructure,
|
||||
stationControls.extraWalls,
|
||||
stationControls.structureSpan,
|
||||
@@ -334,21 +293,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
stationControls.ford,
|
||||
stationControls.box,
|
||||
cutSlopeStore.control,
|
||||
shared,
|
||||
);
|
||||
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
|
||||
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
|
||||
// 계획선 편집(▲/▼) 제공자는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
|
||||
const gradeEditFor = createGradeEdit({
|
||||
routeId: () => currentRouteId,
|
||||
detail: () => sectionDetail,
|
||||
view: () => sectionView,
|
||||
reconcile: () => reconcileStaleDesigns({ force: true }),
|
||||
});
|
||||
sectionView.setGradeEdit(gradeEditFor);
|
||||
// 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙.
|
||||
|
||||
// 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한
|
||||
// 페이지라 같은 자리에서 되어야 한다). 어느 길로 들어와도 좌측 「구조물 배치」와
|
||||
// 같은 함수를 타므로 목록·폼·알약이 함께 선다.
|
||||
// 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로
|
||||
// 들어와도 좌측 「구조물 배치」와 같은 함수를 타므로 목록·폼·알약이 함께 선다.
|
||||
sectionView.setStructureEdit({
|
||||
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
|
||||
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
|
||||
@@ -376,8 +328,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
stationControls,
|
||||
() => sectionDetail,
|
||||
structuresPanel,
|
||||
// 횡단도에서 고른 부재도 같은 세션 칸에 남긴다(2026-09-04 — 두 화면이 한 페이지처럼).
|
||||
(chainageM, key) => writeStructurePick(projectId, chainageM, key),
|
||||
// 횡단도에서 고른 부재도 같은 선택 칸에 남긴다(2026-09-04 — 두 화면이 한 페이지처럼).
|
||||
(chainageM, key) =>
|
||||
selectDesign(projectId, structuresPanel.selectionAt(chainageM), {
|
||||
partKey: key,
|
||||
from: "b06",
|
||||
}),
|
||||
);
|
||||
// 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자).
|
||||
// 연동으로 옆에서 넘어온 카드는 **소유 측점** 시설을 보여 준다.
|
||||
@@ -387,13 +343,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
: null;
|
||||
if (!target) {
|
||||
structuresPanel.showAtChainage(null);
|
||||
writeStructurePick(projectId, null); // 카드 해제 — 세션 선택도 비운다.
|
||||
selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다.
|
||||
return;
|
||||
}
|
||||
const owner = stationControls.structureSpan.ownerOf(target);
|
||||
structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
|
||||
// 카드만 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
|
||||
if (!isWallSelecting()) writeStructurePick(projectId, owner?.chainage_m ?? target.chainage_m);
|
||||
const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
|
||||
// 카드만 고른 것도 남긴다 — 종단 보기로 가면 그 시설이 그대로 열린다.
|
||||
if (!isWallSelecting()) selectDesign(projectId, opened, { from: "b06" });
|
||||
structureSelection.syncInletStructure();
|
||||
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
|
||||
// (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다.
|
||||
@@ -403,10 +359,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
// 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다.
|
||||
// 가운데 위 칸: 횡단 카드(sectionView) 또는 안내 메시지.
|
||||
const mainArea = document.createElement("div");
|
||||
mainArea.className = "b06-profile__main";
|
||||
mainArea.append(sectionView.root);
|
||||
center.append(mainArea);
|
||||
|
||||
function showSectionView(): void {
|
||||
if (!mainArea.contains(sectionView.root)) mainArea.replaceChildren(sectionView.root);
|
||||
@@ -435,7 +392,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/** 표시 반폭 세션 키 — 페이지를 떠났다 와도 조절값이 유지되게 한다. */
|
||||
/** 표시 반폭 세션 키 — 보기를 떠났다 와도 조절값이 유지되게 한다. */
|
||||
const displaySessionKey = (): string | null =>
|
||||
stateKey("cross-display", projectId, currentRouteId);
|
||||
|
||||
@@ -450,46 +407,48 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function updateActionState(): void {
|
||||
saveButton.disabled = sectionDetail === null;
|
||||
confirmButton.disabled = sectionDetail === null;
|
||||
}
|
||||
|
||||
/** 세션에 남은 선택을 이어받는다(2026-09-04 — 두 화면이 한 페이지처럼).
|
||||
* **넘김값이 바뀌었을 때만** 적용한다 — 재렌더마다 다시 돌면 사용자가 옮긴 스크롤이
|
||||
* 튀고, 진입당 한 번으로 막으면 자료가 늦게 온 경우 영영 안 열린다(2026-09-06). */
|
||||
/** 남은 선택을 이어받는다(2026-09-04 — 두 화면이 한 페이지처럼). **넘김값이 바뀌었을 때만**
|
||||
* 적용한다 — 재렌더마다 다시 돌면 사용자가 옮긴 스크롤이 튄다(2026-09-06). */
|
||||
let appliedPick: string | null = null;
|
||||
function restoreStructurePick(): void {
|
||||
if (!sectionDetail) return;
|
||||
const handoff = readStructurePick(projectId);
|
||||
const signature = handoff ? `${handoff.at}|${handoff.key ?? ""}` : null;
|
||||
const signature = handoff
|
||||
? `${handoff.at}|${handoff.key ?? ""}|${handoff.structureId ?? ""}`
|
||||
: null;
|
||||
if (signature === null || signature === appliedPick) return;
|
||||
appliedPick = signature;
|
||||
applyStructurePick(handoff, sectionDetail, {
|
||||
focusStation: (stationId) => sectionView.focusStation(stationId),
|
||||
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
||||
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
|
||||
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
|
||||
boxSelect: (chainageM, role) => stationControls.box.select(chainageM, role),
|
||||
});
|
||||
// 카드를 못 찾았으면(구조물 목록이 아직 안 실림) 적용한 것으로 치지 않는다 — 목록이 실린 뒤
|
||||
// `load().then(restoreStructurePick)` 가 다시 부른다.
|
||||
const applied = applyStructurePick(
|
||||
handoff,
|
||||
sectionDetail,
|
||||
{
|
||||
focusStation: (stationId) => sectionView.focusStation(stationId),
|
||||
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
||||
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
|
||||
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
|
||||
boxSelect: (chainageM, role) => stationControls.box.select(chainageM, role),
|
||||
},
|
||||
structuresPanel.getStructures(),
|
||||
);
|
||||
if (applied) appliedPick = signature;
|
||||
}
|
||||
|
||||
function renderSectionDetail(): void {
|
||||
if (sectionDetail) {
|
||||
showSectionView();
|
||||
// 높이 배율은 항상 1(2026-08-06 사용자 지시 — 옵션 폐지).
|
||||
sectionView.render(
|
||||
sectionDetail,
|
||||
1,
|
||||
crossHalfWidth(),
|
||||
stationInterval,
|
||||
context?.earthwork_conversion,
|
||||
context?.haul_equipment_limits,
|
||||
`${projectId ?? "-"}:${currentRouteId ?? "-"}`,
|
||||
context?.natural_spoil_min_ground_slope ?? undefined,
|
||||
);
|
||||
restoreStructurePick();
|
||||
}
|
||||
if (!sectionDetail) return;
|
||||
showSectionView();
|
||||
// 높이 배율은 항상 1(2026-08-06 사용자 지시 — 옵션 폐지).
|
||||
sectionView.render(
|
||||
sectionDetail,
|
||||
1,
|
||||
crossHalfWidth(),
|
||||
stationInterval,
|
||||
context?.earthwork_conversion,
|
||||
context?.haul_equipment_limits,
|
||||
`${projectId ?? "-"}:${currentRouteId ?? "-"}`,
|
||||
context?.natural_spoil_min_ground_slope ?? undefined,
|
||||
);
|
||||
restoreStructurePick();
|
||||
}
|
||||
|
||||
// [저장]·[확정]과 편집분 수집은 저장 흐름 모듈에 있다(2026-09-02 분리).
|
||||
@@ -504,11 +463,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
// 실려 나가던 자리(2026-09-09 실측).
|
||||
reconcileDesigns: () => reconcileStaleDesigns({ force: true }),
|
||||
patchSources: () => ({
|
||||
rockOffsets,
|
||||
cutSlopeRatios,
|
||||
stationWidths,
|
||||
inletStructures,
|
||||
basinAdjustments,
|
||||
rockOffsets: rockStore.offsets,
|
||||
cutSlopeRatios: cutSlopeStore.ratios,
|
||||
stationWidths: stationControls.widths,
|
||||
inletStructures: stationControls.inletStructures,
|
||||
basinAdjustments: stationControls.basinAdjustments,
|
||||
revetAdjusts: stationControls.revetAdjustsByChainage(),
|
||||
extraCounts: stationControls.extraCountsByChainage(),
|
||||
extraSpans: stationControls.extraSpansByChainage(),
|
||||
@@ -517,39 +476,31 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
linkFlags: stationControls.linkFlagsByChainage(),
|
||||
}),
|
||||
};
|
||||
bindDesignView(shared, "cross", { save: crossSaveParts(persistContext) });
|
||||
|
||||
let workflowState: WorkflowState | undefined;
|
||||
let context: SectionContextResponse | null = null;
|
||||
if (projectId) {
|
||||
const [contextResult, workflowResult] = await Promise.allSettled([
|
||||
fetchSectionContext(projectId),
|
||||
fetchWorkflowState(projectId),
|
||||
]);
|
||||
if (contextResult.status === "fulfilled") context = contextResult.value;
|
||||
else showToast(L("B06_Profile_Context_Failed"), "error");
|
||||
if (workflowResult.status === "fulfilled") workflowState = workflowResult.value;
|
||||
}
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
title: L("B06_Profile_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 3,
|
||||
leftPanel: leftForm,
|
||||
mainContent: mainArea,
|
||||
stages: workflowState?.stages,
|
||||
currentStage: workflowState?.current_stage,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
onStepClick: (stepIndex) => {
|
||||
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
},
|
||||
// 한 페이지 두 보기(계획서 24-3a · 24-3b) — 다시 보이면 종단 보기에서 바뀐 구조물 · 관 ·
|
||||
// 계획고를 가게에서 다시 읽어 폼 · 카드 · 종단도를 그린다. 떠나면 카드 칸 관찰을 뗀다.
|
||||
host.onShow(() => {
|
||||
if (currentRouteId === null) return;
|
||||
void structuresPanel.load().then(restoreStructurePick);
|
||||
void refreshDetailForStructures().then(() => reconcileStaleDesigns());
|
||||
});
|
||||
host.onDispose(() => {
|
||||
window.clearTimeout(pipeReloadTimer);
|
||||
sectionView.dispose();
|
||||
});
|
||||
layout.root.classList.add("b06-profile-layout");
|
||||
root.replaceChildren(layout.root);
|
||||
|
||||
let context: SectionContextResponse | null = null;
|
||||
if (!projectId) {
|
||||
renderMessage(L("B06_Profile_Error_Project"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context = await fetchSectionContext(projectId);
|
||||
} catch {
|
||||
showToast(L("B06_Profile_Context_Failed"), "error");
|
||||
}
|
||||
if (host.disposed()) return;
|
||||
if (!context) {
|
||||
renderMessage(L("B06_Profile_Context_Failed"));
|
||||
return;
|
||||
@@ -562,7 +513,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
rockStore.setDefaults(context.rock_boundary_default_offset_m, context.rock_boundary_step_m);
|
||||
|
||||
// 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값).
|
||||
// 횡단 반폭 입력은 [전체 측점 반영] 버튼 위로 들어간다(2026-08-06 사용자 지시).
|
||||
standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll);
|
||||
// 브라우저 횡단 계산이 옛 암 측점을 서버와 같은 기본값으로 다시 계산하게 기억해 둔다.
|
||||
rememberRockBoundaryDefault(projectId, context.rock_boundary_default_offset_m);
|
||||
@@ -578,15 +528,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
rockStore.load();
|
||||
cutSlopeStore.load();
|
||||
stationControls.load();
|
||||
// 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다.
|
||||
void structuresPanel.load().then(restoreStructurePick);
|
||||
// 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다. 숨긴 채
|
||||
// 섰으면(종단 보기 [확정]) 한 벌 폼은 종단 보기 몫이라 싣지 않는다 — 보일 때 싣는다.
|
||||
if (host.visible()) void structuresPanel.load().then(restoreStructurePick);
|
||||
try {
|
||||
const existing = await getSections(projectId, context.route_id);
|
||||
if (!existing.longitudinal) {
|
||||
leaveForDashboard();
|
||||
return;
|
||||
}
|
||||
// 공유 캐시 — B05가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다(두 페이지 싱크의 핵심).
|
||||
// 공유 캐시 — 종단 보기가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다.
|
||||
sectionDetail = await loadSectionDetail(projectId, context.route_id);
|
||||
// 구조물 초안(저장 안 한 벽)은 저장소가 이미 얹어 준다. 면적만 여기서 다시 얹으면
|
||||
// 첫 화면의 카드·유토곡선이 초안 기준으로 선다(2026-09-06 실측).
|
||||
@@ -608,16 +559,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
// 확정 이력의 표준 횡단면 설정값 복원(진행 중 세션 편집값이 있으면 패널이 무시).
|
||||
if (storedOptions?.standard_cross_section)
|
||||
standardPanel?.applyStored(storedOptions.standard_cross_section);
|
||||
/* 입력칸의 기준값은 **표시 기본 12m**(2026-08-19 사용자 지시, 위에서 이미 넣었다).
|
||||
*
|
||||
* ⚠ `data.options.cross_half_width_m`은 종단·횡단을 만들 때 쓴 **샘플링(계산)
|
||||
* 반폭**이지 사용자가 고른 표시 폭이 아니다(엔진이 offsets 범위를 만들 때 쓰고
|
||||
* 그대로 저장한다). 그 값으로 입력칸을 덮으면 표시 기본값이 언제나 계산 기본
|
||||
* 20m으로 되돌아온다 — 2026-08-19 사용자 보고의 원인이 이것이다. 그래서 여기서는
|
||||
* 읽지 않고, 사용자가 실제로 고른 표시 폭만 되살린다:
|
||||
* ① 측점 개별 표시 반폭의 저장값(design.display_half_width_m) 대표값
|
||||
* ② 세션에 남은 마지막 조절값(같은 노선으로 돌아왔을 때)
|
||||
* 표시값이 보유 샘플 폭보다 크면 [전체 측점 반영]이 알아서 재생성한다. */
|
||||
/* 입력칸의 기준값은 **표시 기본 12m**. ⚠ `data.options.cross_half_width_m`은 종단·횡단을
|
||||
* 만들 때 쓴 **샘플링(계산) 반폭**이지 사용자가 고른 표시 폭이 아니다 — 그 값으로 덮으면
|
||||
* 표시 기본값이 언제나 20m로 되돌아온다(2026-08-19 사용자 보고의 원인). 사용자가 실제로
|
||||
* 고른 표시 폭만 되살린다: ① 측점 개별 표시 반폭 저장값의 대표값 ② 세션의 마지막 조절값. */
|
||||
const savedDisplayWidths = sectionDetail.cross_sections
|
||||
.map((section) => section.design?.display_half_width_m)
|
||||
.filter((width): width is number => typeof width === "number" && width > 0);
|
||||
@@ -625,7 +570,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
// 측점마다 다를 수 있다 — 전역 입력칸에는 가장 넓은 값을 올린다(잘림 방지).
|
||||
crossHalfWidthField.input.value = Math.max(...savedDisplayWidths).toFixed(1);
|
||||
}
|
||||
// 세션에 보관된 표시 반폭이 있으면 그것이 우선한다(사용자가 마지막으로 지정한 값).
|
||||
const sessionDisplayKey = displaySessionKey();
|
||||
if (sessionDisplayKey) {
|
||||
const sessionDisplay = Number(readByKey(sessionDisplayKey));
|
||||
@@ -634,12 +578,18 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0)
|
||||
stationInterval = storedOptions.station_interval_m;
|
||||
// 종단 보기에서 고친 관 옵션(세월교 · BOX 스펙)을 캐시에 얹는 창구 — 담아 둔 것부터(24-3b).
|
||||
bindPipeOptions(shared, (chainageM, patch) =>
|
||||
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
|
||||
);
|
||||
// 새로고침 뒤 캐시 상세는 저장분 — 저장 전에 고친 관 옵션을 초안에서 다시 얹는다(24-7a).
|
||||
replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId));
|
||||
renderSectionDetail();
|
||||
// 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다
|
||||
// (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만.
|
||||
void mountMissingStationNotice(root, projectId, refreshDetailForStructures);
|
||||
void mountMissingStationNotice(mainArea, projectId, refreshDetailForStructures);
|
||||
void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6)
|
||||
updateActionState();
|
||||
designReady(shared);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
renderMessage(L("B06_Profile_Calculate_In_B05"));
|
||||
@@ -0,0 +1,665 @@
|
||||
/* =============================================================================
|
||||
* design_view_profile.ts
|
||||
* 한 페이지의 **종단 보기**(계획서 24-3b) — 가운데 위 칸 = 3D 화면. 좌측 패널 · 아래 단추 ·
|
||||
* 종단도(유토곡선 · 배수유역도)는 한 벌(`design_shared`)을 횡단 보기와 같은 노드로 쓴다.
|
||||
*
|
||||
* 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫.
|
||||
* (옛 `B05_Profile_UI_Page.ts` 의 본체다. 버튼 동작은 `_Page_Actions`, 최신 노선 캐시는
|
||||
* `_Page_Latest`, 3D 측점 바는 `_Page_Stations`, 숨김 · 보임은 `_Page_Life` 가 맡는다.)
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { purgeOtherProjects } from "../b_asset_cache";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import {
|
||||
fetchConfirmedSurface,
|
||||
type SurfaceConfirmedResponse,
|
||||
} from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
updateContourInterval,
|
||||
type RouteLatestResponse,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { createRoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel";
|
||||
import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel";
|
||||
import { leaveForDashboard } from "../b_missing_data_guard";
|
||||
import { remountRoute } from "../router";
|
||||
import { openRouteEditModal } from "../../B05_Profile/B05_Profile_UI_RouteEdit";
|
||||
import { createSelectionSync } from "../../B05_Profile/B05_Profile_UI_Selection";
|
||||
import {
|
||||
readStructurePick,
|
||||
restoreStructurePick,
|
||||
wireStructurePick,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import { NO_SELECTION, nearestWithin, selectDesign } from "./design_selection";
|
||||
import type { DesignPageHost } from "./design_page";
|
||||
import {
|
||||
bindDesignView,
|
||||
designReady,
|
||||
forwardPipeOptions,
|
||||
type DesignPageShared,
|
||||
} from "./design_shared";
|
||||
import { bindProfileViewLife } from "../../B05_Profile/B05_Profile_UI_Page_Life";
|
||||
import { structureAnchorM } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures";
|
||||
import { createRouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer";
|
||||
import {
|
||||
irregularStationId,
|
||||
isPipeStation,
|
||||
} from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import {
|
||||
rememberRockBoundaryDefault,
|
||||
rememberStandardDefaults,
|
||||
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
fetchSectionContext,
|
||||
type SectionDetailResponse,
|
||||
} from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
|
||||
import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
|
||||
import {
|
||||
loadCorridorIfFresh,
|
||||
refreshCorridor,
|
||||
saveCorridorIfDirty,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Corridor";
|
||||
import {
|
||||
profileSaveParts,
|
||||
solveRouteAction,
|
||||
type PageActionContext,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Page_Actions";
|
||||
import { createLatestSource, panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
|
||||
import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations";
|
||||
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
||||
import "../../B05_Profile/B05_Profile_UI_Style.css";
|
||||
// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04).
|
||||
import "../../B05_Profile/B05_Profile_UI_Style_Table.css";
|
||||
import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css";
|
||||
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
|
||||
import {
|
||||
DEFAULT_ROAD_WIDTHS,
|
||||
fetchRoadWidths,
|
||||
loadUphillOverrides,
|
||||
restorePoints,
|
||||
saveUphillOverrides,
|
||||
toBounds,
|
||||
toGradeClass,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Page_Helpers";
|
||||
|
||||
/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */
|
||||
const migratedRoutes = new Set<string>();
|
||||
|
||||
type PipeReport = Parameters<NonNullable<RouteProfilePanelCallbacks["onPipesChanged"]>>[0];
|
||||
|
||||
export async function mountProfileView(
|
||||
center: HTMLElement,
|
||||
host: DesignPageHost,
|
||||
shared: DesignPageShared,
|
||||
): Promise<void> {
|
||||
const activeProjectId = shared.projectId;
|
||||
if (!activeProjectId) return;
|
||||
// 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다.
|
||||
void purgeOtherProjects(activeProjectId);
|
||||
|
||||
const viewer = createRouteViewer();
|
||||
// 구 우클릭 메뉴의 자유 텍스트 구조물 → 레지스트리 타입(2026-08-17 이관 매핑과 동일).
|
||||
const LEGACY_TYPE_IDS: Record<string, string> = {
|
||||
기성막이: "revetment",
|
||||
대피로: "refuge",
|
||||
기타: "etc",
|
||||
};
|
||||
/** 고른 것을 selection 한 곳에 적는다 — 횡단 보기로 이어지는 값이다(chainageM null = 해제). */
|
||||
const rememberPick = (chainageM: number | null, structureId: string | null): void =>
|
||||
selectDesign(activeProjectId, chainageM === null ? NO_SELECTION : { chainageM, structureId }, {
|
||||
from: "b05",
|
||||
});
|
||||
/** 배수유역도가 마지막으로 알린 관 목록 — 숨어 있다 다시 보일 때 폼 · 3D 에 다시 싣는다. */
|
||||
let lastPipes: PipeReport | null = shared.lastPipes ?? null;
|
||||
const applyPipes = (pipes: PipeReport): void => {
|
||||
// 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅
|
||||
// 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다.
|
||||
bridge.setPipes(pipes);
|
||||
panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem));
|
||||
profilePanel.setMinCoverPipes(pipes);
|
||||
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
|
||||
};
|
||||
// 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b).
|
||||
const profilePanel = createRouteProfilePanel(
|
||||
shared,
|
||||
(stationId) => {
|
||||
viewer.markers.selectStation(stationId);
|
||||
syncIrregularSelection(stationId);
|
||||
},
|
||||
// [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다).
|
||||
() => bridge.clearProjectedStations(),
|
||||
{
|
||||
// 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다**
|
||||
// (2026-09-01 사용자 지시). 갱신은 상단 [3D 업데이트] 버튼으로 모았다.
|
||||
onCrossDesignsUpdated: () => panel.setCorridorPending(true),
|
||||
// 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. 정본은 배수유역도의
|
||||
// 관 지점이다. 숨어 있을 때는 담아만 두고 다시 보일 때 싣는다(폼은 한 벌이라 횡단 보기 몫).
|
||||
onPipesChanged: (pipes) => {
|
||||
lastPipes = pipes;
|
||||
if (host.visible()) applyPipes(pipes);
|
||||
},
|
||||
onStructureMove: (from, to, station) => {
|
||||
if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to);
|
||||
},
|
||||
onStructureRemove: (station) => {
|
||||
if (isPipeStation(station)) profilePanel.drainage.removePipe(station.chainage_m);
|
||||
},
|
||||
// 우클릭 [배관 추가] — 사이드 폼에 종류·측점을 실어 주고 [추가]로 확정(2026-08-18).
|
||||
onPipeAdd: (chainage) => panel.structures.addAt(chainage, "pipe"),
|
||||
onStructureAdd: (chainage, type) =>
|
||||
panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"),
|
||||
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
|
||||
// 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다.
|
||||
onBasinSelected: (chainageM) => selectStationOfPipe(chainageM),
|
||||
onPipeSelected: (chainageM) => selectStationOfPipe(chainageM),
|
||||
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
|
||||
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
|
||||
onStructureSelect: (structureId) => {
|
||||
const chainage = bridge.pipeMarkChainage(structureId);
|
||||
rememberPick(bridge.markChainage(structureId), chainage === null ? structureId : null);
|
||||
if (chainage !== null) {
|
||||
panel.structures.selectPipeByChainage(chainage);
|
||||
selectStationOfPipe(chainage);
|
||||
return;
|
||||
}
|
||||
panel.structures.selectById(structureId);
|
||||
},
|
||||
onStructureMarkMove: (structureId, toChainage) => {
|
||||
const chainage = bridge.pipeMarkChainage(structureId);
|
||||
if (chainage !== null) {
|
||||
profilePanel.drainage.movePipe(chainage, toChainage);
|
||||
return;
|
||||
}
|
||||
panel.structures.moveById(structureId, toChainage);
|
||||
},
|
||||
onStructureTypeAdd: (chainage, typeId) => panel.structures.addAt(chainage, typeId),
|
||||
},
|
||||
);
|
||||
|
||||
/** 확정 지표면 — 모델 id 와 3D 로딩에 필요한 범위만 들고 있는다(2026-09-06 호출 정리). */
|
||||
let confirmedSurface: SurfaceConfirmedResponse | null = null;
|
||||
let latest: RouteLatestResponse | null = null;
|
||||
let roadWidths = DEFAULT_ROAD_WIDTHS;
|
||||
let currentSectionDetail: SectionDetailResponse | null = null;
|
||||
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
|
||||
let corridorVisible = true;
|
||||
let routeReady = false;
|
||||
let restoring = true;
|
||||
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
|
||||
let selectionSyncing = false;
|
||||
|
||||
/* ── 측점 상단측(=측구 방향) 사용자 변경분 ─────────────────────────────
|
||||
* solve가 자동 판정한 uphill_side를 3D 램프 클릭으로 바꾼 값. 세션에 보관했다가
|
||||
* 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다. */
|
||||
const uphillOverrides = loadUphillOverrides(activeProjectId);
|
||||
const uphillKey = (chainage: number): string => chainage.toFixed(3);
|
||||
|
||||
const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({
|
||||
stations: () => bridge.irregularStations(),
|
||||
isSyncing: () => selectionSyncing,
|
||||
setSyncing: (value) => {
|
||||
selectionSyncing = value;
|
||||
},
|
||||
selectMarker: (id) => viewer.markers.selectStation(id),
|
||||
selectGraph: (id) => profilePanel.setSelectedStation(id),
|
||||
selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM),
|
||||
selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM),
|
||||
selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM),
|
||||
markStation: (chainageM) => profilePanel.drainage.markStation(chainageM),
|
||||
selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM),
|
||||
});
|
||||
|
||||
/** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다
|
||||
* (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */
|
||||
function restorePick(): void {
|
||||
if (panel.structures.hasSelection()) return;
|
||||
const pick = viewer.structurePick;
|
||||
restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe);
|
||||
}
|
||||
|
||||
const latestSource = createLatestSource(activeProjectId);
|
||||
const loadLatest = latestSource.load;
|
||||
|
||||
const panel = createRoutePanel(
|
||||
{
|
||||
onSolve: () => void solveRouteAction(actionContext),
|
||||
// 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면
|
||||
// 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리).
|
||||
onEditPlannedRoute: () =>
|
||||
void openRouteEditModal(activeProjectId, () => remountRoute(ROUTES.B05_PROFILE), {
|
||||
// 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다.
|
||||
stationIntervalM: panel.values().stationInterval ?? undefined,
|
||||
// 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥).
|
||||
surfaceModelId: confirmedSurface?.model_id ?? null,
|
||||
contourIntervalM: latest?.surface_params.contour_interval_m,
|
||||
smooth: latest?.surface_params.smooth,
|
||||
}),
|
||||
onContourApply: (interval) => applyContours(interval),
|
||||
onSurfaceVisible: viewer.setSurfaceVisible,
|
||||
onCorridorVisible: (visible) => {
|
||||
corridorVisible = visible;
|
||||
viewer.setCorridorVisible(visible);
|
||||
// 측점 바 기준면이 바뀐다(예상형상 위 ↔ 원지반 위) — 다시 그린다.
|
||||
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
||||
},
|
||||
onContoursVisible: viewer.setContoursVisible,
|
||||
onAxesVisible: viewer.setAxesVisible,
|
||||
onStationLinesVisible: viewer.setStationLinesVisible,
|
||||
onStationLabelsVisible: viewer.setStationLabelsVisible,
|
||||
onSurfaceGrayscale: viewer.setSurfaceGrayscale,
|
||||
onView: viewer.setView,
|
||||
onResetView: () => viewer.setView("top"),
|
||||
onProjection: viewer.setProjection,
|
||||
// [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다.
|
||||
onCorridorRefresh: async () => {
|
||||
if (!currentSectionDetail || !latest?.route?.id) return;
|
||||
const routePoints = latest.route_points ?? [];
|
||||
if (routePoints.length <= 1) return;
|
||||
await refreshCorridor(
|
||||
viewer,
|
||||
activeProjectId,
|
||||
latest.route.id,
|
||||
currentSectionDetail,
|
||||
routePoints,
|
||||
profilePanel.alignmentSamples() ?? undefined,
|
||||
);
|
||||
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
|
||||
renderStationLines(currentSectionDetail);
|
||||
// 만든 것을 **바로 올려 둔다**(2026-09-06) — 다음 진입이 네트워크 없이 선다.
|
||||
void saveCorridorIfDirty(activeProjectId, latest.route.id);
|
||||
},
|
||||
onMovePoint: viewer.beginMoveSelected,
|
||||
onDeletePoint: viewer.markers.deleteSelected,
|
||||
onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }),
|
||||
onInputChange: markStale,
|
||||
onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset),
|
||||
onStructuresChange: (next) => bridge.applyStructures(next),
|
||||
// 계곡 통과 시설(A군) — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합).
|
||||
onPipeFacilityAdd: (chainage, attributes) =>
|
||||
profilePanel.drainage.addPipe(chainage, attributes),
|
||||
onPipeFacilityUpdate: (from, to, attributes) => {
|
||||
profilePanel.drainage.updatePipeFacility(from, to, attributes);
|
||||
// 한 벌 폼 — 횡단 보기 캐시(세월교 · BOX 스펙)도 같은 값으로(계획서 24-3b).
|
||||
forwardPipeOptions(shared, from, attributes.options as Record<string, number | string>);
|
||||
},
|
||||
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
|
||||
onPipeFacilitySelect: (chainage) => {
|
||||
rememberPick(chainage, null); // 목록 선택도 횡단 보기로 이어진다.
|
||||
if (selectionSyncing) return;
|
||||
selectionSyncing = true;
|
||||
try {
|
||||
// null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18).
|
||||
const station =
|
||||
chainage === null
|
||||
? undefined
|
||||
: nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m);
|
||||
const id = station ? irregularStationId(station.id) : null;
|
||||
viewer.markers.selectStation(id);
|
||||
profilePanel.setSelectedStation(id);
|
||||
syncBasinHighlight(id);
|
||||
// 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화).
|
||||
profilePanel.drainage.selectPipeAtChainage(chainage);
|
||||
} finally {
|
||||
selectionSyncing = false;
|
||||
}
|
||||
},
|
||||
onStructureSelect: (structure) => {
|
||||
// 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을
|
||||
// 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문).
|
||||
if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null);
|
||||
if (selectionSyncing) return;
|
||||
selectionSyncing = true;
|
||||
try {
|
||||
profilePanel.setSelectedStructure(structure?.structure_id ?? null);
|
||||
} finally {
|
||||
selectionSyncing = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
shared,
|
||||
);
|
||||
|
||||
wireStructurePick(viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe);
|
||||
|
||||
/** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의
|
||||
* 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */
|
||||
function markStale(): void {
|
||||
if (restoring || !routeReady) return;
|
||||
profilePanel.setGradeLimit(panel.gradeLimitPct());
|
||||
// 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01).
|
||||
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
|
||||
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
||||
}
|
||||
|
||||
viewer.markers.onChange(markStale);
|
||||
viewer.markers.onSelectionChange(panel.setSelected);
|
||||
viewer.markers.onStationSelectionChange((stationId) => {
|
||||
profilePanel.setSelectedStation(stationId);
|
||||
syncIrregularSelection(stationId);
|
||||
});
|
||||
// 3D 램프 클릭 → 그 측을 상단측(측구 방향)으로 지정하고 세션 보관 + 램프 재렌더.
|
||||
viewer.markers.onUphillPick((stationId, side) => {
|
||||
if (!currentSectionDetail) return;
|
||||
const base = currentSectionDetail.longitudinal.stations.find(
|
||||
(station) => station.station_id === stationId,
|
||||
);
|
||||
let chainage = base?.chainage_m;
|
||||
if (chainage === undefined) {
|
||||
const prefix = irregularStationId("");
|
||||
if (stationId.startsWith(prefix)) {
|
||||
const id = stationId.slice(prefix.length);
|
||||
chainage = bridge.irregularStations().find((entry) => entry.id === id)?.chainage_m;
|
||||
}
|
||||
}
|
||||
if (chainage === undefined) return;
|
||||
uphillOverrides.set(uphillKey(chainage), side);
|
||||
saveUphillOverrides(activeProjectId, uphillOverrides);
|
||||
renderStationLines(currentSectionDetail);
|
||||
});
|
||||
viewer.root.append(panel.viewControls);
|
||||
|
||||
function restorePanel(next: RouteLatestResponse): void {
|
||||
panel.restore(panelValuesOf(next));
|
||||
viewer.markers.setPoints(restorePoints(next));
|
||||
}
|
||||
|
||||
function renderStationLines(detail: SectionDetailResponse): void {
|
||||
const lines = stationLinesOf({
|
||||
detail,
|
||||
irregular: bridge.irregularStations(),
|
||||
routePoints: latest?.route_points ?? [],
|
||||
liveSamples: profilePanel.alignmentSamples(),
|
||||
corridorVisible,
|
||||
uphillOverrides,
|
||||
});
|
||||
viewer.renderStationLines(lines, roadWidths[panel.values().gradeClass] / 2);
|
||||
}
|
||||
|
||||
/** 구조물·계곡 통과 시설을 사이드 목록·그래프·3D에 맞추는 다리(정본 저장까지 맡는다). */
|
||||
const bridge = createStructuresBridge({
|
||||
projectId: activeProjectId,
|
||||
panel: () => panel,
|
||||
profilePanel: () => profilePanel,
|
||||
renderStationLines: () => {
|
||||
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
||||
},
|
||||
isRestoring: () => restoring,
|
||||
});
|
||||
// 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
|
||||
bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []);
|
||||
|
||||
function renderSections(detail: SectionDetailResponse, routeId?: number): void {
|
||||
currentSectionDetail = detail;
|
||||
profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId);
|
||||
// 저장된 계획선의 정책은 만들 당시 기준이라 지금 설정(임도 종류·설계속도·지형)과
|
||||
// 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19).
|
||||
profilePanel.setGradeLimit(panel.gradeLimitPct());
|
||||
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
|
||||
profilePanel.setStationDisplay(panel.stationDisplayOffset());
|
||||
profilePanel.setIrregularStations(bridge.irregularStations());
|
||||
renderStationLines(detail);
|
||||
// 예상형상 코리도 — 저장본이 지금 정본과 맞을 때만 올린다. 낡았으면 [3D 업데이트] 대기
|
||||
// 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시 만드느라 전환이 16.7초까지 갔다).
|
||||
const routePoints = latest?.route_points ?? [];
|
||||
if (routePoints.length > 1) {
|
||||
void loadCorridorIfFresh(
|
||||
viewer,
|
||||
activeProjectId,
|
||||
routeId ?? latest?.route?.id,
|
||||
detail,
|
||||
routePoints,
|
||||
profilePanel.alignmentSamples() ?? undefined,
|
||||
).then((fresh) => panel.setCorridorPending(!fresh));
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreSections(routeId: number): Promise<void> {
|
||||
let detail: SectionDetailResponse;
|
||||
profilePanel.setLoading("종단면 자료를 불러오는 중…");
|
||||
try {
|
||||
// 공유 캐시 — 횡단 보기가 이미 받아 뒀으면 같은 객체를 재사용해 두 보기가 같은 값을 본다.
|
||||
detail = await loadSectionDetail(activeProjectId, routeId);
|
||||
} catch {
|
||||
// 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다.
|
||||
currentSectionDetail = null;
|
||||
profilePanel.setLoading(null);
|
||||
profilePanel.clear();
|
||||
viewer.renderStationLines([], 0);
|
||||
return;
|
||||
}
|
||||
profilePanel.setLoading(null);
|
||||
try {
|
||||
renderSections(detail, routeId);
|
||||
// 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만
|
||||
// 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측).
|
||||
const legacy = detail.longitudinal.stations
|
||||
.filter((station) => station.kind === "irregular")
|
||||
.map((station) => ({
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.structure ?? "",
|
||||
}));
|
||||
const migrateKey = `${activeProjectId}:${routeId}`;
|
||||
if (legacy.length && !migratedRoutes.has(migrateKey)) {
|
||||
migratedRoutes.add(migrateKey);
|
||||
void migrateLegacyStations(activeProjectId, legacy)
|
||||
.then((result) => {
|
||||
if (result.migrated > 0) {
|
||||
showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`);
|
||||
return bridge.refreshFromServer().then(() => undefined);
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => {
|
||||
/* 이관 실패는 치명적이지 않다 — 다음 진입에서 다시 시도된다. */
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다 — 실패 사실을 드러낸다.
|
||||
console.error("B05 종단면도 렌더 실패", error);
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? `종단면도 표시 실패: ${error.message}`
|
||||
: "종단면도 표시에 실패했습니다.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLatest(next: RouteLatestResponse): void {
|
||||
latest = next;
|
||||
routeReady = Boolean(next.route && next.route_points.length > 1);
|
||||
profilePanel.setRoutePolyline(next.route_points ?? []);
|
||||
if (next.route) {
|
||||
const stored = next.route.algorithm_params ?? {};
|
||||
viewer.markers.renderRoute(
|
||||
next.route_points,
|
||||
(stored.curve_warning_segments as Array<{
|
||||
polyline_start_index: number;
|
||||
polyline_end_index: number;
|
||||
}>) ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyContours(interval: number): Promise<void> {
|
||||
// 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장).
|
||||
try {
|
||||
await viewer.reloadContours(interval);
|
||||
await updateContourInterval(activeProjectId, interval);
|
||||
// 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다.
|
||||
const cached = latestSource.read();
|
||||
if (cached) {
|
||||
latestSource.write({
|
||||
...cached,
|
||||
surface_params: { ...cached.surface_params, contour_interval_m: interval },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// [경로 계산] · 저장 몫은 따로 뗀 모듈이 맡는다(2026-09-02).
|
||||
const actionContext: PageActionContext = {
|
||||
projectId: activeProjectId,
|
||||
latest: () => latest,
|
||||
confirmedSurface: () => confirmedSurface,
|
||||
routeReady: () => routeReady,
|
||||
viewer: () => viewer,
|
||||
panel: () => panel,
|
||||
profilePanel: () => profilePanel,
|
||||
bridge: () => bridge,
|
||||
uphillOverrides,
|
||||
persistUphillOverrides: () => saveUphillOverrides(activeProjectId, uphillOverrides),
|
||||
loadLatest,
|
||||
renderLatest,
|
||||
restoreSections,
|
||||
};
|
||||
// [저장] · [확정] · [초기화]에 보태는 종단 보기 몫 — 한 벌이 두 보기 몫을 모아 보낸다.
|
||||
bindDesignView(shared, "profile", {
|
||||
save: profileSaveParts(actionContext),
|
||||
onReset: () => uphillOverrides.clear(),
|
||||
});
|
||||
|
||||
// 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b).
|
||||
bindProfileViewLife(host, {
|
||||
projectId: activeProjectId,
|
||||
viewer,
|
||||
ready: () => !restoring,
|
||||
// 다시 보이면 횡단 보기가 한 벌(폼 · 종단도)에 실어 둔 값을 종단 보기 값으로 되돌린다.
|
||||
reload: async () => {
|
||||
await bridge.load();
|
||||
if (lastPipes) applyPipes(lastPipes);
|
||||
},
|
||||
syncPick: () => {
|
||||
if (readStructurePick(activeProjectId)) {
|
||||
restoreStructurePick(
|
||||
viewer.structurePick,
|
||||
activeProjectId,
|
||||
panel.structures,
|
||||
selectStationOfPipe,
|
||||
);
|
||||
return;
|
||||
}
|
||||
viewer.markers.selectStation(null);
|
||||
profilePanel.setSelectedStation(null);
|
||||
},
|
||||
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
|
||||
onHide: () => {
|
||||
if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id);
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 진입 로딩 ─────────────────────────────────────────────────────────
|
||||
* 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시).
|
||||
* 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시). 서클은 3D 뷰포트
|
||||
* 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */
|
||||
const LOAD_STEP_COUNT = 3;
|
||||
const progress = createProgressCircle({ label: "노선 정보를 불러오는 중…", overlay: true });
|
||||
viewer.root.append(progress.root);
|
||||
let loadedSteps = 0;
|
||||
function advanceLoading(label: string): void {
|
||||
loadedSteps += 1;
|
||||
progress.set(Math.min(1, loadedSteps / LOAD_STEP_COUNT), label);
|
||||
}
|
||||
|
||||
const mainContent = document.createElement("div");
|
||||
mainContent.className = "b05-route__main";
|
||||
mainContent.append(viewer.root);
|
||||
center.append(mainContent);
|
||||
|
||||
try {
|
||||
// ① 좌측 폼·노선 설정값 — 캐시가 있으면 그것으로 **먼저** 그리고 신선도는 뒤에서 확인한다
|
||||
// (2026-09-06). 캐시가 없을 때만 DB를 기다린다.
|
||||
const cachedLatest = latestSource.read();
|
||||
const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([
|
||||
cachedLatest ? Promise.resolve(cachedLatest) : loadLatest(true),
|
||||
fetchSectionContext(activeProjectId),
|
||||
fetchRoadWidths(activeProjectId),
|
||||
]);
|
||||
if (host.disposed()) return;
|
||||
roadWidths = configuredRoadWidths;
|
||||
panel.restore({
|
||||
stationInterval: sectionContext.defaults.station_interval_m,
|
||||
crossSampleInterval: sectionContext.defaults.cross_sample_interval_m,
|
||||
longSampleInterval: sectionContext.defaults.long_sample_interval_m,
|
||||
// 임도 종류는 프로젝트 등록(B02)이 정본이다 — 화면에서 고르지 않는다(2026-08-19).
|
||||
gradeClass: toGradeClass(sectionContext.road_type),
|
||||
});
|
||||
// 유토곡선용 설정 — config_system 정의 그대로 받아 횡단 보기와 같은 계산을 태운다.
|
||||
profilePanel.setEarthworkContext({
|
||||
conversion: sectionContext.earthwork_conversion,
|
||||
haulLimits: sectionContext.haul_equipment_limits,
|
||||
naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined,
|
||||
});
|
||||
// 표준횡단 config 기본값도 기억한다 — 계획선 편집 중 횡단 재계산이 브라우저에서 돈다
|
||||
// (2026-09-03 로컬 계산 전환). 프론트에 config 사본을 두지 않으려는 같은 원칙이다.
|
||||
rememberStandardDefaults(activeProjectId, sectionContext.standard_cross_section);
|
||||
rememberRockBoundaryDefault(activeProjectId, sectionContext.rock_boundary_default_offset_m);
|
||||
restorePanel(latestResponse);
|
||||
renderLatest(latestResponse);
|
||||
advanceLoading("확정 지표면 모델을 확인하는 중…");
|
||||
|
||||
// ② 확정 지표면 — 모델 id·범위를 한 번에 받는다.
|
||||
const confirmed = await fetchConfirmedSurface(activeProjectId);
|
||||
confirmedSurface = confirmed.model_id === null ? null : confirmed;
|
||||
if (!confirmedSurface) {
|
||||
// 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다.
|
||||
leaveForDashboard();
|
||||
return;
|
||||
}
|
||||
advanceLoading("종단면 자료를 불러오는 중…");
|
||||
|
||||
// ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
|
||||
if (latestResponse.route) await restoreSections(latestResponse.route.id);
|
||||
// 횡단 보기가 먼저 서서 배수유역도가 관 목록을 이미 알렸다면 그것을 먼저 싣는다 — 빈 목록으로
|
||||
// 시작하면 아래 구조물 로딩이 관 대조로 관을 지우고 초안 옵션이 자동 배치로 되돌아간다(24-7a).
|
||||
if (lastPipes) applyPipes(lastPipes);
|
||||
// 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후).
|
||||
await bridge.load();
|
||||
advanceLoading("");
|
||||
designReady(shared);
|
||||
// 캐시로 그렸다면 뒤에서 신선도만 확인한다 — 다른 탭이 자료를 갈아 끼웠을 때만 다시 그린다.
|
||||
if (cachedLatest) {
|
||||
void latestSource.verify(latestResponse, async (fresh) => {
|
||||
renderLatest(fresh);
|
||||
if (fresh.route?.id) await restoreSections(fresh.route.id);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error");
|
||||
} finally {
|
||||
progress.remove();
|
||||
restoring = false;
|
||||
restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
|
||||
}
|
||||
|
||||
// ④ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다.
|
||||
void (async () => {
|
||||
const surface = confirmedSurface;
|
||||
const current = latest as RouteLatestResponse | null; // 닫힘 안에서 채워져 흐름 분석이 못 봄
|
||||
if (!surface || !current || host.disposed()) return;
|
||||
try {
|
||||
// 범위는 ②에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다.
|
||||
if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다.");
|
||||
await viewer.loadSurface(
|
||||
activeProjectId,
|
||||
surface.model_id as number,
|
||||
current.surface_params.method,
|
||||
current.surface_params.smooth,
|
||||
current.surface_params.contour_interval_m,
|
||||
toBounds(surface.bounds),
|
||||
);
|
||||
if (host.disposed()) return; // 올리는 사이 페이지를 떠났다.
|
||||
renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다.
|
||||
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
||||
showToast("3D 지형 준비 완료", "success");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error");
|
||||
}
|
||||
})();
|
||||
}
|
||||
+27
-2
@@ -8,11 +8,19 @@
|
||||
* - 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,
|
||||
showWithinPage,
|
||||
takeLeaveGuard,
|
||||
} from "./router";
|
||||
|
||||
const FOOTERLESS_ROUTES: readonly RoutePath[] = [
|
||||
ROUTES.B01_ACCOUNT,
|
||||
@@ -44,14 +52,31 @@ function bootstrap(): void {
|
||||
initThemeAndLang();
|
||||
|
||||
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
|
||||
let shown = "";
|
||||
let shownHash = "";
|
||||
const renderAll = (): void => {
|
||||
const within = showWithinPage(currentRoute()); // B05 ↔ B06 — 보기만 바꿈(24-3a)
|
||||
shown = currentRoute();
|
||||
shownHash = window.location.hash;
|
||||
if (within) return;
|
||||
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, "", shownHash); // 취소 — 머문 채 주소만 되돌림
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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 {};
|
||||
+57
-11
@@ -9,8 +9,18 @@
|
||||
|
||||
import { ROUTES, DEFAULT_ROUTE, PROTECTED_ROUTES, type RoutePath } from "@config/config_frontend";
|
||||
|
||||
/**
|
||||
* 여러 주소를 맡는 페이지 — 같은 페이지 안 주소 이동은 다시 만들지 않고 `show` 만 부른다
|
||||
* (B05 · B06 한 페이지 · 계획서 24-3a). 다른 페이지로 떠날 때 `dispose` 로 정리한다.
|
||||
*/
|
||||
export interface MountedPage {
|
||||
covers: (route: RoutePath) => boolean;
|
||||
show: (route: RoutePath) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
/** 페이지 렌더 함수 시그니처. root(마운트 대상)를 받아 내용을 채운다. */
|
||||
export type PageRenderer = (root: HTMLElement) => void | Promise<void>;
|
||||
export type PageRenderer = (root: HTMLElement) => void | MountedPage | Promise<void | MountedPage>;
|
||||
|
||||
/** 라우트별 지연 로더 등록 테이블.
|
||||
* 페이지 구현이 완료되는 대로 아래에 한 줄씩 추가한다. */
|
||||
@@ -41,10 +51,9 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
|
||||
(await import("../B03_FileInput/B03_FileInput_UI_Page")).renderB03FileInput,
|
||||
[ROUTES.B04_PREPROCESS]: async () =>
|
||||
(await import("../B04_PreProcess/B04_PreProcess_UI_Page")).renderB04Surface,
|
||||
[ROUTES.B05_PROFILE]: async () =>
|
||||
(await import("../B05_Profile/B05_Profile_UI_Page")).renderB05Route,
|
||||
[ROUTES.B06_SECTION]: async () =>
|
||||
(await import("../B06_Section/B06_Section_UI_Page")).renderB06ProfileCross,
|
||||
// B05 · B06 = 한 페이지 두 보기(계획서 24-3a) — 주소만 바뀌면 보기만 바꾼다.
|
||||
[ROUTES.B05_PROFILE]: async () => (await import("./design/design_page")).renderDesignPage,
|
||||
[ROUTES.B06_SECTION]: async () => (await import("./design/design_page")).renderDesignPage,
|
||||
[ROUTES.B07_DESIGN_DETAIL]: async () =>
|
||||
(await import("../B07_DesignDetail/B07_DesignDetail_UI_Page")).renderB07DesignDetail,
|
||||
// B08_QUANTITY: 수량 본문은 재작성 예정(구 구현은 B08_Quantity/0_old_260726_codex.zip 백업).
|
||||
@@ -65,21 +74,51 @@ 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;
|
||||
|
||||
/** 지금 떠 있는 여러 주소 페이지 · 렌더 차례(늦게 끝난 옛 렌더를 버림). */
|
||||
let mounted: MountedPage | null = null;
|
||||
let renderSeq = 0;
|
||||
|
||||
/** 같은 페이지 안 주소면 보기만 바꾸고 true — 셸 · 페이지를 다시 그리지 않는다. */
|
||||
export function showWithinPage(route: RoutePath): boolean {
|
||||
if (!mounted?.covers(route)) return false;
|
||||
mounted.show(route);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 같은 페이지 안이라도 처음부터 다시 세운다 — [초기화] · 계획노선 편집처럼 옛 캐시를 버린 뒤. */
|
||||
export function remountRoute(route: RoutePath): void {
|
||||
mounted?.dispose();
|
||||
mounted = null;
|
||||
navigateTo(route);
|
||||
}
|
||||
|
||||
/** 로그인 여부 (토큰 존재 확인) */
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
const { fetchSession } = await import("../A06_Login/A06_Login_Api_Fetch");
|
||||
return fetchSession();
|
||||
}
|
||||
|
||||
/** 현재 해시에서 라우트 경로 추출 (#/a01-home → a01-home) */
|
||||
/** 현재 해시에서 라우트 경로 추출 (#/a01-home → a01-home · `?이름=값` 뒤는 뗌) */
|
||||
export function currentRoute(): RoutePath {
|
||||
const hash = window.location.hash.replace(/^#\/?/, "").trim();
|
||||
const hash = window.location.hash.replace(/^#\/?/, "").split("?")[0].trim();
|
||||
return (hash || DEFAULT_ROUTE) as RoutePath;
|
||||
}
|
||||
|
||||
/** 지정 라우트로 이동 (해시 변경 → hashchange 이벤트로 렌더 트리거) */
|
||||
export function navigateTo(route: RoutePath): void {
|
||||
const target = `#/${route}`;
|
||||
/** 현재 해시의 `?이름=값` 인자 (#/m02-master-template?layer=personal → layer=personal) */
|
||||
export function currentQuery(): URLSearchParams {
|
||||
return new URLSearchParams(window.location.hash.split("?")[1] ?? "");
|
||||
}
|
||||
|
||||
/** 지정 라우트로 이동 (해시 변경 → hashchange 이벤트로 렌더 트리거) · `query` = `?이름=값` 인자 */
|
||||
export function navigateTo(route: RoutePath, query?: Record<string, string>): void {
|
||||
const target = `#/${route}${query ? `?${new URLSearchParams(query)}` : ""}`;
|
||||
if (window.location.hash === target) {
|
||||
// 동일 해시면 hashchange가 안 뜨므로 수동 트리거
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
@@ -103,6 +142,10 @@ function renderPlaceholder(root: HTMLElement, route: RoutePath): void {
|
||||
*/
|
||||
export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
|
||||
const route = currentRoute();
|
||||
const seq = ++renderSeq;
|
||||
// 다른 페이지로 떠남 — 3D · 그리기 루프 · 창 듣기를 정리한다(계획서 24-3a).
|
||||
mounted?.dispose();
|
||||
mounted = null;
|
||||
|
||||
if (PROTECTED_ROUTES.includes(route) && !(await isAuthenticated())) {
|
||||
navigateTo(ROUTES.A06_LOGIN);
|
||||
@@ -149,8 +192,11 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
|
||||
|
||||
try {
|
||||
const renderer = await loader();
|
||||
if (seq !== renderSeq) return; // 그 사이 다른 주소로 떠났다.
|
||||
outlet.innerHTML = "";
|
||||
await renderer(outlet);
|
||||
const page = await renderer(outlet);
|
||||
if (page && seq !== renderSeq) page.dispose();
|
||||
else if (page) mounted = page;
|
||||
window.scrollTo(0, 0);
|
||||
} catch (err) {
|
||||
console.error(`[router] 페이지 로드 실패: ${route}`, err);
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/* 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-width: min(720px, calc(100% - 32px));
|
||||
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,294 @@
|
||||
/* =============================================================================
|
||||
* 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;
|
||||
/** 미리보기 수정(카드 안 작은 표) — 도구 모음 · 오른쪽 클릭 메뉴 없이 수식 입력 줄 · 칸 고침 · 단축키 ·
|
||||
* 알약 막대만. 다른 화면 모양은 그대로 */
|
||||
preview?: boolean;
|
||||
}
|
||||
|
||||
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 bare = readOnly || !!opts.preview; // 도구 모음 · 메뉴 없음
|
||||
const root = el("div", {
|
||||
className: `ss${readOnly ? " ss--readonly" : ""}${noTabs ? " ss--notabs" : ""}${opts.preview ? " ss--preview" : ""}`,
|
||||
});
|
||||
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 = bare ? null : mountToolbar(ctx);
|
||||
const tabs: PartHandle | null = noTabs ? null : mountTabs(ctx);
|
||||
const menu: PartHandle | null = bare ? 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,178 @@
|
||||
/* =============================================================================
|
||||
* 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 { showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import { parseA1 } from "./spreadsheet_address";
|
||||
import { st } from "./spreadsheet_text";
|
||||
import type { CellRange } from "./spreadsheet_types";
|
||||
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"]);
|
||||
|
||||
/** 병합 전 확인 — 왼위 밖 칸에 값이 있으면 지워짐을 물음(엑셀과 같음) · 없으면 바로 true */
|
||||
export async function confirmMerge(ctx: SpreadsheetContext, range: CellRange): Promise<boolean> {
|
||||
const lost = Object.entries(ctx.sheet().칸).some(([a1, cell]) => {
|
||||
const p = parseA1(a1);
|
||||
if (!p || (cell.값 === undefined && cell.식 === undefined)) return false;
|
||||
const inside = p.r >= range.r0 && p.r <= range.r1 && p.c >= range.c0 && p.c <= range.c1;
|
||||
return inside && (p.r !== range.r0 || p.c !== range.c0);
|
||||
});
|
||||
return !lost || showConfirmDialog(st("MergeLoseConfirm"), st("Merge"));
|
||||
}
|
||||
|
||||
/** 창 키 — Esc 닫기 · Enter 실행(여러 줄 글 · 단추 · 한글 조합 중은 그대로) · 창 안에 초점을 넣음 */
|
||||
export function dialogKeys(overlay: HTMLElement, close: () => void, run?: () => void): void {
|
||||
overlay.addEventListener("keydown", (e) => {
|
||||
const t = e.target as HTMLElement;
|
||||
if (e.key === "Escape") close();
|
||||
else if (e.key !== "Enter" || !run || e.isComposing) return;
|
||||
else if (t.tagName === "TEXTAREA" || t.tagName === "BUTTON") return;
|
||||
else run();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
queueMicrotask(() => {
|
||||
if (overlay.contains(document.activeElement)) return;
|
||||
const first = overlay.querySelector<HTMLElement>("input:not([type=hidden]), select, textarea");
|
||||
if (first) return first.focus();
|
||||
overlay.tabIndex = -1;
|
||||
overlay.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/** 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,102 @@
|
||||
/* =============================================================================
|
||||
* 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 { Cell, 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);
|
||||
}
|
||||
|
||||
/** 여러 칸을 고른 채 Σ — 엑셀처럼 열마다(한 행만 골랐으면 행 끝) 고름 끝 빈 칸 또는 바로 다음 빈 칸에
|
||||
* 합계 식을 바로 넣을 칸들. 고른 칸이 하나거나 넣을 빈 칸이 없으면 null. */
|
||||
export function autoSumCells(ctx: SpreadsheetContext): Record<string, Cell> | null {
|
||||
const sel = ctx.selection.범위[0];
|
||||
if (!sel || (sel.r0 === sel.r1 && sel.c0 === sel.c1)) return null;
|
||||
const sheet = ctx.sheet();
|
||||
const empty = (r: number, c: number): boolean => {
|
||||
const x = sheet.칸[toA1(r, c)];
|
||||
return !x || (x.식 === undefined && (x.값 === undefined || x.값 === ""));
|
||||
};
|
||||
const across = sel.r0 === sel.r1;
|
||||
const out: Record<string, Cell> = {};
|
||||
const lines = across
|
||||
? [sel.r0]
|
||||
: Array.from({ length: sel.c1 - sel.c0 + 1 }, (_, i) => sel.c0 + i);
|
||||
for (const k of lines) {
|
||||
const at = (i: number): [number, number] => (across ? [k, i] : [i, k]);
|
||||
const first = across ? sel.c0 : sel.r0;
|
||||
const last = across ? sel.c1 : sel.r1;
|
||||
const end = empty(...at(last)) ? last : last + 1;
|
||||
if (!empty(...at(end))) continue;
|
||||
const [r, c] = at(end);
|
||||
const [r0, c0] = at(first);
|
||||
const [r1, c1] = at(end - 1);
|
||||
const key = toA1(r, c);
|
||||
const 식 = `SUM(${rangeToA1({ r0, c0, r1, c1 })})`;
|
||||
const 서식 = sheet.칸[key]?.서식;
|
||||
out[key] = 서식 === undefined ? { 식 } : { 식, 서식 };
|
||||
}
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/** 활성 칸에 `=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,285 @@
|
||||
/* =============================================================================
|
||||
* 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,
|
||||
showToast,
|
||||
} 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 showToast("조건 값을 넣을 것", "warning");
|
||||
const colors = [fill.input.value.trim(), fontColor.input.value.trim()];
|
||||
if (colors.some((c) => c && !/^#[0-9a-f]{6}$/i.test(c)))
|
||||
return showToast("색은 #RRGGBB 꼴로", "warning");
|
||||
if (!colors[0] && !colors[1]) return showToast("채움색이나 글자색을 넣을 것", "warning");
|
||||
const 스타일: CondFmtStyle = {};
|
||||
if (colors[0]) 스타일.채움 = colors[0];
|
||||
if (colors[1]) 스타일.글자색 = colors[1];
|
||||
addCondFmtRule(ctx, { 범위: [...범위], 조건, 스타일 });
|
||||
render();
|
||||
});
|
||||
|
||||
function buildCondition(
|
||||
kind: string,
|
||||
op: string,
|
||||
a: ReturnType<typeof createInputField>,
|
||||
b: ReturnType<typeof createInputField>,
|
||||
): CondFmtCondition | null {
|
||||
const blank = (f: typeof a): boolean => f.input.value.trim() === "";
|
||||
if (kind === "비교") {
|
||||
if (blank(a)) return null;
|
||||
const n = Number(a.input.value);
|
||||
return Number.isFinite(n) ? { 종류: "비교", 연산: op as CondFmtCompareOp, 값: n } : null;
|
||||
}
|
||||
if (kind === "사이") {
|
||||
if (blank(a) || blank(b)) return null;
|
||||
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,329 @@
|
||||
/* =============================================================================
|
||||
* 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 { autoSumCells, 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 a = anchor.getBoundingClientRect();
|
||||
const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 뿌리 기준 자리(칸 우클릭 메뉴와 같음)
|
||||
const r = { left: a.left - o.left, bottom: a.bottom - o.top };
|
||||
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 cells = autoSumCells(ctx);
|
||||
if (cells) return ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
|
||||
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,433 @@
|
||||
/* =============================================================================
|
||||
* 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 { dialogKeys } from "./spreadsheet_actions";
|
||||
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);
|
||||
dialogKeys(overlay, close, () => (onRun(), close()));
|
||||
}
|
||||
|
||||
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) => {
|
||||
const key = toA1(anchor.r + ri, anchor.c + ci);
|
||||
const 서식 = ctx.sheet().칸[key]?.서식;
|
||||
if (text !== "")
|
||||
cells[key] =
|
||||
서식 === undefined ? { 값: cellInputFrom(text) } : { 값: cellInputFrom(text), 서식 };
|
||||
else cells[key] = 서식 === undefined ? null : { 서식 };
|
||||
});
|
||||
});
|
||||
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;
|
||||
const key = toA1(r, c);
|
||||
const 서식 = ctx.sheet().칸[key]?.서식;
|
||||
cells[key] = 서식 === undefined ? { 값: v } : { 값: 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,238 @@
|
||||
/* =============================================================================
|
||||
* 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);
|
||||
// 격자 아래 · 오른쪽 끝에 걸리면 위 · 왼쪽으로 뒤집음(잘려서 [확인] 이 안 보이지 않게)
|
||||
const p = panel.getBoundingClientRect();
|
||||
const g = ctx.grid.root.getBoundingClientRect();
|
||||
if (p.bottom > g.bottom && p.top - box.h - p.height >= g.top)
|
||||
panel.style.top = `${box.y - p.height}px`;
|
||||
if (p.right > g.right) panel.style.left = `${Math.max(0, box.x + box.w - p.width)}px`;
|
||||
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,147 @@
|
||||
/* =============================================================================
|
||||
* 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;
|
||||
min-height: 300px;
|
||||
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,509 @@
|
||||
/* =============================================================================
|
||||
* 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 { dialogKeys } from "./spreadsheet_actions";
|
||||
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);
|
||||
});
|
||||
|
||||
function apply(): void {
|
||||
ctx.dispatch({ 종류: "서식", 시트: ctx.selection.시트, 범위: ctx.selection.범위, 바꿀: draft });
|
||||
const isMerged = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
|
||||
if (mergeWanted !== isMerged) {
|
||||
ctx.dispatch({
|
||||
종류: mergeWanted ? "병합" : "병합풀기",
|
||||
시트: ctx.selection.시트,
|
||||
범위: range,
|
||||
});
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
const actions = el("div", {
|
||||
className: "ss-fmtdlg__actions",
|
||||
children: [
|
||||
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
|
||||
createButton({ label: TEXT.ok, variant: "filled", onClick: apply }),
|
||||
],
|
||||
});
|
||||
panel.append(actions);
|
||||
|
||||
showTab(0);
|
||||
document.body.append(overlay);
|
||||
dialogKeys(overlay, close, apply);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_grid.ts (주인 C)
|
||||
* 격자 — DOM(칸 = 절대 위치 div) · 틀고정 4 판(왼위 · 위 · 왼 · 본문) · 판마다 보이는 행열만 그림 ·
|
||||
* 병합 칸 = div 하나 · 숨긴 행열 건너뜀 · 눈금선 · 고름 · 참조 색 테두리 · 채우기 손잡이.
|
||||
* 칸 글은 엔진 값 → B `formatValue` → 서식 CSS(`spreadsheet_cellstyle.ts`). 머리 · 폭 끌기는 `spreadsheet_headers.ts`.
|
||||
* 폭 · 높이 끌기는 `ctx.dispatch({종류:"열폭"|"행높이"})`.
|
||||
*
|
||||
* 4 판 — 오른아래(`br`) 만 실제 스크롤바를 가짐(overflow:auto · 안에 전체 크기 스페이서) ·
|
||||
* 나머지 세 판은 overflow:hidden 이고 `br` 의 scroll 을 그대로 옮겨 받음(틀고정 방향만).
|
||||
* 보이는 칸만 = 판 크기 안에 들어오는 행열만 실제 div 로 그림(칸 재사용 — `CellPool`).
|
||||
* 칸 그리기 · 넘침은 `spreadsheet_grid_cells.ts` · 행열 머리 · 윤곽은 `spreadsheet_grid_heads.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./spreadsheet_grid.css";
|
||||
import { colIndex, toA1 } from "./spreadsheet_address";
|
||||
import {
|
||||
buildColGeometry,
|
||||
buildOutlineRuns,
|
||||
buildRowGeometry,
|
||||
visibleIndices,
|
||||
type LineGeometry,
|
||||
type OutlineRun,
|
||||
} from "./spreadsheet_headers";
|
||||
import {
|
||||
buildMergeMap,
|
||||
computeExtent,
|
||||
createPool,
|
||||
renderCells,
|
||||
scaledGeometry,
|
||||
type CellPool,
|
||||
type GridView,
|
||||
} from "./spreadsheet_grid_cells";
|
||||
import { createHeads, HEAD_COL_W_BASE, HEAD_ROW_H_BASE } from "./spreadsheet_grid_heads";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type { CellRange, SheetCellAddress } from "./spreadsheet_types";
|
||||
import type {
|
||||
CellBox,
|
||||
GridHandle,
|
||||
HitResult,
|
||||
RefHighlight,
|
||||
SpreadsheetContext,
|
||||
} from "./spreadsheet_view_types";
|
||||
|
||||
const OUTLINE_GUTTER_BASE = 14;
|
||||
const RESIZE_HOTZONE = 4;
|
||||
const REF_COLORS = ["#1a73e8", "#e8710a", "#0f9d58", "#a142f4", "#d93025"];
|
||||
const ZOOM_MIN = 0.1;
|
||||
const ZOOM_MAX = 4;
|
||||
|
||||
export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
const root = document.createElement("div");
|
||||
root.className = "aislo-grid";
|
||||
|
||||
const corner = document.createElement("div");
|
||||
corner.className = "aislo-grid__corner";
|
||||
const colHeadFrozen = paneDiv("aislo-grid__colhead-frozen");
|
||||
const colHeadScroll = paneDiv("aislo-grid__colhead-scroll");
|
||||
const rowHeadFrozen = paneDiv("aislo-grid__rowhead-frozen");
|
||||
const rowHeadScroll = paneDiv("aislo-grid__rowhead-scroll");
|
||||
const paneTL = paneDiv("aislo-grid__pane aislo-grid__pane--tl");
|
||||
const paneTR = paneDiv("aislo-grid__pane aislo-grid__pane--tr");
|
||||
const paneBL = paneDiv("aislo-grid__pane aislo-grid__pane--bl");
|
||||
const paneBR = paneDiv("aislo-grid__pane aislo-grid__pane--br");
|
||||
const overlay = paneDiv("aislo-grid__overlay");
|
||||
|
||||
const colHeadInnerFrozen = innerDiv("aislo-grid__colhead-inner");
|
||||
const colHeadInnerScroll = innerDiv("aislo-grid__colhead-inner");
|
||||
const rowHeadInnerFrozen = innerDiv("aislo-grid__rowhead-inner");
|
||||
const rowHeadInnerScroll = innerDiv("aislo-grid__rowhead-inner");
|
||||
const paneTLInner = innerDiv("aislo-grid__pane-inner");
|
||||
const paneTRInner = innerDiv("aislo-grid__pane-inner");
|
||||
const paneBLInner = innerDiv("aislo-grid__pane-inner");
|
||||
const paneBRInner = innerDiv("aislo-grid__pane-inner");
|
||||
const spacer = document.createElement("div");
|
||||
spacer.className = "aislo-grid__spacer";
|
||||
|
||||
colHeadFrozen.append(colHeadInnerFrozen);
|
||||
colHeadScroll.append(colHeadInnerScroll);
|
||||
rowHeadFrozen.append(rowHeadInnerFrozen);
|
||||
rowHeadScroll.append(rowHeadInnerScroll);
|
||||
paneTL.append(paneTLInner);
|
||||
paneTR.append(paneTRInner);
|
||||
paneBL.append(paneBLInner);
|
||||
paneBR.append(paneBRInner, spacer);
|
||||
root.append(
|
||||
corner,
|
||||
colHeadFrozen,
|
||||
colHeadScroll,
|
||||
rowHeadFrozen,
|
||||
rowHeadScroll,
|
||||
paneTL,
|
||||
paneTR,
|
||||
paneBL,
|
||||
paneBR,
|
||||
overlay,
|
||||
);
|
||||
|
||||
const pools = { tl: createPool(), tr: createPool(), bl: createPool(), br: createPool() };
|
||||
const colHeadPools = { frozen: createPool(), scroll: createPool() };
|
||||
const rowHeadPools = { frozen: createPool(), scroll: createPool() };
|
||||
|
||||
let baseColGeo: LineGeometry = buildColGeometry(ctx.book, ctx.sheet());
|
||||
let baseRowGeo: LineGeometry = buildRowGeometry(ctx.book, ctx.sheet());
|
||||
let colGeo: LineGeometry = baseColGeo;
|
||||
let rowGeo: LineGeometry = baseRowGeo;
|
||||
let extent = computeExtent(ctx.sheet());
|
||||
let merges = buildMergeMap(ctx.sheet());
|
||||
let frozenRows = 0;
|
||||
let frozenCols = 0;
|
||||
let frozenW = 0;
|
||||
let frozenH = 0;
|
||||
let lastSheetId = "";
|
||||
let zoom = 1;
|
||||
let rowOutlineRuns: OutlineRun[] = [];
|
||||
let colOutlineRuns: OutlineRun[] = [];
|
||||
/** 행 묶음 윤곽 띠 — 행 머리 왼쪽(있을 때만) · 열 묶음 띠는 열 머리 위. */
|
||||
const rowGutterW = (): number =>
|
||||
rowOutlineRuns.length ? Math.round(OUTLINE_GUTTER_BASE * zoom) : 0;
|
||||
const colGutterH = (): number =>
|
||||
colOutlineRuns.length ? Math.round(OUTLINE_GUTTER_BASE * zoom) : 0;
|
||||
const headRowH = (): number => Math.round(HEAD_ROW_H_BASE * zoom) + colGutterH();
|
||||
const headColW = (): number => Math.round(HEAD_COL_W_BASE * zoom) + rowGutterW();
|
||||
const view: GridView = {
|
||||
get colGeo() {
|
||||
return colGeo;
|
||||
},
|
||||
get rowGeo() {
|
||||
return rowGeo;
|
||||
},
|
||||
get merges() {
|
||||
return merges;
|
||||
},
|
||||
get extent() {
|
||||
return extent;
|
||||
},
|
||||
get zoom() {
|
||||
return zoom;
|
||||
},
|
||||
get frozenRows() {
|
||||
return frozenRows;
|
||||
},
|
||||
get frozenCols() {
|
||||
return frozenCols;
|
||||
},
|
||||
get rowOutlineRuns() {
|
||||
return rowOutlineRuns;
|
||||
},
|
||||
get colOutlineRuns() {
|
||||
return colOutlineRuns;
|
||||
},
|
||||
rowGutterW,
|
||||
colGutterH,
|
||||
};
|
||||
const { layoutColHeaders, layoutRowHeaders, renderRowOutline, renderColOutline } = createHeads(
|
||||
ctx,
|
||||
view,
|
||||
{
|
||||
colFrozen: colHeadInnerFrozen,
|
||||
colScroll: colHeadInnerScroll,
|
||||
rowFrozen: rowHeadInnerFrozen,
|
||||
rowScroll: rowHeadInnerScroll,
|
||||
},
|
||||
);
|
||||
|
||||
const resize = new ResizeObserver(() => layoutPanes());
|
||||
resize.observe(root);
|
||||
|
||||
/** 판 크기 · 자리 다시 재기(창 크기 · 틀고정 수가 바뀔 때). */
|
||||
function layoutPanes(): void {
|
||||
const w = root.clientWidth;
|
||||
const h = root.clientHeight;
|
||||
root.style.fontFamily = ctx.book.기본?.글꼴 ?? "";
|
||||
root.style.fontSize = ctx.book.기본?.크기 ? `${ctx.book.기본.크기 * zoom}pt` : `${10 * zoom}pt`;
|
||||
root.style.setProperty("--aislo-grid-gridline-w", `${Math.max(1, Math.round(zoom))}px`);
|
||||
frozenW = colGeo.total(frozenCols);
|
||||
frozenH = rowGeo.total(frozenRows);
|
||||
corner.style.width = `${headColW()}px`;
|
||||
corner.style.height = `${headRowH()}px`;
|
||||
colHeadFrozen.style.left = `${headColW()}px`;
|
||||
colHeadFrozen.style.top = "0";
|
||||
colHeadFrozen.style.width = `${frozenW}px`;
|
||||
colHeadFrozen.style.height = `${headRowH()}px`;
|
||||
colHeadScroll.style.left = `${headColW() + frozenW}px`;
|
||||
colHeadScroll.style.top = "0";
|
||||
colHeadScroll.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
|
||||
colHeadScroll.style.height = `${headRowH()}px`;
|
||||
rowHeadFrozen.style.left = "0";
|
||||
rowHeadFrozen.style.top = `${headRowH()}px`;
|
||||
rowHeadFrozen.style.width = `${headColW()}px`;
|
||||
rowHeadFrozen.style.height = `${frozenH}px`;
|
||||
rowHeadScroll.style.left = "0";
|
||||
rowHeadScroll.style.top = `${headRowH() + frozenH}px`;
|
||||
rowHeadScroll.style.width = `${headColW()}px`;
|
||||
rowHeadScroll.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`;
|
||||
paneTL.style.left = `${headColW()}px`;
|
||||
paneTL.style.top = `${headRowH()}px`;
|
||||
paneTL.style.width = `${frozenW}px`;
|
||||
paneTL.style.height = `${frozenH}px`;
|
||||
paneTR.style.left = `${headColW() + frozenW}px`;
|
||||
paneTR.style.top = `${headRowH()}px`;
|
||||
paneTR.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
|
||||
paneTR.style.height = `${frozenH}px`;
|
||||
paneBL.style.left = `${headColW()}px`;
|
||||
paneBL.style.top = `${headRowH() + frozenH}px`;
|
||||
paneBL.style.width = `${frozenW}px`;
|
||||
paneBL.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`;
|
||||
paneBR.style.left = `${headColW() + frozenW}px`;
|
||||
paneBR.style.top = `${headRowH() + frozenH}px`;
|
||||
paneBR.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
|
||||
paneBR.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`;
|
||||
resizeSpacer();
|
||||
overlay.style.left = `${headColW()}px`;
|
||||
overlay.style.top = `${headRowH()}px`;
|
||||
overlay.style.right = "0";
|
||||
overlay.style.bottom = "0";
|
||||
renderVisible();
|
||||
}
|
||||
|
||||
/** `br` 스크롤 위치 → 세 판(머리 둘 · tr · bl)에 옮겨 그리고 보이는 칸을 다시. */
|
||||
function resizeSpacer(): void {
|
||||
spacer.style.transform = `translate(${colGeo.total(extent.cols) - frozenW}px, ${rowGeo.total(extent.rows) - frozenH}px)`;
|
||||
}
|
||||
|
||||
/** 이름 상자 · Ctrl+방향키 등으로 간 칸이 지금 스크롤 영역 밖이면 그만큼 늘림(엑셀처럼 · 빈 시트도 안 막힘). */
|
||||
function ensureExtent(r: number, c: number): void {
|
||||
let grown = false;
|
||||
if (r >= extent.rows - 1) {
|
||||
extent.rows = Math.min(MAX_ROWS, r + 100);
|
||||
grown = true;
|
||||
}
|
||||
if (c >= extent.cols - 1) {
|
||||
extent.cols = Math.min(MAX_COLS, c + 20);
|
||||
grown = true;
|
||||
}
|
||||
if (grown) resizeSpacer();
|
||||
}
|
||||
|
||||
/** 스크롤이 끝 가까이 닿으면 미리 늘림(휠 · 스크롤바로 끝까지 밀 때도 안 막힘). */
|
||||
function growOnScrollEdge(): void {
|
||||
const edge = 100;
|
||||
if (
|
||||
extent.rows < MAX_ROWS &&
|
||||
paneBR.scrollTop + paneBR.clientHeight >= paneBR.scrollHeight - edge
|
||||
) {
|
||||
extent.rows = Math.min(MAX_ROWS, extent.rows + 100);
|
||||
resizeSpacer();
|
||||
}
|
||||
if (
|
||||
extent.cols < MAX_COLS &&
|
||||
paneBR.scrollLeft + paneBR.clientWidth >= paneBR.scrollWidth - edge
|
||||
) {
|
||||
extent.cols = Math.min(MAX_COLS, extent.cols + 20);
|
||||
resizeSpacer();
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll(): void {
|
||||
growOnScrollEdge();
|
||||
const left = paneBR.scrollLeft;
|
||||
const top = paneBR.scrollTop;
|
||||
colHeadInnerScroll.style.transform = `translateX(${-left}px)`;
|
||||
paneTRInner.style.transform = `translateX(${-left}px)`;
|
||||
rowHeadInnerScroll.style.transform = `translateY(${-top}px)`;
|
||||
paneBLInner.style.transform = `translateY(${-top}px)`;
|
||||
renderVisible();
|
||||
}
|
||||
paneBR.addEventListener("scroll", onScroll);
|
||||
|
||||
/** Ctrl+휠 확대 · 축소(10~400%) — 칸 · 머리 · 선 굵기가 같이 커짐(`headRowH`·`headColW`·`applyCellStyle` scale). */
|
||||
function onWheel(ev: WheelEvent): void {
|
||||
if (!ev.ctrlKey) return;
|
||||
ev.preventDefault();
|
||||
const before = zoom;
|
||||
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(-ev.deltaY * 0.0015)));
|
||||
if (next === before) return;
|
||||
zoom = next;
|
||||
colGeo = scaledGeometry(baseColGeo, zoom);
|
||||
rowGeo = scaledGeometry(baseRowGeo, zoom);
|
||||
paneBR.scrollLeft *= zoom / before;
|
||||
paneBR.scrollTop *= zoom / before;
|
||||
layoutPanes();
|
||||
root.dispatchEvent(new CustomEvent("aislo-grid-zoom", { detail: { zoom }, bubbles: true }));
|
||||
}
|
||||
root.addEventListener("wheel", onWheel, { passive: false });
|
||||
|
||||
const gridlines = (): boolean => ctx.sheet().보기?.눈금선 !== false;
|
||||
|
||||
function renderVisible(): void {
|
||||
root.classList.toggle("aislo-grid--gridlines", gridlines());
|
||||
const w = root.clientWidth;
|
||||
const h = root.clientHeight;
|
||||
const scrollW = Math.max(0, w - headColW() - frozenW);
|
||||
const scrollH = Math.max(0, h - headRowH() - frozenH);
|
||||
const frozenColsVisible = visibleIndices(colGeo, 0, frozenCols - 1);
|
||||
// 본문 판 스크롤 0 = 틀고정 바로 다음 칸(`offset(frozen)`) — 그만큼 더해 재야 오른 · 아래 끝이 안 빔.
|
||||
const left = paneBR.scrollLeft + frozenW;
|
||||
const top = paneBR.scrollTop + frozenH;
|
||||
const scrollColFrom = Math.max(frozenCols, colGeo.indexAt(left));
|
||||
const scrollColTo = Math.min(extent.cols - 1, colGeo.indexAt(left + scrollW) + 1);
|
||||
const scrollColsVisible = visibleIndices(
|
||||
colGeo,
|
||||
scrollColFrom,
|
||||
Math.max(scrollColFrom, scrollColTo),
|
||||
);
|
||||
const frozenRowsVisible = visibleIndices(rowGeo, 0, frozenRows - 1);
|
||||
const scrollRowFrom = Math.max(frozenRows, rowGeo.indexAt(top));
|
||||
const scrollRowTo = Math.min(extent.rows - 1, rowGeo.indexAt(top + scrollH) + 1);
|
||||
const scrollRowsVisible = visibleIndices(
|
||||
rowGeo,
|
||||
scrollRowFrom,
|
||||
Math.max(scrollRowFrom, scrollRowTo),
|
||||
);
|
||||
|
||||
layoutColHeaders(frozenColsVisible, colHeadPools.frozen, colHeadInnerFrozen, 0);
|
||||
layoutColHeaders(
|
||||
scrollColsVisible,
|
||||
colHeadPools.scroll,
|
||||
colHeadInnerScroll,
|
||||
colGeo.offset(frozenCols),
|
||||
);
|
||||
layoutRowHeaders(frozenRowsVisible, rowHeadPools.frozen, rowHeadInnerFrozen, 0);
|
||||
layoutRowHeaders(
|
||||
scrollRowsVisible,
|
||||
rowHeadPools.scroll,
|
||||
rowHeadInnerScroll,
|
||||
rowGeo.offset(frozenRows),
|
||||
);
|
||||
renderRowOutline();
|
||||
renderColOutline();
|
||||
|
||||
renderCells(ctx, view, frozenRowsVisible, frozenColsVisible, pools.tl, paneTLInner, 0, 0);
|
||||
renderCells(
|
||||
ctx,
|
||||
view,
|
||||
frozenRowsVisible,
|
||||
scrollColsVisible,
|
||||
pools.tr,
|
||||
paneTRInner,
|
||||
colGeo.offset(frozenCols),
|
||||
0,
|
||||
);
|
||||
renderCells(
|
||||
ctx,
|
||||
view,
|
||||
scrollRowsVisible,
|
||||
frozenColsVisible,
|
||||
pools.bl,
|
||||
paneBLInner,
|
||||
0,
|
||||
rowGeo.offset(frozenRows),
|
||||
);
|
||||
renderCells(
|
||||
ctx,
|
||||
view,
|
||||
scrollRowsVisible,
|
||||
scrollColsVisible,
|
||||
pools.br,
|
||||
paneBRInner,
|
||||
colGeo.offset(frozenCols),
|
||||
rowGeo.offset(frozenRows),
|
||||
);
|
||||
|
||||
renderSelection();
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const sheet = ctx.sheet();
|
||||
baseColGeo = buildColGeometry(ctx.book, sheet);
|
||||
baseRowGeo = buildRowGeometry(ctx.book, sheet);
|
||||
colGeo = scaledGeometry(baseColGeo, zoom);
|
||||
rowGeo = scaledGeometry(baseRowGeo, zoom);
|
||||
extent = computeExtent(sheet);
|
||||
merges = buildMergeMap(sheet);
|
||||
rowOutlineRuns = buildOutlineRuns(sheet.행, (k) => Number(k) - 1);
|
||||
colOutlineRuns = buildOutlineRuns(sheet.열, colIndex);
|
||||
frozenRows = Math.min(sheet.틀고정?.행 ?? 0, extent.rows);
|
||||
frozenCols = Math.min(sheet.틀고정?.열 ?? 0, extent.cols);
|
||||
spacer.style.width = "1px";
|
||||
spacer.style.height = "1px";
|
||||
if (sheet.id !== lastSheetId) {
|
||||
lastSheetId = sheet.id;
|
||||
paneBR.scrollLeft = 0;
|
||||
paneBR.scrollTop = 0;
|
||||
}
|
||||
layoutPanes();
|
||||
}
|
||||
|
||||
/** 값 · 서식이 바뀐 칸만 — 지금 그려진 칸이면 다시(구조는 안 바꿈 · `render()` 는 구조 바뀔 때만). */
|
||||
function invalidate(cells: SheetCellAddress[]): void {
|
||||
const sheet = ctx.sheet();
|
||||
const dirty = new Set(cells.filter((c) => c.시트 === sheet.id).map((c) => `${c.r},${c.c}`));
|
||||
if (dirty.size === 0) return;
|
||||
const anyDirty = (pool: CellPool): boolean => {
|
||||
for (const key of pool.used.keys()) if (dirty.has(key)) return true;
|
||||
return false;
|
||||
};
|
||||
if (anyDirty(pools.tl) || anyDirty(pools.tr) || anyDirty(pools.bl) || anyDirty(pools.br))
|
||||
renderVisible();
|
||||
}
|
||||
|
||||
function boxOf(r: number, c: number): CellBox {
|
||||
const merge = merges.get(`${r},${c}`);
|
||||
const r0 = merge ? merge.r0 : r;
|
||||
const c0 = merge ? merge.c0 : c;
|
||||
const r1 = merge ? merge.r1 : r;
|
||||
const c1 = merge ? merge.c1 : c;
|
||||
return {
|
||||
x: colGeo.offset(c0),
|
||||
y: rowGeo.offset(r0),
|
||||
w: colGeo.offset(c1 + 1) - colGeo.offset(c0),
|
||||
h: rowGeo.offset(r1 + 1) - rowGeo.offset(r0),
|
||||
};
|
||||
}
|
||||
|
||||
function screenBoxOf(r: number, c: number): CellBox {
|
||||
const box = boxOf(r, c);
|
||||
const inFrozenCol = c < frozenCols;
|
||||
const inFrozenRow = r < frozenRows;
|
||||
const x = headColW() + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft);
|
||||
const y = headRowH() + (inFrozenRow ? box.y : box.y - paneBR.scrollTop);
|
||||
return { x, y, w: box.w, h: box.h };
|
||||
}
|
||||
|
||||
/** `overlay` 안 자리 — `overlay` 자신이 이미 머리 칸(headColW·headRowH)만큼 밀려 있어
|
||||
* `screenBoxOf`(격자 뿌리 기준)를 그대로 쓰면 머리 칸만큼 두 번 밀림(찾은 버그 · 2026-09-27). */
|
||||
function overlayBoxOf(r: number, c: number): CellBox {
|
||||
const box = boxOf(r, c);
|
||||
const inFrozenCol = c < frozenCols;
|
||||
const inFrozenRow = r < frozenRows;
|
||||
return {
|
||||
x: inFrozenCol ? box.x : box.x - paneBR.scrollLeft,
|
||||
y: inFrozenRow ? box.y : box.y - paneBR.scrollTop,
|
||||
w: box.w,
|
||||
h: box.h,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSelection(): void {
|
||||
let selLayer = overlay.querySelector<HTMLElement>(".aislo-grid__sel-layer");
|
||||
if (!selLayer) {
|
||||
selLayer = document.createElement("div");
|
||||
selLayer.className = "aislo-grid__sel-layer";
|
||||
overlay.append(selLayer);
|
||||
}
|
||||
selLayer.replaceChildren();
|
||||
const sel = ctx.selection;
|
||||
if (!sel || sel.시트 !== ctx.sheet().id) return;
|
||||
for (const [i, range] of sel.범위.entries()) {
|
||||
const box: CellBox = {
|
||||
x: colGeo.offset(range.c0),
|
||||
y: rowGeo.offset(range.r0),
|
||||
w: colGeo.offset(range.c1 + 1) - colGeo.offset(range.c0),
|
||||
h: rowGeo.offset(range.r1 + 1) - rowGeo.offset(range.r0),
|
||||
};
|
||||
const inFrozenCol = range.c0 < frozenCols;
|
||||
const inFrozenRow = range.r0 < frozenRows;
|
||||
const screen: CellBox = {
|
||||
x: inFrozenCol ? box.x : box.x - paneBR.scrollLeft,
|
||||
y: inFrozenRow ? box.y : box.y - paneBR.scrollTop,
|
||||
w: box.w,
|
||||
h: box.h,
|
||||
};
|
||||
const node = document.createElement("div");
|
||||
node.className = "aislo-grid__ref-highlight";
|
||||
node.style.left = `${screen.x}px`;
|
||||
node.style.top = `${screen.y}px`;
|
||||
node.style.width = `${screen.w}px`;
|
||||
node.style.height = `${screen.h}px`;
|
||||
node.style.borderColor = "var(--aislo-grid-selected)";
|
||||
node.style.borderWidth = i === 0 ? "2px" : "1px";
|
||||
selLayer!.append(node);
|
||||
}
|
||||
// 활성 칸 테두리 — 칸 다시 그리기(`renderRowOfCells`) 없이 고름만 바뀌어도 옛 칸에 남지 않게.
|
||||
const activeKey = `${sel.활성.r},${sel.활성.c}`;
|
||||
const anchor = merges.get(activeKey);
|
||||
const activeAddr = toA1(anchor?.r0 ?? sel.활성.r, anchor?.c0 ?? sel.활성.c);
|
||||
for (const el of root.querySelectorAll<HTMLElement>(".aislo-cell.is-selected"))
|
||||
if (el.dataset.addr !== activeAddr) el.classList.remove("is-selected");
|
||||
for (const el of root.querySelectorAll<HTMLElement>(`.aislo-cell[data-addr="${activeAddr}"]`))
|
||||
el.classList.add("is-selected");
|
||||
const handleAt = fillHandleCell();
|
||||
if (!handleAt) return;
|
||||
const corner = overlayBoxOf(handleAt.r, handleAt.c);
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "aislo-grid__fill-handle";
|
||||
handle.style.left = `${corner.x + corner.w - 4}px`;
|
||||
handle.style.top = `${corner.y + corner.h - 4}px`;
|
||||
selLayer!.append(handle);
|
||||
}
|
||||
|
||||
/** 채우기 손잡이 자리 — 고른 범위 오른아래 칸(엑셀) · 범위가 여럿이거나 읽기 전용이면 없음. */
|
||||
function fillHandleCell(): { r: number; c: number } | null {
|
||||
const sel = ctx.selection;
|
||||
if (ctx.readOnly || !sel || sel.범위.length !== 1) return null;
|
||||
const g = sel.범위[0];
|
||||
return { r: g.r1, c: g.c1 };
|
||||
}
|
||||
|
||||
function setRefHighlights(list: RefHighlight[]): void {
|
||||
let refLayer = overlay.querySelector<HTMLElement>(".aislo-grid__ref-layer");
|
||||
if (!refLayer) {
|
||||
refLayer = document.createElement("div");
|
||||
refLayer.className = "aislo-grid__ref-layer";
|
||||
overlay.append(refLayer);
|
||||
}
|
||||
refLayer.replaceChildren();
|
||||
for (const ref of list) {
|
||||
if (ref.시트 !== ctx.sheet().id) continue;
|
||||
const box = boxOf(ref.범위.r0, ref.범위.c0);
|
||||
const box2 = boxOf(ref.범위.r1, ref.범위.c1);
|
||||
const merged: CellBox = {
|
||||
x: box.x,
|
||||
y: box.y,
|
||||
w: box2.x + box2.w - box.x,
|
||||
h: box2.y + box2.h - box.y,
|
||||
};
|
||||
const inFrozenCol = ref.범위.c0 < frozenCols;
|
||||
const inFrozenRow = ref.범위.r0 < frozenRows;
|
||||
const node = document.createElement("div");
|
||||
node.className = "aislo-grid__ref-highlight";
|
||||
node.style.left = `${inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft}px`;
|
||||
node.style.top = `${inFrozenRow ? merged.y : merged.y - paneBR.scrollTop}px`;
|
||||
node.style.width = `${merged.w}px`;
|
||||
node.style.height = `${merged.h}px`;
|
||||
node.style.borderColor = REF_COLORS[ref.색번호 % REF_COLORS.length];
|
||||
refLayer!.append(node);
|
||||
}
|
||||
}
|
||||
|
||||
function hitTest(clientX: number, clientY: number): HitResult | null {
|
||||
const bounds = root.getBoundingClientRect();
|
||||
const x = clientX - bounds.left;
|
||||
const y = clientY - bounds.top;
|
||||
if (x < 0 || y < 0 || x > root.clientWidth || y > root.clientHeight) return null;
|
||||
if (x < headColW() && y < headRowH()) return { kind: "corner", r: 0, c: 0 };
|
||||
const contentX =
|
||||
x < headColW() ? -1 : x - headColW() + (x - headColW() > frozenW ? paneBR.scrollLeft : 0);
|
||||
const contentY =
|
||||
y < headRowH() ? -1 : y - headRowH() + (y - headRowH() > frozenH ? paneBR.scrollTop : 0);
|
||||
if (y < headRowH()) {
|
||||
const c = colGeo.indexAt(contentX);
|
||||
const edge = Math.abs(colGeo.offset(c + 1) - contentX) <= RESIZE_HOTZONE;
|
||||
return { kind: edge ? "colEdge" : "colHead", r: 0, c };
|
||||
}
|
||||
if (x < headColW()) {
|
||||
const r = rowGeo.indexAt(contentY);
|
||||
const edge = Math.abs(rowGeo.offset(r + 1) - contentY) <= RESIZE_HOTZONE;
|
||||
return { kind: edge ? "rowEdge" : "rowHead", r, c: 0 };
|
||||
}
|
||||
const c = colGeo.indexAt(contentX);
|
||||
const r = rowGeo.indexAt(contentY);
|
||||
const sel = ctx.selection;
|
||||
const handleAt = sel && sel.시트 === ctx.sheet().id ? fillHandleCell() : null;
|
||||
if (handleAt) {
|
||||
const screen = screenBoxOf(handleAt.r, handleAt.c);
|
||||
if (Math.abs(x - (screen.x + screen.w)) <= 5 && Math.abs(y - (screen.y + screen.h)) <= 5) {
|
||||
return { kind: "fillHandle", r: handleAt.r, c: handleAt.c };
|
||||
}
|
||||
}
|
||||
return { kind: "cell", r, c };
|
||||
}
|
||||
|
||||
function reveal(r: number, c: number): void {
|
||||
ensureExtent(r, c);
|
||||
const [top0, left0] = [paneBR.scrollTop, paneBR.scrollLeft];
|
||||
if (r >= frozenRows) {
|
||||
const y0 = rowGeo.offset(r) - rowGeo.offset(frozenRows);
|
||||
const y1 = y0 + rowGeo.size(r);
|
||||
const viewTop = paneBR.scrollTop;
|
||||
const viewBottom = viewTop + paneBR.clientHeight;
|
||||
if (y0 < viewTop) paneBR.scrollTop = y0;
|
||||
else if (y1 > viewBottom) paneBR.scrollTop = y1 - paneBR.clientHeight;
|
||||
}
|
||||
if (c >= frozenCols) {
|
||||
const x0 = colGeo.offset(c) - colGeo.offset(frozenCols);
|
||||
const x1 = x0 + colGeo.size(c);
|
||||
const viewLeft = paneBR.scrollLeft;
|
||||
const viewRight = viewLeft + paneBR.clientWidth;
|
||||
if (x0 < viewLeft) paneBR.scrollLeft = x0;
|
||||
else if (x1 > viewRight) paneBR.scrollLeft = x1 - paneBR.clientWidth;
|
||||
}
|
||||
// scroll 이벤트는 다음 그림 때 옴 — 먼 칸으로 뛸 때 한 틀 비어 보이지 않게 바로 그림
|
||||
if (paneBR.scrollTop !== top0 || paneBR.scrollLeft !== left0) onScroll();
|
||||
}
|
||||
|
||||
function visibleRange(): CellRange {
|
||||
const scrollW = Math.max(0, root.clientWidth - headColW() - frozenW);
|
||||
const scrollH = Math.max(0, root.clientHeight - headRowH() - frozenH);
|
||||
const left = paneBR.scrollLeft + frozenW;
|
||||
const top = paneBR.scrollTop + frozenH;
|
||||
return {
|
||||
r0: rowGeo.indexAt(top),
|
||||
c0: colGeo.indexAt(left),
|
||||
r1: Math.min(extent.rows - 1, rowGeo.indexAt(top + scrollH)),
|
||||
c1: Math.min(extent.cols - 1, colGeo.indexAt(left + scrollW)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 칸이 든 판(틀고정 4 판 중 하나)의 안쪽 층 — 어느 판인지는 이 반환 요소로 앎. */
|
||||
function paneLayerOf(r: number, c: number): HTMLElement {
|
||||
const inFrozenRow = r < frozenRows;
|
||||
const inFrozenCol = c < frozenCols;
|
||||
if (inFrozenRow) return inFrozenCol ? paneTLInner : paneTRInner;
|
||||
return inFrozenCol ? paneBLInner : paneBRInner;
|
||||
}
|
||||
|
||||
/** 편집기 자리 — `layer` = 칸이 든 판의 안쪽 층 · `box` = 격자 뿌리 기준 화면 좌표(D 가 층 차이를 빼서 맞춤). */
|
||||
function editorSlot(r: number, c: number): { layer: HTMLElement; box: CellBox } {
|
||||
return { layer: paneLayerOf(r, c), box: screenBoxOf(r, c) };
|
||||
}
|
||||
|
||||
function getZoom(): number {
|
||||
return zoom;
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
resize.disconnect();
|
||||
paneBR.removeEventListener("scroll", onScroll);
|
||||
root.remove();
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
root,
|
||||
render,
|
||||
invalidate,
|
||||
renderSelection,
|
||||
setRefHighlights,
|
||||
cellBox: boxOf,
|
||||
hitTest,
|
||||
reveal,
|
||||
visibleRange,
|
||||
editorSlot,
|
||||
zoom: getZoom,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
|
||||
function paneDiv(className: string): HTMLElement {
|
||||
const node = document.createElement("div");
|
||||
node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
function innerDiv(className: string): HTMLElement {
|
||||
const node = document.createElement("div");
|
||||
node.className = className;
|
||||
return node;
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_grid_cells.ts
|
||||
* 격자 칸 그리기 — 판 하나(틀고정 4 판 중)의 보이는 칸을 div 로 그림(칸 재사용 `CellPool`) ·
|
||||
* 값 → 글(`formatValue`) · 서식 CSS · 수 `#####` · 긴 글 넘침(`spreadsheet_spill.ts`) 붙이기.
|
||||
* `spreadsheet_grid.ts` 가 판마다 부름 — 자리 재기 · 병합 · 확대는 `GridView` 로 받음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { toFrac } from "@ui/sheet/ui_template_sheet_frac";
|
||||
import { colIndex, parseA1, parseRange, toA1 } from "./spreadsheet_address";
|
||||
import {
|
||||
applyCellStyle,
|
||||
cellStyleAt,
|
||||
isAutoInk,
|
||||
resolveBoxBorders,
|
||||
setCellText,
|
||||
type ResolvedBorders,
|
||||
} from "./spreadsheet_cellstyle";
|
||||
import type { LineGeometry, OutlineRun } from "./spreadsheet_headers";
|
||||
import { resolveCondFmtStyle, type CondFmtValue } from "./spreadsheet_condfmt";
|
||||
import { formatValue } from "./spreadsheet_numfmt";
|
||||
import { spillOf, type SpillAlign, type SpillSlot } from "./spreadsheet_spill";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type {
|
||||
CellRange,
|
||||
CellStyle,
|
||||
ErrorValue,
|
||||
HAlign,
|
||||
Scalar,
|
||||
Sheet,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
/** 격자가 지금 쓰는 자리 재기 · 병합 · 틀고정 — `createGrid` 의 값을 읽기만(바뀌면 새 값이 보임). */
|
||||
export interface GridView {
|
||||
readonly colGeo: LineGeometry;
|
||||
readonly rowGeo: LineGeometry;
|
||||
readonly merges: Map<string, CellRange>;
|
||||
readonly extent: { rows: number; cols: number };
|
||||
readonly zoom: number;
|
||||
readonly frozenRows: number;
|
||||
readonly frozenCols: number;
|
||||
readonly rowOutlineRuns: OutlineRun[];
|
||||
readonly colOutlineRuns: OutlineRun[];
|
||||
rowGutterW(): number;
|
||||
colGutterH(): number;
|
||||
}
|
||||
|
||||
/** 칸 div 재사용 — 새 보이는 칸 집합에 없는 건 떼어 다음 판에 다시 씀. */
|
||||
export interface CellPool {
|
||||
used: Map<string, HTMLElement>;
|
||||
free: HTMLElement[];
|
||||
}
|
||||
export const createPool = (): CellPool => ({ used: new Map(), free: [] });
|
||||
|
||||
function poolGet(
|
||||
pool: CellPool,
|
||||
key: string,
|
||||
container: HTMLElement,
|
||||
className: string,
|
||||
): HTMLElement {
|
||||
const found = pool.used.get(key);
|
||||
if (found) return found;
|
||||
const node = pool.free.pop() ?? document.createElement("div");
|
||||
node.className = className;
|
||||
pool.used.set(key, node);
|
||||
container.append(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
export function poolSweep(pool: CellPool, keep: Set<string>): void {
|
||||
for (const [key, node] of pool.used) {
|
||||
if (keep.has(key)) continue;
|
||||
pool.used.delete(key);
|
||||
node.remove();
|
||||
pool.free.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
function toScalar(value: number | string | boolean): Scalar {
|
||||
return typeof value === "number" ? toFrac(value) : value;
|
||||
}
|
||||
|
||||
/** 시트에 실제 쓰인 자리 + 여유(엑셀 최대 자리까지 다 쓰지 않음 — 스크롤판 스페이서를 적당히 둠). */
|
||||
export function computeExtent(sheet: Sheet): { rows: number; cols: number } {
|
||||
let maxR = 0;
|
||||
let maxC = 0;
|
||||
for (const key of Object.keys(sheet.칸)) {
|
||||
const addr = parseA1(key);
|
||||
if (!addr) continue;
|
||||
maxR = Math.max(maxR, addr.r);
|
||||
maxC = Math.max(maxC, addr.c);
|
||||
}
|
||||
for (const key of Object.keys(sheet.행 ?? {})) maxR = Math.max(maxR, Number(key) - 1);
|
||||
for (const key of Object.keys(sheet.열 ?? {})) maxC = Math.max(maxC, colIndex(key));
|
||||
for (const m of sheet.병합 ?? []) {
|
||||
const range = parseRange(m);
|
||||
if (!range) continue;
|
||||
maxR = Math.max(maxR, range.r1);
|
||||
maxC = Math.max(maxC, range.c1);
|
||||
}
|
||||
return {
|
||||
rows: Math.min(MAX_ROWS, Math.max(maxR + 100, 200)),
|
||||
cols: Math.min(MAX_COLS, Math.max(maxC + 20, 60)),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMergeMap(sheet: Sheet): Map<string, CellRange> {
|
||||
const map = new Map<string, CellRange>();
|
||||
for (const m of sheet.병합 ?? []) {
|
||||
const range = parseRange(m);
|
||||
if (!range) continue;
|
||||
for (let r = range.r0; r <= range.r1; r += 1) {
|
||||
for (let c = range.c0; c <= range.c1; c += 1) map.set(`${r},${c}`, range);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const SPILL_ALIGN: Partial<Record<HAlign, SpillAlign>> = {
|
||||
general: "left",
|
||||
left: "left",
|
||||
right: "right",
|
||||
center: "center",
|
||||
};
|
||||
|
||||
function resetSpill(cell: HTMLElement): void {
|
||||
cell.classList.remove("is-spill", "is-spill-r");
|
||||
const t = cell.firstElementChild as HTMLElement | null;
|
||||
if (t) t.style.width = t.style.marginLeft = t.style.clipPath = "";
|
||||
}
|
||||
|
||||
let measureRuler: CanvasRenderingContext2D | null = null;
|
||||
|
||||
/** 글 너비(px) — 칸 재기용(수가 칸 폭보다 길면 `#####`). */
|
||||
function measureText(text: string, font: string): number {
|
||||
measureRuler ??= document.createElement("canvas").getContext("2d");
|
||||
if (!measureRuler) return text.length * 7;
|
||||
measureRuler.font = font;
|
||||
return measureRuler.measureText(text).width;
|
||||
}
|
||||
|
||||
/** 칸 글꼴 CSS 글(캔버스 재기용) — `applyCellStyle` 이 칸에 입히는 글꼴과 같은 차례로 고름. */
|
||||
function cellFont(style: CellStyle, book: Workbook, scale: number): string {
|
||||
const family = style.글꼴 || book.기본?.글꼴 || "굴림";
|
||||
const size = (style.크기 ?? book.기본?.크기 ?? 10) * scale;
|
||||
const weight = style.굵게 ? "700" : "400";
|
||||
const italic = style.기울임 ? "italic " : "";
|
||||
return `${italic}${weight} ${size}pt ${family}`;
|
||||
}
|
||||
|
||||
/** 확대 · 축소 — 밑감(1 배) 자리 재기를 그대로 두고 픽셀만 곱함(수는 그대로 · 화면만 커짐). */
|
||||
export function scaledGeometry(base: LineGeometry, factor: number): LineGeometry {
|
||||
if (factor === 1) return base;
|
||||
return {
|
||||
size: (i) => base.size(i) * factor,
|
||||
offset: (i) => base.offset(i) * factor,
|
||||
total: (i) => base.total(i) * factor,
|
||||
indexAt: (px) => base.indexAt(px / factor),
|
||||
};
|
||||
}
|
||||
|
||||
interface CellDisplay {
|
||||
text: string;
|
||||
hAlign: HAlign;
|
||||
title: string;
|
||||
isError: boolean;
|
||||
isNumeric: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** 칸 하나 값 — 식이면 엔진 값 · 아니면 입력 값(수는 Frac로) · 둘 다 없으면 빈 칸. */
|
||||
function scalarAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): Scalar {
|
||||
const cell = sheet.칸[toA1(r, c)];
|
||||
return cell?.식 !== undefined
|
||||
? ctx.engine.value(sheet.id, r, c)
|
||||
: cell?.값 !== undefined
|
||||
? toScalar(cell.값)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** 조건부 서식(sub4) 이 값을 보는 형 — Frac 는 근사 수로(조건 비교용 · 오류는 조건 대상 아님). */
|
||||
function toCondFmtValue(scalar: Scalar): CondFmtValue {
|
||||
if (scalar === null || typeof scalar === "boolean" || typeof scalar === "string") return scalar;
|
||||
if ("error" in scalar) return null;
|
||||
return Number(scalar.n) / Number(scalar.d);
|
||||
}
|
||||
|
||||
function cellDisplay(
|
||||
ctx: SpreadsheetContext,
|
||||
sheet: Sheet,
|
||||
r: number,
|
||||
c: number,
|
||||
hAlign0: HAlign,
|
||||
code?: string,
|
||||
): CellDisplay {
|
||||
const scalar = scalarAt(ctx, sheet, r, c);
|
||||
const formatted = formatValue(scalar, code);
|
||||
const isError = typeof scalar === "object" && scalar !== null && "error" in scalar;
|
||||
const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬;
|
||||
const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : "";
|
||||
const isNumeric =
|
||||
!isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean";
|
||||
return { text: formatted.글, hAlign, title, isError, isNumeric, color: formatted.색 };
|
||||
}
|
||||
|
||||
/** 판 하나의 보이는 칸 — 병합 칸은 div 하나 · 안 보이게 된 칸은 떼어 둠(다음에 다시 씀). */
|
||||
export function renderCells(
|
||||
ctx: SpreadsheetContext,
|
||||
view: GridView,
|
||||
rowsVisible: number[],
|
||||
colsVisible: number[],
|
||||
pool: CellPool,
|
||||
container: HTMLElement,
|
||||
baseX: number,
|
||||
baseY: number,
|
||||
): void {
|
||||
const { colGeo, rowGeo, merges, extent, zoom } = view;
|
||||
const sheet = ctx.sheet();
|
||||
const book = ctx.book;
|
||||
const keep = new Set<string>();
|
||||
// 긴 글 넘침 — 줄마다 칸 자리를 모았다가 판을 다 그린 뒤 폄(옆 칸이 빈지 알아야 해서)
|
||||
const slots = new Map<number, Map<number, SpillSlot>>();
|
||||
const longs: {
|
||||
r: number;
|
||||
c: number;
|
||||
el: HTMLElement;
|
||||
own: number;
|
||||
need: number;
|
||||
align: SpillAlign;
|
||||
}[] = [];
|
||||
for (const r of rowsVisible) {
|
||||
for (const c of colsVisible) {
|
||||
const merge = merges.get(`${r},${c}`);
|
||||
if (merge && (merge.r0 !== r || merge.c0 !== c)) continue;
|
||||
const r0 = merge ? merge.r0 : r;
|
||||
const c0 = merge ? merge.c0 : c;
|
||||
const r1 = merge ? merge.r1 : r;
|
||||
const c1 = merge ? merge.c1 : c;
|
||||
const key = `${r0},${c0}`;
|
||||
if (keep.has(key)) continue;
|
||||
keep.add(key);
|
||||
const cellEl = poolGet(pool, key, container, "aislo-cell");
|
||||
const x = colGeo.offset(c0) - baseX;
|
||||
const y = rowGeo.offset(r0) - baseY;
|
||||
const w = colGeo.offset(c1 + 1) - colGeo.offset(c0);
|
||||
const h = rowGeo.offset(r1 + 1) - rowGeo.offset(r0);
|
||||
cellEl.style.left = `${x}px`;
|
||||
cellEl.style.top = `${y}px`;
|
||||
cellEl.style.width = `${w}px`;
|
||||
cellEl.style.height = `${h}px`;
|
||||
cellEl.dataset.r = String(r0);
|
||||
cellEl.dataset.c = String(c0);
|
||||
const addr = toA1(r0, c0);
|
||||
cellEl.dataset.addr = addr;
|
||||
const baseStyle = cellStyleAt(book, sheet, r0, c0);
|
||||
const condStyle = sheet.조건부서식?.length
|
||||
? resolveCondFmtStyle(sheet, r0, c0, {
|
||||
value: (rr, cc) => toCondFmtValue(scalarAt(ctx, sheet, rr, cc)),
|
||||
})
|
||||
: null;
|
||||
const style = condStyle ? { ...baseStyle, ...condStyle } : baseStyle;
|
||||
const borders: ResolvedBorders = resolveBoxBorders(
|
||||
book,
|
||||
sheet,
|
||||
r0,
|
||||
c0,
|
||||
r1,
|
||||
c1,
|
||||
extent.rows - 1,
|
||||
extent.cols - 1,
|
||||
);
|
||||
const display = cellDisplay(ctx, sheet, r0, c0, style.가로 ?? "general", style.형식);
|
||||
applyCellStyle(cellEl, style, borders, display.hAlign, zoom);
|
||||
// 조건부 서식 글자색이 이기고(엑셀과 같음) · 없으면 숫자 형식([Red] 등)이 정한 색.
|
||||
if (display.color && !condStyle?.글자색 && !isAutoInk(display.color))
|
||||
cellEl.style.color = display.color;
|
||||
const comment = sheet.comments?.[addr];
|
||||
// 수는 칸 폭보다 길면 `#####`(엑셀과 같음) · 글은 지금처럼 줄임표.
|
||||
let text = display.text;
|
||||
if (display.isNumeric && !style.줄바꿈) {
|
||||
const font = cellFont(style, book, zoom);
|
||||
const avail = w - 4;
|
||||
if (measureText(text, font) > avail) {
|
||||
const hashW = measureText("#", font) || 1;
|
||||
text = "#".repeat(Math.max(1, Math.floor(avail / hashW)));
|
||||
}
|
||||
}
|
||||
setCellText(cellEl, text, display.title || comment || undefined);
|
||||
resetSpill(cellEl);
|
||||
if (!merge) {
|
||||
(slots.get(r0) ?? slots.set(r0, new Map()).get(r0)!).set(c0, { w, empty: text === "" });
|
||||
const align = SPILL_ALIGN[display.hAlign];
|
||||
if (align && text && !display.isNumeric && !display.isError && !style.줄바꿈) {
|
||||
// 캔버스 폭은 거르기만(글꼴 대체로 조금 어긋남) — 정확한 폭은 아래서 글 요소 scrollWidth 로
|
||||
const need = measureText(text, cellFont(style, book, zoom)) + 4;
|
||||
if (need > w * 0.9) longs.push({ r: r0, c: c0, el: cellEl, own: w, need, align });
|
||||
}
|
||||
}
|
||||
cellEl.classList.toggle("is-error", display.isError);
|
||||
cellEl.classList.toggle("has-comment", !!comment);
|
||||
cellEl.classList.toggle(
|
||||
"is-selected",
|
||||
ctx.selection.활성.r === r0 && ctx.selection.활성.c === c0,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 글 폭 + 칸 안 여백(양쪽 2px) · 오른 선 1px — 읽기를 먼저 몰아 한 번만 배치 계산
|
||||
for (const l of longs) l.need = (l.el.firstElementChild as HTMLElement).scrollWidth + 5;
|
||||
for (const l of longs) {
|
||||
const sp = spillOf(slots.get(l.r)!, l.c, l.own, l.need, l.align);
|
||||
if (!sp) continue;
|
||||
l.el.classList.add("is-spill");
|
||||
const t = l.el.firstElementChild as HTMLElement;
|
||||
t.style.width = `calc(100% + ${sp.left + sp.right}px)`;
|
||||
t.style.marginLeft = `${-sp.left}px`;
|
||||
if (sp.clipLeft || sp.clipRight)
|
||||
t.style.clipPath = `inset(0 ${sp.clipRight}px 0 ${sp.clipLeft}px)`;
|
||||
for (let k = sp.from; k < sp.to; k++) pool.used.get(`${l.r},${k}`)?.classList.add("is-spill-r");
|
||||
}
|
||||
poolSweep(pool, keep);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_grid_heads.ts
|
||||
* 격자 머리 — 행 · 열 머리 칸(보이는 것만 · 폭 · 높이 끌기) · 숨긴 자리 표시 · 묶음 윤곽 +/- 단추.
|
||||
* `spreadsheet_grid.ts` 가 판을 그릴 때 부름 — 머리 칸 모양 · 끌기는 `spreadsheet_headers.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { colName } from "./spreadsheet_address";
|
||||
import {
|
||||
commitColWidth,
|
||||
commitRowHeight,
|
||||
isExplicitHidden,
|
||||
makeColHeaderCell,
|
||||
makeRowHeaderCell,
|
||||
startDragResize,
|
||||
type OutlineRun,
|
||||
} from "./spreadsheet_headers";
|
||||
import { poolSweep, type CellPool, type GridView } from "./spreadsheet_grid_cells";
|
||||
import type { Sheet } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
export const HEAD_ROW_H_BASE = 24;
|
||||
export const HEAD_COL_W_BASE = 46;
|
||||
|
||||
/** 머리 안쪽 층 넷 — 틀고정 쪽 · 스크롤 쪽. */
|
||||
export interface HeadLayers {
|
||||
colFrozen: HTMLElement;
|
||||
colScroll: HTMLElement;
|
||||
rowFrozen: HTMLElement;
|
||||
rowScroll: HTMLElement;
|
||||
}
|
||||
|
||||
export function createHeads(ctx: SpreadsheetContext, view: GridView, layers: HeadLayers) {
|
||||
const {
|
||||
colFrozen: colHeadInnerFrozen,
|
||||
colScroll: colHeadInnerScroll,
|
||||
rowFrozen: rowHeadInnerFrozen,
|
||||
rowScroll: rowHeadInnerScroll,
|
||||
} = layers;
|
||||
|
||||
/** 건너뛴 자리(prev+1..cur-1) 안에 사용자 숨김 · 필터 걸러짐이 있나(묶음 접힘은 빼고 — 화면 표시가 다름). */
|
||||
function colHiddenGap(sheet: Sheet, prev: number, cur: number): boolean {
|
||||
for (let i = prev + 1; i < cur; i += 1)
|
||||
if (isExplicitHidden(sheet.열?.[colName(i)])) return true;
|
||||
return false;
|
||||
}
|
||||
function rowHiddenGap(sheet: Sheet, prev: number, cur: number): boolean {
|
||||
for (let i = prev + 1; i < cur; i += 1)
|
||||
if (isExplicitHidden(sheet.행?.[String(i + 1)])) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function layoutColHeaders(
|
||||
cols: number[],
|
||||
pool: CellPool,
|
||||
container: HTMLElement,
|
||||
base: number,
|
||||
): void {
|
||||
const sheet = ctx.sheet();
|
||||
const keep = new Set<string>();
|
||||
const top = view.colGutterH();
|
||||
const height = Math.round(HEAD_ROW_H_BASE * view.zoom);
|
||||
cols.forEach((c, i) => {
|
||||
const key = String(c);
|
||||
keep.add(key);
|
||||
const prev = i > 0 ? cols[i - 1] : c - 1;
|
||||
const hiddenBefore = c - prev > 1 && colHiddenGap(sheet, prev, c);
|
||||
let cellEl = pool.used.get(key);
|
||||
if (!cellEl) {
|
||||
const built = makeColHeaderCell(c, 0, 0, hiddenBefore);
|
||||
cellEl = built.el;
|
||||
pool.used.set(key, cellEl);
|
||||
container.append(cellEl);
|
||||
const handle = cellEl.querySelector<HTMLElement>(".aislo-grid__resize--col")!;
|
||||
startDragResize({
|
||||
handle,
|
||||
axis: "col",
|
||||
currentPx: () => view.colGeo.size(c),
|
||||
onPreview: (px) => {
|
||||
cellEl!.style.width = `${px}px`;
|
||||
},
|
||||
onCommit: (px) => commitColWidth(ctx, c, px),
|
||||
});
|
||||
}
|
||||
cellEl.style.left = `${view.colGeo.offset(c) - base}px`;
|
||||
cellEl.style.top = `${top}px`;
|
||||
cellEl.style.width = `${view.colGeo.size(c)}px`;
|
||||
cellEl.style.height = `${height}px`;
|
||||
cellEl.classList.toggle("aislo-grid__colhead--hidden-before", hiddenBefore);
|
||||
});
|
||||
poolSweep(pool, keep);
|
||||
}
|
||||
|
||||
function layoutRowHeaders(
|
||||
rows: number[],
|
||||
pool: CellPool,
|
||||
container: HTMLElement,
|
||||
base: number,
|
||||
): void {
|
||||
const sheet = ctx.sheet();
|
||||
const keep = new Set<string>();
|
||||
const left = view.rowGutterW();
|
||||
const width = Math.round(HEAD_COL_W_BASE * view.zoom);
|
||||
rows.forEach((r, i) => {
|
||||
const key = String(r);
|
||||
keep.add(key);
|
||||
const prev = i > 0 ? rows[i - 1] : r - 1;
|
||||
const hiddenBefore = r - prev > 1 && rowHiddenGap(sheet, prev, r);
|
||||
let cellEl = pool.used.get(key);
|
||||
if (!cellEl) {
|
||||
const built = makeRowHeaderCell(r, 0, 0, hiddenBefore);
|
||||
cellEl = built.el;
|
||||
pool.used.set(key, cellEl);
|
||||
container.append(cellEl);
|
||||
const handle = cellEl.querySelector<HTMLElement>(".aislo-grid__resize--row")!;
|
||||
startDragResize({
|
||||
handle,
|
||||
axis: "row",
|
||||
currentPx: () => view.rowGeo.size(r),
|
||||
onPreview: (px) => {
|
||||
cellEl!.style.height = `${px}px`;
|
||||
},
|
||||
onCommit: (px) => commitRowHeight(ctx, r, px),
|
||||
});
|
||||
}
|
||||
cellEl.style.top = `${view.rowGeo.offset(r) - base}px`;
|
||||
cellEl.style.left = `${left}px`;
|
||||
cellEl.style.height = `${view.rowGeo.size(r)}px`;
|
||||
cellEl.style.width = `${width}px`;
|
||||
cellEl.classList.toggle("aislo-grid__rowhead--hidden-before", hiddenBefore);
|
||||
});
|
||||
poolSweep(pool, keep);
|
||||
}
|
||||
|
||||
/** 묶음 접힘 뒤집기(같은 묶음 자리 모두) → `묶음설정` dispatch. */
|
||||
function outlineToggle(axis: "행" | "열", run: OutlineRun): void {
|
||||
const idx: number[] = [];
|
||||
for (let i = run.from; i <= run.to; i += 1) idx.push(i);
|
||||
ctx.dispatch({
|
||||
종류: "묶음설정",
|
||||
시트: ctx.sheet().id,
|
||||
축: axis,
|
||||
번호: idx,
|
||||
묶음: { 층: 1, 접힘: !run.collapsed },
|
||||
});
|
||||
}
|
||||
|
||||
/** 윤곽 +/- 단추 — 행 묶음 마지막 다음 자리(머리 왼쪽 띠) · 열 묶음도 같은 이치(머리 위 띠).
|
||||
* 여러 층(중첩)은 1단계 다음(지금은 늘 층 1). */
|
||||
function renderRowOutline(): void {
|
||||
rowHeadInnerFrozen.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove());
|
||||
rowHeadInnerScroll.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove());
|
||||
if (!view.rowOutlineRuns.length) return;
|
||||
const gw = view.rowGutterW();
|
||||
for (const run of view.rowOutlineRuns) {
|
||||
const btnRow = Math.min(run.to + 1, view.extent.rows - 1);
|
||||
const container = btnRow < view.frozenRows ? rowHeadInnerFrozen : rowHeadInnerScroll;
|
||||
const base = btnRow < view.frozenRows ? 0 : view.rowGeo.offset(view.frozenRows);
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "aislo-grid__outline-btn";
|
||||
btn.textContent = run.collapsed ? "+" : "−";
|
||||
btn.style.top = `${view.rowGeo.offset(btnRow) - base}px`;
|
||||
btn.style.left = "0";
|
||||
btn.style.width = `${gw}px`;
|
||||
btn.style.height = `${view.rowGeo.size(btnRow) || Math.round(HEAD_ROW_H_BASE * view.zoom)}px`;
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
outlineToggle("행", run);
|
||||
});
|
||||
container.append(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function renderColOutline(): void {
|
||||
colHeadInnerFrozen.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove());
|
||||
colHeadInnerScroll.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove());
|
||||
if (!view.colOutlineRuns.length) return;
|
||||
const gh = view.colGutterH();
|
||||
for (const run of view.colOutlineRuns) {
|
||||
const btnCol = Math.min(run.to + 1, view.extent.cols - 1);
|
||||
const container = btnCol < view.frozenCols ? colHeadInnerFrozen : colHeadInnerScroll;
|
||||
const base = btnCol < view.frozenCols ? 0 : view.colGeo.offset(view.frozenCols);
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "aislo-grid__outline-btn";
|
||||
btn.textContent = run.collapsed ? "+" : "−";
|
||||
btn.style.left = `${view.colGeo.offset(btnCol) - base}px`;
|
||||
btn.style.top = "0";
|
||||
btn.style.width = `${view.colGeo.size(btnCol) || Math.round(HEAD_COL_W_BASE * view.zoom)}px`;
|
||||
btn.style.height = `${gh}px`;
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
outlineToggle("열", run);
|
||||
});
|
||||
container.append(btn);
|
||||
}
|
||||
}
|
||||
|
||||
return { layoutColHeaders, layoutRowHeaders, renderRowOutline, renderColOutline };
|
||||
}
|
||||
@@ -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,242 @@
|
||||
/* =============================================================================
|
||||
* 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 { dialogKeys } from "./spreadsheet_actions";
|
||||
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);
|
||||
dialogKeys(overlay, close, () => (onOk(choice()), close()));
|
||||
}
|
||||
|
||||
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-top: 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,403 @@
|
||||
/* =============================================================================
|
||||
* 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;
|
||||
/** 호스트가 정한 알약 — 자재 모드에서도 [목록에서 빼기]가 안 뜸(예: 선택 입력 카드가 자리를 고른 입력) */
|
||||
고정?: boolean;
|
||||
}
|
||||
|
||||
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 } });
|
||||
// 상태줄(합계 줄) 아래 맨 끝(표는 flex 라 막대 높이만큼 줄어듦 — 여러 줄이어도 페이지 스크롤 없음)
|
||||
if (opts) ctx.root.querySelector(":scope > .ss-statusbar")?.after(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?.모드 === "자재" && !item.고정 && 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.bottom = `${o.bottom - bar.getBoundingClientRect().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,266 @@
|
||||
/* =============================================================================
|
||||
* 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 { confirmMerge, dialogKeys } from "./spreadsheet_actions";
|
||||
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);
|
||||
dialogKeys(overlay, done);
|
||||
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"),
|
||||
async () => {
|
||||
const merged = isMerged(range);
|
||||
if (!merged && !(await confirmMerge(ctx, range))) return;
|
||||
ctx.dispatch({
|
||||
종류: merged ? "병합풀기" : "병합",
|
||||
시트: 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("MenuCut"), () => cutCopy("cut")],
|
||||
[st("MenuCopy"), () => cutCopy("copy")],
|
||||
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
|
||||
[
|
||||
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("MenuCut"), () => cutCopy("cut")],
|
||||
[st("MenuCopy"), () => cutCopy("copy")],
|
||||
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
|
||||
[
|
||||
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,215 @@
|
||||
/* =============================================================================
|
||||
* 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.length > 255) return "255 글자까지";
|
||||
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 sheetName = ctx.book.시트.find((s) => s.id === 시트)?.이름 ?? 시트;
|
||||
const label = el("span", { text: `${이름} = ${sheetName}!${rangeToA1(범위)}` });
|
||||
const rename = createButton({ label: "이름 바꾸기", variant: "ghost" });
|
||||
// 창 안에서 바로 고침(브라우저 prompt 는 자동화 창에서 저절로 취소됨) — Enter 바꿈 · Esc 그만
|
||||
rename.addEventListener("click", () => {
|
||||
const input = el("input", { className: "ui-input" }) as HTMLInputElement;
|
||||
input.value = 이름;
|
||||
const finish = (commit: boolean): void => {
|
||||
const next = input.value.trim();
|
||||
if (commit && next && next !== 이름) showError(renameName(ctx, 이름, next));
|
||||
render();
|
||||
};
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Enter" && e.key !== "Escape") return;
|
||||
e.stopPropagation();
|
||||
finish(e.key === "Enter");
|
||||
});
|
||||
label.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
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,634 @@
|
||||
/* =============================================================================
|
||||
* 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")) {
|
||||
// 글 자리(@)뿐인 형식을 수에 걸면 엑셀처럼 일반으로 보임(빈 칸이 되지 않게)
|
||||
if (toks.some((t) => t.k === "text"))
|
||||
return { 글: generalFormat(value), 정렬: "right", ...extra };
|
||||
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,134 @@
|
||||
/* 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;
|
||||
z-index: 5;
|
||||
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,115 @@
|
||||
/* =============================================================================
|
||||
* 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;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.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-secondary);
|
||||
}
|
||||
@@ -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,132 @@
|
||||
/* =============================================================================
|
||||
* 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"],
|
||||
MergeLoseConfirm: [
|
||||
"왼위 칸 값만 남고 나머지 칸 값은 지워짐 — 병합할까?",
|
||||
"Merging keeps only the upper-left value — merge anyway?",
|
||||
],
|
||||
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,325 @@
|
||||
/* =============================================================================
|
||||
* 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-secondary);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-toolbar__popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
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-secondary);
|
||||
font-size: 0.72rem;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-toolbar__formula-hint:hover {
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.ss-toolbar__formula-hint-detail {
|
||||
color: var(--color-text-secondary);
|
||||
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,535 @@
|
||||
/* =============================================================================
|
||||
* 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 { confirmMerge } from "./spreadsheet_actions";
|
||||
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 ? Math.min(n, 409) : 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", async () => {
|
||||
const range = ctx.selection.범위[0];
|
||||
if (!range) return;
|
||||
const merged = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
|
||||
if (!merged && !(await confirmMerge(ctx, range))) return;
|
||||
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.세로 ?? "bottom";
|
||||
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,556 @@
|
||||
/* ====================================================================== * 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 판 · 병합 · 고름 그림 ~650
|
||||
* C spreadsheet_grid_cells.ts 판 하나 칸 그리기 · 값 글 · #### · 넘침 붙이기 ~320
|
||||
* C spreadsheet_grid_heads.ts 행열 머리 칸 · 숨김 표시 · 묶음 윤곽 단추 ~200
|
||||
* C spreadsheet_spill.ts 긴 글 넘침 계산(옆 빈 칸) ~60
|
||||
* 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,206 @@
|
||||
/* =============================================================================
|
||||
* 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, showToast } from "@ui/ui_template_elements";
|
||||
import { dialogKeys } from "./spreadsheet_actions";
|
||||
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);
|
||||
dialogKeys(overlay, close);
|
||||
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.활성;
|
||||
// 고정 판이 격자를 거의 다 덮으면 나머지가 안 움직임(시트 머리부터 고정) — 화면 안쪽 칸만
|
||||
const box = ctx.grid.cellBox(at.r, at.c);
|
||||
const g = ctx.grid.root;
|
||||
if (box.y > g.clientHeight * 0.8 || box.x > g.clientWidth * 0.8) {
|
||||
showToast("고정할 칸이 너무 아래 · 오른쪽 — 화면 안쪽 칸에서 고정할 것", "warning");
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -360,16 +360,23 @@ export async function searchMemberCandidates(query: string): Promise<Member[]> {
|
||||
}
|
||||
|
||||
/** 이미 가입한 사람을 회사에 붙인다 — 계정을 대신 만들지 않는다. */
|
||||
export function addCompanyMember(userId: number): Promise<{ member: Member }> {
|
||||
return request("/dashboard/admin/members", {
|
||||
export function addCompanyMember(
|
||||
userId: number,
|
||||
companyId?: number | null,
|
||||
): Promise<{ member: Member }> {
|
||||
return request(`/dashboard/admin/members${companyQuery(companyId)}`, {
|
||||
method: "POST",
|
||||
body: body({ user_id: userId }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 아직 가입하지 않은 사람에게 가입 안내 메일만 보낸다. */
|
||||
export function inviteMember(email: string, name?: string | null): Promise<unknown> {
|
||||
return request("/dashboard/admin/members/invite", {
|
||||
export function inviteMember(
|
||||
email: string,
|
||||
name?: string | null,
|
||||
companyId?: number | null,
|
||||
): Promise<unknown> {
|
||||
return request(`/dashboard/admin/members/invite${companyQuery(companyId)}`, {
|
||||
method: "POST",
|
||||
body: body({ email, name: name || null }),
|
||||
});
|
||||
@@ -395,8 +402,11 @@ export function removeCompanyMember(userId: number): Promise<unknown> {
|
||||
return request(`/dashboard/admin/members/${userId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function fetchCompanyJoinRequests(): Promise<JoinRequest[]> {
|
||||
const data = await request<{ requests: JoinRequest[] }>("/dashboard/admin/join-requests");
|
||||
/** companyId 는 시스템관리자가 대시보드에서 고른 회사(29-1) — 없으면 자기 회사. */
|
||||
export async function fetchCompanyJoinRequests(companyId?: number | null): Promise<JoinRequest[]> {
|
||||
const data = await request<{ requests: JoinRequest[] }>(
|
||||
`/dashboard/admin/join-requests${companyQuery(companyId)}`,
|
||||
);
|
||||
return data.requests;
|
||||
}
|
||||
|
||||
@@ -410,8 +420,10 @@ export function processJoinRequest(
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCompanyProjects(): Promise<ProjectItem[]> {
|
||||
const data = await request<{ projects: ProjectItem[] }>("/dashboard/admin/projects");
|
||||
export async function fetchCompanyProjects(companyId?: number | null): Promise<ProjectItem[]> {
|
||||
const data = await request<{ projects: ProjectItem[] }>(
|
||||
`/dashboard/admin/projects${companyQuery(companyId)}`,
|
||||
);
|
||||
return data.projects;
|
||||
}
|
||||
|
||||
@@ -425,8 +437,10 @@ export async function fetchAllCompanies(): Promise<CompanyInfo[]> {
|
||||
return data.companies;
|
||||
}
|
||||
|
||||
export async function fetchAllUsers(): Promise<DashboardUser[]> {
|
||||
const data = await request<{ users: DashboardUser[] }>("/dashboard/admin/users");
|
||||
export async function fetchAllUsers(companyId?: number | null): Promise<DashboardUser[]> {
|
||||
const data = await request<{ users: DashboardUser[] }>(
|
||||
`/dashboard/admin/users${companyQuery(companyId)}`,
|
||||
);
|
||||
return data.users;
|
||||
}
|
||||
|
||||
|
||||
@@ -338,15 +338,17 @@ async def soft_delete_project(project_id: str, actor_id: int, request: Any | Non
|
||||
return changed
|
||||
|
||||
|
||||
async def list_all_users() -> list[dict[str, Any]]:
|
||||
async def list_all_users(company_id: int | None = None) -> list[dict[str, Any]]:
|
||||
where = " AND u.company_id = %s" if company_id else ""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT u.id, u.email, u.name, u.position, u.department, u.phone,
|
||||
f"""SELECT u.id, u.email, u.name, u.position, u.department, u.phone,
|
||||
u.role, u.status, u.company_id,
|
||||
c.name AS company_name, u.last_login
|
||||
FROM users u LEFT JOIN companies c ON c.id = u.company_id
|
||||
WHERE u.deleted_at IS NULL ORDER BY u.created_at DESC LIMIT 200"""
|
||||
WHERE u.deleted_at IS NULL{where} ORDER BY u.created_at DESC LIMIT 200""",
|
||||
(company_id,) if company_id else (),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
for row in rows:
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
"""B01_Dashboard 역할별 대시보드 API."""
|
||||
|
||||
import html
|
||||
import mimetypes
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Path,
|
||||
Query,
|
||||
Request,
|
||||
Response,
|
||||
UploadFile,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, Response
|
||||
from pymysql.err import IntegrityError
|
||||
|
||||
from common_util.common_util_auth import require_company, require_system_admin, verify_session
|
||||
from common_util.common_util_email import send_email
|
||||
from common_util.common_util_project_delete import hard_delete_project
|
||||
from common_util.common_util_storage import read_stored_asset
|
||||
from config.config_system import APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED
|
||||
|
||||
from .B01_Dashboard_Map import fetch_base_tile, geocode_address, search_address
|
||||
@@ -47,17 +33,6 @@ from .B01_Dashboard_Repository import (
|
||||
update_project,
|
||||
update_user_profile,
|
||||
)
|
||||
from .B01_Dashboard_Repository_Assets import (
|
||||
ASSET_EXTENSIONS,
|
||||
ASSET_KINDS,
|
||||
ASSET_MAX_BYTES,
|
||||
create_company_asset,
|
||||
delete_company_asset,
|
||||
get_company_asset,
|
||||
list_company_assets,
|
||||
update_company_asset,
|
||||
write_company_asset_file,
|
||||
)
|
||||
from .B01_Dashboard_Repository_Company import (
|
||||
create_company,
|
||||
get_user_company,
|
||||
@@ -75,9 +50,9 @@ from .B01_Dashboard_Repository_Members import (
|
||||
list_company_members,
|
||||
list_unassigned_users,
|
||||
remove_company_member,
|
||||
set_company_logo,
|
||||
set_project_members,
|
||||
)
|
||||
from .B01_Dashboard_Router_Assets import router as assets_router
|
||||
from .B01_Dashboard_Schema import (
|
||||
AddMemberRequest,
|
||||
AdminUpdateUserRequest,
|
||||
@@ -88,52 +63,19 @@ from .B01_Dashboard_Schema import (
|
||||
JoinCompanyRequest,
|
||||
ProcessJoinRequest,
|
||||
UiPrefsRequest,
|
||||
UpdateCompanyAssetRequest,
|
||||
UpdateCompanyLogoRequest,
|
||||
UpdateProjectRequest,
|
||||
UpdateUserRequest,
|
||||
)
|
||||
from .B01_Dashboard_Scope import (
|
||||
_require_company_id,
|
||||
_same_company,
|
||||
_scope_company,
|
||||
require_company_admin,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["B01_Dashboard"])
|
||||
|
||||
|
||||
async def require_company_admin(
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
if session["role"] not in ("ADMIN", "SYSTEM_ADMIN") and not session["is_master"]:
|
||||
raise HTTPException(status_code=403, detail="회사 관리자 권한이 필요합니다.")
|
||||
return session
|
||||
|
||||
|
||||
def _require_company_id(session: dict[str, Any]) -> int:
|
||||
company_id = session.get("company_id")
|
||||
if company_id is None:
|
||||
raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.")
|
||||
return int(company_id)
|
||||
|
||||
|
||||
def _same_company(session: dict[str, Any], company_id: int | None) -> bool:
|
||||
return company_id is not None and int(session.get("company_id") or 0) == int(company_id)
|
||||
|
||||
|
||||
def _scope_company(session: dict[str, Any], company_id: int | None) -> int:
|
||||
"""회사 범위 자원(구성원·로고·서명)의 회사. 시스템관리자만 남의 회사를 지정할 수 있다."""
|
||||
if session["role"] == "SYSTEM_ADMIN" and company_id is not None:
|
||||
return int(company_id)
|
||||
own = _require_company_id(session)
|
||||
if company_id is not None and int(company_id) != own:
|
||||
raise HTTPException(status_code=403, detail="다른 회사의 자료는 볼 수 없습니다.")
|
||||
return own
|
||||
|
||||
|
||||
async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, Any]:
|
||||
asset = await get_company_asset(asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="자산을 찾을 수 없습니다.")
|
||||
_scope_company(session, int(asset["company_id"]))
|
||||
return asset
|
||||
|
||||
|
||||
async def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool:
|
||||
if session["role"] == "SYSTEM_ADMIN":
|
||||
return True
|
||||
@@ -326,9 +268,10 @@ async def admin_member_candidates(
|
||||
@router.post("/admin/members")
|
||||
async def admin_add_member(
|
||||
payload: AddMemberRequest,
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
member = await attach_company_member(_require_company_id(session), payload.user_id)
|
||||
member = await attach_company_member(_scope_company(session, company_id), payload.user_id)
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="이미 다른 회사 소속이거나 찾을 수 없는 사용자입니다."
|
||||
@@ -339,10 +282,11 @@ async def admin_add_member(
|
||||
@router.post("/admin/members/invite")
|
||||
async def admin_invite_member(
|
||||
payload: InviteMemberRequest,
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
"""아직 가입하지 않은 사람에게 안내 메일만 보낸다 — 계정을 대신 만들지 않는다."""
|
||||
company = await get_user_company(session.get("company_id"))
|
||||
company = await get_user_company(_scope_company(session, company_id))
|
||||
company_name = (company or {}).get("name") or "회사"
|
||||
# 메일 본문에 사람이 넣은 글자가 그대로 들어가면 남의 편지함에 임의 HTML 을 보낼 수 있다.
|
||||
safe_company = html.escape(company_name)
|
||||
@@ -369,8 +313,12 @@ async def admin_remove_member(
|
||||
|
||||
|
||||
@router.get("/admin/join-requests")
|
||||
async def admin_join_requests(session: dict[str, Any] = Depends(require_company_admin)):
|
||||
return {"status": "success", "requests": await list_join_requests(_require_company_id(session))}
|
||||
async def admin_join_requests(
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
scoped = _scope_company(session, company_id)
|
||||
return {"status": "success", "requests": await list_join_requests(scoped)}
|
||||
|
||||
|
||||
@router.patch("/admin/join-requests/{request_id}")
|
||||
@@ -392,10 +340,13 @@ async def admin_process_join_request(
|
||||
|
||||
|
||||
@router.get("/admin/projects")
|
||||
async def admin_projects(session: dict[str, Any] = Depends(require_company_admin)):
|
||||
async def admin_projects(
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
return {
|
||||
"status": "success",
|
||||
"projects": await list_company_projects(_require_company_id(session)),
|
||||
"projects": await list_company_projects(_scope_company(session, company_id)),
|
||||
}
|
||||
|
||||
|
||||
@@ -470,9 +421,13 @@ async def system_create_company(
|
||||
|
||||
|
||||
@router.get("/admin/users")
|
||||
async def system_users(session: dict[str, Any] = Depends(require_system_admin)):
|
||||
async def system_users(
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(require_system_admin),
|
||||
):
|
||||
"""company_id = 대시보드에서 고른 회사 사람만(29-1) · 없으면 전체."""
|
||||
_ = session
|
||||
return {"status": "success", "users": await list_all_users()}
|
||||
return {"status": "success", "users": await list_all_users(company_id)}
|
||||
|
||||
|
||||
@router.patch("/admin/users/{user_id}/role")
|
||||
@@ -607,95 +562,4 @@ async def system_projects(session: dict[str, Any] = Depends(require_system_admin
|
||||
return {"status": "success", "projects": await list_all_projects()}
|
||||
|
||||
|
||||
# ---- 회사 공유 도면 자산(로고·서명) — 회사 구성원 누구나 작성·수정, 시스템관리자는 전체 ----
|
||||
|
||||
|
||||
@router.get("/company/assets")
|
||||
async def company_assets(
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
return {
|
||||
"status": "success",
|
||||
"assets": await list_company_assets(_scope_company(session, company_id)),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/company/assets")
|
||||
async def company_add_asset(
|
||||
kind: str = Form(...),
|
||||
label: str = Form(...),
|
||||
user_id: int | None = Form(None, gt=0),
|
||||
company_id: int | None = Form(None, gt=0),
|
||||
file: UploadFile = File(...),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
kind = kind.upper()
|
||||
if kind not in ASSET_KINDS:
|
||||
raise HTTPException(status_code=400, detail="자산 종류는 LOGO 또는 SIGNATURE 입니다.")
|
||||
label = label.strip()[:100]
|
||||
if not label:
|
||||
raise HTTPException(status_code=400, detail="이름을 넣어 주세요.")
|
||||
suffix = os.path.splitext(file.filename or "")[1].lower()
|
||||
if suffix not in ASSET_EXTENSIONS:
|
||||
raise HTTPException(status_code=400, detail="png·jpg·webp·svg 그림만 올릴 수 있습니다.")
|
||||
blob = await file.read()
|
||||
if not blob or len(blob) > ASSET_MAX_BYTES:
|
||||
raise HTTPException(status_code=400, detail="그림은 2MB 이하여야 합니다.")
|
||||
scoped = _scope_company(session, company_id)
|
||||
if user_id and user_id not in {m["id"] for m in await list_company_members(scoped)}:
|
||||
raise HTTPException(status_code=400, detail="주인은 같은 회사 구성원이어야 합니다.")
|
||||
path = write_company_asset_file(scoped, kind, suffix, blob)
|
||||
asset_id = await create_company_asset(
|
||||
scoped, kind, label, path, user_id, int(session["user_id"])
|
||||
)
|
||||
return {"status": "success", "asset_id": asset_id}
|
||||
|
||||
|
||||
@router.put("/company/assets/{asset_id}")
|
||||
async def company_update_asset(
|
||||
asset_id: int,
|
||||
payload: UpdateCompanyAssetRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
await _company_asset(session, asset_id)
|
||||
await update_company_asset(asset_id, payload.label.strip(), payload.user_id)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.delete("/company/assets/{asset_id}")
|
||||
async def company_delete_asset(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
|
||||
await _company_asset(session, asset_id)
|
||||
await delete_company_asset(asset_id)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.put("/company/logo")
|
||||
async def company_set_logo(
|
||||
payload: UpdateCompanyLogoRequest,
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
"""회사 대표 로고를 지정·변경한다 (2026-09-02 사용자 확정).
|
||||
|
||||
프로젝트가 따로 고르지 않으면 도면은 이 로고를 쓴다.
|
||||
"""
|
||||
scoped = _scope_company(session, company_id)
|
||||
if payload.logo_asset_id is not None:
|
||||
asset = await get_company_asset(payload.logo_asset_id)
|
||||
if not asset or asset["company_id"] != scoped or asset["kind"] != "LOGO":
|
||||
raise HTTPException(status_code=400, detail="같은 회사의 로고 자산이어야 합니다.")
|
||||
if not await set_company_logo(scoped, payload.logo_asset_id):
|
||||
raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/company/assets/{asset_id}/file")
|
||||
async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
|
||||
"""목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다."""
|
||||
asset = await _company_asset(session, asset_id)
|
||||
blob = read_stored_asset(asset["file_path"])
|
||||
if blob is None:
|
||||
raise HTTPException(status_code=404, detail="그림 파일이 없습니다.")
|
||||
media_type = mimetypes.guess_type(asset["file_path"])[0] or "application/octet-stream"
|
||||
return Response(content=blob, media_type=media_type)
|
||||
router.include_router(assets_router)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""B01_Dashboard 회사 공유 도면 자산(로고·서명) API — 본체 라우터가 include 함."""
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_storage import read_stored_asset
|
||||
|
||||
from .B01_Dashboard_Repository_Assets import (
|
||||
ASSET_EXTENSIONS,
|
||||
ASSET_KINDS,
|
||||
ASSET_MAX_BYTES,
|
||||
create_company_asset,
|
||||
delete_company_asset,
|
||||
get_company_asset,
|
||||
list_company_assets,
|
||||
update_company_asset,
|
||||
write_company_asset_file,
|
||||
)
|
||||
from .B01_Dashboard_Repository_Members import list_company_members, set_company_logo
|
||||
from .B01_Dashboard_Schema import UpdateCompanyAssetRequest, UpdateCompanyLogoRequest
|
||||
from .B01_Dashboard_Scope import _scope_company, require_company_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, Any]:
|
||||
asset = await get_company_asset(asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="자산을 찾을 수 없습니다.")
|
||||
_scope_company(session, int(asset["company_id"]))
|
||||
return asset
|
||||
|
||||
|
||||
# ---- 회사 공유 도면 자산(로고·서명) — 회사 구성원 누구나 작성·수정, 시스템관리자는 전체 ----
|
||||
|
||||
|
||||
@router.get("/company/assets")
|
||||
async def company_assets(
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
return {
|
||||
"status": "success",
|
||||
"assets": await list_company_assets(_scope_company(session, company_id)),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/company/assets")
|
||||
async def company_add_asset(
|
||||
kind: str = Form(...),
|
||||
label: str = Form(...),
|
||||
user_id: int | None = Form(None, gt=0),
|
||||
company_id: int | None = Form(None, gt=0),
|
||||
file: UploadFile = File(...),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
kind = kind.upper()
|
||||
if kind not in ASSET_KINDS:
|
||||
raise HTTPException(status_code=400, detail="자산 종류는 LOGO 또는 SIGNATURE 입니다.")
|
||||
label = label.strip()[:100]
|
||||
if not label:
|
||||
raise HTTPException(status_code=400, detail="이름을 넣어 주세요.")
|
||||
suffix = os.path.splitext(file.filename or "")[1].lower()
|
||||
if suffix not in ASSET_EXTENSIONS:
|
||||
raise HTTPException(status_code=400, detail="png·jpg·webp·svg 그림만 올릴 수 있습니다.")
|
||||
blob = await file.read()
|
||||
if not blob or len(blob) > ASSET_MAX_BYTES:
|
||||
raise HTTPException(status_code=400, detail="그림은 2MB 이하여야 합니다.")
|
||||
scoped = _scope_company(session, company_id)
|
||||
if user_id and user_id not in {m["id"] for m in await list_company_members(scoped)}:
|
||||
raise HTTPException(status_code=400, detail="주인은 같은 회사 구성원이어야 합니다.")
|
||||
path = write_company_asset_file(scoped, kind, suffix, blob)
|
||||
asset_id = await create_company_asset(
|
||||
scoped, kind, label, path, user_id, int(session["user_id"])
|
||||
)
|
||||
return {"status": "success", "asset_id": asset_id}
|
||||
|
||||
|
||||
@router.put("/company/assets/{asset_id}")
|
||||
async def company_update_asset(
|
||||
asset_id: int,
|
||||
payload: UpdateCompanyAssetRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
await _company_asset(session, asset_id)
|
||||
await update_company_asset(asset_id, payload.label.strip(), payload.user_id)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.delete("/company/assets/{asset_id}")
|
||||
async def company_delete_asset(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
|
||||
await _company_asset(session, asset_id)
|
||||
await delete_company_asset(asset_id)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.put("/company/logo")
|
||||
async def company_set_logo(
|
||||
payload: UpdateCompanyLogoRequest,
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
"""회사 대표 로고를 지정·변경한다 (2026-09-02 사용자 확정).
|
||||
|
||||
프로젝트가 따로 고르지 않으면 도면은 이 로고를 쓴다.
|
||||
"""
|
||||
scoped = _scope_company(session, company_id)
|
||||
if payload.logo_asset_id is not None:
|
||||
asset = await get_company_asset(payload.logo_asset_id)
|
||||
if not asset or asset["company_id"] != scoped or asset["kind"] != "LOGO":
|
||||
raise HTTPException(status_code=400, detail="같은 회사의 로고 자산이어야 합니다.")
|
||||
if not await set_company_logo(scoped, payload.logo_asset_id):
|
||||
raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/company/assets/{asset_id}/file")
|
||||
async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
|
||||
"""목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다."""
|
||||
asset = await _company_asset(session, asset_id)
|
||||
blob = read_stored_asset(asset["file_path"])
|
||||
if blob is None:
|
||||
raise HTTPException(status_code=404, detail="그림 파일이 없습니다.")
|
||||
media_type = mimetypes.guess_type(asset["file_path"])[0] or "application/octet-stream"
|
||||
return Response(content=blob, media_type=media_type)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""B01_Dashboard 권한 · 회사 범위 도우미 — 라우터 둘(본체 · 자산)이 함께 씀."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
|
||||
|
||||
async def require_company_admin(
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
if session["role"] not in ("ADMIN", "SYSTEM_ADMIN") and not session["is_master"]:
|
||||
raise HTTPException(status_code=403, detail="회사 관리자 권한이 필요합니다.")
|
||||
return session
|
||||
|
||||
|
||||
def _require_company_id(session: dict[str, Any]) -> int:
|
||||
company_id = session.get("company_id")
|
||||
if company_id is None:
|
||||
raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.")
|
||||
return int(company_id)
|
||||
|
||||
|
||||
def _same_company(session: dict[str, Any], company_id: int | None) -> bool:
|
||||
return company_id is not None and int(session.get("company_id") or 0) == int(company_id)
|
||||
|
||||
|
||||
def _scope_company(session: dict[str, Any], company_id: int | None) -> int:
|
||||
"""회사 범위 자원의 회사 — 시스템관리자면 인자 우선(대시보드 회사 고르기) · 남은 세션 회사."""
|
||||
if session["role"] == "SYSTEM_ADMIN" and company_id is not None:
|
||||
return int(company_id)
|
||||
own = _require_company_id(session)
|
||||
if company_id is not None and int(company_id) != own:
|
||||
raise HTTPException(status_code=403, detail="다른 회사의 자료는 볼 수 없습니다.")
|
||||
return own
|
||||
@@ -0,0 +1,100 @@
|
||||
/* =============================================================================
|
||||
* 회사 조회 후 고르기(시스템 관리자 회사 알약 · 29-5)
|
||||
* 찾기 칸(이름 · 사업자번호 · 띄어쓰기 단어 모두 맞음) → 거른 목록(처음엔 최근 등록 순) →
|
||||
* 줄 누르면 고름. 목록은 LIMIT 줄까지만 그림 — 회사가 많아도 찾기로 좁힘.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField } from "@ui/ui_template_elements";
|
||||
import type { CompanyInfo } from "./B01_Dashboard_Api_Fetch";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
const LIMIT = 50;
|
||||
|
||||
/** 이름 · 사업자번호(줄표 빼고도)에 모든 단어가 들어간 회사 — 순서는 받은 그대로 */
|
||||
export function filterCompanies(companies: CompanyInfo[], text: string): CompanyInfo[] {
|
||||
const words = text.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return companies.filter((c) => {
|
||||
const no = c.business_registration_number ?? "";
|
||||
const hay = `${c.name} ${no} ${no.replace(/-/g, "")}`.toLowerCase();
|
||||
return words.every((w) => hay.includes(w));
|
||||
});
|
||||
}
|
||||
|
||||
export function openCompanyPicker(
|
||||
companies: CompanyInfo[],
|
||||
currentId: number | null,
|
||||
onPick: (company: CompanyInfo) => void,
|
||||
): void {
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "b01-dashboard__modal";
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b01-dashboard__modal-panel";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "b01-dashboard__modal-title";
|
||||
heading.textContent = L("B01_Dashboard_PickCompany");
|
||||
const query = createInputField({
|
||||
label: L("B01_Dashboard_Field_Search"),
|
||||
placeholder: L("B01_Dashboard_PickCompanyHint"),
|
||||
});
|
||||
const count = document.createElement("p");
|
||||
count.className = "b01-dashboard__company-count";
|
||||
const list = document.createElement("div");
|
||||
list.className = "b01-dashboard__company-list";
|
||||
|
||||
const close = (): void => {
|
||||
modal.remove();
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
const pick = (company: CompanyInfo): void => {
|
||||
close();
|
||||
onPick(company);
|
||||
};
|
||||
let shown: CompanyInfo[] = [];
|
||||
const render = (): void => {
|
||||
const found = filterCompanies(companies, query.input.value);
|
||||
shown = found.slice(0, LIMIT);
|
||||
count.textContent =
|
||||
found.length > LIMIT
|
||||
? L("B01_Dashboard_PickCompanyMore")
|
||||
.replace("{count}", String(found.length))
|
||||
.replace("{limit}", String(LIMIT))
|
||||
: L("B01_Dashboard_PickCompanyCount").replace("{count}", String(found.length));
|
||||
list.replaceChildren(
|
||||
...shown.map((company) => {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "b01-dashboard__company-row";
|
||||
row.classList.toggle("is-current", company.id === currentId);
|
||||
const name = document.createElement("span");
|
||||
name.textContent = company.name;
|
||||
const no = document.createElement("small");
|
||||
no.textContent = company.business_registration_number ?? "";
|
||||
row.append(name, no);
|
||||
row.addEventListener("click", () => pick(company));
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
};
|
||||
// Enter = 첫 줄 고름 · Esc = 닫기
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (event.key === "Escape") close();
|
||||
else if (event.key === "Enter" && event.target === query.input && shown[0]) pick(shown[0]);
|
||||
else return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
query.input.addEventListener("input", render);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
modal.addEventListener("click", (event) => {
|
||||
if (event.target === modal) close();
|
||||
});
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b01-dashboard__actions";
|
||||
actions.append(createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", onClick: close }));
|
||||
panel.append(heading, query.root, count, list, actions);
|
||||
modal.append(panel);
|
||||
document.body.append(modal);
|
||||
render();
|
||||
query.input.focus();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user