313 Commits
Author SHA1 Message Date
eomsangdonandClaude Opus 5.5 9a3645bcaa fix(M02): 좌측 한 곳 다시 그리기에 「자재 조합」 줄도 넣음 — 고름 표시 늘 한 줄
- dev(sub5 990e8e67 「좌측 고름 표시 한 줄만」) 받기 충돌을 두 쪽 다 살려 풂
- 자재 조합 상세를 열 때도 편집 칸이 onOpened 로 좌측 paint 를 부름 · paint 는 양식이 열려 있으면 자재 조합 고름을 지움
- 시험: 자재 조합 상세도 같은 한 곳을 거치는지 확인 줄 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwJQ5eRrCKf3nH9EFeKvJz
2026-09-28 22:48:44 +09:00
eomsangdon 9dfb9d61e6 Merge remote-tracking branch 'origin/dev' into sub_laptop_7
# Conflicts:
#	M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts
2026-09-28 22:45:08 +09:00
eomsangdon 2700746110 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-09-28 22:40:26 +09:00
eomsangdon 3a508ac1e1 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-09-28 22:40:23 +09:00
eomsangdonandClaude Opus 5.5 ee0475d0a4 fix(ui): 공용 확인 창 닫힐 때 초점을 연 자리로 되돌림 · Esc 로 취소
- showConfirmDialog: 열기 전 초점을 기억해 취소 · 확인 · Esc 로 닫힐 때 그 요소가 아직 화면에 있으면 되돌림
- 열리면 [취소]에 초점 — 뒤 칸에 글이 들어가지 않고 Esc 가 먹음 · Esc 는 확인 창만 닫음(뒤 창 · 페이지로 안 넘김)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 22:38:37 +09:00
eomsangdonandClaude Opus 5.5 96b7409411 fix(ui): 입력칸 · 고르기칸 높이 크기마다 한 값 — 같은 줄 위아래 맞춤 · 로직 고르기 창이 자기 모양 파일 직접 불러옴
- 공용 높이 변수(보통 42 · 작은 30) · 입력칸 작은 크기(compact) 더함
- M01 조합 미리 보기 입력칸 작은 크기 · M01 로직 계산 칸 작은 높이
- 서식 창 글꼴 이름 칸 28 로(속성 없는 칸이 규칙에서 빠졌음)
- M02 가 대신 불러오던 M01 로직 고르기 창 모양 걷어냄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 22:35:50 +09:00
eomsangdonandClaude Opus 5.5 cd2690450e feat(spreadsheet): 긴 글이 옆 빈 칸으로 넘쳐 보이게(엑셀)
- 왼쪽 · 일반 맞춤 글은 오른쪽으로 · 오른쪽 맞춤은 왼쪽으로 · 가운데는 양쪽으로 — 옆 칸이 빌 때만
- 비지 않은 칸 · 병합 칸 · 틀 고정 경계에서 멈춤 · 가운데 맞춤이 한쪽만 막히면 그쪽을 잘라 냄
- 줄 바꿈 켠 칸 · 숫자(####) · 오류는 안 넘침 · 덮은 칸 사이 눈금선 감춤 · 고치는 중에는 활성 칸 넘침 감춤
- 폭 계산은 새 파일 spreadsheet_spill.ts(격자 파일이 이미 700 줄 넘어 따로) · 정확한 글 폭은 글 요소
  scrollWidth 를 판마다 한 번에 읽음
- ORCA(M02 면적산출 편집 창) 수치 확인 — 왼 · 오른 · 가운데 넘침 글 잘림 0 · 막힘 · 병합 · 틀 고정 멈춤 ·
  스크롤 · 확대 뒤 칸에 붙어 있음 · 비지 않은 칸과 겹침 0 · 실제 문서 새 그림 겹침 0 · 크기 시험 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYN9gMie8e6pXzSxJNb6Xt
2026-09-28 22:33:55 +09:00
eomsangdon db8d3d02d4 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_2 2026-09-28 22:33:30 +09:00
eomsangdonandClaude Opus 5.5 60246df689 fix(M02): 떠나기 확인 창 취소 뒤 초점을 시트 · 집계표 · 도면으로 되돌림
- 「저장 안 한 고침이 있음」 창에서 취소하면 고른 칸 입력 자리(시트) · 집계표 · 도면 iframe 으로 초점 복귀 — 키보드 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 22:31:10 +09:00
eomsangdonandClaude Opus 5.5 990e8e6733 fix(M02): 좌측 고름 표시가 두 줄 동시에 남던 것 — 열린 것 한 줄만
- 원인: 좌측 목록 줄은 목록만 · 구조물 열 줄은 열 줄만 다시 그려 이전 컨테이너 고름이 남음 · 카드 [도면 수정] 등은 좌측에 안 알림
- 고침: 편집 칸이 무엇을 열든(좌측 · 카드 · 다시 읽기) `onOpened` 로 좌측 `paint` 한 곳을 부름 — 목록 · 열 줄 · 단추를 함께 그림 · 따로 든 고름 상태(`picked` · 안 쓰던 `select`) 지움
- 확인: ORCA 5180 에서 카드 [도면 · 면적산출 · 재료산출 수정] → 다른 컨테이너 · 같은 컨테이너 다른 줄 · 집계표로 돌아옴 17 걸음 모두 고름 줄 1 개(고치기 전 2 개 재현) · 만든 것 없음 · 탭 닫음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zGnt85s95baQ72rhKBCJ4
2026-09-28 22:28:15 +09:00
eomsangdon 5f500ab8b9 Merge remote-tracking branch 'origin/dev' into sub_laptop_7
# Conflicts:
#	M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts
#	M02_MasterTemplete/M02_MasterTemplete_Branch.py
2026-09-28 22:28:01 +09:00
eomsangdon e5118855e2 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_7 2026-09-28 22:25:53 +09:00
eomsangdon ff1436c85b Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_7 2026-09-28 22:25:52 +09:00
eomsangdon 59c394efaf Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_7 2026-09-28 22:25:51 +09:00
eomsangdon fe8136fb3f Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_7 2026-09-28 22:25:50 +09:00
eomsangdonandClaude Opus 5.5 4abfc5b265 refactor(M02): 자재 조합 표를 마스터 템플릿으로 옮김 · M02 좌측 「자재 조합」 컨테이너
- 표 resources/master_data/자재조합.json → resources/master_template/자재조합.json(git mv) · 키 UM 은 표 안 「다음」 번호 · _키대장 · master_keys 의 UM 뺌 · check_master 틀이 새 자리를 같이 봄
- 서버: M01 /api/m01/matset* 뺌 · M02 저장소 M02_MasterTemplete_Matset.py(목록 · 새로 · 고치기 · 지우기 · 쓰는 열 검사) · 길 GET/POST /api/m02/material-combos · PUT/DELETE /api/m02/material-combos/{키} · 갈라 내기(branchMaterialCombo)도 이 저장소로
- 화면: M01 좌측 「자재 조합」 컨테이너 뺌 · M02 좌측 「자재 조합」 컨테이너(찾기 · 키 맨 앞 목록 · 새로 · 본떠 만들기 · 지우기) · 메인 칸 상세(이름 · 비고 · 본뜬키 · 담은 자재 표 · 자재 더하기 · 빼기 · 저장) · 쓰는 열이 있으면 지우기는 확인 창 없이 알림
- 시험: test_m01_matset → test_m02_matset · test_m02_combo_branch 사본 자리 · 실제 자재 코드

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwJQ5eRrCKf3nH9EFeKvJz
2026-09-28 22:25:30 +09:00
eomsangdon 5932f5d260 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_8 2026-09-28 22:24:26 +09:00
eomsangdonandClaude Opus 5.5 e4e2dc2aac fix(M02): 조합 갈라 내기 같은 내용 판정에 차례 · 메모 포함 · 서버 거절 까닭 그대로 알림
- 일위대가 조합: 담은 로직 차례 · 메모까지 같아야 있는 키(원래 키 먼저) — 차례 · 메모만 바꿔도 새 조합
- 자재 조합: 담은 자재 차례까지 비교
- M02 Api_Fetch: detail 문장 · errors 목록 · 입력 검사 목록을 알림 글로(HTTP 422 대신)
- 새 조합 자동 이름: 원래 이름 끝 「 (n)」 뗌(겹친 (2) (2) 막음)
- test_m02_combo_branch.py 새 규칙으로 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 22:24:08 +09:00
eomsangdon 513cebe023 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_5 2026-09-28 22:21:46 +09:00
eomsangdonandClaude Opus 5.5 52d441e3a9 fix(spreadsheet): 칸 다루기 전수 점검 흠 11 고침 — 고름 테두리 · 채우기 손잡이 · 틀고정 빈 띠 · 입력 형식 · 잘라 붙이기 옮김
- 격자: 고름을 옮겨도 옛 활성 칸 파란 테두리가 남던 것 · 채우기 손잡이가 범위 오른아래가 아니라
  활성 칸 모서리에 있던 것(여러 범위 · 읽기 전용 카드에서는 손잡이 없음) · 틀 고정 뒤 본문 판
  아래 · 오른 끝(틀 고정 크기만큼)이 안 그려져 비던 것(보이는 행열을 틀 고정만큼 밀어 잼) ·
  오류 칸 글자색을 테마 토큰으로(어두운 테마 대비 2.7 → 5.0)
- 입력: `50%` → 0% 형식 · `1,234.5` → #,##0.0 형식을 함께 붙임(엑셀 · 되돌리기 한 번) · 식의
  함수 · 참조 · 참거짓을 대문자로 · 닫는 괄호만 모자라면 채움 · 「식이 덜 끝남」 알림 글
- 클립보드: 잘라낸 칸을 같은 문서에 처음 붙이면 `옮기기` 로 — 그 칸을 가리키던 식도 따라감
  (전에는 식이 0 · 빈 칸을 가리킴) · 그 사이 잘라낸 자리에 새로 쓰면 보통 붙여넣기
- 시험: test_spreadsheet_saved_values.py(면적 · 재료 저장본 102 권 서버 재계산 = 저장 계산값) ·
  d_input 에 형식 · 대문자 · 괄호 규칙 · ORCA M02 면적산출 편집 창 목록 38 항목 재실행 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYN9gMie8e6pXzSxJNb6Xt
2026-09-28 22:20:35 +09:00
eomsangdonandClaude Opus 5.5 26772b87bd fix(B07 CAD·M02): 16장 점검 — 테마 어긋남 · 첫 맞춤 · 불러온 양식 떠나기 · 작도 영역 안내
- CAD 테마: 저장값이 없으면 부모 문서 테마(호스트 기본 밝음)를 따름 — 시스템 설정을 따라 CAD 만 어둡던 것
- 맞춤 뒤 화면을 안 옮겼으면 캔버스 크기가 바뀔 때 다시 맞춤 — 첫 표시 때 iframe 이 늦게 커져 어긋나던 것 · 빈 도면은 원점을 가운데로
- 도면 양식 📂 불러온 뒤 저장 안 하고 떠나면 물음
- 작도 영역 칸: 빈 칸 · 숫자 아님은 그 칸만 짚음
- 화면 검증용 창구에 기준점 · 보기 전용 상태 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qWfH6MMhLwjhtLYnDVUXv
2026-09-28 22:18:59 +09:00
eomsangdon de7032bc3b Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_2 2026-09-28 22:15:35 +09:00
eomsangdonandClaude Opus 5.5 214c55a389 fix(M02): 쓰는 양식 지우기는 확인 창 전에 바로 막음
- 서버 목록이 쓰는 양식(구조물집계표 · B07 도각 · 방위표 · 표지)에 `쓰는곳` 을 실음 — IN_USE 한 벌
- 좌측 [지우기] 가 `쓰는곳` 이 있으면 확인 창 없이 「쓰는 양식이라 못 지움 「이름」 — 쓰는 곳」 알림
- 서버 409 막기(6606ef16)는 그대로 두 겹 · M01 화면은 안 건드림
- 시험: 목록의 `쓰는곳` 대조 · ORCA 5180 에서 방위표 · 구조물집계표는 확인 창 없이 알림, 임시 양식은 확인 창 뒤 지워짐(만든 것 되돌림)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zGnt85s95baQ72rhKBCJ4
2026-09-28 22:14:35 +09:00
eomsangdon a7ffde0247 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-09-28 22:09:35 +09:00
eomsangdonandClaude Opus 5.5 841af0a033 fix(M02): 16장 점검 — 면적산출 · 재료산출 · 조합 고치기 · 알약 · 자재 옆 판 흠 고침
- 조합 고치기 [로직 더하기] 창이 M02 에서 화면 밖(페이지 아래)으로 뜨던 것 — 고르기 창 겉틀 CSS 를 같이 받음
- 조합 고치기 창을 고친 채 닫기 · Esc · 바깥 누르기 — 버릴지 물음(공용 창 틀에 beforeClose 더함)
- 새 조합 이름: 빈 이름 · 60자 넘음은 까닭 글로 막음(서버 422 「HTTP 422」 대신) · Enter 로 만듦 · 자동 이름 겹침 없앰(구조물 이름이 조합 이름을 품으면 하나만)
- 담은 로직 · 담은 자재를 다 빼고 [적용] — 서버 400 대신 미리 까닭 알림
- 옆 판에서 이은 자재 [빼기] — 확인 글대로 연결을 바로 풂(전엔 적용 전까지 알약이 연결된 채 남음)
- 이미 담은 자재를 또 고르면 알림 · 메모 칸 전체 글을 title 로
- 알약 우클릭 메뉴가 누른 자리에서 부품 원점만큼 밀려 뜨던 것 바로잡음
- 저장 전 새 열(분류 더하기)의 [수정] — 집계표 고침을 버리고 빈 화면으로 가던 것 막고 「집계표를 먼저 저장」 알림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 22:09:24 +09:00
eomsangdon db52cdabae Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-09-28 22:03:52 +09:00
eomsangdonandClaude Opus 5.5 ae47bcd921 fix(M01): 일위대가 조합 화면 점검 흠 고침 — 찾기 · 떠나기 확인 · 본뜬키 칸 · 표 폭 · 글
- 왼쪽 컨테이너에 이름 · 키 찾기 칸 · 거름 · 찾기를 만지면 다른 화면이 떠 있어도 조합 화면으로 · 걸러서 없으면 「찾은 조합 없음」
- 저장 안 한 고침이 있으면 [←] 에서 물음 · 다른 컨테이너 갔다 와도 고침 판정이 안 풀림
- 본뜬키를 다른 칸과 같은 읽기 전용 칸으로 · 표 · 미리 보기가 칸 폭을 채움 · 금액 오른쪽 맞춤 · 긴 이름 풍선
- 로직 고르기 창 열면 찾기 칸에 글쇠 · Enter 로 찾기 · 이름 칸 60자까지 · 서버 검사 글 머리(파일 · 새 키) 뗌 · 지우기 확인에 키 · 이름
- 흐린 안내 글을 보조 글색으로(밝은 테마 대비 3.1 → 4.5 넘김) · 계산 · 저장 규칙은 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 22:03:46 +09:00
eomsangdonandClaude Opus 5.5 f8dda99fc8 fix(ui): 표 도움말 글 대비 3.1 → 6.3(보조 글 색)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vX53nJGC8ucBRFJns2n7B
2026-09-28 22:01:34 +09:00
eomsangdonandClaude Opus 5.5 16de9eed8b fix(M02): 16장 점검 — 지운 열 번호 재사용 막음 · 이름 창 커서 · Enter 만들기 · 바인딩 열 손 입력 막음
- 지운 구조물 열의 도번 8자리를 조합 장부 지운번호에 남겨 새 열이 다시 안 받음(다음 저장에서도)
- 공용 창 틀: 몸의 첫 입력 칸에 커서(붙기 전 focus 가 안 먹던 것)
- 좌측 새로 · 본떠 만들기 이름 창에서 Enter = 만들기
- 표: 바인딩 · 계산 열은 손 입력 열 단추 꺼짐(켜도 프로젝트에서 잠김)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vX53nJGC8ucBRFJns2n7B
2026-09-28 21:56:51 +09:00
eomsangdonandClaude Opus 5.5 6606ef167a fix(M02): 쓰는 양식(구조물집계표 · B07 도각 · 방위표 · 표지) 지우기 막음
- 서버 delete 가 409 「쓰는 양식이라 못 지움」 · 이름은 B07 엔진 상수와 시험으로 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vX53nJGC8ucBRFJns2n7B
2026-09-28 21:35:24 +09:00
eomsangdonandClaude Sonnet 5 527e68db85 fix(ui): 공용 창 틀 뒤를 어둡게 · 창 그림자 · 표시 형식 칸 흐린 글
- ui-modal 뒤 배경을 공용 확인 창처럼 어둡게 가리고 창에 테두리와 테마 그림자(--shadow-lg)를 줌 — 창이 겹치면 앞 창이 또렷이 섬
- M01 LogicLab 창(.m01-logic__backdrop · .m01-logic__pick) 도 같은 겉모양으로 맞춤
- 단추 아래 뜨는 작도 영역 작은 창(.m02-pop)은 뒤를 가리지 않음
- 스프레드시트 도구 막대 표시 형식 입력 칸에 흐린 글 「표시 형식」

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 20:59:06 +09:00
eomsangdonandClaude Sonnet 5 fb5aee7ea2 fix(M02): 옛 알약 연결 이름+단위로 맞춤 · 옆 판 이은 칸 즉시 갱신 · 새 조합 이름 겹침 · 첫 자재 조합 모달 글
- 알약 막대: 키가 목록에 없는 옛 연결(로직 키)을 이름 + 단위가 같은 알약으로 이어 새 키로 바꿈 — 따로 붙던 「연결」 알약 제거
- 재료산출 옆 판: 알약을 이음 · 풂 즉시 「이은 칸」 갱신
- 새 조합 이름: 원래 이름이 구조물 이름으로 끝나면 덧붙이지 않음(일위대가 · 자재)
- 자재 조합이 없는 열의 첫 생성 모달 글: 「이 열에 새 자재 조합을 만듦」

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 20:22:06 +09:00
eomsangdon 453095f6db Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-09-28 19:43:40 +09:00
eomsangdon 8851668024 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-28 19:43:38 +09:00
eomsangdonandClaude Opus 5.5 f528378f2c chore(M02): 사용자 화면 저장분 — 돌기슭막이 구조물 도면 · c4 면적산출 · 재료산출
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYNJBegcapQYMwkKAf8D7S
2026-09-28 19:43:30 +09:00
eomsangdonandClaude Sonnet 5 2d70be32ea fix(spreadsheet): 어두운 테마에서 글자가 검정으로 남던 메뉴 · 팝업을 테마 토큰으로
브레인 지시로 M02 면적산출(SA01010100) · 재료산출(SM01010100) 화면의 메뉴 · 팝업을 ORCA eval 로
전수로 열어 글자색 대 실제 바탕색 대비를 잼(투명이면 조상으로 올라가 겹침 · 4.5 미만 + 어두운
테마에서 흰 바탕 폼 칸 표시). 원인은 color 를 안 준 브라우저 기본 색(button · input · textarea)과
없는 토큰 · 검정 고정.
- spreadsheet.css: 서식 붓 단추(.ss-brush · 서식… 붓 ↑ ↓ 필터 Σ 데이터 ▾) · 찾기/바꾸기 입력 ·
  찾기 단추 · 메모 · 골라 붙여넣기 단추 · 목록 ▼ 단추에 color 를 줌 · 주소 상자(.ss-addr) ·
  수식 입력줄(.ss-formula)은 흰 바탕 · 검정 글이던 것을 투명 바탕 · 글 상속으로
- spreadsheet_links.css: 자재 찾기 입력 바탕 · 글 토큰
- spreadsheet_toolbar.css: 시트 탭 이름 바꾸기 입력 · select 의 option 바탕 · 글 토큰
- spreadsheet_sortfilter.css · spreadsheet_analysis.css · spreadsheet_links.css: 정의 안 된
  --color-surface-sunken(마우스 올림 바탕이 안 나오던 것)을 --color-mist-violet 로(색 줄만 ·
  주인 A · D 파일이라 최소로)
- spreadsheet_sortfilter.css: 정렬 창 select 의 option 바탕 · 글 토큰
- spreadsheet_format_dialog.ts: 글꼴 미리보기 글자색 · 테두리 미리보기 기본 선색이 검정 고정이던 것을
  「자동」(검정 · windowtext · auto)이면 테마 글자색으로(isAutoInk 재사용)
- spreadsheet_data_tools.ts: 텍스트 나누기 · 연속 채우기 창의 select 두 곳에 ss-toolbar__select 를 붙여
  흰 바탕 · 검정 글이던 것을 테마로

ORCA(IP http://100.72.32.27:5181 · 전환 애니메이션 끄고 잼) — 어두운 테마: 기본 화면 · 셀/행 머리/열 머리
우클릭 메뉴 · 데이터 메뉴와 하위 · 조건부 서식 · 유효성 · 이름 관리 · 정렬 · 텍스트 나누기 · 중복 제거 ·
연속 채우기 · 칸 넣기/지우기 · 이동 · 셀 서식 창 여섯 탭 · 찾기/바꾸기 · 수식 편집 + 함수 자동완성 ·
메모 창 · 단위 · 테두리 패널 · 자재 알약 찾기 · 자동 필터 값 목록 · 풍선 · 붙여넣기 선택 · 유효성 목록 ·
오류 ⚠ 메뉴 · 수식 계산 · 알림, SM · SA 두 시트 모두 대비 4.5 미만 · 흰 바탕 폼 칸 0 건(고치기 전에는
서식 붓 계열 7 개가 글 rgb(0,0,0) 대 바탕 rgb(37,31,56) 대비 1.33 등). 밝은 테마도 같은 목록으로
재서 새로 깨진 곳 없음(남은 것은 --color-text-muted 가 밝은 테마 바탕에서 3.08 인 기존 토큰 값).
css 8KB 한도: 가장 큰 spreadsheet_toolbar.css 7432 바이트.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W9YEkMnYQnNnRZRPXoxFsw
2026-09-28 19:42:25 +09:00
eomsangdon 1c371fca85 Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_3' into sub_laptop_6 2026-09-28 19:19:51 +09:00
eomsangdonandClaude Sonnet 5 e06d43a6bc feat(M02): 조합 고치기 · 자재 목록을 실제 갈라 내기 길에 연결 · 알약 자재 항목 = 옆 판 목록 · 줄 누르면 이은 칸으로
- 가정하던 서버 함수를 실제 branchCombo · branchMaterialCombo · fetchMaterialCombo(M02 Api_Fetch)로 바꾸고 가정 파일은 열 조합 읽기만 남김
- 재료산출 알약 막대 자재 항목을 그 열 자재 조합 담은 자재로 · 알약 더하기 · 빼기(onItemsChange)와 옆 판 표를 같은 목록으로 맞춤
- 옆 판 줄을 누르면 이은 알약을 눌러 그 칸으로 이동(깜빡임) · 옆 판에서 고치면 알약 막대만 다시 만듦(시트 고침 유지)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWHNLQYPZN7kKf1rzrrRZq
2026-09-28 19:19:27 +09:00
eomsangdonandClaude Sonnet 5 4c8ee6828f feat(M01): 자재 조합 화면 — 목록 · 상세 · 자재 더하기 · 지우기 알림
- 좌측 「자재 조합」 컨테이너(찾기) + 우측 목록(키 · 이름 · 자재 수 · 본뜬키 · 소유) · 상세(이름 · 비고 · 본뜬키 · 담은 자재 표 · 빼기)
- [자재 더하기] = 자재DB 낱말 찾기 → 하나씩 고름(이미 담은 자재는 잠김) · 저장 · 지우기는 M01 길 /api/m01/matset…
- 쓰는 구조물 열이 있어 지울 수 없으면 서버가 준 구조물 이름들을 알림으로 보임
- 알약 키 「이름|단위」 주석 갱신

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 19:14:10 +09:00
eomsangdon 76bb9aec55 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_2 2026-09-28 19:11:00 +09:00
eomsangdonandClaude Sonnet 5 d35269e32a feat(M01): 자재 조합 M01 길 — 목록 · 하나 · 새로 · 고치기 · 지우기(쓰는 열 있으면 막음)
- /api/m01/matsets · matset · matset/new|edit|delete — sub4 의 자재조합.json(UM) 위에 얹음 · 담은 자재 코드가 마스터에 없으면 422 · 본뜬키는 있는 다른 UM 만
- 지우기 전 집계표 · 구조물집계표조합의 「자재조합」 칸을 훑어 쓰는 열이 있으면 409 + 구조물 이름들
- 일위대가 · 자재 조합 지우기 검사가 users_of 한 곳을 씀 · 계약 9장 「자재 조합」 표 더함
- 시험 다섯(resources/tester/test_m01_matset.py)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 19:08:20 +09:00
eomsangdonandClaude Sonnet 5 625c1012c8 Merge origin/sub_laptop_4 into sub_laptop_3 — 본뜬키 칸 · 자재 조합 표는 sub4 판 기준
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 19:01:22 +09:00
eomsangdonandClaude Sonnet 5 1904cd5072 wip(M01): 자재 조합 M01 길 초안 — 합치기 전 임시(sub4 자재조합 표 위에 얹을 예정)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 19:00:53 +09:00
eomsangdon ea636fb6fc Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-09-28 19:00:13 +09:00
eomsangdonandClaude Opus 5.5 e76e638013 feat(M02): 조합 갈라 내기 길 · 자재 조합(UM) 표 · 본뜬키 칸 · 분류 더하기 열 복사
- POST /api/m02/structures/{열}/combo — 고친 담은 로직으로 새 UA(같은 로직 집합이면 있는 키) · 집계표 그 열만 새 키 · 판 어긋나면 조합 만들기 전 409 · SS 장부 맞춤
- 일위대가 조합 줄에 본뜬키 칸(원래 키) · 기존 37 줄 null · M01 고치기에서 안 보내도 유지
- 자재 조합 표 resources/master_data/자재조합.json(키 UM · 대장 등록 · 틀 검사) · POST /structures/{열}/material-combo · GET /material-combos/{키}
- 구조물집계표조합 칸 자재조합 · 분류 더하기로 복사한 열은 저장 때 원본 열의 일위대가 · 자재조합 키와 구조물 도면 · 면적산출 · 재료산출 내용을 새 번호로 받음
- Api_Fetch branchCombo · branchMaterialCombo · fetchMaterialCombo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vX53nJGC8ucBRFJns2n7B
2026-09-28 18:56:51 +09:00
eomsangdonandClaude Sonnet 5 0fb69c053b feat(M01): 일위대가 조합에 칸 「본뜬키」 더함 · 목록 · 상세에 보임 (계약 더함)
- 열 한 벌에 「본뜬키」(소유 다음 · 비고 앞) — 비면 원본 · 있으면 본뜬 원본 조합 키
- 서버가 없는 키 · 자기 키를 400 으로 막고 검사(check_master)도 같은 규칙
- 정본 조합 37 줄에 본뜬키 null 채움 · 목록 「본뜬키」 열 · 상세 「원본 / 본뜬키」 줄
- _틀.md 10장 · _화면_계약.md 9장 갱신 · 시험 하나 더함

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 18:56:28 +09:00
eomsangdonandClaude Sonnet 5 0c3b089719 feat(M02): 면적산출 조합 줄 · 조합 고치기 · 재료산출 자재 목록 옆 판
- 면적산출 편집 화면 알약 막대 위 한 줄(일위대가 키 · 이름 · 담은 로직 수 · 같이 쓰는 다른 열 수 · 본뜬키) + [조합 고치기](시스템 관리자만)
- 담은 로직 표(더하기 = M01 로직 찾기 · 빼기 · 차례 · 메모) → [적용] 때 갈라 내기 모달(이름 자동 미리 채움) → 새 조합 알림 → 알약만 다시 받음(시트 고침 유지)
- 재료산출 엑셀 옆 접히는 판에 자재 조합 담은 자재 표 · 더하기 · 빼기(이은 칸이면 연결도 풂 확인) · [적용] 모달
- 서버 길(branchCombo · branchMaterialCombo · 자재 조합 읽기)은 아직 안 올라와 M02_MasterTemplete_Combo_Api.ts 한 곳에 가정 모양으로 둠

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWHNLQYPZN7kKf1rzrrRZq
2026-09-28 18:51:03 +09:00
eomsangdon 820a44f79d Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_3 2026-09-28 18:46:26 +09:00
eomsangdon 9d24cb04b9 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-09-28 18:46:24 +09:00
eomsangdonandClaude Sonnet 5 b6e71c729d feat(M01): 조합 알약 키 「이름|단위」 · 쓰는 구조물 열이 있으면 조합 지우기 막음
- 알약 키를 첫 로직 코드에서 「이름|단위」 로 — 로직을 빼고 더하고 차례를 바꿔도 같은 물량이면 같은 키
- 조합 지우기 전 집계표 · 구조물집계표조합의 「일위대가」 칸을 훑어 쓰는 열이 있으면 409 + 구조물 이름들
- 시험 셋 더함(키 안 바뀜 · 쓰는 조합 못 지움 · 안 쓰는 조합 지워짐)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 18:46:12 +09:00
eomsangdon c32e2cbd5d auto: 2026-09-28 18:46 (ESD_LAPTOP) 2026-09-28 18:46:00 +09:00
eomsangdonandClaude Sonnet 5 ff0d9775ec feat(ui): 공용 단추 작은 크기 size sm 추가 · M01 표 단추 · M02 단추 적용 · M02 상세 머리 이름 줄바꿈
- createButton 에 size: "sm"(글자 캡션 크기 · 위아래 여백 2px) — 다섯 모양 모두에 먹음 · 기본은 그대로
- M01 표 안 단추는 sm 으로 바꾸고 M01 자체 축소 규칙 삭제
- M02 좌측 새로 · 본떠 만들기 · 지우기 · 카드 수정 단추에 sm
- M02 상세 화면 머리 번호 + 이름 잘림 없이 줄바꿈 · 집계표 카드 머리는 번호 · 수정 한 줄 + 이름 아래 전체 폭

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWHNLQYPZN7kKf1rzrrRZq
2026-09-28 18:42:23 +09:00
eomsangdon 3f39b5b6c3 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_4' into main_laptop_1 2026-09-28 17:15:21 +09:00
eomsangdonandClaude Sonnet 5 00b08bf152 fix(M02): 구조물 도면에서 작도 영역 단추 · 자리표 표시 제거
- 구조물 도면(집계표 도면 정보 미리보기 · 도면 수정 편집)은 도각이 없어 작도 영역이 필요 없음
- 저장 문서의 작도 영역 값은 읽지도 쓰지도 않음
- 도면 양식(A1 · 표지 등) 편집은 그대로

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qWfH6MMhLwjhtLYnDVUXv
2026-09-28 17:13:23 +09:00
eomsangdonandClaude Sonnet 5 6198fad176 feat(M02): 좌측 새로 · 본떠 만들기 · 지우기를 표 양식 · 도면 양식 컨테이너 안으로 · 본떠 만들기 흠 고침
- 맨 아래 단추 줄 없앰 · 표 양식 · 도면 양식 컨테이너마다 [새로] [본떠 만들기] [지우기] · 새로 = 그 종류로 이름만 · 본떠 · 지우기 = 그 컨테이너에서 고른 양식이 있을 때만 켜짐 · 구조물 도면 · 면적산출 · 재료산출 컨테이너는 단추 없음 · 시스템 관리자만
- 본떠 만들기 흠: 표 복사본의 머리 이름(양식)이 원본 이름으로 남던 것을 새 이름으로
- 이름 규칙: 80자 넘는 이름이 서버 500 이던 것 · 앞 밑줄 이름이 새 프로젝트 복사를 깨던 것을 서버 · 화면 둘 다 막음
- 시험: 복사 독립 · 새 프로젝트 복사 · 이름 규칙 · 컨테이너 안 단추

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vX53nJGC8ucBRFJns2n7B
2026-09-28 17:08:53 +09:00
eomsangdon 73829e7bde Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-28 17:01:07 +09:00
eomsangdon 4ea695907e auto: 2026-09-28 17:00 (ESD_LAPTOP) 2026-09-28 17:00:57 +09:00
eomsangdonandClaude Sonnet 5 c4735e4669 feat(M02): 집계표 아래 카드 — 표 아래 남은 창 높이만큼만 차지(메인 화면 세로 스크롤 없음) · 세 카드 같은 높이 · 최소 16rem 에서 멈추고 그때만 스크롤
- 집계표 카드가 보이면 메인 칸 = 창 높이 · 카드 영역 flex 로 남은 높이 · 카드 본문이 높이를 채움 · 표는 줄지 않음

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWHNLQYPZN7kKf1rzrrRZq
2026-09-28 16:48:14 +09:00
eomsangdon f85aa9136a Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-28 16:38:48 +09:00
eomsangdon f1e0809d6c auto: 2026-09-28 16:38 (ESD_LAPTOP) 2026-09-28 16:38:10 +09:00
eomsangdon 2a0f359c7c Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-28 16:30:24 +09:00
eomsangdonandClaude Opus 5.5 32c3081b68 fix(B07 CAD·M02): 가운데 단추 끌기 때 페이지가 같이 움직이던 것 · 집계표 미리보기는 보기 전용 조작
- 캔버스 가운데 단추 mousedown · auxclick 의 기본 동작(자동 스크롤 · 붙여넣기)을 막음 — 휠은 이미 passive:false 로 막음.
  pointerdown 은 막지 않음(막으면 mousedown 이 안 와 팬이 끊김).
- 미리보기(viewOnly — M02 구조물집계표 「도면 정보」 카드): 왼쪽 끌기 = 화면 옮기기 · 휠 확대 ·
  두 번 누름 = 전체 맞춤 · 선택 · 가까운 개체 강조 · 스냅 · 십자 커서 · 명령 안내 · 좌표 풍선 없음(손 모양 커서).
  [도면 수정] 편집 화면 · 도면 양식 · B07 은 그대로.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 16:27:55 +09:00
eomsangdon a9ad227068 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_5 2026-09-28 16:27:15 +09:00
eomsangdonandClaude Sonnet 5 c8c0698c91 fix(ui_template): 펼친 좌측 패널 손잡이를 패널 안쪽 가장자리로
- 손잡이가 패널 밖 24px 로 나와 본문 카드 왼쪽 끝을 덮던 조각 제거
- 펼침: 폭 16px · 패널 오른쪽 안쪽 · 접힘: 지금처럼 화면 왼쪽 끝
- M01 · M02 · B03~B09 손잡이 오른쪽 끝 319 · 본문 시작 320 이상(겹침 0)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-28 16:22:23 +09:00
eomsangdon 14c4a507d6 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-28 16:15:27 +09:00
eomsangdonandClaude Opus 5.5 44a6ec51db fix(B07 CAD): 어두운 테마에서 검정 선이 안 보이던 것 — 자동색(흰/검정)
검정 · 흰색(DXF 색 7) · 기본색(#f5f7fa = 새 개체 · DXF ByLayer)은 오토캐드처럼 자동색으로 보아
어두운 바탕에서는 흰색 · 밝은 바탕에서는 검정으로 그림(그릴 때만 · 저장 값은 그대로).
다른 색은 그대로 · 어두운 바탕에서 어두운 색을 밝히지 않음 · 밝은 바탕의 밝은 색 대비 보정은 기존대로.
좌표 입력 칸 바탕도 자동색(검정)으로 바꿔 두 테마에서 글이 읽힘.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 16:14:22 +09:00
eomsangdonandClaude Opus 5.5 6c0014a430 feat(M02): 구조물집계표조합 표 — 구조물 열마다 한 줄 · 키 SS · 집계표 [저장] 때 서버가 맞춤
- resources/master_template/구조물집계표조합.json (그룹 · 판 · 비고 · 다음 · 줄) · 지금 열 51개로 처음 만듦(SS000001~SS000051)
- 줄 칸: 키 · 열 · 종류 · 분류1~3 · 단위 · 일위대가 · 도번 · 면적번 · 재료번 · 바인딩 · 펼침 · 꼴=수 열만
- 새 열 = 새 SS 키 · 지운 열 = 줄 지움(키 다시 안 씀) · 고친 열 = 칸 갱신 · 새 프로젝트 복사에도 실림
- GET /api/m02/structure-combos · Api_Fetch fetchStructureCombos()
- 시험: 견본 표로 키 유지 · 새 열 · 지운 열 · 글 열 제외 · 키대장 머리글과 안 겹침 · 프로젝트 복사

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 16:12:12 +09:00
eomsangdon 165901d58d Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_4 2026-09-28 16:09:19 +09:00
eomsangdonandClaude Opus 5.5 eed7ffaf42 feat(cad): 구조물 도면 미리보기 좌측 패널은 접힌 채로 엶 · 리본 접힘 모드에서 탭으로 편 리본은 밖을 누르면 다시 접힘
- M02 미리보기: 특성 · 도면층 패널을 숨기던 스타일에서 .cad-inspector 뺌 · load 곁값 panelsCollapsed: true 로 접힌 채 시작(› 누르면 펼침)
- cad_host CadLoadExtra · 다리에 panelsCollapsed 칸 · B07 은 안 보냄 = 지금처럼 펼침
- 좌측 패널 접힘 상태를 ui-state 로 옮김
- 리본: 접힘 모드에서 탭 = 도면 위에 겹쳐 잠깐 펼침 · 리본 밖 누름 = 다시 접힘 · 접기 단추 = 고정 펼침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 16:08:57 +09:00
eomsangdonandClaude Sonnet 5 15715c2bda feat(spreadsheet): C 격자 — 엑셀 「자동」 색(검정 글 · 검정 선 · 흰 채움)이 테마를 따름
엑셀에서 붙여넣은 칸의 글자색 #000000 · 테두리색 검정이 저장돼 어두운 테마에서 검정 글이
안 보이던 문제. 그릴 때만 「자동」으로 봄(저장 값은 그대로).
- 글자색 · 테두리색: #000 · #000000 · #FF000000 · rgb(0,0,0) · black · windowtext · auto →
  테마 글자색(--aislo-grid-fg) 을 따름
- 채움: #fff · #ffffff · white · window → 채움 없음(테마 바탕이 비침)
- 다른 색(#c00000 · #FFFF00 · #1F3864 등)과 조건부 서식 색은 그대로
- 테두리 기본색을 currentColor 에서 --aislo-grid-fg 로 — 글자색이 빨강이어도 선은 자동색
- 숫자 형식 [Black] 색도 자동으로 봄

ORCA(시험 틀 · localhost:5181 과 100.72.32.27:5181 둘 다) — 밝은: 자동 칸 글 rgb(51,51,51) ·
바탕 rgb(255,255,255) · 선 rgb(51,51,51). 어두운: 글 rgb(228,224,240) · 바탕 rgb(37,31,56) ·
선 rgb(228,224,240). 지정색 칸(#c00000 글 · #FFFF00 채움 · #0000FF 선 · #1F3864 · #D9E1F2)은 두
테마에서 값 그대로. 붙여넣기 쪽은 안 바꿔도 됨(저장 값 그대로 두고 그릴 때만).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-28 16:01:21 +09:00
eomsangdon fa572cd0b7 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_2' into main_laptop_1 2026-09-28 15:47:42 +09:00
eomsangdonandClaude Sonnet 5 ac83e1b550 feat(M02): 집계표 아래 카드 — 도면 정보 · 면적산출 · 재료산출 3열 같은 폭 · 카드 최소 300px 아래로 좁으면 2열 → 1열 자동
- grid auto-fit/minmax 한 규칙 · 도면 정보 전체 폭 고정 제거 · 높이는 통일 변수 그대로

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 15:37:58 +09:00
eomsangdonandClaude Opus 5.5 ec41d50d92 fix(A00·B07 CAD): http + IP 주소(Tailscale)에서 CAD 가 죽던 것 — crypto.randomUUID 대체
보안 문맥이 아닌 http + IP 에서는 crypto.randomUUID 가 없어 CAD 개체 만들기에서 멈춤.
앱 본체(A00_Common/main.ts)와 CAD(openwebcad/src/main.tsx) 진입 맨 앞에서
getRandomValues 로 v4 UUID 를 채움 · 이미 있으면 그대로.
crypto.subtle(B03 지문) · serviceWorker(B03) 는 이미 없을 때 건너뜀 · navigator.clipboard 는 안 씀.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 15:37:22 +09:00
eomsangdonandClaude Sonnet 5 fedc860365 feat(M02): 집계표 아래 카드 — 도면 정보 CAD 높이 = 면적산출 · 재료산출 표 높이(한 값 · 창 높이에 맞춰 함께 변함)
- 세 카드 내용 높이를 CSS 변수 하나(clamp 18rem · 60vh · 40rem)로 통일 · 한 줄씩일 때도 같음

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 15:36:05 +09:00
eomsangdonandClaude Sonnet 5 93c817658a feat(M02): 집계표 아래 카드 — 창 크기에 맞춰 자동 분할(두 열 최대 · 카드 최소 360px) · 카드 높이도 창 높이에 맞춤
- 칸 폭 728px 이상이면 면적산출 · 재료산출 반반 · 그보다 좁으면 한 줄에 하나씩 · 창 크기를 바꾸면 바로 반영
- 미리보기 · 표 높이를 창 높이 비율(clamp)로 · 작은 창에서 카드가 화면을 다 먹지 않게

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 15:28:49 +09:00
eomsangdonandClaude Sonnet 5 73040246ae feat(M02): 집계표 아래 카드 — 면적산출 · 재료산출 두 카드 + 간격이 도면 정보 폭과 같음 · 카드 최소 폭 520px
- grid auto-fit/minmax 한 규칙 · 카드 하나가 520px 안 되면 한 줄에 하나씩(각각 도면 정보와 같은 전체 폭)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 15:13:12 +09:00
eomsangdonandClaude Opus 5.5 c608b9554f test(M02): 구조물 번호 시험이 실제 구조물집계표 대신 견본 표를 읽음
- 사용자가 화면에서 제목 · 열을 고쳐도 번호 시험이 안 밀림 · 번호 규칙 시험은 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 14:52:52 +09:00
eomsangdonandClaude Opus 5.5 ec8812e829 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 — 사용자 열 c2~c5 살림
- 구조물집계표.json 충돌: 이쪽 4칸 제목(분류 없는 열 빈칸)을 바탕으로 사용자 열 c2~c5 를 사용자 자리 · 값 그대로 넣음 · rv · c1 제목 · 열 너비(h_soil_ditch)도 사용자 것
- 번호: c2 SD15010000 · c3 SD02010600 · c4 SD01010101 · c5 SD01020000(Store 규칙과 맞음 · 파일 셋씩 · 셋 같은 8자리)
- area/rv.json 사용자 것 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 14:43:57 +09:00
eomsangdon 7b62305e12 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-09-28 14:41:14 +09:00
eomsangdon 2388d02da9 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-28 14:41:13 +09:00
eomsangdon 3660924f80 auto: 2026-09-28 14:40 (ESD_LAPTOP) 2026-09-28 14:40:18 +09:00
eomsangdonandClaude Opus 5.5 789b62be9d fix(M02): 집계표 [저장] 뒤 새 열 도면 정보가 안 열리던 것 — 번호 다시 받고 카드 새로
- 원인: 새 열은 [저장] 때 서버가 구조물 · 면적산출 · 재료산출 파일과 번호를 만드는데 화면은 번호 목록을 처음 한 번만 받음 · 같은 열을 다시 고르면 카드를 안 다시 읽어 「도면 없음」 · 번호 빈칸이 다시 열 때까지 남음
- [저장] 뒤 번호 다시 받기 · 도면 · 면적산출 · 재료산출 줄 다시 그림 · 보던 열 카드 다시 읽음
- 같은 열을 다시 고르면 이름만 새로(머리 고친 열)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 14:39:39 +09:00
eomsangdonandClaude Opus 5.5 ccd9bc89c7 feat(M02): 집계표 아래 카드 — 도면 정보 한 줄 전체 · 면적산출 · 재료산출 나란히 두 열
- 좁은 화면(카드 폭 30rem 안 됨)에서는 한 열

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 14:29:20 +09:00
eomsangdonandClaude Opus 5.5 3869852d3d chore(tools): 오토캐드 WEBCLIP LISP 지움 — 별도 설치 없이 WBLOCK·DXFOUT 파일 끌어다 놓기로 바꿈
끌어다 놓기 길(서버 /api/projects/dxf-clip/file · CAD 흐린 테두리 · 안내 글)은 8ebb5532(auto:)에 함께 올라감.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 14:27:56 +09:00
eomsangdon 1d35254c90 Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_2 2026-09-28 14:26:15 +09:00
eomsangdon 17e0e92604 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-09-28 14:26:14 +09:00
eomsangdonandClaude Opus 5.5 f33fa9dac0 feat(M02): 구조물집계표 분류 없는 열도 제목 4칸(종류 · 분류1 · 분류2 · 분류3)
- 소단 · 대피소 · 폐기물처리 등 머리 한 칸 열 · 세월교 · 골막이 · 기타 · 돌붙임 — 세로로 합치던 빈 층을 각각 빈칸으로(빈값 = 분류 없음)
- 글 열(NO · 측점 · 비고)은 그대로 합침 · 번호(도번 · 면적번 · 재료번)는 빈칸도 00 으로 읽어 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 14:26:06 +09:00
eomsangdonandClaude Sonnet 5 a927ae64bc feat(spreadsheet): 엑셀 수식 복사 안내 · 값만 온 붙여넣기 세션 알림
- 도구 모음 오른쪽 끝에 흐린 글씨 안내 추가 — 「엑셀 수식까지: 엑셀에서 Ctrl+`
  → 복사 → 여기 Ctrl+V · 끝나면 엑셀 Ctrl+` 다시」 · 누르면 왜 그런지 두세 줄
  설명(엑셀 Ctrl+` = 「수식 표시」 켜기 · 그 상태로 복사해야 수식이 실림 · 그냥
  복사하면 값만 옴 · 붙인 뒤 엑셀에서 Ctrl+` 다시 눌러 원래대로)
- 도구 모음은 readOnly 면 안 붙는 기존 길을 그대로 써 보기 전용 카드에서는
  자동으로 숨음(새 분기 없음)
- 엑셀 HTML 붙여넣기(출처: "excel")인데 뭉치 전체에 식이 하나도 없으면 세션에
  한 번만 토스트 — 「값만 들어왔음 — 수식까지는 엑셀 Ctrl+` 뒤 복사」
  (sessionStorage 로 한 번 표시 기억 · 못 쓰면 매번 뜰 수 있으나 치명적이지 않음)
- ORCA 로 확인 — 안내 글 그대로 보임 · 눌러 설명 펼침/접힘 · 식 없는 엑셀
  붙여넣기 첫 번은 토스트 뜨고 세션 안 지운 채 두 번째는 안 뜸 · 식이 있으면
  (「=A1+B1」) 애초에 안 뜸 · 기존 클립보드 회귀 시험(TSV) 통과

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-28 14:24:04 +09:00
eomsangdon 8ebb553259 auto: 2026-09-28 14:23 (ESD_LAPTOP) 2026-09-28 14:23:39 +09:00
eomsangdon 1b229fa4a7 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_8' into main_laptop_1 2026-09-28 13:58:09 +09:00
eomsangdonandClaude Sonnet 5 c5ad429dc3 fix(spreadsheet): 회계 형식 따옴표 CSS 유니코드 이스케이프(\0022) 안 풀리던 것 고침
- 사용자 실제 엑셀 견본(resources/tester/spreadsheet/fixture_excel_user_won.html,
  origin/main_desktop_1)으로 붙여넣기 시험 중 회계류 형식(예: `.xl126`)의
  `\0022` — 진짜 CSS 유니코드 이스케이프(16진 1~6 자리, 따옴표 `"` 를 이렇게
  실음) — 가 한 글자만 풀리고(`\0` → `0`) 나머지 `022` 가 그대로 남아 형식에
  글자 "0022" 가 끼던 것 발견 · `unescapeMsoNumFmt` 가 `\x` 한 글자 이스케이프
  앞에 `\` + 16진 1~6 자리 이스케이프를 먼저 찾게 고침
- 고친 뒤 회계 4구역 형식이 `_-* #,##0.00_-;\-* #,##0.00_-;_-* "-"??_-;_-@_-`
  처럼 온전히 들어옴(글자 "0022" 없음 · 남는 홑 역슬래시 없음 · ₩ 없음)
- ORCA 로 이 견본 통째 붙여넣기 확인 — 칸 133개 값 · 병합 9개 · Fixed·0·General·
  0% 이름 형식 그대로 넘김 · 「 =」(앞에 공백 있어 원래도 식 아님) · 「1식」·
  「3식」 글자 칸 모두 글로 남음(식으로 안 잡힘), 기존 회계 형식 견본 회귀 없음
- 수식 흔적(x:fmla) 없음 재확인 — 이 실 견본에도 x:str/x:num 만 있고 수식은
  칸 글 자체가 「=...」 로 시작하는 형태(HTML 클립보드엔 수식이 안 실림)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-28 13:45:41 +09:00
eomsangdonandClaude Opus 5.5 5fdd5f8a4e feat(tools): 오토캐드 WEBCLIP 명령(AutoLISP)
고른 개체만 DXFOUT(ASCII) → %TEMP%\aislo_webclip.dxf → PowerShell Set-Clipboard 로
파일 글을 클립보드 글로(LISP 로 읽으면 UTF-8 한글이 깨져서) → 명령줄에 「N개 복사됨 ·
웹 CAD 에서 Ctrl+V」. 설치(APPLOAD · 시작하기 세트)는 파일 머리 주석.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 13:44:48 +09:00
eomsangdonandClaude Opus 5.5 e3a9cb65d0 feat(B07 CAD): Ctrl+V 로 오토캐드 DXF 붙여넣기 · 오른쪽 위 WEBCLIP 안내
Ctrl+V 기본 동작을 막지 않아 paste 이벤트로 시스템 클립보드 글을 읽음 — DXF 면 서버에서
도형으로 바꿔 원좌표 그대로 붙이고 고른 상태로 둠(같은 이름 층은 기존 층 · 없는 층은 DXF
층 색으로 새로 · id 는 모두 새로). DXF 가 아니면 앱 안 클립보드 붙여넣기(PASTECLIP).
볼 수만 있는 도면은 안 붙임. 캔버스 오른쪽 위 흐린 안내 「오토캐드에서 WEBCLIP → 여기서
Ctrl+V」 · 누르면 설치 한 줄(보기 전용이면 숨김 · B07 · M02 양식 둘 다).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 13:44:48 +09:00
eomsangdonandClaude Opus 5.5 be4f3d33ac feat(B07): 클립보드 DXF 글을 웹 CAD 도형으로 바꾸는 서버 길
오토캐드 WEBCLIP 이 클립보드에 넣은 DXF 글을 받는 POST /api/projects/dxf-clip 추가
(프로젝트 무관 · M02 양식도 씀 · 저장 안 함). 변환은 기존 도각 불러오기 엔진을 그대로 쓰고
dxf_text_to_entities 만 더함 — 엔티티 layerId 는 DXF 도면층 이름 그대로 · 쓰인 층 이름과
층 색을 함께 돌려줌. 시험 = 견본 DXF(선 · 폴리선 · 원 · 글 · 도면층 둘) 3건.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 13:44:47 +09:00
eomsangdon 289daf899a Merge remote-tracking branches 'origin/main_desktop_1' and 'origin/sub_laptop_5' into sub_laptop_8 2026-09-28 13:41:03 +09:00
eomsangdon fa01245b8b Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-09-28 13:41:02 +09:00
eomsangdonandClaude Opus 5.5 8739027872 test(spreadsheet): 엑셀 칸 범위 복사 원본 fixture(HTML·글)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cTe8U3dnigRfEmiscHHpp
2026-09-28 13:39:56 +09:00
eomsangdonandClaude Sonnet 5 41a717f62b fix(spreadsheet): 형식 끝·구역 나눔 앞 홀로 남은 역슬래시가 ₩ 로 보이던 것 고침
브레인이 증거로 찾은 rv.json(사용자 붙여넣기 저장분)의 형식 문자열이 끝에
역슬래시 하나만 남아(`_-* #,##0.00_-\` 등) 있었음 — 엑셀 mso-number-format
의 `\;`(구역 나눔 이스케이프) 자르기가 깨진 자투리. numfmt 가 이 홀로 남은
역슬래시를 글자로 찍었고 굴림 등 한글 글꼴은 역슬래시(0x5C)를 ₩ 모양으로
그려 사용자 화면에 ₩ 가 붙어 보였음. 세 자리(splitSections · tokenize ·
detectDateFormat)에서 ① 형식 끝에 다음 글자가 없는 역슬래시 ② 구역 나눔
`;` 바로 앞 역슬래시를 모두 버림(뒤 `;` 는 그대로 나눔으로 처리)으로 고침.
이미 저장된 rv.json 의 13 곳도 자투리 역슬래시를 지움. 사용자 견본값
2.20 · 1.70 · -6.82 로 회귀 시험 세 건 더함 — 시험 196건 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-28 13:39:50 +09:00
eomsangdonandClaude Opus 5.5 137796158b chore: 남은 엑셀 잠금 파일 2개 저장소에서 뺌
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cTe8U3dnigRfEmiscHHpp
2026-09-28 13:37:41 +09:00
eomsangdonandClaude Sonnet 5 e0f9df9ba9 fix(spreadsheet): 엑셀 회계 숫자 형식 끝 역슬래시 남던 것 고침
- mso-number-format 처럼 값 안에 이스케이프된 `\;`(구역 나눔 자리표시, 회계·통화
  형식처럼 구역이 여럿인 형식에 흔함)를 선언 나눔 `parseStyleAttr` 가 진짜 `;`
  로 착각해 값을 그 자리서 잘라 버리던 것 고침 — `\` 뒤 글자는 통째로 건너뛰고
  이스케이프 안 된 `;` 에서만 나누는 `splitDecls` 로 바꿈
- 잘리면 값 끝에 역슬래시 하나만 남고 뒤 구역이 통째로 날아가던 것(사용자 저장본
  material/rv.json 서식 형식 끝 '\' 로 나타남)이었음 — 고친 뒤 4구역 회계 형식
  (양수·음수·0·글자)이 통째로 살아 들어옴
- resources/tester/spreadsheet/fixture_excel_accounting_fmt.html 새 견본으로
  ORCA 실 엔진 확인 — 형식이 `_-* #,##0.00_-;-* #,##0.00_-;_-* "-"??_-;_-@_-`
  로 온전히 들어옴(역슬래시 없음), 기존 fixture_excel_style_paste.html 회귀
  없음 확인
- 수식 흔적(x:fmla) 확인 — 기존 견본엔 x:str/x:num 만 있고 x:fmla 없음(수식 칸도
  x:str 이 `=...` 글 그대로 담아 기존 「=」 앞머리 잡기로 이미 됨). 사용자 실제
  클립보드 견본(main_desktop_1)은 아직 안 옴 — 오면 그걸로 다시 봄

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-28 13:35:18 +09:00
eomsangdonandClaude Opus 5.5 b98fbccdd0 fix(spreadsheet): 크기 예산 합 128 KB — 엔진 48 · 화면 72 · css 8 KB(사용자 지시)
- 계약 머리 주석 · 크기 시험 숫자 같이 올림 · 지금 크기 엔진 31.5 · 화면 28.8 · css 2.9 KB(합 63.2 KB)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 13:30:50 +09:00
eomsangdon 91cc795102 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_5' into sub_laptop_2 2026-09-28 13:29:02 +09:00
eomsangdon 7eacf332ee auto: 2026-09-28 13:28 (ESD_LAPTOP) 2026-09-28 13:28:17 +09:00
eomsangdonandClaude Sonnet 5 c4edc751be fix(spreadsheet): 회계 0 구역 '?' 자리 빈칸 처리 · 통화 로캘 태그 대조 확인
브레인이 짐작한 [$-412](로캘만 · 기호 없음) 이 통화 기호로 잘못 바뀌는지
여러 실제 꼴(단순 태그·회계 네 구역·접두/접미 위치)로 대조해 봤으나 지금
코드는 이미 옳게 기호를 안 보임(회귀 시험으로 고정). 그 김에 회계 0 구역의
`"-"??` 관용구가 진짜 0 값에서 정수부 `?` 를 「0」 으로 채우던 것을 고쳐
엑셀처럼 빈칸으로 보이게 함. 사용자가 보고한 ₩ 는 이 두 경로로는 재현이
안 돼 — main_desktop_1 의 fixture_excel_user_won.html 이 아직 안 와서
실제 원문으로는 대조 못 함(오면 바로 대조).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-28 13:15:24 +09:00
eomsangdon a268859540 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-28 13:10:12 +09:00
eomsangdonandClaude Opus 5.5 bb0bd97c84 chore: 엑셀 잠금 파일 무시 규칙 더함
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cTe8U3dnigRfEmiscHHpp
2026-09-28 13:10:05 +09:00
eomsangdonandClaude Opus 5.5 d1f627ea31 chore: 엑셀 잠금 파일 저장소에서 뺌
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cTe8U3dnigRfEmiscHHpp
2026-09-28 13:09:46 +09:00
eomsangdonandClaude Opus 5.5 b813de897f fix(M02): 식 없는 계산 열(pv_a · pv_jt · pp_cp)은 빈 값 — 줄눈 간격 줄 식도 열 식이 있을 때만
- 값은 면적산출 알약 연결로 받음(PLAN 13장) · 표 채우기 시험을 새 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 12:47:09 +09:00
eomsangdonandClaude Opus 5.5 023c5838c4 refactor(M02): Api_Fetch 옛 이름 별칭 지움 — copyBasis · 표번 · Kind basis
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 12:47:09 +09:00
eomsangdon 0ca69fbc47 Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_8' into sub_laptop_4 2026-09-28 12:45:10 +09:00
eomsangdonandClaude Opus 5.5 b60c10e73e feat(M02): 집계표 카드 = 도면 정보 · 면적산출 · 재료산출 · 편집 화면 알약 막대
- 집계표 아래 카드 셋(보기 + [수정] → 전체 편집) · 번호 = 도번 · 면적번 · 재료번
- 면적산출 · 재료산출 줄 조회 후 선택 = 다른 열 것 복사해 가져오기(copyArea · copyMaterial · 덮어쓰기 확인)
- 면적산출 · 재료산출은 표(스프레드시트) 편집 화면으로 엶(좌측 줄 · 카드 [수정] 둘 다 · 옛 CAD 로 뜨던 것 고침)
- 알약 막대 — 면적 = 그 열 일위대가 입력값(fetchComboInputs · 없으면 안내 글) · 재료 = 자재DB 찾기(searchMaterials)
- 옛 산출근거 이름(표번 · copyBasis) 안 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 12:16:25 +09:00
eomsangdon 9ae91ea5da Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_2 2026-09-28 12:13:16 +09:00
eomsangdonandClaude Opus 5.5 ba91e9e63d feat(M01): fetchComboInputs · searchMaterials 알약 자료 함수
- fetchComboInputs(조합 코드) → {키, 이름, 물량: [{키, 이름, 단위, 로직, 배?}], 조건}
- searchMaterials(글) = /pick?kind=price 감쌈 → [{코드, 이름, 규격, 단위}]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-28 12:11:49 +09:00
eomsangdonandClaude Opus 5.5 32d73aa2c9 feat(M01): 조합 알약 물량 길 GET /combo/{키}/inputs
- 담은 로직 결과단위 분모(원/㎥ → ㎥)가 물량 하나 · 설계 조건은 안 넣음(조건 자리만)
- 이름 = 로직 이름 끝 괄호 뗀 것 · 같은 이름·단위는 합침 · 원/10㎡ 은 배 10
- 시험 test_m01_combo_inputs.py(UA000004 · 031 · 002 · 없는 조합 404)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-28 12:11:49 +09:00
eomsangdon 14092e715b Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_2 2026-09-28 12:11:09 +09:00
eomsangdon 017d559401 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_2 2026-09-28 12:09:33 +09:00
eomsangdonandClaude Opus 5.5 5de2f75828 test(spreadsheet): D 알약 연결 — 행열 따라가기 · 되돌리기 규칙 시험 · 시험 틀(harness_links)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-28 12:09:33 +09:00
eomsangdonandClaude Opus 5.5 854531cfd8 feat(spreadsheet): D 알약 막대 — createSpreadsheet({links}) · 칸 연결 · 풀기 · 자재 고르기
- 칸을 고른 채 알약을 누르면 연결(흐림 + 주소 · 칸 왼아래 세모) · 흐린 알약은 연결 칸으로 · 우클릭 연결 풀기
- 자재 모드 [자재 더하기] → 자재찾기 창 → 새 알약
- 행열 넣기 · 지우기에 연결 칸 따라감(지운 칸이면 빠짐) · 되돌리기 짝 · getLinkedValues()
- 늦게 불러옴(links 선택 또는 연결 있는 문서만) · 화면 28,518 B(예산 28,672)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-28 12:09:33 +09:00
eomsangdonandClaude Opus 5.5 d3c93b4520 feat(spreadsheet): 계약 더함 — Sheet.연결 · CellLink · LinkMaterial(알약 연결)
- 시트마다 알약 ↔ 칸 연결 목록 · 자재DB 한 줄 모양
- 시트에 둠 — 바꿈 · 행열 지우기 되돌림이 A 의 시트통째 한 길로 돎(칸 주소에 시트 id 불필요)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-28 12:09:33 +09:00
eomsangdonandClaude Opus 5.5 79a0994ffb chore(M02): 구조물 양식 파일 8자리 번호 · 산출근거 → 면적산출 옮김 · 재료산출 47벌 새로
- structure 47 도번 SD+8자리 · basis 47 → area(면적번 SA) · material 47 빈 시트(재료번 SM)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 12:08:57 +09:00
eomsangdonandClaude Opus 5.5 0e5a19eab8 feat(M02): 좌측 구조물 도면 · 면적산출 · 재료산출 줄 · Api_Fetch 새 이름
- copyArea · copyMaterial · fetchStructureNumbers → {도번, 면적번, 재료번} · Kind area · material
- 옛 이름 copyBasis · 표번 · Kind basis 는 sub2 가 옮길 때까지 별칭(@deprecated)
- 좌측 목록 셋 · 줄 글 = 번호 맨 앞 + 이름 + 변수값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 12:08:56 +09:00
eomsangdonandClaude Opus 5.5 89fc686962 feat(M02): 구조물 번호 8자리(분류3) · 면적산출 SA · 재료산출 SM 서버
- 번호 [종류][분류1][분류2][분류3] · 없는 층 00 · 12장 6자리는 끝에 00 을 붙여 읽음
- 양식 종류 basis → area(면적산출) · material(재료산출) 새로 · 집계표 저장 때 세 파일 자동 만들기/지우기
- 복사 길 POST /structures · /area · /material/{받는 열}/copy?from= · 알약 연결은 문서와 같이 복사
- 번호 목록 {열 id: {도번, 면적번, 재료번}} · 저장 때 번호 칸은 서버 것 그대로
- renumber: basis/ → area/ 옮김(표번 → 면적번 · 시트 이름 면적산출) · 빠진 재료산출 만듦

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 12:08:56 +09:00
eomsangdonandClaude Sonnet 5 74b6937bc0 feat(spreadsheet): 도구 모음 단위 기호 골라 넣기
- [단위 ▾] 단추 → 자주 쓰는 기호 판(넓이·부피·길이 · 무게·부피(액체) · 개수 ·
  속도·각도·비율 · 연산·비교로 무리 지음) · 최근 쓴 것 앞(localStorage, 최대 8개)
- 누르면 편집 중이면 커서 자리에 끼워 넣고(D 편집기 textarea 캐럿 위치) · 아니면
  고른 칸 글 끝에 붙임(ctx.dispatch 라 되돌리기 짝 그대로)
- 체크박스로 「숫자 형식으로 붙이기」 고르면 값은 그대로 두고 형식만
  0"㎡" 처럼 단위를 붙임(기존 형식 끝 따옴표 단위는 새 것으로 교체)
- e_harness.ts 에 편집 중 커서 갈래를 시험할 숨은 textarea + __setEditing 시험
  전용 걸이 더함
- ORCA 실 엔진 확인 — 칸 글 끝 붙이기(터파기 계산서 → …㎡) · 형식 붙이기(12.5 값
  그대로 · 형식 0"㎡") · 편집 중 커서 자리 끼우기(f|oo → fkgoo, 캐럿 이동) · 최근
  목록 순서·중복 제거(개 본 개 식 → 식·개·본) 모두 확인

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-28 12:08:26 +09:00
eomsangdonandClaude Opus 5.5 326d5ec61a feat(M02): 구조물집계표 제목행 4층(종류 · 분류1 · 분류2 · 분류3) · 계산 열 식 지움
- 층 = 종류 · 분류1 · 분류2 · 분류3 · 지금 열은 분류3 빈칸(분류2 가 합쳐진 열은 그대로 합침)
- pv_a · pv_jt · pp_cp 식 지움(pp_cp 펼침 식틀 포함 · 사용자 확정 PLAN 13장)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 12:07:11 +09:00
eomsangdonandClaude Opus 5.5 5bdedb5da9 feat(sheet): 층마다 열 더하기(분류1 · 분류2 · 분류3) · 마스터 줄 = 일위대가 · 도면 · 면적산출 · 재료산출
- 분류2 더하기 안 됨 고침 — 옛 「열 더하기」는 고른 열 맨 아래 글을 「새 열」로 바꿔 넣어, 같은 글이면 머리가 가로로 합쳐져 새 열이 안 보였음 · 같은 무리에 같은 글이면 뒤에 수를 붙임
- 단추 = 층 이름 + 더하기(「분류1 더하기」 · 「분류2 더하기」 · 「분류3 더하기」) · 고른 열과 그 층 위 머리가 같은 이웃 끝에 둠 · 아래 층은 빈칸
- 마스터 줄에서 식 · 들어갈 것 줄 지움 · 산출근거 줄 → 면적산출 · 재료산출 줄(areaLabel · materialLabel · 조회 후 선택 · 글 열은 빈칸 잠김)
- 쓰지 않게 된 열 딱지 글 · 줄임 겉모양 지움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 12:07:11 +09:00
eomsangdon e010f72505 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-09-28 10:56:08 +09:00
eomsangdonandClaude Opus 5.5 dbda85e8ee Merge origin/main_laptop_1 — 구조물집계표 열 c1 살림 · 새 번호 SD010102 · SC010102
제목행은 종류 · 분류1 · 분류2 그대로 · 사용자가 더한 열 c1(돌기슭막이 · (형태마다) · 새 열)을 rv 뒤에 둠 · c1 구조물 도면 · 산출근거 번호를 새 체계로(옛 구-50 · 표-50 없앰) · 빈 산출근거는 빈 시트

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 10:55:32 +09:00
eomsangdon a0845bb978 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-09-28 10:51:57 +09:00
eomsangdon b9820cff1a Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-09-28 10:51:57 +09:00
eomsangdon 25f1276d7b Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-09-28 10:51:55 +09:00
eomsangdon 403e3acfda Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-09-28 10:51:54 +09:00
eomsangdon 59b83efe0c Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-28 10:51:52 +09:00
eomsangdon 24b63ca1ea auto: 2026-09-28 10:51 (ESD_LAPTOP) 2026-09-28 10:51:27 +09:00
eomsangdon cb5858bb64 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_2
# Conflicts:
#	M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts
2026-09-28 10:05:10 +09:00
eomsangdonandClaude Sonnet 5 22835ca9e2 fix(M02): 집계표 열 고른 뒤 CAD 불러오기 죽는 자리 고침
M02_MasterTemplete_UI_Main.ts 의 표 onSelect 가 `sheet?.getDoc().열 ?? []` 로
짜여 있었음 — 옵셔널 체이닝이 `sheet?.getDoc()` 까지만 걸리고 그 뒤 `.열` 은
그대로라, [다시 열기](재열기)가 drop() 으로 sheet 를 null 로 되돌린 그 짧은 사이에
옛 표 부품에 남은 클릭 이벤트가 이 줄을 부르면 `undefined.열` 을 읽어 죽음
(사용자 보고: 여러 열 · 빠르게 바꿔 고르기 · 새로 고친 직후에 CAD 불러오기 오류).
`.열` 앞에도 `?.` 를 이어 붙여 sheet 가 없으면 조용히 빈 배열로 감 — 그 뒤
StructurePanel.show() 는 이미 빈 배열 · 없는 colId 를 안전하게 처리함.

ORCA 로 정상 경로(열 고르면 도면 정보 패널이 오류 없이 뜸)는 확인함 — 이
브라우저 환경이 오늘 자주 끊겨(Tab was closed while command was running,
crashpad 등록 실패가 매 명령마다 뜸) 경합(레이스) 그 자체의 재현까지는
못 잡음. 원인은 코드를 끝까지 읽어 확인함(정황 증거 아님).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 10:02:41 +09:00
eomsangdonandClaude Opus 5.5 c25db2636e fix(M02): 일위대가 고르기 목록 — 이름에 붙은 단위를 겹쳐 쓰지 않음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 10:01:10 +09:00
eomsangdonandClaude Opus 5.5 9b9787f60a fix(sheet): 글 열(꼴=글)의 일위대가 · 도면 · 산출근거 칸은 빈칸 · 잠김
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 10:01:09 +09:00
eomsangdonandClaude Opus 5.5 86ae6db5a0 feat(M02): 산출근거 카드 · 전체 편집 — 시트 탭 없이(tabs: false)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-28 09:59:09 +09:00
eomsangdonandClaude Opus 5.5 b79793ea31 feat(spreadsheet): D 시트 탭 없앰 선택 — createSpreadsheet({tabs: false})
- 탭 줄을 안 그림 · Ctrl+PageDown/PageUp · Shift+F11 을 받지 않음(브라우저 몫) · [데이터 ▾] 의 시트 숨기기 · 탭 색 빠짐
- 산출근거(시트 하나)용 · 크기 화면 28,199 B(예산 28,672)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-28 09:59:08 +09:00
eomsangdon 31bcad6454 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_2
# Conflicts:
#	M02_MasterTemplete/M02_MasterTemplete_Structure.ts
2026-09-28 09:58:38 +09:00
eomsangdon 4b6602484d Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_2 2026-09-28 09:57:46 +09:00
eomsangdon 3990ca1c28 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_2 2026-09-28 09:57:46 +09:00
eomsangdon ba47d33877 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-09-28 09:57:36 +09:00
eomsangdonandClaude Opus 5.5 04f2476741 feat(M02): 집계표 조회 후 선택 — 일위대가 목록 · 다른 열 도면 · 산출근거 복사해 가져오기 · 산출근거 줄 표번
- 일위대가 칸 = M01 일위대가 목록(fetchCombos)에서 찾아 고름 · 「비움」
- 도면 · 산출근거 칸 = 다른 열(번호 + 이름) 골라 덮어쓰기 확인 뒤 copyStructure · copyBasis(sub4) — 번호 · 열은 그대로
- 집계표 산출근거 줄 칸 글 = 표번

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 09:57:34 +09:00
eomsangdonandClaude Opus 5.5 2623633b86 feat(m02): 옛 구조물 도면 · 산출근거 46 벌 새 번호로 옮김 · 빈 산출근거 머리 줄 지움
- renumber() 한 번 — 구-NN · 표-NN 버리고 SD · SC 6자리(돌기슭막이 SD010101 … 폐기물처리 SD360000) · 내용 그대로
- 산출근거 46 개 1 행 기본 머리(공종 · 규격 · 산출 내역 · 단위 · 수량(m당)) 지워 진짜 빈 시트
- 글 열 no · sta · memo 의 구조물 도면 · 산출근거 여섯 파일 지움
- 두 번째 renumber() 바뀜 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 09:55:53 +09:00
eomsangdonandClaude Opus 5.5 fc249c00f6 feat(m02): 좌측 · 카드 · 편집 머리 번호 맨 앞 「SD010101 돌기슭막이 …」 · copyStructure · copyBasis
- Api_Fetch — copyStructure(열, from) · copyBasis(열, from): Promise<void> · fetchStructureNumbers(): Record<열 id, {도번, 표번}>(브레인 확정 이름 · 모양) · fetchBasisNumbers 뺌
- 좌측 구조물 도면 · 산출근거 줄 = 번호 + 구조물 이름 + 변수값(제목행) · 구조물 열(꼴=수)만
- 설계 컨테이너 카드 머리 = 번호 · 이름 차례 · 글 열 고르면 컨테이너 닫음
- 편집 화면 머리 = 「SD010101 …」 · 「SC010101 …」
- 시험: test_m02_structure_panel(이름 · 모양 · 줄 글)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 09:55:44 +09:00
eomsangdonandClaude Opus 5.5 3a4b306636 feat(m02): 구조물 번호 SD·SC 6자리 · 집계표 저장 때 파일 만들기/지우기 · 복사 가져오기 길
- 도번 SD · 표번 SC + [종류][분류1][분류2] — 제목행(열 머리 세 층) 차례 · 없는 층 00 · 한 열 두 번호 같은 6자리 · 준 번호 유지(새 열 = 그 무리 다음 번호 · 지운 번호 비움)
- 구조물 = 꼴=수 열만(NO · 측점 · 비고 빠짐 · 브레인 판정)
- 집계표 [저장] — 새 열 두 파일 만들고 지운 열 두 파일 지움
- 새 산출근거 = 진짜 빈 시트(머리 줄 없음) · renumber() = 옛 번호 한 번 옮김 + 옛 기본 머리 줄 지움
- POST /structures/{열}/copy?from= · /basis/{열}/copy?from= — 내용만 · 번호 · 열은 받는 쪽
- GET /structures/numbers = {열 id: {도번, 표번}} · /basis/numbers 뺌
- 시험: test_m02_structure · test_spreadsheet_store 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 09:55:35 +09:00
eomsangdonandClaude Sonnet 5 ee64781a7c fix(spreadsheet): D · sub4 대신 색 줄만 — 두 css 도 테마 토큰으로
브레인 승인(sub7 · sub4 는 같은 파일의 다른 부분 작업 중이라 색 줄만 최소로 고침 · 주인은
그대로 D(spreadsheet.css) · sub_laptop_4(spreadsheet_stage3.css)).

spreadsheet.css — 부품 틀 · 주소 상자 · 수식 입력줄 · 칸 편집기 · 채우기 미리보기 · 알림 ·
찾기 패널 · 메모 · 골라 붙여넣기 · 자동완성 · 데이터 메뉴의 하드코딩 색(#fff · #1f2328 ·
#d0d7de · #217346 · #6e7781 · #57606a · #24292f · #dafbe1 · #bf8700 · #fff8c5 · #ddf4ff ·
#8c959f · #f6f8fa · 그림자 rgb(0 0 0/15%))을 전역 토큰(--color-surface-raised · -text-body ·
-border · -focus-ring · -text-muted · -text-secondary · -primary/-primary-text · -warning ·
-accent · -surface · --shadow-lg/-sm)으로 바꿈. 메모(주석) 배경 · 자동완성 강조 · 서식 붓
켜짐 배경은 이름 없는 톤(포스트잇 옅은 노랑 등)이라 color-mix 로 기존 토큰에서 옅게 섞어냄
(새 토큰 안 만듦).
spreadsheet_stage3.css — 조건부 서식 · 유효성 드롭다운 그림자 한 줄(rgba(0,0,0,0.12))만
남아 있던 것을 --shadow-sm 으로.

ORCA 수치(C 시험 틀에 두 css 임시로 얹어 밝은 · 어두운 전환) — 밝은: .ss 배경
rgb(255,255,255)·글 rgb(51,51,51) · 알림(toast) 배경 rgb(38,17,74)·글 흰색 · 메모 테두리
rgb(184,134,11). 어두운: .ss 배경 rgb(37,31,56)·글 rgb(228,224,240) · 알림 배경
rgb(185,154,255)·글 rgb(22,18,31)(반전) · 메모 테두리 rgb(224,182,74) — 대화상자 · 찾기 ·
자동완성 · 조건부 서식 패널 · 상태 표시줄까지 모두 두 테마에서 배경 · 글 대비 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-28 09:53:53 +09:00
eomsangdonandClaude Sonnet 5 4bbef4ac54 fix(spreadsheet): 엑셀 칸 형식 이름(Fixed·Standard·Currency 등) 오독 고침
엑셀이 클립보드로 칸 형식을 코드 대신 이름으로 넘길 때(mso-number-format:Fixed
등) numfmt 가 그 이름을 코드로 잘못 읽어 `d·m·y` 낱자를 날짜 자리표시로 잡아
글자가 깨지던 것(Fixed→Fixe11 등, 2026-09-27 날짜 형식 추가의 부작용)을 고침.
엑셀 이름표(General·General Number·Fixed·Standard·Currency·Percent·
Scientific·Short Date·Long Date)를 실제 형식 코드로 바꿔치기하고, 모르는
이름은 일반으로 떨어뜨리되 aaa·yyyy 같은 진짜 날짜 자리표시는 그대로 살림
(날짜 자리표시 탐지도 앞뒤가 딴 낱말 글자가 아닐 때만 인정하게 다듬음).
지수(과학) 형식(0.00E+00)도 새로 구현. Currency 는 사용자 실제 화면 확인대로
통화 기호 없이 렌더 — ₩ 가 붙는 자리는 이 여섯 이름으로는 재현 안 됨(실제
붙여넣기 원문이 다시 나타나면 추가 확인 필요). 사용자 표 견본(43.91·14,182·
0.48·21.07·1.93·15,166·23.20)으로 시험 191건 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-28 09:53:16 +09:00
eomsangdon db899349a4 Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_8' into sub_laptop_6 2026-09-28 09:49:23 +09:00
eomsangdonandClaude Sonnet 5 4f7470bbbd feat(spreadsheet): C 격자 — 앱 밝은 · 어두운 테마 따라가게
.aislo-grid 안 색 변수(--aislo-grid-bg · -fg · -line · -head-bg · -head-fg ·
-head-border · -selected) 를 전부 앱 전역 토큰(ui_template_theme.css 의
--color-surface-raised · -text-body · -border · -surface · -text-secondary ·
-accent)에 연결 — 새 값 안 적음, 앱이 data-theme 를 바꾸면 그대로 따라감(다른
스프레드시트 파일(spreadsheet_toolbar.css)과 같은 방식). 칸 · 머리 배경 · 눈금선 ·
선택 테두리 · 채우기 손잡이 테두리의 하드코딩 흰색 · 회색 · 파랑을 이 변수로 바꿈.
테두리 기본 색(사용자가 색 안 지정)은 #000000 대신 currentColor 로 — 칸 글자색과
같이 테마를 따라감. 사용자가 지정한 채움색 · 글자색(조건부 서식 포함)은 그대로 둠
(엑셀과 같음 — 이미 빈 문자열 인라인 스타일로 CSS 기본값에 떨어지는 구조라 따로 안 고침).
시험 틀(html)에 ui_template_theme.css 를 넣어 data-theme 전환을 실제로 시험.

ORCA 수치(시험 틀 · data-theme 전환) — 밝은: 격자 배경 rgb(255,255,255) · 글자
rgb(51,51,51) · 선택 #3e0079 · 눈금선 #e6e2e3. 어두운: 격자 배경 rgb(37,31,56) ·
글자 rgb(228,224,240) · 선택 #b99aff · 눈금선 #322a45 — 모두 전환됨. 사용자 지정
채움(A3 #F2F2F2)·조건부 서식 글자색(H4 #c00000)은 두 테마에서 값 그대로(안 바뀜)
확인. A3 무색 테두리는 currentColor 로 밝은 rgb(51,51,51)/어두운 rgb(228,224,240)
로 글자색과 같이 바뀜 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-28 09:48:20 +09:00
eomsangdonandClaude Opus 5.5 dde6f0c2f8 feat(M02): 구조물집계표 제목행 층 이름 — 종류 · 분류1 · 분류2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 09:48:05 +09:00
eomsangdonandClaude Opus 5.5 119a390bd7 feat(sheet): 산출근거 줄 · 조회 후 선택(onPick) · 둘째 층 묶음 더하기 · 층 이름 고치기
- 마스터 첫 쪽 도면 줄 아래 산출근거 줄 s:basis — 칸 글 basisLabel(colId) · 없으면 「없음」 · 손으로 못 침
- onPick 을 주면 일위대가 · 도면 · 산출근거 칸은 두 번 누르기 · Enter · F2 로 조회 후 선택(글을 돌려주면 일위대가 칸에 넣음)
- 「<둘째 층 이름> 더하기」 단추 — 같은 종류 묶음 끝에 [종류, 새 분류, 새 열] 열(층 셋 이상일 때)
- 머리 층 줄 머리 칸 두 번 누르기 = 층 이름 고치기(master)
- 시험 test_sheet_group_layer.py · 줄 차례 시험에 산출근거 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 09:47:58 +09:00
eomsangdonandClaude Sonnet 5 b2e2fcff91 fix(spreadsheet): 엑셀 붙여넣기 서식 — 묶음 선택자 클래스 · 선 굵기별 종류
- <style> 안 클래스 규칙 읽기가 「.xl65, .xl70 {...}」 같은 묶음 선택자에서 앞쪽 클래스를
  놓치던 것 고침(전체 선택자를 쉼표로 나눠 각 클래스에 선언 병합)
- 테두리 pt 굵기 → 우리 선 종류 매핑 추가(.5pt 이하 hair · ~1.25pt thin · ~2pt medium · 그 위
  thick, dashed·dotted·double 은 굵기와 상관없이 그 모양) — 「합계 줄 위 굵은 선」처럼 같은
  칸 안 굵기가 다른 테두리를 구별해 살림
- 실제 엑셀 내보내기 짜임(머리 채움 · 가는 테두리 · 합계 줄 위 굵은 테두리 · #,##0 숫자 형식)을
  재현한 새 견본 resources/tester/spreadsheet/fixture_excel_style_paste.html 로 시험
- ORCA 실 엔진 확인 — 머리 칸 굵게·채움 #d9d9d9·가운데 정렬·사방 hair, 숫자 칸 오른쪽 정렬·
  형식 #,##0·사방 hair, 합계 줄 굵게·위 테두리만 medium(나머지 hair)까지 전부 반영 확인

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-28 09:45:50 +09:00
eomsangdonandClaude Opus 5.5 e4eef2a47b fix(M02): 산출근거 전체 편집 격자 높이 0 — 메인 칸을 화면 높이로 채움
- .ss 는 height:100% 로 부모 높이를 받는데 host 에 정해진 높이가 없어 격자 0
- 도면 편집과 같은 규칙(.m02-main:has(> .m02-main__host > .ss) · host flex) 으로 메인 칸을 채움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 00:51:20 +09:00
eomsangdonandClaude Opus 5.5 87417b3a31 perf(spreadsheet): D 크기 줄임 — sub2 · sub8 병합 뒤 화면 예산 넘침 해소
- 함수 자동 완성 · 인자 도움말, 이름 상자 · 이름 관리, 서식 붓, 메모 편집(새 spreadsheet_memo.ts), 열 너비 맞춤을 처음 쓸 때 불러옴
- 화면 30,126 B → 28,102 B(예산 28,672) · 합 62.6 KB

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-28 00:46:08 +09:00
eomsangdon 0f7617f99d Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_8' into sub_laptop_7 2026-09-28 00:33:31 +09:00
eomsangdonandClaude Sonnet 5 69f9c64c98 feat(M02): 산출근거 전체 편집 화면 — 집계표 카드는 보기 + [산출근거 수정]
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-28 00:30:48 +09:00
eomsangdonandClaude Sonnet 5 e31e1b0016 feat(M02): 산출근거 표번 — 도번과 같은 규칙 자동 채번 · 좌측 「산출근거」 목록
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-28 00:30:32 +09:00
eomsangdonandClaude Sonnet 5 7a8d37d544 fix(M02): 상세 산출근거 카드 — 칸 높이 없어 빈 격자가 안 보이던 것 고침(약 30행 · 스크롤)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-28 00:14:07 +09:00
eomsangdonandClaude Sonnet 5 444aed129f fix(M02): 도면 정보 카드 미리보기가 cad-host 최소 높이 탓에 카드 밖으로 넘침
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-28 00:13:55 +09:00
eomsangdonandClaude Sonnet 5 66812026c1 chore(M02): 임시 시험 단추 호출부 · inspect 옵션 · 시험 정리(이어서)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-28 00:13:19 +09:00
eomsangdonandClaude Sonnet 5 860e251425 chore(M02): 임시 스프레드시트 시험 단추 · 서버 tmp 견본 길 · inspect 통로 지움
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-28 00:13:06 +09:00
eomsangdonandClaude Sonnet 5 cd7a81a4af fix(spreadsheet): 도구 모음 글꼴·크기 빈칸 — 기본값 흐리게 표시
- 칸·행·열·시트 기본 어디에도 글꼴·크기가 없으면 통합문서 기본 → 앱 기본(굴림·10, 격자
  fontCss 와 같은 값)을 value 대신 placeholder 로 보여 흐리게(기본값임을 표시)
- 직접 지정하면 다시 실값으로
- ORCA 실 엔진 확인 — 굵게만 있는 칸·아무 서식 없는 칸 모두 글꼴 placeholder "굴림"·크기
  placeholder "10" 표시, 글꼴 Arial·크기 14 로 지정하면 value 로 바뀌고 placeholder 비워짐

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-27 23:56:40 +09:00
eomsangdonandClaude Sonnet 5 ede12fbc0e feat(spreadsheet): 상태 표시줄 확대 비율 — sub6 zoom() 이음
sub6 격자 확대 커밋(origin/sub_laptop_6 7b294adc) 받아 GridHandle.zoom() 으로 처음 값 · 격자 root 「aislo-grid-zoom」 사건으로 바뀔 때마다 「확대 N%」 다시 씀.
가짜 SpreadsheetContext(test_stage2_helpers.ts) 격자에 zoom() 스텁 더함(0 계약 필수 자리라 안 채우면 모양 안 맞음).
ORCA(포트 5179) — 실제 createSpreadsheet 를 띄워 Ctrl+휠(WheelEvent ctrlKey) 로 확인: 「확대 100%」→「확대 332%」로 실시간 바뀜.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 23:12:57 +09:00
eomsangdonandClaude Sonnet 5 7b294adca2 feat(spreadsheet): C 격자 — 확대 이음(zoom() · aislo-grid-zoom 이벤트)
계약 더함 — GridHandle.zoom(): number(1 = 100%). 확대가 바뀔 때마다 격자 root 에서
CustomEvent("aislo-grid-zoom", { detail: { zoom }, bubbles: true }) 를 쏨(sub4 상태줄이 들음).
ORCA 시험 — WheelEvent(ctrlKey · deltaY -300) 로 확대 흉내 → zoom() 1→1.568 · 이벤트
detail.zoom 도 같은 값으로 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 22:35:54 +09:00
eomsangdon 5bea31ee5c Merge remote-tracking branches 'origin/sub_laptop_4', 'origin/sub_laptop_7' and 'origin/sub_laptop_8' into sub_laptop_6 2026-09-27 22:33:04 +09:00
eomsangdon a9a9b73961 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-09-27 22:33:02 +09:00
eomsangdonandClaude Sonnet 5 35d0ce3853 fix(spreadsheet): 엑셀 붙여넣기 서식 · 병합 부분 덮어쓰기 막기 · 도구 모음 기본 서식
- parseHtmlTable 이 td 인라인 style · <style> 클래스(mso-number-format 이스케이프 포함) 를 읽어
  굵게 · 기울임 · 글자색 · 채움 · 가로 정렬 · 숫자 형식 · 테두리를 칸 서식으로 살림
- 붙일 자리가 기존 병합을 일부만 걸치면 막고 알림(엑셀처럼) · 통째로 덮으면 먼저 풀고 칸마다 값
- 도구 모음 · 클립보드 styleAt 이 Sheet.기본.서식(시트 기본)까지 물려받게 고쳐 글꼴 · 크기 칸이 빔 방지
- ORCA 실 엔진 확인 — 엑셀 견본(xl65/xl66 클래스) 붙여넣기로 굵게 · 가운데 정렬 · 0.00 형식 반영,
  병합 부분 덮기 막음 · 알림 · 값 안 바뀜, 병합 통째 덮기는 자동 풀림 + 칸마다 값, 시트 전체 서식
  지정 뒤 새 칸에서 도구 모음 글꼴 · 크기 표시 확인

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-27 22:32:20 +09:00
eomsangdonandClaude Sonnet 5 b59de25664 fix(spreadsheet): 상태 표시줄 어두운 화면 글자 안 보임
spreadsheet_stage3.css .ss-statusbar·.ss-statusbar__zoom 에 테마 글자색 토큰(--color-text-body·--color-text-muted) 지정 — 전엔 색을 안 지정해 어두운 바탕에 기본 검정 글자가 겹침.
ORCA(포트 5179)에서 라이트·다크 둘 다 계산된 색으로 확인: 다크 글자 rgb(228,224,240)/바탕 rgb(30,25,48), 라이트 글자 rgb(51,51,51)/바탕 rgb(246,247,250) — 둘 다 읽힘.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 22:25:22 +09:00
eomsangdonandClaude Opus 5.5 76f917813b fix(spreadsheet): D 편집기 — 틀린 식(인자 수 등)은 Enter 로 확정하지 않음
- 확정 때 = 로 시작하면 parseFormula(sub3 a3434e3c) — 실패면 편집 유지 · 오류 알림 · 커서를 오류 자리로(엑셀처럼)
- M02 [스프레드시트 시험] ORCA: =SUM() Enter 막힘 · 「SUM 인수가 너무 적음(1 개부터)」 알림 · =SUM(1,2) → 3
- 크기 시험 통과(화면 28,613 B) · D 규칙 시험 9 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 22:24:30 +09:00
eomsangdonandClaude Sonnet 5 7c1e95482d fix(spreadsheet): C 격자 — 브레인 최종 검증 흠 네 가지 고침
① 스크롤 영역이 빈 시트 기본 200행에서 안 늘어나 이름 상자 · Ctrl+방향키로 먼 칸 가도
   화면이 안 따라가던 것 — reveal() 이 그 칸 밖이면 스페이서를 늘림(ensureExtent) · 스크롤
   끝 가까이 닿아도 미리 늘림(growOnScrollEdge, 엑셀처럼).
② 열 폭 자동 맞춤 상한 없어 아주 긴 글로 약 4만 px 까지 늘던 것 — colCharsToPx 에 엑셀
   상한(255 자) 캡을 걸어 렌더 쪽에서 막음.
③ 칸 폭보다 긴 수는 줄임표 대신 `#####`(엑셀과 같음) — 캔버스로 재 폭 넘으면 바꿔치기 ·
   글은 그대로 줄임표.
④ 틀 고정을 풀어도 왼위 · 위 · 왼 판에 칸이 하나 남던 것(A1 이 DOM 에 네 벌) —
   `Math.max(0, frozenCols-1)` 이 0 일 때도 0 을 줘 범위가 안 비던 계산 버그 고침.

ORCA 수치 — ① 스크롤 영역 201,901px→1,001,901px 로 늘고 scrollTop 목표 자리로 이동 확인 ·
② 5,702 자 요청에도 1,790px(255 자) 로 막힘 확인 · ③ 「123456789012345」→「#######」 확인 ·
④ 해제 뒤 A1 DOM 칸 수 1 로 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 22:24:11 +09:00
eomsangdonandClaude Opus 5.5 a3434e3c74 fix(spreadsheet): 함수 인자 수를 입력 때 막음 — =SUM() 이 0 이 되던 흠
- 파서가 함수 표(B) 최소 · 최대 인자 수로 검사 → 「SUM 인수가 너무 적음(1 개부터)」 식 오류(편집기가 이 실패로 Enter 를 막는 이음은 sub7)
- B 표 min 0 인 SUM · MIN · MAX · COUNT · COUNTA · PRODUCT 는 엑셀대로 1 로 덧댐(sub5 가 표를 고치면 지움)
- 풀이 때 인자 수 검사는 파서로 옮겨 뺌(엔진 31,787 → 31,603 B)
- 시험: 엔진 오류 묶음에 인자 수 사례

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 22:21:59 +09:00
eomsangdonandClaude Opus 5.5 a144d4019e perf(spreadsheet): D — sub6 조건부 서식 · 초록 세모 받은 뒤 크기 예산 맞춤
- 골라 붙여넣기 몸통을 고를 때 불러옴 · 조건부 서식 창은 격자가 이미 끌어오므로 바로 import(정적 끌어옴 시험)
- M02 [스프레드시트 시험] ORCA: 조건부 서식 색(3 · 2 칠함 · 1 안 칠함 · 고치면 다시 칠함) · #DIV/0! 초록 세모 · 필터 행 숨김 수치 확인
- 크기 시험 통과 — 화면 27.6 KB · 합 61.5 KB

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:54:35 +09:00
eomsangdon a3fa9f152a Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_7 2026-09-27 21:52:12 +09:00
eomsangdonandClaude Opus 5.5 fca4463768 feat(spreadsheet): D 잇기 3 — 칸 넣기 · 이동 · 틀 고정 · 데이터 도구 · 묶기 · 단축키 표 「안됨」 잇기
- spreadsheet_actions(키 · 메뉴 → 이름) + actions_run(몸통 · 늦게): Ctrl+Shift++ · Ctrl+- (행 · 열 통째면 바로) · 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+→/←
- [데이터 ▾] 에 이동 · 틀 고정 ▸ · 칸 넣기/지우기 · 텍스트 나누기 · 중복 제거 · 연속 채우기 · 윤곽 ▸
- 식 편집 중 F4 참조 $ 돌리기(cycleAbs) · 접힌 묶음 행 · 열은 방향키가 건너뜀
- 크기: 수식 분석 · 자동 필터 · 찾기 패널을 처음 쓸 때 불러옴 — 화면 27.0 KB · 합 60.9 KB
- M02 [스프레드시트 시험] ORCA 수치 확인 · D 규칙 시험 9 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:51:06 +09:00
eomsangdonandClaude Sonnet 5 e48870765e feat(spreadsheet): C 격자 — 조건부 서식(sub4) 얹음 · 오류 칸 초록 세모(왼 위)
resolveCondFmtStyle 결과를 칸 서식 위에 덮어씀(채움·글자색·굵게) · 글자색은 조건부 서식이
숫자 형식([Red] 등)보다 이김(엑셀과 같음). 오류 칸은 기존 title 풍선에 더해 왼 위 초록
세모(엑셀 오류 검사 표시)도 그림. 필터로 걸러진 행 숨김은 이전 커밋(ebdee04f)에서 이미
buildRowGeometry 에 같이 넣어 둠(걸러짐 · 숨김 · 묶음 접힘 모두 처리).
ORCA 시험 틀 — 조건 맞는 칸 배경·글자색·굵기 수치로 확인 · 오류 칸 title·세모 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 21:34:58 +09:00
eomsangdon 117ea76ec2 Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_7 2026-09-27 21:34:22 +09:00
eomsangdonandClaude Sonnet 5 ebdee04faa feat(spreadsheet): C 격자 — 행·열 묶어 접기(윤곽 +/- 단추, 계약 더함)
RowInfo·ColumnInfo.묶음{층,접힘} · Command「묶음설정」더함(계약 더함) — 접힘 상태 저장 ·
접으면 숨김처럼 그리되 머리 이중선 표시는 숨김·걸러짐과 갈라 안 보임(따로 표시).
commands.ts 는 기존 setAxis 틀에 「묶음」prop·case 만 최소로 얹음(exhaustive switch 깨짐 막음).
ORCA 시험 틀에서 접기·펴기 dispatch·저장·머리 이동·표시 구분 수치로 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 21:31:11 +09:00
eomsangdon 8d49a38df2 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_3' into sub_laptop_8 2026-09-27 21:29:55 +09:00
eomsangdonandClaude Sonnet 5 4f1c048bb2 fix(spreadsheet): func_help 자동 완성에 B 2단계 함수 이어 붙임(브레인 지시로 B 가 sub1 파일 대신 고침)
spreadsheet_func_help.ts(주인 sub_laptop_1)가 B 의 FUNC_HELP_BATCH2 · 3(57개)을
안 읽어 자동 완성에 새 함수가 안 뜨던 것을 고침 — sub1 이 지금 다른 파일을 만져
브레인 지시로 B 가 이번만 대신 고침. sub1 의 test_stage2_func_help.ts 도 늘어난
개수(48→105)에 맞춰 고치고 2단계 함수 자동 완성 검증을 더함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-27 21:29:46 +09:00
eomsangdon ddfff14fb6 feat(spreadsheet): 탭에 숨긴 시트 · 탭 색 반영(sub4 spreadsheet_sheet_extra 잇기)
- 숨긴 시트(Sheet.숨김)는 탭 목록에서 뺌
- 탭 우클릭에 [숨기기] · 숨긴 시트가 있으면 [숨긴 시트 보이기] 하위 목록(이름 눌러 바로 보임)
- Sheet.탭색 은 탭 밑줄 색으로
- ORCA 실 엔진 확인 — 숨기기 → 탭 사라짐 → 하위 목록에서 보이기 → 다시 나타남 · 탭색 지정 → 밑줄 반영
2026-09-27 21:29:39 +09:00
eomsangdonandClaude Opus 5.5 610ac8564f fix(spreadsheet): 이름 정의 풀이 — =SUM(값들) 이 범위로 풀림 · 행열 넣기 · 지우기 때 이름 범위 따라감
- 엔진: 식 나무의 이름을 Sheet.이름정의 범위로 바꿔 풂(자기 시트 먼저 · 없으면 다른 시트 · 없는 이름 #NAME?) · 의존 그래프도 범위로 이어짐
- 식 캐시는 시트마다 · rebuild 때 비움 — 이름 바꾸기 · 지우기(시트통째) 뒤 새로 풂
- 행열 넣기 · 지우기: 병합과 같은 범위 옮김으로 이름 범위도 옮김 · 통째로 지워진 이름은 뺌
- 수식 계산 걸음도 이름을 풂
- 시험: test_spreadsheet_analysis.py names 묶음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 21:29:17 +09:00
eomsangdonandClaude Opus 5.5 4e7f2e2c2a fix(M02): 표 부품은 진입 파일만 늦게 받음 — 스프레드시트가 compass 조각을 끌지 않게
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-27 21:28:14 +09:00
eomsangdon 36e33a0e3b auto: 2026-09-27 21:27 (ESD_LAPTOP) 2026-09-27 21:27:50 +09:00
eomsangdonandClaude Sonnet 5 de94486af7 feat(spreadsheet): 2단계 — 데이터 도구(텍스트 나누기·중복 제거·연속 채우기)
텍스트 나누기(구분 기호 탭·세미콜론·쉼표·공백·사용자 지정 또는 고정 폭 · 미리보기) ·
중복된 항목 제거(비교할 열 고르기 · 머리글 포함 여부 · 지운 수를 토스트로 알림) ·
채우기 연속 데이터(단계 값 · 끝 값 · 행/열 방향)를 새 파일로 짬. 셋 다 「칸」 명령
하나로만 문서를 바꿈. 순수 로직(나누기 · 중복 찾기 · 등차수열 짓기)은 DOM 없이도
시험 가능해 resources/tester/spreadsheet/test_stage2_data_tools.ts 로 다 검증함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 21:27:45 +09:00
eomsangdonandClaude Opus 5.5 c6e826fe1f perf(spreadsheet): D — 누를 때만 쓰는 창을 늦게 불러옴(크기 예산 화면 28 KB 안)
- spreadsheet_data_menu.ts — 정렬(↑ ↓ · 정렬…) · 조건부 서식 · 시트 숨김/탭 색 창은 import()
- spreadsheet_extras.ts — 셀 서식 창(Ctrl+1 · [서식…]) import()
- 크기 시험: 엔진 31.4 · 화면 27.2 · css 3.9 · 합 62.6 KB

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:23:54 +09:00
eomsangdon e222ddc172 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_7 2026-09-27 21:22:03 +09:00
eomsangdonandClaude Opus 5.5 16c1443b93 feat(spreadsheet): D 잇기 — 수식 분석 · 자동 합계 · 상태 표시줄 · 시트 숨김/탭 색(sub3 · sub4)
- spreadsheet_data_menu.ts — 데이터 ▾ 안 「수식 분석」(선행 · 종속 추적 · 화살표 지우기 · 수식 계산) · 오류 ⚠ 부품 · Σ 단추 · 「시트 숨기기 · 탭 색…」 창
- spreadsheet_keys.ts — Alt+= 자동 합계(=SUM(잡은 범위) 로 편집을 열고 Enter 는 사용자)
- spreadsheet.ts — 상태 표시줄(합계 · 평균 · 개수 …)을 맨 아래

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:21:46 +09:00
eomsangdon 7aa9f2229d Merge remote-tracking branches 'origin/sub_laptop_3', 'origin/sub_laptop_4', 'origin/sub_laptop_5' and 'origin/sub_laptop_7' into sub_laptop_1 2026-09-27 21:18:44 +09:00
eomsangdon 18117e16b4 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-27 21:18:42 +09:00
eomsangdonandClaude Sonnet 5 4804caa9bc feat(spreadsheet): 통계 함수 13개 · 날짜 함수 9개 · numfmt 날짜 형식
MEDIAN·MODE·STDEV(.S/.P)·VAR·RANK(.EQ)·LARGE·SMALL·PERCENTILE·QUARTILE·
COUNTBLANK을 새 spreadsheet_functions_stat.ts에 둠. NOW·WEEKDAY·EDATE·
EOMONTH·DATEDIF·NETWORKDAYS·HOUR·MINUTE·TIME을 misc.ts에 더함(EDATE는
월말 넘침을 다음 달로 안 넘기고 그 달 말일로 눌러 담음 — 엑셀과 같게).
numfmt에 날짜 형식(yyyy-mm-dd·m/d·요일 aaa/aaaa)을 더함. sub1 func_help용
이름 목록은 functions.ts의 FUNC_HELP_BATCH3에 둠(그 파일은 안 고침).
경계값 시험 180건 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-27 21:18:23 +09:00
eomsangdon d887a8cec3 Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into sub_laptop_7 2026-09-27 21:17:32 +09:00
eomsangdonandClaude Opus 5.5 73ad5c7070 feat(spreadsheet): D 잇기 — 셀 서식 창(Ctrl+1 · [서식…] 단추 · sub1)
- spreadsheet_keys.ts — Ctrl+1 자리
- spreadsheet_extras.ts — openFormatDialog 잇기 · 수식 입력줄 앞 [서식…](Ctrl+1 을 ORCA · 브라우저가 가로챌 때) · 창이 닫히면 초점을 격자로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:17:16 +09:00
eomsangdonandClaude Opus 5.5 6acc8d0c52 feat(spreadsheet): 수식 분석 — 선행 · 종속 추적 화살표 · 수식 계산 단계 보기 · 오류 검사 ⚠ 풍선
- 추적 화살표(spreadsheet_trace.ts): 누를 때마다 한 단계 · 범위는 상자 · 오류 칸 화살 빨강 · 다른 시트 점선 · 지우기
- 수식 계산 창(spreadsheet_eval_steps.ts): 왼쪽부터 한 마디씩 값으로 · 밑줄 · IF 류 첫 인자만 먼저 · 값은 엔진 풀이
- 오류 검사(spreadsheet_errcheck.ts): 순환 · 식 오류 · 숫자처럼 보이는 글 까닭 · ⚠ 메뉴(숫자로 변환 · 계산 단계 · 선행 추적 · 오류 무시)
- 격자 초록 세모는 C(sub6) · 잇기는 sub7 — 머리 주석에 잇는 법
- 엔진: evalSafe 내보냄 · formulaToText 에 갈음 · 밑줄 자리 · 계약 파일 목록 줄만 더함
- 시험: test_spreadsheet_analysis.py(3 묶음) · harness_analysis(ORCA) · 시험 틀은 inspect 로 문맥 꺼냄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 21:14:40 +09:00
eomsangdonandClaude Sonnet 5 4aca316ace feat(spreadsheet): 3단계 — 시트 숨김·보이기·탭 색(계약 더함)
spreadsheet_sheet_extra.ts 새로 만듦 — 시트 관리 창(숨김 토글·탭 색 고르기), 마지막 보이는 시트는 숨김 거절, 활성 시트를 숨기면 다른 시트로 옮김.
계약(spreadsheet_types.ts)에 Sheet.숨김·탭색 최소로 더함(git-sync 뒤 더함) · 파일 목록 표에 이번 셋(상태 표시줄·자동 합계·시트 여분) 적음 · css(spreadsheet_stage3.css)에 세 파일 스타일 얹음.
엔진 A·격자 C 안 건드림 — 탭 줄(spreadsheet_tabs.ts) 이음은 sub7 몫(머리 주석에 적음).
시험 resources/tester/spreadsheet/test_stage3_sheet_extra.ts 9개 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 21:14:21 +09:00
eomsangdonandClaude Sonnet 5 6275e48396 feat(spreadsheet): 3단계 — 아래 상태 표시줄
spreadsheet_statusbar.ts 새로 만듦 — 고른 범위 합계·평균·개수·숫자개수·최소·최대, 우클릭으로 보일 줄 고름, 확대 비율 자리(아직 안 이어짐).
계약 안 건드림 · 엔진 A·격자 C 안 건드림 — 화면 붙이기·확대 이음은 sub7 몫(머리 주석에 적음).
시험 resources/tester/spreadsheet/test_stage3_statusbar.ts 9개 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 21:14:09 +09:00
eomsangdonandClaude Sonnet 5 647ae91469 feat(spreadsheet): 3단계 — 자동 합계(Alt+=·Σ)
spreadsheet_autosum.ts 새로 만듦 — 위 또는 왼쪽 연속 숫자 범위를 찾아 =SUM() 식을 활성 칸에 넣음(엑셀 규칙), 여러 칸을 고른 채면 그 안에서만 찾음.
계약 안 건드림 · 엔진 A·격자 C 안 건드림 — 실제 단축키·Σ 단추 이음은 sub7 몫(머리 주석에 적음).
시험 resources/tester/spreadsheet/test_stage3_autosum.ts 6개 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 21:13:58 +09:00
eomsangdon 0c3e4028e5 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_6' into sub_laptop_7 2026-09-27 21:13:23 +09:00
eomsangdonandClaude Opus 5.5 61d9c0de58 feat(spreadsheet): D 잇기 — 데이터 메뉴(조건부 서식 · 유효성 · 이름 · 눈금선) · 유효성 검사 · 이름 상자 · 여러 범위
- spreadsheet_data_menu.ts(새) — [↑] [↓] [필터] [데이터 ▾] · 패널 창(sub4 조건부 서식 · 유효성 · 이름 관리) · 눈금선 · 유효성 목록 ▼ · Alt+↓
- spreadsheet_editor.ts — 확정 때 유효성 검사(막음 = 편집 유지 · 경고 = 알림) · 주소 상자에 이름 = 그 범위로 · 새 이름 = 지금 고름에 정의
- spreadsheet_mouse.ts · spreadsheet_selection.ts — Ctrl+누르기 여러 범위 · Shift 는 마지막 범위
- spreadsheet_keys.ts · spreadsheet.ts — 유효성에 막히면 이동 · 시트 바꿈 멈춤
- spreadsheet_extras.ts — 정렬 · 필터를 데이터 메뉴로 옮김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:13:10 +09:00
eomsangdonandClaude Sonnet 5 327226e252 test(spreadsheet): C 시험 틀 — ctx 도 전역에 걸어 여러 범위 선택 시험 가능하게
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 21:12:21 +09:00
eomsangdonandClaude Sonnet 5 69694a3f6b fix(spreadsheet): C 격자 — 고름 테두리·채우기 손잡이 머리 칸만큼 겹쳐 밀리던 것 고침
overlay 가 이미 headColW·headRowH 만큼 밀려 있는데 renderSelection·setRefHighlights 가
screenBoxOf(격자 뿌리 기준)를 그대로 써서 두 번 밀렸다. overlay 안 전용 좌표 함수(overlayBoxOf)로
갈라 고침. M02 [스프레드시트 시험] 화면에서 ORCA 수치로 칸 자리와 정확히 일치함을 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 21:11:12 +09:00
eomsangdonandClaude Sonnet 5 e1fc529f45 feat(spreadsheet): 2단계 — 셀 서식 대화상자(Ctrl+1)
엑셀과 같은 탭 여섯 — 표시 형식(범주·소수 자리·천 단위·음수 모양·사용자 지정 코드·미리보기,
B의 numfmt로 실제 그림) · 맞춤(가로·세로·줄 바꿈·셀 병합·들여쓰기는 자리만) · 글꼴(실시간
미리보기) · 테두리(선 모양·색·위치별 적용·미리보기) · 채우기 · 보호(브레인 지시대로 자리만).
[확인] 누를 때 서식 명령 하나(+ 병합 바뀌면 병합·병합풀기)로 나감 — 도구 모음처럼 바로 안 바꿈.

표시 형식 코드 짓기는 DOM 없는 별도 파일(spreadsheet_format_code.ts)로 빼서 시험 가능하게 함
(대화상자 자체는 이 저장소에 jsdom 이 없어 단위 시험 밖 — Ctrl+1 을 이은 뒤 ORCA 로 확인할 것).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 21:10:46 +09:00
eomsangdon 015c35ad0e Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-09-27 21:06:02 +09:00
eomsangdonandClaude Opus 5.5 500424d7ae feat(spreadsheet): D 잇기 — 정렬 · 자동 필터(sub3)
- spreadsheet_extras.ts — 수식 입력줄 앞 데이터 단추(↑ ↓ 정렬… 필터) · attachFilter 부품 붙임 · Alt+↓ 머리 칸 값 목록
- spreadsheet_keys.ts — Ctrl+Shift+L 필터 · Alt+↓ 자리
- spreadsheet_selection.ts — 걸러진 행도 방향키 · Enter 가 건너뜀
- spreadsheet.ts — recalc 뒤 부품 refresh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:05:39 +09:00
eomsangdon acdac34572 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-09-27 21:04:12 +09:00
eomsangdonandClaude Sonnet 5 85ada11e9d feat(spreadsheet): C 격자 — Ctrl+휠 확대·축소(10~400%, 칸·머리·선 굵기 같이)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 21:04:03 +09:00
eomsangdon e02a3c9a26 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into sub_laptop_8 2026-09-27 21:03:03 +09:00
eomsangdon fc20316c0c Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-09-27 21:03:01 +09:00
eomsangdon 851fdb63e5 fix(spreadsheet): E 글자표가 앱 전체 사전을 끌어오던 것 끊음
- spreadsheet_text.ts 가 @ui/ui_template_locale 의 currentLanguageIndex 만 쓰려다 페이지별
  사전(a·b1~4·common·m1·m2) 전부를 끌어옴 — 자기 언어 상태(setSpreadsheetLanguage)로 대신
- 확인: toolbar+tabs+menu+clipboard 만 묶어 본 압축 크기 59 KB → 18.8 KB(gzip)
2026-09-27 21:02:46 +09:00
eomsangdonandClaude Sonnet 5 1a4e24b8a9 feat(spreadsheet): 2단계 — 엑셀 단축키 전수 표(데이터 파일)
키 53 개(이동·고르기·편집·복사붙이기·찾기·메모·서식·시트·범위밖) — 뜻·동작 이름·지금
상태(됨·안됨·범위밖)·자리를 적음. `spreadsheet_keys.ts`(D) 를 오늘 기준으로 읽고 매김.
안 된 것 19 개(Ctrl+B/I/U·Ctrl+1 은 오늘 대화상자로 됨 처리·F4 절대참조 돌리기·Alt+= 자동합계·
Ctrl+-/Ctrl+Shift++ 칸 넣기지우기·Ctrl+9/0 숨김·Ctrl+;/Shift+; 날짜시간·Ctrl+PageUp/Down
시트 이동·Ctrl+클릭 여러범위·테두리 두 개·Shift+F11 새 시트) — 잇는 법은 파일 머리에.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 21:02:28 +09:00
eomsangdon 3656f02704 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_3
# Conflicts:
#	A00_Common/spreadsheet/spreadsheet_types.ts
2026-09-27 21:01:37 +09:00
eomsangdon d299ca3d3c Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_3 2026-09-27 21:01:16 +09:00
eomsangdon 8606272f5c auto: 2026-09-27 21:00 (ESD_LAPTOP) 2026-09-27 21:00:23 +09:00
eomsangdonandClaude Opus 5.5 a939d0bf55 fix(spreadsheet): 크기 예산 고침 — 엔진 32 KB · 합 64 KB(브레인 판정)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-27 21:00:16 +09:00
eomsangdonandClaude Sonnet 5 5718aa7f9d feat(spreadsheet): 3단계 — 조건부 서식(계약 더함)
Sheet.조건부서식·유효성·이름정의 계약 더함(0 계약 · git-sync 뒤 더함) · spreadsheet_condfmt.ts —
값 비교·사이·글 포함·수식 조건 · 채움·글자색 · 규칙 관리 창 · 우선순위 옮김 · 행열 넣기·지우기 범위
따라가기(remapCondFmtRanges). 엔진(A)·격자(C) 파일은 안 건드림 — 잇는 법을 머리 주석에 적어 둠(잇기는 sub7).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 21:00:12 +09:00
eomsangdon 6dad7497dc Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_5' and 'origin/sub_laptop_8' into sub_laptop_7 2026-09-27 21:00:03 +09:00
eomsangdonandClaude Opus 5.5 c7f18d533b test(spreadsheet): 크기 시험을 실제 앱 빌드 조각으로 잼 · 정적으로 끌어오는 곳 없음 확인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-27 20:59:43 +09:00
eomsangdon 57ddb3866d feat(spreadsheet): 칸 우클릭 메뉴에 메모 삽입 · 편집 · 삭제
- sub1 `spreadsheet_comments.ts` 의 setComment · deleteComment · getComment 호출
- 글 입력은 E 자체 작은 오버레이(native prompt 안 씀 — 자동화 창 자동 취소 문제)
- ORCA 실 엔진 시험 — 삽입 → 메뉴 글자가 「메모 편집·삭제」로 바뀜 → 삭제 → 취소(저장 안 됨) 확인
2026-09-27 20:59:25 +09:00
eomsangdonandClaude Sonnet 5 d60b1ccc7e feat(spreadsheet): 분수 형식 · 함수 2차 묶음(30개) · misc 파일 쪼갬
numfmt — 분수 형식(0/0 · # ?/? · # ??/?? · 분모 고정 # ?/8) 엑셀과 같게 구현.
함수 — MROUND·ODD·EVEN·LOG·LOG10·LN·EXP(math) · SUMIFS·COUNTIFS·AVERAGEIF(S)·MAXIFS·MINIFS·
IFS·SWITCH·XLOOKUP(찾기·조건부 집계) · TEXTJOIN·TRIM·UPPER·LOWER·SUBSTITUTE·REPLACE·FIND·
SEARCH·REPT·ISNUMBER·ISTEXT·ISBLANK·ISERROR·NA·TODAY·DATE·YEAR·MONTH·DAY(misc) 더함.
misc.ts 가 700 줄을 넘어 찾기·조건부 집계를 spreadsheet_functions_lookup.ts(새 파일)로 쪼갬.
sub1 의 func_help 이름 목록에 더할 것을 spreadsheet_functions.ts 의 FUNC_HELP_BATCH2 에 둠
(그 파일은 안 고침 · 머리 주석에 읽는 길 적음). 경계값 시험 135건 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-27 20:59:21 +09:00
eomsangdon 6b08058a58 Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into sub_laptop_6 2026-09-27 20:57:31 +09:00
eomsangdonandClaude Opus 5.5 ce3df39206 feat(spreadsheet): D 잇기 마무리 — 진짜 부품으로 createSpreadsheet · 2단계 찾기 · 메모 · 서식 붓 · 골라 붙여넣기 · 폭 맞춤 · 함수 도움말
- spreadsheet.ts — 가짜 부품 자리(parts) 걷고 A · B · C · E 직접 잇기 · 2단계는 spreadsheet_extras.ts 로
- spreadsheet_extras.ts(새) — 메모 풍선(마우스 올림) · Shift+F2 메모 편집 · 서식 붓 단추 · Ctrl+Shift+V 값만 · 서식만 · 수식만 · 도구 모음 뒤 초점 되돌림
- spreadsheet_find_panel.ts(새) — Ctrl+F · Shift+F5 찾기 · Ctrl+H 바꾸기 패널
- spreadsheet_editor.ts — C editorSlot(격자 뿌리 기준) → 층 안 좌표 맞춤(slotOf) · 함수 자동 완성(Tab) · 인자 도움말
- spreadsheet_keys.ts · spreadsheet_mouse.ts — 2단계 잇기 자리 · 열 경계 두 번 누르면 폭 자동 맞춤
- 시험 — 함수 도움말 자리 판정 추가(8 묶음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 20:57:25 +09:00
eomsangdonandClaude Opus 5.5 77c132adb5 feat(spreadsheet): 자동 필터 화면 — 머리 행 단추 · 값 목록 체크 · 이 열 정렬 · 필터 켜기 · 끄기
- spreadsheet_filter.ts — attachFilter(ctx) 단추 층(격자 overlay) · 값 목록 창(모두 선택 · 빈 칸 · 이 열 필터 지움 · 오름 · 내림) · toggleFilter · open(열)
  · 걸러진 행은 엔진이 행.걸러짐 으로 적음(사용자 숨김과 따로) · 잇는 법(D · C)은 머리 주석
- 시험 틀 harness_sortfilter.html · .ts · _bundle.cjs — 진짜 부품에 정렬 · 필터를 얹어 ORCA 로 봄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 20:56:38 +09:00
eomsangdonandClaude Opus 5.5 a4ad54571c feat(spreadsheet): 정렬 — 엑셀 차례 · 여러 기준 · 머리 행 · 식 복사식 옮김 · 대화 상자 — 계약 더함(정렬 · 필터 명령 · Sheet.필터 · RowInfo.걸러짐)
- 계약 더함: Command 「정렬」(차례를 박음 → 다시 하기가 같음) · 「필터」(걸러짐 행을 박음) · AutoFilter · Sheet.필터 · RowInfo.걸러짐 · 파일 목록
- spreadsheet_sortfilter.ts(A 엔진) — 수 < 글 < 참거짓 < 오류 · 빈 칸 늘 맨 뒤 · 같은 값 원래 차례 · 대소문자 옵션 · 현재 영역
  · 정렬 옮김 = 범위 열만 · 식은 상대 참조만 복사처럼 · 범위 밖 식 그대로 · 서식 · 메모 따라감 · 걸러진 행 제자리 · 병합 걸치면 막음
  · 자동 필터 엔진(값 목록 · 걸러짐 셈 · 적용 · 행열 넣기 · 지우기 때 필터 따라감) · 되돌림 짝 = 시트통째
- spreadsheet_sort_dialog.ts — 정렬 대화 상자(범위 · 머리 행 짐작 · 기준 더하기 · 오름 · 내림) · 빠른 정렬 sortSelection
- spreadsheet_commands_sheet.ts — 시트 명령을 떼어 냄(commands 700 줄 안쪽)
- 시험 test_spreadsheet_sortfilter.py 엑셀 규칙표 4 묶음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 20:56:28 +09:00
eomsangdon a52f8fcd87 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-09-27 20:54:31 +09:00
eomsangdon fddaa4edfb feat(spreadsheet): E 화면 시험 진짜 A · B 엔진으로 다시 · 클립보드 HTML 견본 추가
- 화면 시험 틀 가짜(fakes/) 걷고 진짜 `applyCommand` · `createCalcEngine` · `createHistory` · 주소 · 숫자 형식으로 재시험
- 서식 · 병합 · 탭 이름 바꾸면 다른 시트 참조 식도 옮김 · 안↔안 클립보드 수식 상대 이동을 실제 엔진 계산값으로 확인
- 엑셀(<table> + mso 스타일 · colspan · x:str/x:num · 「수식 표시」) · 구글(google-sheets-html-origin · data-sheets-formula) 클립보드 HTML 견본 추가 · `parseClipboard` 로 붙여넣기 시험
- 이 PC 는 엑셀 앱 없음 · 구글 시트는 로그인 상태로 새 문서까지 열었으나 자동화 창이 OS 포커스를 못 받아(Document is not focused) 실제 클립보드 왕복은 못 함 — 견본 파일 시험으로 갈음
- toolbar · tabs · menu · clipboard 머리 주석에 D(`spreadsheet.ts`) 잇는 자리 설명 추가
2026-09-27 20:54:17 +09:00
eomsangdon a644b2617c Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_4 2026-09-27 20:46:10 +09:00
eomsangdon 83a3a57568 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-09-27 20:46:09 +09:00
eomsangdonandClaude Sonnet 5 7d9fc6eea5 feat(spreadsheet): C 격자 — 시트 기본값 반영 · data-addr · 판별 editorSlot · 메모 세모 · 시험 틀
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 20:45:30 +09:00
eomsangdon d1164e29e4 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_2 2026-09-27 20:44:09 +09:00
eomsangdon 5c570e5a95 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_2 2026-09-27 20:44:08 +09:00
eomsangdon 4f1510bb48 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_3 2026-09-27 20:44:04 +09:00
eomsangdon 0f970c8c20 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_3 2026-09-27 20:44:03 +09:00
eomsangdonandClaude Sonnet 5 c8bb862a5c Merge remote-tracking branch 'origin/dev' into sub_laptop_4
충돌 49개 구조물 json + Store.py + test_m02_structure.py — dev 쪽 산출근거 분리(basis/) 구조를 따르고 내 쪽 도면.layers 기본 도면층을 얹어 합침.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 20:43:35 +09:00
eomsangdon 0415177f44 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-09-27 20:42:49 +09:00
eomsangdon e214350683 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_4 2026-09-27 20:40:28 +09:00
eomsangdonandClaude Opus 5.5 85fa62ae4d feat(M02): 임시 스프레드시트 시험 단추가 inspect 문맥을 켬(브레인 검증용)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-27 20:39:35 +09:00
eomsangdonandClaude Sonnet 5 b93e03e505 feat(spreadsheet): fill.ts를 A 엔진 실 구현으로 잇기 · numfmt 실무 서식 대조 고침
fill.ts가 A의 실제 moveFormula·toA1을 쓰도록 정리(식 채우기 시험 통과).
실무 구조도 xlsx 세 벌 실측 형식 코드 66개 대조 — `_x` trailing 공백을 trim이 삼키던 버그,
글 값에 순수 수 형식이 적용되던 버그, `General"단위"` 접미 버릇, `[$통화-로캘]` 태그 미지원을 고침.
분수 형식(`0/0`)은 1단계 형식 부분집합 밖이라 남겨둠. 회귀 시험 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-27 20:39:03 +09:00
eomsangdon 88866fb4a5 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-09-27 20:38:18 +09:00
eomsangdon 8ad456d1e1 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_8 2026-09-27 20:37:43 +09:00
eomsangdonandClaude Opus 5.5 6f8ab6e067 test(spreadsheet): 늦게 불러옴 시험에서 시험용 진입(resources/tester) 뺌
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-27 20:37:05 +09:00
eomsangdon 3dc523ef41 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-09-27 20:36:08 +09:00
eomsangdonandClaude Opus 5.5 48a2135132 feat(spreadsheet): D 잇기 — 진짜 A 엔진과 재검증 · inspect 선택(window.__aisloSheet · 임시) · 시험 틀 한 번 시나리오
- spreadsheet.ts — `inspect` 선택이면 문맥을 window.__aisloSheet 에 걺(M02 [스프레드시트 시험] 단추 전용 · destroy 때 뗌 · 1단계 검증 뒤 지움)
- 시험 틀 — ssScenario()(페이지 안 한 번에 한글 · 가리키기 · 재계산 · 되돌리기 · Delete · Ctrl+Enter · 수식 입력줄 · F2) · 가짜 격자 data-addr · 진짜 분수 값 보임 · 오류 모음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 20:33:55 +09:00
eomsangdon bfd7123829 feat(spreadsheet): E 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 구현
- 도구 모음: 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 · 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식 · 되돌리기
- 시트 탭: 더하기 · 이름 바꾸기 · 지우기 · 복사 · 끌어 옮기기
- 우클릭 메뉴: 칸 · 행 머리 · 열 머리별 명령(공용 `ui_template_context_menu` 재사용)
- 클립보드: 안 → 안 서식 · 병합 · 수식 상대 이동 왕복 · 밖 → 안(엑셀 값 + 「수식 표시」 · 구글 R1C1) · 안 → 밖(TSV + 인라인 서식 HTML)
- E 화면 시험 틀(A · B 몫 가짜 대역) · TSV 파싱 Node 시험 추가
2026-09-27 20:33:26 +09:00
eomsangdon b8312343eb Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-09-27 20:30:35 +09:00
eomsangdonandClaude Sonnet 5 d5c5090b79 feat(spreadsheet): C 격자 그리기 — DOM 4 판 · 병합 · 틀고정 · 숨김 · 폭 끌기 · 서식 CSS
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 20:30:17 +09:00
eomsangdon b184d18a50 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_2 2026-09-27 20:29:47 +09:00
eomsangdon 095eca0ddf Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_2 2026-09-27 20:29:44 +09:00
eomsangdon f77b14b51c Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_2 2026-09-27 20:29:43 +09:00
eomsangdon 9f240931f5 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_5 2026-09-27 20:28:16 +09:00
eomsangdon d8db2683f9 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_5 2026-09-27 20:28:14 +09:00
eomsangdon 1bdbba72f9 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-09-27 20:26:38 +09:00
eomsangdon f27c83cd93 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_4 2026-09-27 20:26:21 +09:00
eomsangdon 82910bec0a Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_4 2026-09-27 20:26:21 +09:00
eomsangdonandClaude Opus 5.5 b58073730f test(spreadsheet): A 엔진 무게 시험 — 칸 1만 · 식 5천 재계산 · 6만 8천 칸 불러오기 · 나눗셈 사슬 분모 상한
- bench_spreadsheet_engine.cjs — 얕은 시트 · 깊은 사슬 5천 · 큰 시트(271 행 × 253 열) · 소수 역수 합 · 곱나눗셈 사슬 수치
- test_spreadsheet_bench.py — 딸린 칸 수 · 시간 한도(실측 몇 배) · 분모 41 자리 안 · double 과 상대 오차 1e-12 안
- helper_spreadsheet_engine.cjs — 적재 · 가짜 함수를 무게 시험이 같이 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 20:25:30 +09:00
eomsangdonandClaude Sonnet 5 0d2d5614aa feat(spreadsheet): 2단계 여섯째 — 메모(풍선 설명 · 빨간 세모)
브레인 답(2026-09-27) — 저장 자리는 `Sheet.comments?: Record<A1, 글>`(sub3 가 0 계약에 보탬 ·
이 창은 손대지 않고 `SheetWithComments` 로 모양만 가정). 명령 전용 「메모」 종류가 아직 없어
`시트통째` 명령에 얹음(전용 명령이 생기면 `applyComments` 한 곳만 고치면 됨). 행열 넣기·지우기
때 메모 주소 옮김은 A 의 refshift 를 부르는 자리로 파일 머리에 적어 둠(지금은 비움).

시험 harness(test_stage2_helpers.ts) 에 「시트통째」 명령 적용도 더함(다른 2단계 시험도 씀).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 20:24:39 +09:00
eomsangdonandClaude Sonnet 5 7725b39ecb feat(spreadsheet): 스프레드시트 B(함수 · 숫자 형식 · 채우기) 1단계 구현
수학 26 · 논리/글/찾기/세기 22 함수 · 엑셀 숫자 형식 코드 해석기 · 채우기 핸들(복사 · 등차 연속 · 글 끝번호 · 식 상대이동) 구현.
자기 시험 69건(resources/tester/test_spreadsheet_b.py, vite ssr 번들 → node) 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-27 20:23:45 +09:00
eomsangdonandClaude Opus 5.5 279ef5fb4f feat(spreadsheet): A 엔진 뺀 셋 채움 · 메모 옮김 — 계약 더함(Sheet.기본 · Sheet.comments)
- 계약 더함: Sheet.기본 { 서식 · 열폭 · 행높이 } — 모두 고르기 서식 · 폭 · 높이 · 칸 서식 물려받기 = 칸 → 행 → 열 → 시트 기본 → 0
- 계약 더함: Sheet.comments — A1 → 메모 글 · 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 · 덮은 칸 메모는 지움)
- 행 · 열 넣기 = 위(왼) 줄 칸 서식 · 높이 · 폭 따라감(숨김은 안 따라감 · 첫 줄 앞 넣기는 안 따라감)
- 범위를 칸 하나 자리에 넣으면 엑셀 암묵 교차(한 열 범위 = 식 든 행 · 한 행 범위 = 식 든 열)
- 풀이 차례를 반복 위상 순서로 — 2만 칸 사슬도 스택 안 넘침
- 시험 resources/tester/spreadsheet/test_spreadsheet_engine.py 규칙 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 20:23:32 +09:00
eomsangdon bc5b4217c1 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-09-27 20:22:13 +09:00
eomsangdonandClaude Opus 5.5 a2011c8035 feat(spreadsheet): D 입력 — 고름 · 칸 편집기 · 한글 조합 · 수식 입력줄 · 참조 가리키기 · 단축키 · 채우기 끌기 · 잇기
- spreadsheet.ts — createSpreadsheet 잇기(문맥 · 명령 → 되돌림 → 엔진 → 격자 · onChange · onSelect · 시트마다 고름 기억) · parts 한 곳(시험 틀이 빈 몸 부품을 바꿔 끼움)
- spreadsheet_selection.ts — 병합으로 늘림 · Shift/Ctrl 방향 · 숨김 건너뜀 · 행열 머리 · 고름 안 Enter/Tab 돌기
- spreadsheet_editor.ts — 숨은 textarea 늘 초점 · compositionstart/input 로 편집 시작 · enter/edit 방식 · 수식 입력줄 · 주소 상자 · 참조 넣기 · 색 테두리 · 입력 글 규칙(' · % · 천 단위)
- spreadsheet_keys.ts · spreadsheet_mouse.ts — 엑셀 단축키 · 끌기 고르기 · 채우기 핸들(늘림 · 줄여 지움 · 두 번 누르면 아래로) · 우클릭 자리
- 시험 resources/tester/spreadsheet — 규칙 7 묶음 · 가짜 부품 번들 시험 틀 html(ORCA)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 20:22:02 +09:00
eomsangdon 5807313d21 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-27 20:20:23 +09:00
eomsangdonandClaude Sonnet 5 d38ef573e0 feat(spreadsheet): 2단계 다섯 — 찾기 · 붙여넣기 골라서 · 서식 붓 · 폭 자동 맞춤 · 함수 도움말
PLAN 11-8 스프레드시트 2단계. 0 계약(spreadsheet_types.ts · spreadsheet_view_types.ts)
타입만 보고 새 파일 다섯 짬 — 잇기는 1단계 끝에 D(sub7)가 파일 머리 주석대로:

- spreadsheet_find.ts        찾기 · 바꾸기(대소문자 · 전체칸 · 수식 안)
- spreadsheet_paste_special.ts 붙여넣기 골라서(값만 · 서식만 · 수식만)
- spreadsheet_format_painter.ts 서식 붓(칸→행→열→기본 차례로 서식을 찾음)
- spreadsheet_autofit.ts      열 머리 두 번 눌러 폭 자동 맞춤(어림 재기 · 실제 재기 자리 열어 둠)
- spreadsheet_func_help.ts    함수 자동 완성 · 인자 도움말(1단계 함수 48 개)

여러 범위 · 확대 · 눈금선 끄기는 이미 계약에 있어 새 파일 없이 붙일 자리만 확인(autofit.ts 머리).
시험 = resources/tester/spreadsheet/test_stage2_*.ts(공용 견본 · 가짜 컨텍스트는 test_stage2_helpers.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 20:20:16 +09:00
eomsangdon cd05bfef3b Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_3 2026-09-27 20:18:44 +09:00
eomsangdon 0750b2f0d3 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-09-27 20:17:26 +09:00
eomsangdonandClaude Sonnet 5 dd7300aa02 fix(M02): 구조물·도면 빈 문서에 기본 도면층 채움
- Store.py create_structure · UI_Side.ts stubDoc(도면 양식 「새로」) 둘 다
  빈 CAD 문서에 기본 도면층 하나(id·name "기본") 넣음 — 없으면 sub1 이 고친
  CAD import 가드가 대신 채우지만 뿌리도 처음부터 채움
- 이미 만들어진 구조물 도면 49개(resources/master_template/structure/*.json)
  도면.layers 채움(한 번 도는 정리) — 다른 칸·도번은 그대로 · 줄바꿈도 그대로
- 시험(test_m02_structure.py) — 새 문서 layers 1개 확인으로 바꿈

검증: M02 시험 28개 · 루트 npm run typecheck 통과. ORCA 로 확인 —
빈 구조물 도면(rv) 오류 없이 열림 · 도면 양식 「새로」 빈 도면 오류 없이 열림 ·
실제 선 하나 넣어 저장 → 다시 열기 → 화면에 그대로 · [저장] 단추로도 저장됨
(검증 뒤 rv 는 원래 빈 문서로 되돌림 · 검증용 임시 양식은 지움).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 20:16:46 +09:00
eomsangdonandClaude Opus 5.5 736dac4eb5 feat(spreadsheet): 산출근거 스프레드시트 A 엔진 핵심 — 주소 · 파서 · 풀이 · 의존 재계산 · 참조 옮김 · 명령 · 되돌리기
- address · parser — A1 ↔ 행열 · 낱말(편집기 색칠용 자리) · 엑셀 우선순위 나무(-2^2=4 · % · & · 비교) · 나무 → 글
- eval — 분수 풀이 · 오류 값 전파(왼쪽 먼저) · 형 바꾸기(빈 칸 · 수 글 · 참거짓) · 일반 형식 글 15 자리 · 무리수 15 자리
- graph — 의존 그래프 · 고친 칸에 딸린 칸만 재계산 · 순환 = 고리 칸마다 #CYCLE! + 까닭
- refshift — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 · 시트 지우기 참조 옮김(지운 칸 #REF!) · 복사 상대 옮김 · 구글 R1C1 → A1
- commands · history · recalc — 명령마다 되돌림 짝 · 병합 규칙 · 서식 한 벌 표 · 묶음 원자 · Node 한 벌 풀이
- 시험 resources/tester/spreadsheet/test_spreadsheet_engine.py — 엑셀 규칙표 9 묶음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 20:14:31 +09:00
eomsangdonandClaude Opus 5.5 cd824aca25 feat(spreadsheet): F 서버 재계산 · 산출근거 따로 저장 · M02 끼우기 · 금빛 시험
- common_util_spreadsheet_node.ts · common_util_spreadsheet.py · build:spreadsheet — [저장] 때 화면과 같은 TS 엔진을 Node 로 돌려 계산값 정본(브라우저 값 버림 · 못 풀면 503)
- M02 새 종류 basis — resources/master_template/basis/<열 id>.json(구조물집계표 열마다) · 층 복사 · 뼈대 검사 · 옛 구조물 문서 산출근거 49 벌 옮김(migrate_basis) · 새 열도 같이 만듦
- 상세 산출근거 카드 = 새 스프레드시트(import() 로 늦게 받음) · basis 읽기 · 저장
- ⚠ 임시 [스프레드시트 시험] 단추 — 빈 통합문서(시트 셋) · 저장 자리 tmp/spreadsheet_trial.json · 1단계 검증 뒤 지움
- 시험 resources/tester/spreadsheet/ — 저장 · 서버 다리 · 크기 예산 · M02 만 늦게 불러옴 · 실무 구조도 xlsx 세 벌 수식 2,699 칸 엑셀 캐시값 대조(엔진 빈 몸이면 건너뜀)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-27 20:12:39 +09:00
eomsangdon d53ef8d3bf Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_1 2026-09-27 20:02:04 +09:00
eomsangdon 14bf7b1def Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-27 20:02:02 +09:00
eomsangdonandClaude Sonnet 5 2dc0a71ba7 feat(cad): 리본 접기 기능 · M02 도면 편집을 접힌 채로 엶
- ui-state.ts ribbonCollapsed · Ribbon.tsx 접기 단추 · 탭 누르면 펼침
- App.css --cad-ribbon-height 를 Toolbar.tsx 가 접힘 때 26px 로
- aislo-drawing-bridge.ts load 곁값 ribbonCollapsed 받아 반영
- cad_host.ts CadLoadExtra 칸 추가 · M02_MasterTemplete_Drawing.ts 가 true 로 보냄
  (도면 양식 · 구조물 도면 둘 다 이 파일 한 곳을 씀)
- B07 은 곁값을 안 보내 펼침 그대로(코드 확인 · cad.load 호출 둘 다 5번째 인자 없음)
- 구조물 편집 머리 제목(② 열 머리 글)은 이미 다른 창이 끝냈음(commit 65f43ca0) — 손대지 않음

검증: CAD 빌드 · 루트 npm run typecheck · M02 시험 18개(구조물·도면 영역) 통과 ·
ORCA 로 도면 양식(00_template_A1) 처음 접힘(26px) → 단추로 펼침(122px) 확인.
구조물 도면(rv 등) 은 서버가 만드는 빈 문서에 `layers` 키가 없어 CAD import 단계에서
조용히 실패(레이어 접근 에러) — 리본 접기와 무관한 기존 결함 · 손대지 않음(브레인에게 보고).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 20:00:53 +09:00
eomsangdonandClaude Sonnet 5 4a6e0ffb3a fix(B07): CAD 파일 뿌리 서식으로 되돌림(방금 커밋에 딸려 온 서식 사고)
바로 앞 커밋에 뿌리 prettier 서식(큰따옴표·스페이스)이 실수로 같이 실려 옴 —
이 파일은 openwebcad 자기 서식(biome · tab · 홑따옴표)을 그대로 씀 · 논리는 그대로.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 19:58:39 +09:00
eomsangdonandClaude Sonnet 5 1c5a803e1b fix(M02): 구조물 도면 미리보기 특성·도면층 숨김 · CAD 빈 도면 불러오기 오류 고침
- 구조물 도면 미리보기(도면 정보 카드)에서 CAD 특성·도면층 패널(.cad-inspector)도 같이 숨김
- B07 CAD `layers` 칸이 없는 도면(빈 구조물 도면 · 「새 도면」 빈 도면 공통)을 열면
  "Cannot read properties of undefined (reading 'length')" 로 죽던 것을 고침
  (`layers` 없으면 새 도면층 하나로 채움)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 19:57:24 +09:00
eomsangdonandClaude Opus 5.5 26fd4fb72e feat(spreadsheet): 산출근거 스프레드시트 0 계약 — 타입 · 엔진 머리 · 저장 견본
- spreadsheet_types.ts · spreadsheet_view_types.ts — 문서 · 칸 · 서식 · 수식 나무 · 값 · 명령 · 계산 엔진 · 화면 문맥 계약
- 머리 주석에 파일 · 주인(A~F) · 예산 줄 수 · 크기 예산(gzip 54 KB) · 규칙(외부 라이브러리 0 · M02 만 늦게 불러옴)
- A · B 엔진 · C 격자 · D 진입 · E 도구 공개 함수 머리(빈 몸)
- spreadsheet_sample.json — basis/<열 id>.json 한 벌 견본(병합 · 서식 · 다른 시트 참조 · 숨김 · 계산값)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-27 19:48:20 +09:00
eomsangdonandClaude Sonnet 5 65f43ca083 feat(M02): 구조물 도면 = 좌측 셋째 컨테이너 · 열마다 미리 만듦
- 좌측 「구조물 도면」을 「표 양식」·「도면 양식」과 같은 층 컨테이너로
- 구조물집계표 열마다 빈 구조물 도면을 서버가 미리 만들어 둠(도번 자동 · 도각 없는
  빈 CAD 문서 · 산출근거 빈 줄 셋) — 좌측 줄에 항상 도번이 붙음
- 새 열을 더해 집계표를 저장하면 그 열의 빈 구조물 도면도 서버가 같이 만듦
  (`M02_MasterTemplete_Store.write` → `backfill_missing_structures`)
- 좌측 구조물 도면 줄을 누르면 그 CAD 화면이 곧장 열림(열 머리글 · 도번을 제목에)
- 지금 있는 49개 열의 구조물 도면을 미리 만들어 커밋(`resources/master_template/structure/`)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 19:17:17 +09:00
eomsangdonandClaude Sonnet 5 ef5a7e9771 fix(M02): 좌측 패널 공용 틀 M01 과 맞춤
- ui_template_overlay.css 에 ui-sidebar-panel · ui-sidebar-card · ui-sidebar-list ·
  ui-sidebar-row 공용 클래스 추가
- M01·M02 좌측 코드가 공용 클래스를 씀 — 카드 여백·둥글기·바탕 · 목록 간격 ·
  줄 선택 표시 한 벌
- M02 좌측 전용 CSS 중복 규칙 제거(없는 토큰 --radius-md 로 둥글기 안 먹던 것도 해소)
- M02 표/도면/구조물 카드 처음 접힘으로 M01 과 맞춤 · 새로·본떠·지우기 단추 줄 카드 안으로

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 18:24:34 +09:00
eomsangdonandClaude Sonnet 5 243d00c218 fix(M02): 구조물 설계 컨테이너 미리보기에서 CAD 도구 모음 숨김
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EprXZenk62RAhh6G4v1iEF
2026-09-27 12:04:47 +09:00
eomsangdonandClaude Sonnet 5 7e694a8825 fix(M02): 구조물 산출근거 빈 자리 글 「표 없음」 으로 맞춤
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EprXZenk62RAhh6G4v1iEF
2026-09-27 11:58:34 +09:00
eomsangdon 65a6b6e942 Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-09-27 11:35:10 +09:00
eomsangdonandClaude Opus 5 9d1a4f9bdb feat(M02): 구조물집계표 열마다 설계 컨테이너 · 도면 줄 도번
- 집계표(시스템 층) 도면 줄에 `구-0N` 도번 — `fetchStructureNumbers` 를 `drawingLabel` 로
- 칸 고르기(`onSelect`)마다 표 밖 형제 자리에 컨테이너 — 열 머리·도번 · 도면 미리보기(읽기만) · [도면 수정]
- [도면 수정] = 고침 확인 뒤 없으면 `createStructure` · 같은 페이지에서 그 구조물 도면 CAD 편집 · 좌측 고름 표시
- 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번 · 산출근거는 그대로
- 컨테이너 아래 상세 산출근거 표(공종 · 규격 · 산출 내역 · 단위 · 수량(m당)) · 줄 더하기 · 저장
- 새 시험 `test_m02_structure_panel.py` — 집계표 이름 대조 · 창끼리 이름 계약 · `columnHead`

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TamXZKonwSwyGT74XwwCbC
2026-09-27 11:32:06 +09:00
eomsangdon a7f2383d90 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-27 11:22:24 +09:00
eomsangdon 8ae1111f16 auto: 2026-09-27 11:22 (EOMSANGDON-HOME) 2026-09-27 11:22:10 +09:00
eomsangdon 38a13bcb0b Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_3' into sub_desktop_1 2026-09-27 11:14:27 +09:00
eomsangdonandClaude Opus 5.5 1ae10e64e2 feat(sheet): 마스터 첫 쪽 도면 줄 · 칸 고르기 신호 onSelect
- 일위대가 줄 아래 도면 줄 s:drawing — 칸 글은 SheetOptions.drawingLabel(colId) · 없거나 빈 글이면 「없음」 · 읽기만
- 줄 차례(navRows) · MASTER_ROWS · KEY_UNIT 를 ops 로 옮김(Node 시험 가능)
- 칸을 고르면 SheetOptions.onSelect({ row, col, colId }) — 누르기 · 방향키 · 머리 칸
- 시험 test_sheet_drawing_select.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-27 11:14:10 +09:00
eomsangdonandClaude Sonnet 5 83e08c2c7f feat(M02): 좌측 표 양식 · 도면 양식 접히는 컨테이너 · 구조물 도면 묶음 · 롱기튜디널 두 양식 지움
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53E6xKBaqhUZucMJ1wBYX
2026-09-27 11:12:12 +09:00
eomsangdonandClaude Opus 5.5 fabc033c53 feat(M02): 양식 종류 structure — 구조물 도면 새로(도번 자동) · 도번 목록 길 · 프로젝트 복사에 실림
- 종류 셋(table · drawing · structure) · 뼈대 검사 한 벌(도면.entities · 산출근거.열)
- POST /api/m02/structures/{열 id} — A1 도각 + 빈 산출근거 표(머리 다섯) · 도번 = 있는 것 중 가장 큰 번호 + 1 · 있으면 409 · 없는 열 404
- GET /api/m02/structures/numbers — {열 id: 도번}
- Api_Fetch — Kind 에 structure · StructureDoc · listStructures · createStructure · fetchStructureNumbers
- 폴더 resources/master_template/structure/ · 시험 test_m02_structure.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yTsevyL7QysasVVgWW7vW
2026-09-27 11:09:55 +09:00
eomsangdon db46810f7b auto: 2026-09-27 10:31 (EOMSANGDON-HOME) 2026-09-27 10:31:36 +09:00
eomsangdon ddfe562c2e Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-25 22:30:23 +09:00
eomsangdon c6af3b9b2a Merge remote-tracking branch 'origin/main_desktop_1' into main_desktop_1 2026-09-25 22:30:02 +09:00
eomsangdon 4f71b4a5ce feat(M02): 마스터 템플릿 라우터 연결 · formula 빌드 경로 정리
- main.py: M02_MasterTemplete 라우터 3개(시스템·층·도면) 등록
- tsconfig.json: include 목록에 M0* 추가
- package.json: build:formula 대상을 common_util_sheet_recalc_node.ts 로 변경
2026-09-25 22:29:21 +09:00
eomsangdonandClaude Sonnet 5 db489c7c3a fix(M02): 작도 영역 창을 단추 바로 아래 오른쪽 맞춤으로 · 창 밖으로 안 나가게
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
2026-09-25 18:18:10 +09:00
eomsangdonandClaude Sonnet 5 ef63904866 fix(M02): 작도 영역을 값이 다 보이는 단추 하나로 — 누르면 칸 넷이 작은 창으로 펼쳐짐
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
2026-09-25 18:04:27 +09:00
eomsangdonandClaude Sonnet 5 39c79f05d0 refactor(M02): 좌측 패널을 시스템 양식 목록만으로 — 층 · 프로젝트 · 프로젝트 단추 다섯 뺌 · 제목 줄 headerSlot 연결
작도 영역 칸은 제목 줄 가운데로. 그림 칸 위쪽 246px.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
2026-09-25 17:55:38 +09:00
eomsangdonandClaude Sonnet 5 5b6cf0103e feat(M02): 도면 칸 위쪽을 줄임 — 자리표 글 줄 뺌 · CAD 제목 줄 숨김 · 작도 영역 한 줄 · headerSlot 옵션
CAD 그림 칸 위쪽 448px 에서 285px 로, 그림 높이 598 에서 760 으로.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
2026-09-25 17:43:43 +09:00
eomsangdonandClaude Sonnet 5 847f0e44de fix(M02): 개인 층 지우기에 판을 보냄 · 저장 안 한 고침이 있으면 머리 메뉴·뒤로 가기로 떠날 때도 확인(취소하면 M02 에 남고 주소 되돌림) · 내 양식·회사 공식 저장 뒤 열던 양식 유지
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148XFUpPfpiuTjxF1EK9c95
2026-09-25 17:19:24 +09:00
eomsangdonandClaude Sonnet 5 7b2c22d920 fix(M02): 병합에서 빠진 CAD 호스트 생성 · 💾 저장 통로를 도면 부품에 되살림
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
2026-09-25 17:11:24 +09:00
eomsangdonandClaude Sonnet 5 8f55821a6a merge: origin/dev 받음 — 도면 칸 CAD 💾 · 접기와 sub1 저장 안 한 고침 알림 통로를 다 살림
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
2026-09-25 17:06:37 +09:00
eomsangdon b6b4d7772d Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-09-25 17:04:53 +09:00
eomsangdon d3ca77fcb5 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-09-25 17:04:52 +09:00
eomsangdonandClaude Sonnet 5 6555313a22 fix(M02): 재검증 흠 넷 — 같은 이름 토스트 · 쓸 수 없는 글자 막기(화면·서버 400) · 저장 안 한 고침 버리기 확인 · 덮어쓰기 확인과 개인·회사 지우기 단추
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148XFUpPfpiuTjxF1EK9c95
2026-09-25 17:01:47 +09:00
eomsangdonandClaude Sonnet 5 1cd52cfdfb fix(M02): 도면 칸 CAD 💾 · Ctrl+S 가 페이지 [저장] 을 부름 · 엉터리 도각 파일은 400 · 자리표 패널 접기 단추 (PLAN 10-3)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
2026-09-25 16:56:05 +09:00
eomsangdonandClaude Sonnet 5 c411d866bb fix(sheet): 합계 칸 풍선에 알림 까닭과 식을 함께 (M02 전수 재검증)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
2026-09-25 16:55:05 +09:00
eomsangdonandClaude Sonnet 5 1e29f5e0b2 fix(sheet): 수 칸에 수 아닌 글이면 오류 색 + 풍선 · 합계 칸에도 알림 (M02 전수 재검증)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
2026-09-25 16:53:17 +09:00
eomsangdonandClaude Sonnet 5 576b012eae feat(M02): 개인 · 회사 층 양식 지우기 — DELETE /layers/{층}/templates/{종류}/{이름} · 개인 본인만 · 회사 관리자만 · 시스템 · 프로젝트 거절 · 판 다르면 409 · 이름 검사 400 + 까닭
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LuapLYqN1GGFD8Y1PStD5
2026-09-25 16:52:52 +09:00
eomsangdonandClaude Sonnet 5 2aa4c80577 fix(M02): 저장 직후 지우기가 409 로 거절되던 흠 — 저장 뒤 좌측 목록 다시 읽음 · 지우기 전에도 최신 판으로
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148XFUpPfpiuTjxF1EK9c95
2026-09-25 13:03:01 +09:00
eomsangdonandClaude Sonnet 5 acca7d1fb6 fix(sheet): 머리 칸 / 로 층 나눔 · 줄 머리 칸 고정 · 줄 머리 칸 폭 자동 (M02 재검증)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
2026-09-25 12:45:57 +09:00
eomsangdon 4679261634 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_4' into sub_laptop_3 2026-09-25 12:44:02 +09:00
eomsangdonandClaude Sonnet 5 28b7e70f28 fix(M02): 층 길 저장 PUT 이 뼈대 없는 문서(빈 {} · 표 열 목록 없음 · 도면 entities 없음)를 400 으로 거절 — 파일 안 씀 (브레인 급한 흠)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LuapLYqN1GGFD8Y1PStD5
2026-09-25 12:43:25 +09:00
eomsangdonandClaude Sonnet 5 9bf3258509 fix(M02): 도면 양식 저장이 빈 {} 로 덮이던 흠 — getDoc 을 await · 서버는 뼈대 없는 문서를 400 으로 거절
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148XFUpPfpiuTjxF1EK9c95
2026-09-25 12:43:03 +09:00
eomsangdon dd60b6a1cb Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into main_laptop_1 2026-09-25 12:16:18 +09:00
eomsangdonandClaude Opus 5.5 07953fa523 feat(M02): 옛 표 틀 새 판으로 갱신 — Migrate --refresh-tables · 손 값 · 변수 · 보기 지킴 · _initial 도 같은 판 (브레인 최종 확인)
- 계산 열에 바인딩이 붙은 옛 틀만 골라 시스템 새 판으로
- 손댄 것이 없으면 시스템 파일 그대로 복사(판이 시스템과 같음) · 있으면 고쳐 쓰고 manifest 에 출처 판
- 기존 프로젝트 6개 작업본 · _initial 갱신 완료

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LuapLYqN1GGFD8Y1PStD5
2026-09-25 12:03:10 +09:00
eomsangdonandClaude Opus 5.5 6cc98d68f1 fix(sheet): 마스터 딱지는 식 먼저 · 식 줄은 열 이름 · 머리 글 안 잘리는 최소 폭 · 쪽 나눔 시험 (M02 브레인 확인)
- 식 있는 열 = 계산 · 바인딩만 = 설계값(펼침이면 값마다 열) · 손 = 손 입력
- 식 줄은 [열id] 대신 머리 글 이름 · 같은 종류는 짧게 · 풍선은 긴 이름 + id 식 · 변수도 이름
- 열 폭 최소 = 머리 글 · 단위 폭(캔버스로 잼) · 들어갈 것 · 식 줄은 세 줄로 자르고 풍선에 전문 · 머리 칸도 풍선
- 쪽 나눔을 pagePlan 으로 떼어 시험 test_sheet_pages.py(줄 120 → 쪽 3 · 합계 마지막 쪽만)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
2026-09-25 12:02:13 +09:00
eomsangdonandClaude Opus 5.5 abd0d4b126 fix(M02): 구조물집계표 묶음 열은 값마다 한데 · 빈 설계값 머리는 「높이 미입력」 꼴 · 계산 열 설계값 딱지 뗌 (브레인 최종 확인)
- 관공 등 묶음 열 차례 = 관종·관경마다 관매설 · 커플링 짝 — 머리 합치기가 값마다 묶임
- 머리틀은 맨 자리표 + 값꼴(H={} · T={}cm · Φ{}) · 빈 값은 빈값 글 · 열 id 는 그대로(빈 값 -)
- 포장 A · 수축줄눈 · 커플링 · 큰돌쌓기 면적 = 바인딩 대신 펼침틀(묶음 · 머리틀 · 식틀)
- 식틀 자리 값이 비면 식을 빼고 설명에 적음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LuapLYqN1GGFD8Y1PStD5
2026-09-25 12:00:54 +09:00
eomsangdonandClaude Sonnet 5 e8615db238 fix(M02): 층 단추 눌린 표시 · 한 줄 폭 맞춤, 프로젝트 기본값은 마지막 고른 것 → 내 최근 것 → 빈 칸
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148XFUpPfpiuTjxF1EK9c95
2026-09-25 11:57:41 +09:00
407 changed files with 54769 additions and 247665 deletions
+3
View File
@@ -67,6 +67,8 @@ config/corridor_node/
config/server_calc_node/
# 구조물도 식 풀이 번들 — `npm run build:formula` 산출물(2026-09-13).
config/formula_node/
# 산출근거 스프레드시트 재계산 번들 — `npm run build:spreadsheet` 산출물.
config/spreadsheet_node/
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
@@ -77,3 +79,4 @@ docs/wiki/graphify-out/.graphify_incremental.json
# 까닭 — 코드 창에서 graphify 가 돌 때마다 이 폴더가 바뀌어, 위키 브랜치를 받을 때
# 「덮으면 네 것이 날아간다」로 **병합이 막혔다**. 4.3MB·443파일도 함께 가벼워진다.
docs/wiki/graphify-out/cache/
~$*
+22 -2
View File
@@ -8,11 +8,18 @@
* - hashchange 1회 구독: 셸 chrome 갱신 + 현재 라우트 렌더
* ========================================================================== */
import "./random_uuid_polyfill";
import "@ui/ui_template_theme.css";
import { injectBaseStyles } from "@ui/ui_template_elements";
import { ROUTES, type RoutePath } from "@config/config_frontend";
import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
import { currentRoute, renderCurrentRoute, ensureInitialHash } from "./router";
import {
currentRoute,
renderCurrentRoute,
ensureInitialHash,
setLeaveGuard,
takeLeaveGuard,
} from "./router";
const FOOTERLESS_ROUTES: readonly RoutePath[] = [
ROUTES.B01_ACCOUNT,
@@ -44,14 +51,27 @@ function bootstrap(): void {
initThemeAndLang();
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
let shown = "";
const renderAll = (): void => {
shown = currentRoute();
const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute());
const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신
void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더
};
// 4. hashchange 1회 구독 (리스너 누적 방지)
window.addEventListener("hashchange", renderAll);
window.addEventListener("hashchange", () => {
const guard = takeLeaveGuard();
if (!guard || currentRoute() === shown) return renderAll();
void guard().then((ok) => {
if (ok) {
setLeaveGuard(null);
renderAll();
} else {
history.replaceState(null, "", `#/${shown}`); // 취소 — 머문 채 주소만 되돌림
}
});
});
// 5. 최초 진입
ensureInitialHash();
+16
View File
@@ -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 {};
+7
View File
@@ -65,6 +65,13 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate,
};
/** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */
let leaveGuard: (() => Promise<boolean>) | null = null;
export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
leaveGuard = guard;
};
export const takeLeaveGuard = (): (() => Promise<boolean>) | null => leaveGuard;
/** 로그인 여부 (토큰 존재 확인) */
export async function isAuthenticated(): Promise<boolean> {
const { fetchSession } = await import("../A06_Login/A06_Login_Api_Fetch");
+319
View File
@@ -0,0 +1,319 @@
/* spreadsheet.css (주인 D) — 부품 틀 · 주소 상자 · 수식 입력줄 · 칸 편집기 · 채우기 미리보기 · 알림 */
.ss {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
font-size: 13px;
color: var(--color-text-body);
background: var(--color-surface-raised);
}
.ss-grid-host {
flex: 1 1 auto;
min-height: 0;
}
.ss-bar {
display: flex;
flex: none;
align-items: stretch;
height: 26px;
border-bottom: 1px solid var(--color-border);
}
.ss-addr {
width: 96px;
padding: 0 6px;
border: none;
border-right: 1px solid var(--color-border);
background: transparent;
color: inherit;
font: inherit;
outline: none;
}
.ss-addr:focus {
box-shadow: inset 0 0 0 2px var(--color-focus-ring);
}
.ss-fx {
align-self: center;
padding: 0 8px;
color: var(--color-text-muted);
font-style: italic;
user-select: none;
}
.ss-formula {
flex: 1;
padding: 4px 6px;
border: none;
border-left: 1px solid var(--color-border);
background: transparent;
color: inherit;
font: inherit;
line-height: 18px;
white-space: pre;
overflow: hidden;
resize: none;
outline: none;
}
.ss--readonly .ss-formula {
color: var(--color-text-secondary);
}
/* 칸 편집기 = 숨은 textarea — 쉴 때는 활성 칸 자리에 투명하게(한글 조합 창 자리) */
.ss-input {
position: absolute;
z-index: 5;
box-sizing: border-box;
margin: 0;
padding: 1px 3px;
border: 2px solid var(--color-focus-ring);
background: var(--color-surface-raised);
color: var(--color-text-body);
font: inherit;
line-height: 1.25;
white-space: pre;
overflow: hidden;
resize: none;
outline: none;
}
.ss-input--idle {
width: 1px;
height: 1px;
min-width: 0 !important;
min-height: 0 !important;
padding: 0;
border: 0;
opacity: 0;
pointer-events: none;
}
.ss-fill-preview {
position: absolute;
z-index: 4;
box-sizing: border-box;
border: 1px dashed var(--color-text-secondary);
pointer-events: none;
}
.ss-toast {
position: absolute;
right: 12px;
bottom: 40px;
z-index: 20;
padding: 6px 10px;
border-radius: 4px;
background: var(--color-primary);
color: var(--color-primary-text);
}
/* 2단계 잇기 — 서식 붓 · 찾기 패널 · 메모 · 골라 붙여넣기 */
.ss-brush {
flex: none;
padding: 0 8px;
border: none;
border-right: 1px solid var(--color-border);
background: none;
color: inherit;
font: inherit;
cursor: pointer;
}
.ss-brush.is-on {
background: color-mix(in srgb, var(--color-focus-ring) 20%, var(--color-surface-raised));
box-shadow: inset 0 0 0 2px var(--color-focus-ring);
}
.ss--painting .ss-grid-host {
cursor: copy;
}
.ss-find {
position: absolute;
top: 4px;
right: 16px;
z-index: 20;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-find[hidden],
.ss-find__row[hidden],
.ss-memo[hidden],
.ss-pick[hidden],
.spreadsheet-comment-balloon[hidden] {
display: none;
}
.ss-find__row {
display: flex;
align-items: center;
gap: 4px;
}
.ss-find__input {
width: 180px;
padding: 2px 6px;
border: 1px solid var(--color-border);
background: var(--color-surface-raised);
color: var(--color-text-body);
font: inherit;
}
.ss-find__btn {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-surface);
color: var(--color-text-body);
font: inherit;
cursor: pointer;
}
.ss-find__opt {
display: flex;
align-items: center;
gap: 2px;
font-size: 12px;
}
.ss-find__status {
font-size: 12px;
color: var(--color-text-secondary);
}
.ss-memo,
.spreadsheet-comment-balloon {
position: absolute;
z-index: 15;
min-width: 140px;
max-width: 260px;
padding: 4px 6px;
border: 1px solid var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 15%, var(--color-surface-raised));
color: var(--color-text-body);
font: inherit;
white-space: pre-wrap;
box-shadow: var(--shadow-sm);
}
.ss-memo {
min-height: 60px;
resize: both;
outline: none;
}
.ss-pick {
position: absolute;
z-index: 20;
display: flex;
gap: 2px;
padding: 4px;
border: 1px solid var(--color-border);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-pick > button {
padding: 2px 8px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-body);
font: inherit;
cursor: pointer;
}
/* 함수 자동 완성 · 인자 도움말 */
.ss-hint {
position: absolute;
z-index: 6;
min-width: 200px;
max-width: 360px;
border: 1px solid var(--color-border);
background: var(--color-surface-raised);
font-size: 12px;
box-shadow: var(--shadow-lg);
}
.ss-hint[hidden] {
display: none;
}
.ss-hint__item,
.ss-hint__help {
padding: 2px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ss-hint__item {
cursor: pointer;
}
.ss-hint__item.is-on {
background: color-mix(in srgb, var(--color-accent) 18%, var(--color-surface-raised));
}
/* 데이터 메뉴 — 패널 창 · 유효성 목록 ▼ */
.ss-dialog {
position: absolute;
top: 4px;
right: 16px;
z-index: 21;
max-height: calc(100% - 8px);
overflow: auto;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-dialog[hidden],
.ss-list-arrow[hidden] {
display: none;
}
.ss-dialog__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid var(--color-border);
font-weight: bold;
}
.ss-dialog__body {
padding: 8px;
}
.ss-list-arrow {
position: absolute;
z-index: 8;
width: 16px;
padding: 0;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-body);
font-size: 9px;
cursor: pointer;
}
.ss-validation__dropdown {
position: absolute;
z-index: 20;
}
+290
View File
@@ -0,0 +1,290 @@
/* =============================================================================
* spreadsheet.ts (주인 D)
* 산출근거 스프레드시트 진입 — `createSpreadsheet(칸, 통합문서, {readOnly, onChange, onSelect})` →
* `{getDoc, setDoc, recalc, destroy}`(표 부품 `createSheet` 와 같은 모양 · M02 는 부르는 줄만 바꿈).
* 문맥(`SpreadsheetContext`)을 만들어 C 격자 · E 도구 · 탭 · 메뉴 · 클립보드를 붙이고 D 입력(고름 · 편집기 ·
* 키 · 마우스)을 잇음. 한글 조합 = 숨은 textarea 에 늘 초점 · compositionstart 로 편집 시작(keydown 글자 방식 금지).
* ⚠ M02 만 `import()` 로 불러옴 — 정적 import 금지(다른 페이지 번들에 안 섞이게).
* 저장은 부른 쪽 몫(`onChange` 로 문서 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
* 화면 차례: 도구 모음(E) · 주소 상자 + 수식 입력줄(D) · 격자(C) · 시트 탭(E) · 상태 표시줄(sub4).
* 2단계 부품(찾기 · 메모 · 서식 붓 · 골라 붙여넣기)은 `spreadsheet_extras.ts` 로 이음.
* ========================================================================== */
import "./spreadsheet.css";
import { el } from "@ui/ui_template_elements";
import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
import { attachClipboard } from "./spreadsheet_clipboard";
import { applyCommand, CommandError } from "./spreadsheet_commands";
import { createEditor, slotOf } from "./spreadsheet_editor";
import { toCalcValue } from "./spreadsheet_eval";
import { attachExtras } from "./spreadsheet_extras";
import { createCalcEngine } from "./spreadsheet_graph";
import { createGrid } from "./spreadsheet_grid";
import { createHistory } from "./spreadsheet_history";
import { attachKeys } from "./spreadsheet_keys";
import type { LinkOptions, Links } from "./spreadsheet_links";
import { attachMenu } from "./spreadsheet_menu";
import { attachMouse } from "./spreadsheet_mouse";
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
import { createStatusBar } from "./spreadsheet_statusbar";
import { mountTabs } from "./spreadsheet_tabs";
import { mountToolbar } from "./spreadsheet_toolbar";
import type {
CalcValue,
CalcValues,
Command,
CommandEffect,
SheetCellAddress,
Workbook,
} from "./spreadsheet_types";
import type {
EditorHandle,
GridHandle,
PartHandle,
Selection,
SpreadsheetContext,
} from "./spreadsheet_view_types";
export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types";
export type { LinkItem, LinkOptions } from "./spreadsheet_links";
export interface SpreadsheetOptions {
/** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */
readOnly?: boolean;
/** 명령마다 문서 사본 — 페이지가 캐시(sessionStorage)에 쌓고 [저장] 단추를 켬 */
onChange?: (book: Workbook) => void;
/** 고름이 바뀜 — 시트 id · 활성 칸 A1 · 범위 A1 */
onSelect?: (selection: { 시트: string; 칸: string; 범위: string }) => void;
/** 아래 시트 탭 줄 — false 면 안 그림(산출근거 = 시트 하나) · 시트 오가기 · 새 시트 단축키 · 시트 메뉴도 끔 */
tabs?: boolean;
/** 위 알약 막대 — 입력값 · 자재를 칸에 연결(`spreadsheet_links.ts` · 이때만 불러옴) */
links?: LinkOptions;
}
export interface SpreadsheetHandle {
/** 문서 사본 */
getDoc(): Workbook;
/** 문서 바꿔 끼움 — 되돌리기 이력 비움 · 고름 A1 */
setDoc(book: Workbook): void;
/** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */
recalc(): CalcValues;
/** 알약 키 → 연결 칸 값(빈 칸 0) */
getLinkedValues(): Record<string, CalcValue>;
destroy(): void;
}
/** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */
function farCorner(sel: Selection): { r: number; c: number } {
const g = sel.범위[sel.범위.length - 1];
const a = sel.기준;
const whole = (lo: number, hi: number, last: number): boolean => lo === 0 && hi === last;
return {
r: whole(g.r0, g.r1, LAST_R)
? sel.활성.r
: Math.abs(g.r1 - a.r) >= Math.abs(a.r - g.r0)
? g.r1
: g.r0,
c: whole(g.c0, g.c1, LAST_C)
? sel.활성.c
: Math.abs(g.c1 - a.c) >= Math.abs(a.c - g.c0)
? g.c1
: g.c0,
};
}
export function createSpreadsheet(
host: HTMLElement,
book: Workbook,
opts: SpreadsheetOptions = {},
): SpreadsheetHandle {
const readOnly = !!opts.readOnly;
const noTabs = opts.tabs === false;
const root = el("div", {
className: `ss${readOnly ? " ss--readonly" : ""}${noTabs ? " ss--notabs" : ""}`,
});
host.append(root);
const firstSheet = (b: Workbook) => b.시트.find((s) => s.id === b.활성) ?? b.시트[0];
/** 시트마다 마지막 고름(탭을 오가도 그대로) */
const memory = new Map<string, Selection>();
const ctx: SpreadsheetContext = {
root,
book,
readOnly,
engine: createCalcEngine(book),
history: createHistory(),
selection: selectCell(firstSheet(book), 0, 0),
sheet: () => ctx.book.시트.find((s) => s.id === ctx.selection.시트) ?? ctx.book.시트[0],
dispatch,
undo: () => replay(ctx.history.undo()),
redo: () => replay(ctx.history.redo()),
select,
showSheet,
grid: null as unknown as GridHandle,
editor: null as unknown as EditorHandle,
};
const grid = createGrid(ctx);
ctx.grid = grid;
const ed = createEditor(ctx);
ctx.editor = ed;
const toolbar: PartHandle | null = readOnly ? null : mountToolbar(ctx);
const tabs: PartHandle | null = noTabs ? null : mountTabs(ctx);
const menu: PartHandle | null = readOnly ? null : attachMenu(ctx);
const clip = attachClipboard(ctx);
const status = createStatusBar(ctx);
const others = [toolbar, tabs, menu, clip, status].filter((p): p is PartHandle => !!p);
grid.root.classList.add("ss-grid-host");
for (const node of [toolbar?.root, ed.bar, grid.root, tabs?.root, status.root])
if (node) root.append(node);
const extras = attachExtras(ctx, ed, toolbar?.root ?? null);
others.push(...extras.parts);
const detachKeys = attachKeys(ctx, ed, extras.keys);
const detachMouse = attachMouse(ctx, ed, extras.mouse);
const toast = el("div", { className: "ss-toast" });
let toastTimer = 0;
/** 알약 막대 · 연결 따라가기 — 선택이 있거나 문서에 연결이 있을 때만 불러옴(크기 예산) */
let links: Links | null = null;
let linking = false;
function loadLinks(): void {
if (linking || !(opts.links || ctx.book.시트.some((s) => s.연결?.length))) return;
linking = true;
void import("./spreadsheet_links").then((m) => {
if (!root.isConnected) return; // 받기 전에 닫힘
links = m.mountLinks(ctx, opts.links, () => ed.focus(), slotOf);
others.push(links);
links.refresh();
});
}
grid.render();
select(ctx.selection);
loadLinks();
function notify(message: string): void {
toast.textContent = message;
root.append(toast);
clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => toast.remove(), 3000);
}
function apply(command: Command): CommandEffect | null {
try {
return (links?.apply ?? applyCommand)(ctx.book, command);
} catch (e) {
if (e instanceof CommandError) return (notify(e.message), null);
throw e;
}
}
function dispatch(command: Command): void {
if (readOnly) return;
const eff = apply(command);
if (!eff) return;
ctx.history.push(command, eff.undo);
after(command, eff);
}
function replay(command: Command | null): void {
if (readOnly || !command || ed.editing()) return;
const eff = apply(command);
if (eff) after(command, eff);
}
function after(command: Command, eff: CommandEffect): void {
if (eff.rebuild) {
ctx.engine.rebuild(ctx.book);
if (!ctx.book.시트.some((s) => s.id === ctx.selection.시트)) {
const s = firstSheet(ctx.book);
ctx.selection = memory.get(s.id) ?? selectCell(s, 0, 0);
}
grid.render();
} else {
const again = ctx.engine.update(eff.cells);
if (command.종류 === "칸") grid.invalidate([...again, ...touched(command.시트, command.칸)]);
else grid.render();
}
select(ctx.selection);
opts.onChange?.(structuredClone(ctx.book));
}
/** 칸 명령이 건드린 칸(서식만 바뀐 칸도 다시 그리게) */
function touched(sheet: string, cells: Record<string, unknown>): SheetCellAddress[] {
const out: SheetCellAddress[] = [];
for (const key of Object.keys(cells)) {
const a = parseA1(key);
if (a) out.push({ 시트: sheet, ...a });
}
return out;
}
function select(selection: Selection, reveal = false): void {
ctx.selection = selection;
memory.set(selection.시트, selection);
grid.renderSelection();
if (reveal) {
const far = farCorner(selection);
grid.reveal(far.r, far.c);
}
ed.sync();
for (const p of others) p.refresh();
const g = selection.범위[0];
opts.onSelect?.({
시트: selection.시트,
칸: toA1(selection.활성.r, selection.활성.c),
범위: g ? rangeToA1(g) : toA1(selection.활성.r, selection.활성.c),
});
}
function showSheet(sheetId: string): void {
const sheet = ctx.book.시트.find((s) => s.id === sheetId);
if (!sheet || sheetId === ctx.selection.시트) return;
ed.commit();
if (ed.editing()) return; // 유효성에 막힘
ctx.book.활성 = sheetId;
ctx.selection = memory.get(sheetId) ?? selectCell(sheet, 0, 0);
grid.render();
select(ctx.selection, true);
ed.focus();
}
return {
getDoc: () => structuredClone(ctx.book),
setDoc(next: Workbook) {
ed.cancel();
ctx.book = next;
ctx.engine.rebuild(next);
ctx.history.clear();
memory.clear();
ctx.selection = selectCell(firstSheet(next), 0, 0);
grid.render();
select(ctx.selection);
loadLinks();
},
recalc() {
ctx.engine.rebuild(ctx.book);
grid.render();
for (const p of others) p.refresh();
return ctx.engine.snapshot();
},
getLinkedValues() {
const out: Record<string, CalcValue> = {};
for (const s of ctx.book.시트)
for (const l of s.연결 ?? []) {
const p = parseA1(l.칸);
if (p) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
}
return out;
},
destroy() {
clearTimeout(toastTimer);
detachKeys();
detachMouse();
for (const p of others) p.destroy();
ed.destroy();
grid.destroy();
root.remove();
},
};
}
@@ -0,0 +1,143 @@
/* =============================================================================
* spreadsheet_actions.ts (주인 D)
* 단축키 표(sub1 `spreadsheet_shortcuts.ts`) 「안됨」 잇기 + 3차 부품 메뉴 자리([데이터 ▾] 에 붙임).
* Ctrl+Shift++ · Ctrl+- 칸 넣기 · 지우기(행 · 열 통째면 바로 · 아니면 sub1 창) · F5 · Ctrl+G 이동 ·
* Ctrl+9/0 숨김 · Ctrl+Shift+9/0 풀기 · Ctrl+B/I/U/5 글꼴 뒤집기 · Ctrl+Shift+& 바깥 테두리 ·
* Ctrl+Shift+_ 테두리 없앰 · Ctrl+; 오늘 날짜 · Ctrl+PageDown/PageUp 시트 · Shift+F11 새 시트 ·
* Shift+Alt+→/← 행 · 열 묶기 · 풀기(C 윤곽 +/- 가 접음).
* 여기는 키 · 메뉴 → 이름만 · 몸통은 `spreadsheet_actions_run.ts`(늦게 — 크기 예산).
* ========================================================================== */
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
goTo: "이동…(F5)",
freeze: "틀 고정",
freezeRow: "첫 행 고정",
freezeCol: "첫 열 고정",
freezeAt: "지금 칸 기준 고정",
unfreeze: "틀 고정 풀기",
insert: "칸 넣기…(Ctrl+Shift++)",
remove: "칸 지우기…(Ctrl+-)",
split: "텍스트 나누기…",
dedupe: "중복된 항목 제거…",
series: "연속 데이터 채우기…",
group: "묶기(Shift+Alt+→)",
ungroup: "묶음 풀기(Shift+Alt+←)",
outline: "윤곽",
};
export type Act =
| "goTo"
| "insert"
| "remove"
| "hideRows"
| "hideCols"
| "showRows"
| "showCols"
| "group"
| "ungroup"
| "outline"
| "clearBorders"
| "today"
| "next"
| "prev"
| "newSheet"
| "freezeRow"
| "freezeCol"
| "freezeAt"
| "unfreeze"
| "split"
| "dedupe"
| "series"
| `flip:${"굵게" | "기울임" | "밑줄" | "취소선"}`;
/** Ctrl+글자(e.code) → 이름 */
const CTRL: Record<string, Act> = {
KeyB: "flip:굵게",
KeyI: "flip:기울임",
KeyU: "flip:밑줄",
Digit5: "flip:취소선",
KeyG: "goTo",
Minus: "remove",
NumpadSubtract: "remove",
NumpadAdd: "insert",
Digit9: "hideRows",
Digit0: "hideCols",
Semicolon: "today", // ponytail: 시각(Ctrl+Shift+;)은 numfmt 시간 형식이 오면
PageDown: "next",
PageUp: "prev",
};
/** Ctrl+Shift+글자 → 이름 */
const CTRL_SHIFT: Record<string, Act> = {
Equal: "insert",
NumpadAdd: "insert",
Minus: "clearBorders",
Digit9: "showRows",
Digit0: "showCols",
Digit7: "outline",
PageDown: "next",
PageUp: "prev",
};
const SHEET_ACTS = new Set<Act>(["next", "prev", "newSheet"]);
/** body 에 뜬 창(.ui-confirm)이 다 닫히면 초점을 격자로 — 창을 연 바로 뒤에 부름 */
export function watchClose(back: () => void): void {
const w = new MutationObserver(() => {
if (document.querySelector("body > .ui-confirm, body > .ui-confirm__panel")) return;
w.disconnect();
back();
});
w.observe(document.body, { childList: true });
}
function pick(e: KeyboardEvent): Act | undefined {
const ctrl = e.ctrlKey || e.metaKey;
if (e.altKey)
return e.shiftKey && !ctrl
? ({ ArrowRight: "group", ArrowLeft: "ungroup" } as Record<string, Act>)[e.key]
: undefined;
if (ctrl) return (e.shiftKey ? CTRL_SHIFT : CTRL)[e.code];
if (e.key === "F5" && !e.shiftKey) return "goTo";
if (e.key === "F11" && e.shiftKey) return "newSheet";
}
export interface Actions {
key(e: KeyboardEvent): boolean;
items: MapContextMenuItem[];
}
export function mountActions(ctx: SpreadsheetContext, back: () => void): Actions {
const go = (act: Act): void =>
void import("./spreadsheet_actions_run").then((m) => m.run(ctx, back, act));
const item = (label: string, act: Act): [string, () => void] => [label, () => go(act)];
return {
key(e) {
const act = pick(e);
// 시트 탭이 없으면(산출근거) 시트 오가기 · 새 시트를 받지 않음 — 브라우저 몫으로 둠
if (!act || (SHEET_ACTS.has(act) && ctx.root.classList.contains("ss--notabs"))) return false;
go(act);
return true;
},
items: [
item(TEXT.goTo, "goTo"),
{
label: TEXT.freeze,
children: [
item(TEXT.freezeRow, "freezeRow"),
item(TEXT.freezeCol, "freezeCol"),
item(TEXT.freezeAt, "freezeAt"),
item(TEXT.unfreeze, "unfreeze"),
],
},
item(TEXT.insert, "insert"),
item(TEXT.remove, "remove"),
item(TEXT.split, "split"),
item(TEXT.dedupe, "dedupe"),
item(TEXT.series, "series"),
{ label: TEXT.outline, children: [item(TEXT.group, "group"), item(TEXT.ungroup, "ungroup")] },
],
};
}
@@ -0,0 +1,170 @@
/* =============================================================================
* spreadsheet_actions_run.ts (주인 D)
* `spreadsheet_actions.ts` 단축키 · 메뉴가 부르는 몸통 — 처음 누를 때 늦게 불러옴(크기 예산).
* ========================================================================== */
import { toA1 } from "./spreadsheet_address";
import { watchClose, type Act } from "./spreadsheet_actions";
import { cellStyleAt } from "./spreadsheet_cellstyle";
import { LAST_C, LAST_R } from "./spreadsheet_selection";
import type { CellRange, CellStyle, Command } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
/** 엑셀 날짜 일련번호 0 = 1899-12-30 */
const EPOCH = Date.UTC(1899, 11, 30);
export function run(ctx: SpreadsheetContext, back: () => void, act: Act): void {
const insert = () => import("./spreadsheet_insert_dialog");
const view = () => import("./spreadsheet_view_menu");
const tools = () => import("./spreadsheet_data_tools");
/** 늦게 불러 창을 열고 닫히면 격자로 */
const dialog = <M>(load: () => Promise<M>, open: (m: M) => void) =>
void load().then((m) => (open(m), watchClose(back)));
const freezeBy = (pick: (m: Awaited<ReturnType<typeof view>>) => void) =>
void view().then((m) => (pick(m), back()));
const range = (): CellRange => ctx.selection.범위[0];
const fullRows = (g: CellRange) => g.c0 === 0 && g.c1 === LAST_C;
const fullCols = (g: CellRange) => g.r0 === 0 && g.r1 === LAST_R;
const span = (a: number, b: number) => Array.from({ length: b - a + 1 }, (_, i) => a + i);
const id = () => ctx.selection.시트;
/** 칸 넣기 · 지우기 — 행 · 열 통째 고름이면 엑셀처럼 창 없이 바로 */
function cells(add: boolean): void {
const g = range();
if (fullRows(g) || fullCols(g)) {
const rows = fullRows(g);
ctx.dispatch({
종류: rows ? (add ? "행넣기" : "행지우기") : add ? "열넣기" : "열지우기",
시트: id(),
at: rows ? g.r0 : g.c0,
수: rows ? g.r1 - g.r0 + 1 : g.c1 - g.c0 + 1,
});
} else
dialog(insert, (m) => (add ? m.openInsertCellsDialog(ctx) : m.openDeleteCellsDialog(ctx)));
}
function hide(rows: boolean, 숨김: boolean): void {
const g = range();
const 번호 = rows ? span(g.r0, g.r1) : span(g.c0, g.c1);
ctx.dispatch({ 종류: "숨김", 시트: id(), 축: rows ? "행" : "열", 번호, 숨김 });
}
function group(on: boolean): void {
const g = range();
const cols = fullCols(g) && !fullRows(g);
const 번호 = cols ? span(g.c0, g.c1) : span(g.r0, g.r1);
const 묶음 = on ? { 층: 1 } : null;
ctx.dispatch({ 종류: "묶음설정", 시트: id(), 축: cols ? "열" : "행", 번호, 묶음 });
}
function flip(key: keyof CellStyle): void {
const { r, c } = ctx.selection.활성;
const on = !!cellStyleAt(ctx.book, ctx.sheet(), r, c)[key];
ctx.dispatch({ 종류: "서식", 시트: id(), 범위: ctx.selection.범위, 바꿀: { [key]: !on } });
}
/** 바깥 테두리(가는 선) — 가장자리 칸마다 지금 테두리에 변을 더함 */
function outline(): void {
const g = range();
const n = (g.r1 - g.r0 + 1) * (g.c1 - g.c0 + 1);
// ponytail: 가장자리 칸만 셈 · 행 · 열 통째(백만 칸)는 안 함 — 쓴 범위로 자르면 풀림
if (fullRows(g) || fullCols(g) || n > 1e5) return;
const cmds: Command[] = [];
for (let r = g.r0; r <= g.r1; r++)
for (let c = g.c0; c <= g.c1; c++) {
const sides = [
r === g.r0 && "위",
r === g.r1 && "아래",
c === g.c0 && "왼",
c === g.c1 && "오른",
].filter(Boolean) as ("위" | "아래" | "왼" | "오른")[];
if (!sides.length) continue;
const 테두리 = { ...cellStyleAt(ctx.book, ctx.sheet(), r, c).테두리 };
for (const s of sides) 테두리[s] = { 선: "thin" };
cmds.push({
종류: "서식",
시트: id(),
범위: [{ r0: r, c0: c, r1: r, c1: c }],
바꿀: { 테두리 },
});
}
ctx.dispatch({ 종류: "묶음", 명령: cmds });
}
function today(): void {
const d = new Date();
const 값 = Math.round((Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) - EPOCH) / 864e5);
const { r, c } = ctx.selection.활성;
const key = toA1(r, c);
const 서식 = ctx.sheet().칸[key]?.서식;
ctx.dispatch({
종류: "묶음",
명령: [
{ 종류: "칸", 시트: id(), 칸: { [key]: 서식 === undefined ? { 값 } : { 값, 서식 } } },
{
종류: "서식",
시트: id(),
범위: [{ r0: r, c0: c, r1: r, c1: c }],
바꿀: { 형식: "yyyy-mm-dd" },
},
],
});
}
/** 다음 · 이전 보이는 시트 */
function sheetStep(d: number): void {
const list = ctx.book.시트.filter((s) => !s.숨김);
const i = list.findIndex((s) => s.id === id());
const to = list[i + d];
if (to) ctx.showSheet(to.id);
}
const goTo = () => dialog(view, (m) => m.openGoToDialog(ctx));
function clearBorders(): void {
ctx.dispatch({ 종류: "서식", 시트: id(), 범위: ctx.selection.범위, 바꿀: { 테두리: null } });
}
if (act.startsWith("flip:")) return flip(act.slice(5) as keyof CellStyle);
switch (act) {
case "goTo":
return goTo();
case "insert":
case "remove":
return cells(act === "insert");
case "hideRows":
case "hideCols":
case "showRows":
case "showCols":
return hide(act.endsWith("Rows"), act.startsWith("hide"));
case "group":
case "ungroup":
return group(act === "group");
case "outline":
return outline();
case "clearBorders":
return clearBorders();
case "today":
return today();
case "next":
case "prev":
return sheetStep(act === "next" ? 1 : -1);
case "newSheet":
return ctx.root.querySelector<HTMLElement>(".ss-tabs__add")?.click();
case "freezeRow":
return freezeBy((m) => m.freezeFirstRow(ctx));
case "freezeCol":
return freezeBy((m) => m.freezeFirstColumn(ctx));
case "freezeAt":
return freezeBy((m) => m.freezeAtActiveCell(ctx));
case "unfreeze":
return freezeBy((m) => m.unfreezePanes(ctx));
case "split":
return dialog(tools, (m) => m.openTextToColumnsDialog(ctx));
case "dedupe":
return dialog(tools, (m) => m.openRemoveDuplicatesDialog(ctx));
case "series":
return dialog(tools, (m) => m.openFillSeriesDialog(ctx));
}
}
@@ -0,0 +1,115 @@
/* =============================================================================
* spreadsheet_address.ts (주인 A)
* 주소 — A1 ↔ 행열(0 부터) · 범위 글 · 열 글자 · 시트 이름 따옴표 · Map 열쇠.
* 계약은 `spreadsheet_types.ts`.
* ========================================================================== */
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type { CellAddress, CellRange } from "./spreadsheet_types";
/** 0 → `A` · 27 → `AB` */
export function colName(c: number): string {
let s = "";
for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26)) {
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
}
return s;
}
/** `AB` → 27 · 못 읽으면 -1 (대소문자 가리지 않음) */
export function colIndex(letters: string): number {
if (!/^[A-Za-z]{1,3}$/.test(letters)) return -1;
let n = 0;
for (const ch of letters.toUpperCase()) n = n * 26 + ch.charCodeAt(0) - 64;
return n <= MAX_COLS ? n - 1 : -1;
}
/** 행 글(1 부터) → 0 부터 · 못 읽으면 -1 */
export function rowIndex(digits: string): number {
const n = /^\d{1,7}$/.test(digits) ? Number(digits) : 0;
return n >= 1 && n <= MAX_ROWS ? n - 1 : -1;
}
/** (30, 4) → `E31` */
export function toA1(r: number, c: number): string {
return colName(c) + (r + 1);
}
/** `E31` · `$E$31` → {r:30, c:4} · 못 읽으면 null */
export function parseA1(text: string): CellAddress | null {
const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(text.trim());
if (!m) return null;
const c = colIndex(m[1]);
const r = rowIndex(m[2]);
return c < 0 || r < 0 ? null : { r, c };
}
/** 칸 하나면 `E31` · 아니면 `C3:AD3` · 행 전체 `3:3` · 열 전체 `A:A` */
export function rangeToA1(range: CellRange): string {
const { r0, c0, r1, c1 } = range;
if (c0 === 0 && c1 === MAX_COLS - 1) return `${r0 + 1}:${r1 + 1}`;
if (r0 === 0 && r1 === MAX_ROWS - 1) return `${colName(c0)}:${colName(c1)}`;
const a = toA1(r0, c0);
return r0 === r1 && c0 === c1 ? a : `${a}:${toA1(r1, c1)}`;
}
/** 닫힌 범위로 세움(뒤집힌 것 바로) */
export function normRange(r0: number, c0: number, r1: number, c1: number): CellRange {
return {
r0: Math.min(r0, r1),
c0: Math.min(c0, c1),
r1: Math.max(r0, r1),
c1: Math.max(c0, c1),
};
}
/** `C3:AD3` · `E31` · `A:A` · `3:5` → 범위(뒤집힌 글은 바로 세움) · 못 읽으면 null */
export function parseRange(text: string): CellRange | null {
const parts = text.trim().split(":");
if (parts.length > 2) return null;
const [a, b = a] = parts;
const p = parseA1(a);
const q = parseA1(b);
if (p && q) return normRange(p.r, p.c, q.r, q.c);
if (parts.length !== 2) return null;
const strip = (s: string) => s.replace(/^\$/, "");
const ca = colIndex(strip(a));
const cb = colIndex(strip(b));
if (ca >= 0 && cb >= 0) return normRange(0, ca, MAX_ROWS - 1, cb);
const ra = rowIndex(strip(a));
const rb = rowIndex(strip(b));
if (ra >= 0 && rb >= 0) return normRange(ra, 0, rb, MAX_COLS - 1);
return null;
}
export function inRange(range: CellRange, r: number, c: number): boolean {
return r >= range.r0 && r <= range.r1 && c >= range.c0 && c <= range.c1;
}
export function rangesOverlap(a: CellRange, b: CellRange): boolean {
return a.r0 <= b.r1 && b.r0 <= a.r1 && a.c0 <= b.c1 && b.c0 <= a.c1;
}
/** `a` 가 `b` 안에 통째로 듦 */
export function rangeInside(a: CellRange, b: CellRange): boolean {
return a.r0 >= b.r0 && a.r1 <= b.r1 && a.c0 >= b.c0 && a.c1 <= b.c1;
}
/** 식에 넣을 시트 이름 — 글자 · 숫자 · _ 밖이 있으면 `'…'`(안의 `'` 는 `''`) */
export function quoteSheet(name: string): string {
const plain =
/^[\p{L}_][\p{L}\p{N}_]*$/u.test(name) &&
!/^[A-Za-z]{1,3}\d+$/.test(name) && // 칸 주소처럼 보이는 이름
!/^(TRUE|FALSE)$/i.test(name) &&
!/^R\d*C\d*$/i.test(name); // R1C1 처럼 보이는 이름
return plain ? name : `'${name.replace(/'/g, "''")}'`;
}
/** Map 열쇠 — r × MAX_COLS + c */
export function cellId(r: number, c: number): number {
return r * MAX_COLS + c;
}
export function fromCellId(id: number): CellAddress {
return { r: Math.floor(id / MAX_COLS), c: id % MAX_COLS };
}
@@ -0,0 +1,187 @@
/* spreadsheet_analysis.css (주인 A) — 추적 화살표 겹판 · 수식 계산 창 · 오류 ⚠ 단추 */
/* 추적 화살표 — 크기 0 겹판(격자 조작을 막지 않음) */
.ss-trace {
position: absolute;
left: 0;
top: 0;
width: 1px;
height: 1px;
overflow: visible;
pointer-events: none;
}
.ss-trace__line {
stroke-width: 1.5;
}
.ss-trace__line.is-plain,
.ss-trace__dot.is-plain,
.ss-trace__head.is-plain {
stroke: var(--color-info);
fill: var(--color-info);
}
.ss-trace__line.is-error,
.ss-trace__dot.is-error,
.ss-trace__head.is-error {
stroke: var(--color-danger);
fill: var(--color-danger);
}
.ss-trace__line.is-sheet,
.ss-trace__dot.is-sheet,
.ss-trace__head.is-sheet {
stroke: var(--color-text-body);
fill: var(--color-text-body);
}
.ss-trace__line.is-sheet {
stroke-dasharray: 4 3;
}
.ss-trace__box {
fill: none;
stroke-width: 2;
stroke: var(--color-info);
}
.ss-trace__box.is-error {
stroke: var(--color-danger);
}
.ss-trace__sheet {
fill: var(--color-surface);
stroke: var(--color-text-body);
}
/* 수식 계산 창 */
.ss-steps {
position: absolute;
inset: 0;
z-index: var(--z-dropdown);
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 18%);
}
.ss-steps__box {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 420px;
max-width: 90%;
padding: var(--spacing-12);
background: var(--color-surface-raised);
color: var(--color-text-body);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-lg);
font-size: 13px;
}
.ss-steps__title {
font-weight: 600;
}
.ss-steps__meta {
color: var(--color-text-secondary);
}
.ss-steps__text {
min-height: 60px;
padding: var(--spacing-8);
font-family: var(--font-mono, monospace);
white-space: pre-wrap;
word-break: break-all;
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.ss-steps__foot {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
/* 오류 ⚠ 단추 · 풍선 · 메뉴 */
.ss-errtip {
position: absolute;
z-index: var(--z-dropdown);
width: 0;
height: 0;
overflow: visible;
}
.ss-errtip__btn {
position: absolute;
width: 18px;
height: 18px;
padding: 0;
font-size: 11px;
line-height: 16px;
cursor: pointer;
color: var(--color-warning);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.ss-errtip__balloon {
display: none;
position: absolute;
left: 0;
top: 22px;
z-index: var(--z-dropdown);
width: max-content;
max-width: 320px;
padding: var(--spacing-4) var(--spacing-8);
font-size: 12px;
color: var(--color-text-body);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
}
.ss-errtip__btn:hover + .ss-errtip__balloon {
display: block;
}
.ss-errtip__menu {
position: absolute;
left: 0;
top: 22px;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
min-width: 200px;
max-width: 320px;
padding: var(--spacing-4);
font-size: 13px;
color: var(--color-text-body);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
}
.ss-errtip__head {
padding: var(--spacing-4);
font-weight: 600;
}
.ss-errtip__item {
padding: var(--spacing-4);
text-align: left;
color: inherit;
background: none;
border: none;
cursor: pointer;
}
.ss-errtip__item:hover {
background: var(--color-mist-violet);
}
@@ -0,0 +1,61 @@
/* =============================================================================
* spreadsheet_autofit.ts (2단계 · 주인 sub_laptop_1)
* 열 머리 경계를 두 번 눌러 폭 자동 맞춤 — 그 열 안 글자 수로 어림(엑셀 「글자 단위」).
* 진짜 픽셀 값은 C(격자)가 쓰는 글꼴로 재야 더 정확 — `measure` 자리에 C 의 실제 재기 함수를
* 나중에 꽂을 수 있게 열어 둠(안 주면 아래 어림 규칙을 씀).
*
* 잇는 법(D · sub7 · C 격자 머리) — 열 머리 경계에서 두 번 누르면(dblclick)
* `autofitColumn(ctx, col)` 을 부름. C 가 완성되면 `measure` 자리에 캔버스 `measureText` 를 넘겨 더 정밀하게.
*
* ── 이미 계약에 있어 새 파일이 필요 없는 것(붙일 자리만 확인) ──────────────────────
* · 여러 범위(Ctrl+누르기) — `spreadsheet_view_types.ts` `Selection.범위` 가 이미 배열(D `spreadsheet_selection.ts` 가 씀).
* · 눈금선 끄기 — `Command`(`종류:"눈금선"`) · `Sheet.보기?.눈금선` 이미 있음(A 가 처리).
* · 확대 · 축소 — 값 자체를 저장하지 않는 화면 전용 CSS 배율(C 격자 뿌리에 transform) · 계약 밖.
* ========================================================================== */
import type { Workbook } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
export type TextMeasurer = (text: string) => number;
const PADDING = 2;
const MIN_WIDTH = 4;
/** 글꼴을 모를 때 어림 — 한글 · 한자 · 전각은 1.9 배 폭 · 나머지는 1 배. */
const defaultMeasure: TextMeasurer = (text) => {
let width = 0;
for (const ch of text) width += ch.codePointAt(0)! >= 0x1100 ? 1.9 : 1;
return width;
};
const colFromKey = (key: string): number => {
const m = /^([A-Z]+)\d+$/.exec(key);
if (!m) return -1;
let col = 0;
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
return col - 1;
};
/** 그 열에 든 칸 글자(식 칸은 식 글로 어림 — 계산값은 여기서 모름) 중 가장 넓은 것 + 여백. */
export function estimateColumnWidth(
book: Workbook,
sheetId: string,
col: number,
measure: TextMeasurer = defaultMeasure,
): number {
const sheet = book.시트.find((s) => s.id === sheetId);
if (!sheet) return MIN_WIDTH;
let widest = 0;
for (const [key, cell] of Object.entries(sheet.칸)) {
if (colFromKey(key) !== col) continue;
const text = cell.식 !== undefined ? cell.식 : cell.값 !== undefined ? String(cell.값) : "";
if (text) widest = Math.max(widest, measure(text));
}
return Math.max(MIN_WIDTH, Math.ceil(widest + PADDING));
}
/** 폭을 자동 맞춤 값으로 바꿈(열넓이 명령 하나). */
export function autofitColumn(ctx: SpreadsheetContext, col: number, measure?: TextMeasurer): void {
const width = estimateColumnWidth(ctx.book, ctx.selection.시트, col, measure);
ctx.dispatch({ 종류: "열폭", 시트: ctx.selection.시트, 열: [col], 폭: width });
}
@@ -0,0 +1,70 @@
/* =============================================================================
* spreadsheet_autosum.ts (3단계 · 주인 sub_laptop_4)
* 자동 합계(Alt+= · 도구 단추 Σ) — 엑셀 규칙: 활성 칸 바로 위가 숫자면 그 위 연속 숫자 칸을
* 위로 죽 잡고, 아니면 바로 왼쪽이 숫자면 왼쪽으로 죽 잡음. 여러 칸을 고른 채면 그 고름
* 안에서(활성 칸 위 또는 왼쪽만) 잡음. 아무것도 못 찾으면 빈 `=SUM()`.
*
* 잇는 법(D · E · sub7 — 지금은 하나도 안 이어짐):
* 1) `spreadsheet_keys.ts` 에 Alt+= 를 매핑해 `applyAutoSum(ctx)` 를 부름.
* 2) `spreadsheet_toolbar.ts` 에 Σ 단추를 두고 눌리면 `applyAutoSum(ctx)` 를 부름.
* 3) 지금은 칸에 식 글만 바로 넣음(편집기를 안 거침) — 사용자가 범위를 눈으로 고쳐 잡게 하려면
* `ctx.editor.begin("=SUM(...)")` 로 편집 상태로 열고 커밋은 사용자가 Enter.
* ========================================================================== */
import { rangeToA1, toA1 } from "./spreadsheet_address";
import { isFrac } from "./spreadsheet_functions_math";
import type { CellRange } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
function isNumericCell(ctx: SpreadsheetContext, sheet: string, r: number, c: number): boolean {
if (r < 0 || c < 0) return false;
return isFrac(ctx.engine.value(sheet, r, c));
}
function verticalRun(
ctx: SpreadsheetContext,
sheet: string,
r: number,
c: number,
top: number,
): CellRange | null {
if (r - 1 < top || !isNumericCell(ctx, sheet, r - 1, c)) return null;
let start = r - 1;
while (start - 1 >= top && isNumericCell(ctx, sheet, start - 1, c)) start--;
return { r0: start, c0: c, r1: r - 1, c1: c };
}
function horizontalRun(
ctx: SpreadsheetContext,
sheet: string,
r: number,
c: number,
left: number,
): CellRange | null {
if (c - 1 < left || !isNumericCell(ctx, sheet, r, c - 1)) return null;
let start = c - 1;
while (start - 1 >= left && isNumericCell(ctx, sheet, r, start - 1)) start--;
return { r0: r, c0: start, r1: r, c1: c - 1 };
}
/** 활성 칸 기준 합산 범위를 찾음 — 없으면 null(빈 `=SUM()`). */
export function autoSumRange(ctx: SpreadsheetContext): CellRange | null {
const sheet = ctx.selection.시트;
const { r, c } = ctx.selection.활성;
const sel = ctx.selection.범위[0];
if (sel && (sel.r1 > sel.r0 || sel.c1 > sel.c0)) {
if (r > sel.r0) return { r0: sel.r0, c0: c, r1: r - 1, c1: c };
if (c > sel.c0) return { r0: r, c0: sel.c0, r1: r, c1: c - 1 };
}
return verticalRun(ctx, sheet, r, c, 0) ?? horizontalRun(ctx, sheet, r, c, 0);
}
/** 활성 칸에 `=SUM(...)` 을 바로 넣음(칸 명령 — 편집기를 안 거침). */
export function applyAutoSum(ctx: SpreadsheetContext): void {
const range = autoSumRange(ctx);
const { r, c } = ctx.selection.활성;
const 식 = range ? `SUM(${rangeToA1(range)})` : "SUM()";
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: { [toA1(r, c)]: { 식 } } });
}
@@ -0,0 +1,204 @@
/* =============================================================================
* spreadsheet_cellstyle.ts (주인 C)
* 서식 → CSS — 글꼴 · 정렬(균등 분할 · 선택 영역 가운데 · 세로 · 줄 바꿈) · 채움색 ·
* 테두리(hair · thin · medium · thick · double · dotted · dashed · 색) · 겹친 테두리 고르기.
* 칸 하나가 위 · 왼 이웃과 테두리를 나눠 가짐(표 `border-collapse` 방식) — 겹쳐 그리지 않게
* 위 · 왼만 두 이웃 중 굵은 쪽으로 그리고 오른 · 아래는 시트 끝 칸만 그림(`resolveEdgeBorders`).
* 0 계약 머리 — 몸은 C 가 채움. 계약은 `spreadsheet_types.ts`.
* ========================================================================== */
import { colName, toA1 } from "./spreadsheet_address";
import type {
BorderLine,
BorderSide,
CellStyle,
HAlign,
Sheet,
VAlign,
Workbook,
} from "./spreadsheet_types";
/** 선 굵기 차례 — 겹치면 굵은 쪽이 이김(엑셀과 완전히 같지 않음 · 근사). */
const BORDER_WEIGHT: Record<BorderLine, number> = {
hair: 0,
dotted: 1,
dashed: 2,
thin: 3,
double: 4,
medium: 5,
thick: 6,
};
/** 두 이웃 칸이 한 자리에 그리려는 테두리 중 굵은 쪽. 없으면 있는 쪽 · 둘 다 없으면 undefined. */
export function strongerBorder(a?: BorderSide, b?: BorderSide): BorderSide | undefined {
if (!a) return b;
if (!b) return a;
return BORDER_WEIGHT[b.선] > BORDER_WEIGHT[a.선] ? b : a;
}
/** 칸 하나가 실제로 그릴 네 변 — 위 · 왼은 이웃과 겹쳐 고름 · 오른 · 아래는 시트 끝(그 다음 칸이 없음)일 때만. */
export interface ResolvedBorders {
위?: BorderSide;
아래?: BorderSide;
왼?: BorderSide;
오른?: BorderSide;
}
/** `서식` 표 번호 — 칸 → 행 → 열 → 시트 `기본.서식` → 0(기본) 차례(칸 안의 계약 주석과 같음). */
export function styleIndexAt(sheet: Sheet, r: number, c: number, key: string): number {
const cell = sheet.칸[key];
if (cell?.서식 !== undefined) return cell.서식;
const rowFmt = sheet.행?.[String(r + 1)]?.서식;
if (rowFmt !== undefined) return rowFmt;
const colFmt = sheet.열?.[colName(c)]?.서식;
if (colFmt !== undefined) return colFmt;
if (sheet.기본?.서식 !== undefined) return sheet.기본.서식;
return 0;
}
/** (r, c) 칸이 쓸 서식 — 서식 표 밖 번호는 기본(빈 `{}`)으로. */
export function cellStyleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle {
const key = toA1(r, c);
const idx = styleIndexAt(sheet, r, c, key);
return book.서식[idx] ?? {};
}
/** 칸 하나 · 병합 칸 한 덩이(box)의 테두리 — 네 변마다 안쪽 이웃과 겹쳐 굵은 쪽으로 고름.
* 병합이면 왼위(r0,c0) · 오른아래(r1,c1) 이 다른 칸이라 네 변을 따로 봄(테두리는 칸마다 있음). */
export function resolveBoxBorders(
book: Workbook,
sheet: Sheet,
r0: number,
c0: number,
r1: number,
c1: number,
lastRow: number,
lastCol: number,
): ResolvedBorders {
const top = cellStyleAt(book, sheet, r0, c0).테두리?.위;
const aboveOf = r0 > 0 ? cellStyleAt(book, sheet, r0 - 1, c0).테두리?.아래 : undefined;
const left = cellStyleAt(book, sheet, r0, c0).테두리?.왼;
const leftOf = c0 > 0 ? cellStyleAt(book, sheet, r0, c0 - 1).테두리?.오른 : undefined;
const bottomOwn = cellStyleAt(book, sheet, r1, c0).테두리?.아래;
const belowOf = r1 < lastRow ? cellStyleAt(book, sheet, r1 + 1, c0).테두리?.위 : undefined;
const rightOwn = cellStyleAt(book, sheet, r0, c1).테두리?.오른;
const rightOf = c1 < lastCol ? cellStyleAt(book, sheet, r0, c1 + 1).테두리?.왼 : undefined;
return {
위: strongerBorder(top, aboveOf),
왼: strongerBorder(left, leftOf),
아래: r1 >= lastRow ? bottomOwn : strongerBorder(bottomOwn, belowOf),
오른: c1 >= lastCol ? rightOwn : strongerBorder(rightOwn, rightOf),
};
}
/** 색 글을 비교용으로 정리 — 공백 · 대소문자 · `#` 3자리 · ARGB(`#FF000000`) · `rgb(0,0,0)` 를 `#rrggbb` 로. */
function normalizeColor(c: string): string {
const t = c.trim().toLowerCase().replace(/\s+/g, "");
const rgb = /^rgba?\((\d+),(\d+),(\d+)(?:,1(?:\.0+)?)?\)$/.exec(t);
if (rgb)
return `#${[rgb[1], rgb[2], rgb[3]].map((n) => Number(n).toString(16).padStart(2, "0")).join("")}`;
if (/^#[0-9a-f]{3}$/.test(t)) return `#${t[1]}${t[1]}${t[2]}${t[2]}${t[3]}${t[3]}`;
if (/^#ff[0-9a-f]{6}$/.test(t)) return `#${t.slice(3)}`;
return t;
}
/** 엑셀 「자동」 글자색 · 테두리색 — 검정 · windowtext · auto 는 테마 글자색을 따름(저장 값은 그대로 · 그릴 때만). */
export function isAutoInk(c: string | undefined): boolean {
if (!c) return true;
const n = normalizeColor(c);
return n === "#000000" || n === "black" || n === "windowtext" || n === "auto";
}
/** 엑셀 「자동」 채움 — 흰색 · window 는 채움 없음처럼(테마 바탕이 비침). */
export function isAutoPaper(c: string | undefined): boolean {
if (!c) return true;
const n = normalizeColor(c);
return n === "#ffffff" || n === "white" || n === "window";
}
/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). `scale` = 확대 · 축소 배율(선 굵기도 같이). */
function borderCss(side: BorderSide | undefined, scale: number): string {
if (!side) return "";
const color = isAutoInk(side.색) ? "var(--aislo-grid-fg)" : (side.색 as string);
const w = (px: number): number => Math.max(1, Math.round(px * scale));
switch (side.선) {
case "hair":
return `${w(1)}px dotted ${color}`;
case "thin":
return `${w(1)}px solid ${color}`;
case "medium":
return `${w(2)}px solid ${color}`;
case "thick":
return `${w(3)}px solid ${color}`;
case "double":
return `${w(3)}px double ${color}`;
case "dotted":
return `${w(1)}px dotted ${color}`;
case "dashed":
return `${w(1)}px dashed ${color}`;
}
}
/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). */
const HALIGN_CSS: Record<HAlign, string> = {
general: "left",
left: "left",
right: "right",
center: "center",
distributed: "justify",
centerContinuous: "center",
};
const VALIGN_CSS: Record<VAlign, string> = {
top: "flex-start",
center: "center",
bottom: "flex-end",
};
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`).
* `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */
export function applyCellStyle(
cell: HTMLElement,
style: CellStyle,
borders: ResolvedBorders,
hAlign: HAlign,
scale = 1,
): void {
const s = cell.style;
s.fontFamily = style.글꼴 ?? "";
s.fontSize = style.크기 ? `${style.크기 * scale}pt` : "";
s.fontWeight = style.굵게 ? "700" : "400";
s.fontStyle = style.기울임 ? "italic" : "normal";
s.textDecorationLine = [style.밑줄 && "underline", style.취소선 && "line-through"]
.filter(Boolean)
.join(" ");
s.color = isAutoInk(style.글자색) ? "" : (style.글자색 as string);
s.backgroundColor = isAutoPaper(style.채움) ? "" : (style.채움 as string);
s.borderTop = borderCss(borders.위, scale);
s.borderLeft = borderCss(borders.왼, scale);
s.borderBottom = borderCss(borders.아래, scale);
s.borderRight = borderCss(borders.오른, scale);
s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"];
s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
let text = cell.firstElementChild as HTMLElement | null;
if (!text || !text.classList.contains("aislo-cell__text")) {
text = document.createElement("div");
text.className = "aislo-cell__text";
cell.replaceChildren(text);
}
text.style.textAlign = HALIGN_CSS[hAlign];
text.style.textAlignLast = hAlign === "distributed" ? "justify" : "";
}
/** 칸 글 — 서식 CSS 와 따로 두어 `invalidate()` 가 값만 바꿀 때 서식을 다시 잴 필요가 없게. */
export function setCellText(cell: HTMLElement, text: string, title?: string): void {
const node = cell.firstElementChild as HTMLElement | null;
if (node && node.classList.contains("aislo-cell__text")) {
node.textContent = text;
} else {
cell.textContent = text;
}
if (title) cell.title = title;
else cell.removeAttribute("title");
}
@@ -0,0 +1,624 @@
/* =============================================================================
* spreadsheet_clipboard.ts (주인 E)
* 복사 · 잘라내기 · 붙여넣기 — `copy` · `cut` · `paste` 사건만(허락 창 없음 · navigator.clipboard 안 씀).
* 형 · 수식 살리기 규칙은 `spreadsheet_types.ts` `ClipBlock` 머리. 잘라내기 = 복사 + `내용지움`(누른 순간
* 바로 지움) · 붙여넣기 = `칸` 명령(식은 A `moveFormula` · 구글 R1C1 은 `r1c1ToA1`) + 서식 · 병합 명령
* 묶음(칸 하나하나). 잘라낸 것을 같은 문서에 처음 붙이면(클립보드 HTML 의 `aislo-cut` 표 · 그 사이 잘라낸
* 자리에 새로 안 씀) 지운 칸을 되살려 `옮기기` 명령으로 — 그 칸을 가리키던 다른 식도 따라감(엑셀).
* 다른 앱 · 두 번째 붙여넣기는 보통 붙여넣기. 편집 중(`ctx.editor.editing()`)이면 사건을 편집기에 맡김.
*
* 이음(D `spreadsheet.ts` 잇는 자리) — `attachClipboard(ctx)` 한 번 부름(반환 `root` 없음 · 안 붙여도 됨).
* `ctx.root` 의 copy/cut/paste 사건을 직접 받으므로 그 요소가 포커스를 받을 수 있어야 함
* (D 의 숨은 textarea 가 늘 그 안에 있어야 함).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
import { formatValue } from "./spreadsheet_numfmt";
import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
import { st } from "./spreadsheet_text";
import type {
BorderLine,
BorderSide,
Cell,
CellAddress,
CellInput,
CellRange,
CellStyle,
ClipBlock,
ClipCell,
Command,
Scalar,
Sheet,
Workbook,
} from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
/* ── 안 → 밖 ─────────────────────────────────────────────────────────────── */
const BORDER_CSS: Record<BorderLine, string> = {
hair: "dotted",
thin: "solid",
medium: "solid",
thick: "solid",
double: "double",
dotted: "dotted",
dashed: "dashed",
};
function styleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle {
const cell = sheet.칸[toA1(r, c)];
const idx =
cell?.서식 ??
sheet.열?.[colName(c)]?.서식 ??
sheet.행?.[String(r + 1)]?.서식 ??
sheet.기본?.서식 ??
0;
return book.서식[idx] ?? {};
}
function styleToCss(style: CellStyle): string {
const parts: string[] = [];
if (style.글꼴) parts.push(`font-family:${style.글꼴}`);
if (style.크기) parts.push(`font-size:${style.크기}pt`);
if (style.굵게) parts.push("font-weight:bold");
if (style.기울임) parts.push("font-style:italic");
const deco = [style.밑줄 && "underline", style.취소선 && "line-through"]
.filter(Boolean)
.join(" ");
if (deco) parts.push(`text-decoration:${deco}`);
if (style.글자색) parts.push(`color:${style.글자색}`);
if (style.채움) parts.push(`background-color:${style.채움}`);
if (style.가로 && style.가로 !== "general") {
parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`);
}
if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`);
if (style.줄바꿈) parts.push("white-space:pre-wrap");
if (style.테두리) {
(["위", "아래", "왼", "오른"] as const).forEach((side) => {
const s = style.테두리?.[side];
if (!s) return;
const prop = {
위: "border-top",
아래: "border-bottom",
왼: "border-left",
오른: "border-right",
}[side];
parts.push(`${prop}:1px ${BORDER_CSS[s.선]} ${s.색 ?? "#000"}`);
});
}
return parts.join(";");
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** 안 → 안 왕복용 — 칸 원래 값 · 식 · 서식(인라인, 표 번호 아님) 그대로. */
function buildAisloBlock(book: Workbook, sheet: Sheet, range: CellRange): ClipBlock {
const { r0, c0, r1, c1 } = range;
const 칸: Record<string, ClipCell> = {};
for (let r = r0; r <= r1; r++) {
for (let c = c0; c <= c1; c++) {
const cell = sheet.칸[toA1(r, c)];
if (!cell) continue;
const clip: ClipCell = {};
if (cell.값 !== undefined) clip.값 = cell.값;
if (cell.식 !== undefined) clip.식 = cell.식;
if (cell.서식 !== undefined) clip.서식 = book.서식[cell.서식];
if (Object.keys(clip).length) 칸[`${r - r0},${c - c0}`] = clip;
}
}
const 병합 = (sheet.병합 ?? [])
.map((m) => parseRange(m))
.filter((m): m is CellRange => !!m && m.r0 >= r0 && m.c0 >= c0 && m.r1 <= r1 && m.c1 <= c1)
.map((m) => ({ r0: m.r0 - r0, c0: m.c0 - c0, r1: m.r1 - r0, c1: m.c1 - c0 }));
return { rows: r1 - r0 + 1, cols: c1 - c0 + 1, 칸, 병합, 원점: { r: r0, c: c0 }, 출처: "aislo" };
}
/** 범위 → 클립보드 글 둘(TSV = 보이는 값 · HTML = 인라인 서식 표 + 안쪽 뭉치 JSON). */
export function blockToClipboard(
book: Workbook,
sheetId: string,
range: CellRange,
valueAt: (r: number, c: number) => Scalar,
): { html: string; text: string } {
const sheet = book.시트.find((s) => s.id === sheetId);
if (!sheet) return { html: "", text: "" };
const { r0, c0, r1, c1 } = range;
const aisloBlock = buildAisloBlock(book, sheet, range);
const rowsHtml: string[] = [];
const tsvRows: string[] = [];
const skip = new Set<string>();
for (let r = r0; r <= r1; r++) {
const cellsHtml: string[] = [];
const tsvCells: string[] = [];
for (let c = c0; c <= c1; c++) {
const key = `${r},${c}`;
if (skip.has(key)) continue;
const style = styleAt(book, sheet, r, c);
const text = formatValue(valueAt(r, c), style.형식).글;
tsvCells.push(text.replace(/\t/g, " "));
let span = "";
const merge = (sheet.병합 ?? [])
.map((m) => parseRange(m))
.find((m): m is CellRange => !!m && m.r0 === r && m.c0 === c && m.r1 <= r1 && m.c1 <= c1);
if (merge) {
const rs = merge.r1 - merge.r0 + 1;
const cs = merge.c1 - merge.c0 + 1;
if (rs > 1) span += ` rowspan="${rs}"`;
if (cs > 1) span += ` colspan="${cs}"`;
for (let dr = 0; dr < rs; dr++)
for (let dc = 0; dc < cs; dc++) skip.add(`${r + dr},${c + dc}`);
}
cellsHtml.push(
`<td${span} style="${escapeHtml(styleToCss(style))}">${escapeHtml(text)}</td>`,
);
}
rowsHtml.push(`<tr>${cellsHtml.join("")}</tr>`);
tsvRows.push(tsvCells.join("\t"));
}
const table = `<table>${rowsHtml.join("")}</table>`;
const html = `<!--aislo:${encodeURIComponent(JSON.stringify(aisloBlock))}-->${table}`;
return { html, text: tsvRows.join("\r\n") };
}
/* ── 밖 → 안 ─────────────────────────────────────────────────────────────── */
/** 「1,234.56」 · 「TRUE」 는 값으로 · 나머지는 글로. */
function coerceValue(text: string): CellInput {
const t = text.trim();
if (/^[+-]?\d[\d,]*(\.\d+)?$/.test(t)) {
const n = Number(t.replace(/,/g, ""));
if (Number.isFinite(n)) return n;
}
if (/^(TRUE|FALSE)$/i.test(t)) return t.toUpperCase() === "TRUE";
return text;
}
/** 「=」 뒤에 글이 있고 식으로 보이면 식 — 엑셀 「수식 표시」 복사 대응(「=」 한 글자는 글). */
function cellFromText(text: string): ClipCell {
if (text.startsWith("=") && text.length > 1) return { 식: text.slice(1) };
return { 값: coerceValue(text) };
}
interface TableWalk {
rows: number;
cols: number;
owner: Map<string, HTMLTableCellElement>;
merges: CellRange[];
}
function walkTable(table: HTMLTableElement): TableWalk {
const occupied = new Set<string>();
const owner = new Map<string, HTMLTableCellElement>();
const merges: CellRange[] = [];
const rows = Array.from(table.rows);
rows.forEach((row, r) => {
let c = 0;
Array.from(row.cells).forEach((cellEl) => {
while (occupied.has(`${r},${c}`)) c++;
const rowspan = cellEl.rowSpan || 1;
const colspan = cellEl.colSpan || 1;
owner.set(`${r},${c}`, cellEl);
for (let dr = 0; dr < rowspan; dr++) {
for (let dc = 0; dc < colspan; dc++) occupied.add(`${r + dr},${c + dc}`);
}
if (rowspan > 1 || colspan > 1) {
merges.push({ r0: r, c0: c, r1: r + rowspan - 1, c1: c + colspan - 1 });
}
c += colspan;
});
});
let cols = 0;
for (const key of occupied) {
const c = Number(key.split(",")[1]);
if (c + 1 > cols) cols = c + 1;
}
return { rows: rows.length, cols, owner, merges };
}
/** 선언 나눔 — `mso-number-format:"...\;..."` 안 이스케이프된 `\;`(구역 나눔 자리표시)는 진짜
* 선언 끝이 아님(엑셀 회계 형식처럼 구역이 여럿인 값을 여기서 잘라 버리면 값 끝에 떨어진 `\` 만
* 남아 형식이 깨짐) — `\` 뒤 글자는 통째로 건너뛰어 그 안 `;` 는 안 자름. */
function splitDecls(s: string): string[] {
return s.split(/(?<!\\);/);
}
/** 엑셀 · 구글이 `td style="..."` 에 싣는 인라인 서식 → `CellStyle`(읽을 것 없으면 undefined). */
function parseStyleAttr(styleAttr: string): Record<string, string> {
const out: Record<string, string> = {};
for (const decl of splitDecls(styleAttr)) {
const i = decl.indexOf(":");
if (i < 0) continue;
const key = decl.slice(0, i).trim().toLowerCase();
const value = decl.slice(i + 1).trim();
if (key) out[key] = value;
}
return out;
}
function cssColorToHex(v: string | undefined): string | undefined {
if (!v) return undefined;
const s = v.trim().toLowerCase();
if (!s || s === "windowtext" || s === "transparent" || s === "none" || s === "auto")
return undefined;
if (/^#[0-9a-f]{3}$/.test(s)) return `#${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}`;
if (/^#[0-9a-f]{6}$/.test(s)) return s;
const rgb = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
if (rgb) {
const hex = (n: string) => Number(n).toString(16).padStart(2, "0");
return `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}`;
}
return undefined;
}
/** 엑셀 `mso-number-format:"\#\,\#\#0\.00"` — 따옴표 떼고 `\x` 이스케이프 풀기(`\0022` 처럼 진짜
* CSS 유니코드 이스케이프(`\` + 16진 1~6 자리, 회계 형식 글자 구역 따옴표 `"` 를 이렇게 실음)도
* 먼저 풀지 않으면 따옴표 대신 글자 "0022" 가 그대로 남아 형식이 깨짐 — sub5 numfmt 가 받는
* 형식 코드에는 이 CSS 껍데기가 없어야 함). */
function unescapeMsoNumFmt(v: string): string {
return v
.replace(/^"|"$/g, "")
.replace(/\\([0-9a-fA-F]{1,6}) ?|\\(.)/g, (_, hex, ch) =>
hex !== undefined ? String.fromCodePoint(parseInt(hex, 16)) : ch,
);
}
/** pt 굵기 → 우리 선 종류(엑셀 내보내기 흔한 값 기준 — hairline ≈ .5pt 이하 · thin ≈ 1pt · medium ≈
* 1.5~2pt · thick ≈ 2pt 넘음). `solid` 아닌 것은 굵기와 상관없이 그 모양. */
function ptToBorderLine(pt: number, cssStyle: string): BorderLine {
if (cssStyle === "dashed") return "dashed";
if (cssStyle === "dotted") return "dotted";
if (cssStyle === "double") return "double";
if (pt <= 0.5) return "hair";
if (pt <= 1.25) return "thin";
if (pt <= 2) return "medium";
return "thick";
}
function parseBorderShorthand(v: string): BorderSide | undefined {
const m = /([\d.]+)\s*(?:pt|px)\s+(solid|dashed|dotted|double)\s*([^;]*)/i.exec(v);
if (!m) return undefined;
const 선 = ptToBorderLine(Number(m[1]), m[2].toLowerCase());
const 색 = cssColorToHex(m[3]);
return 색 ? { 선, 색 } : { 선 };
}
/** `<style>...</style>` 안 `.xl65 {...}` · `.xl65, .xl70 {...}`(묶음 선택자) 짜임(엑셀이 인라인 대신
* 클래스로 서식을 실을 때) → 클래스별 선언(같은 클래스가 여러 규칙에 나오면 뒤엣것이 이김). */
function parseClassRules(doc: Document): Record<string, Record<string, string>> {
const css = Array.from(doc.querySelectorAll("style"))
.map((s) => s.textContent ?? "")
.join("\n");
const rules: Record<string, Record<string, string>> = {};
for (const m of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
const decls = parseStyleAttr(m[2]);
for (const selectorPart of m[1].split(",")) {
const cls = /\.([a-zA-Z0-9_-]+)\s*$/.exec(selectorPart.trim());
if (cls) Object.assign((rules[cls[1]] ??= {}), decls);
}
}
return rules;
}
function styleFromTd(
cellEl: HTMLTableCellElement,
classRules: Record<string, Record<string, string>>,
): CellStyle | undefined {
const decls: Record<string, string> = {};
for (const cls of cellEl.classList) Object.assign(decls, classRules[cls]);
const raw = cellEl.getAttribute("style");
if (raw) Object.assign(decls, parseStyleAttr(raw));
if (!Object.keys(decls).length) return undefined;
const style: CellStyle = {};
const weight = decls["font-weight"];
if (weight === "bold" || Number(weight) >= 700) style.굵게 = true;
if (decls["font-style"] === "italic") style.기울임 = true;
const color = cssColorToHex(decls["color"]);
if (color) style.글자색 = color;
const bg = cssColorToHex(decls["background-color"] ?? decls["background"]);
if (bg) style.채움 = bg;
const align = decls["text-align"];
if (align === "left" || align === "center" || align === "right") style.가로 = align;
if (decls["mso-number-format"]) style.형식 = unescapeMsoNumFmt(decls["mso-number-format"]);
const 테두리: NonNullable<CellStyle["테두리"]> = {};
const sideMap: [string, "위" | "아래" | "왼" | "오른"][] = [
["border-top", "위"],
["border-bottom", "아래"],
["border-left", "왼"],
["border-right", "오른"],
];
for (const [prop, side] of sideMap) {
const b = parseBorderShorthand(decls[prop] ?? decls["border"] ?? "");
if (b) 테두리[side] = b;
}
if (Object.keys(테두리).length) style.테두리 = 테두리;
return Object.keys(style).length ? style : undefined;
}
function parseHtmlTable(html: string, at: CellAddress): ClipBlock | null {
const doc = new DOMParser().parseFromString(html, "text/html");
const table = doc.querySelector("table");
if (!table) return null;
const walk = walkTable(table);
const sheetsMode = /data-sheets-formula/.test(html);
const classRules = parseClassRules(doc);
const 칸: Record<string, ClipCell> = {};
for (const [key, cellEl] of walk.owner) {
const [r, c] = key.split(",").map(Number);
const text = cellEl.textContent ?? "";
const formula = sheetsMode ? cellEl.getAttribute("data-sheets-formula") : null;
if (formula) {
const target = { r: at.r + r, c: at.c + c };
칸[key] = { 식: r1c1ToA1(formula.replace(/^=/, ""), target) };
continue;
}
const style = styleFromTd(cellEl, classRules);
if (!text) {
if (style) 칸[key] = { 서식: style };
continue;
}
const clip = cellFromText(text);
if (style) clip.서식 = style;
칸[key] = clip;
}
return {
rows: walk.rows,
cols: walk.cols,
칸,
병합: walk.merges,
원점: null,
출처: sheetsMode ? "google" : "excel",
};
}
function parseTsv(text: string): ClipBlock {
const lines = text.replace(/\r\n/g, "\n").split("\n");
while (lines.length && lines[lines.length - 1] === "") lines.pop();
const grid = lines.map((line) => line.split("\t"));
let cols = 0;
for (const row of grid) if (row.length > cols) cols = row.length;
const 칸: Record<string, ClipCell> = {};
grid.forEach((row, r) => {
row.forEach((text, c) => {
if (text === "") return;
칸[`${r},${c}`] = cellFromText(text);
});
});
return { rows: grid.length, cols, 칸, 병합: [], 원점: null, 출처: "text" };
}
/** 클립보드 글 → 뭉치(HTML 먼저 · 없으면 TSV) · 읽을 것 없으면 null · `at` = 붙일 왼위(구글 R1C1 풀이). */
export function parseClipboard(html: string, text: string, at: CellAddress): ClipBlock | null {
const aislo = /<!--aislo:([^>]*)-->/.exec(html);
if (aislo) {
try {
return JSON.parse(decodeURIComponent(aislo[1])) as ClipBlock;
} catch {
// 손상됐으면 아래 일반 경로로 폴백.
}
}
if (/<table/i.test(html)) return parseHtmlTable(html, at);
if (text) return parseTsv(text);
return null;
}
const NO_FORMULA_NOTIFIED_KEY = "aislo-ss-notified-no-formula";
/** 붙인 뭉치 어디에도 수식(`식`)이 없음 — 엑셀 HTML 클립보드는 「수식 표시」(Ctrl+`) 없이 복사하면
* 계산값만 실림(수식 자체는 절대 안 실림). */
function hasNoFormula(block: ClipBlock): boolean {
return !Object.values(block.칸).some((cell) => cell.식 !== undefined);
}
/** 세션에 한 번만 — 엑셀에서 값만 온 붙여넣기마다 매번 뜨면 성가심. */
function notifyNoFormulaOnce(): void {
try {
if (sessionStorage.getItem(NO_FORMULA_NOTIFIED_KEY)) return;
sessionStorage.setItem(NO_FORMULA_NOTIFIED_KEY, "1");
} catch {
// 세션 저장 안 되면 매번 뜰 수 있으나 치명적이지 않음.
}
showToast(st("ClipboardExcelNoFormula"));
}
/* ── 화면 붙임 — copy · cut · paste 사건 ────────────────────────────────── */
function activeRange(ctx: SpreadsheetContext): CellRange {
return (
ctx.selection.범위[0] ?? {
r0: ctx.selection.활성.r,
c0: ctx.selection.활성.c,
r1: ctx.selection.활성.r,
c1: ctx.selection.활성.c,
}
);
}
function rangesOverlap(a: CellRange, b: CellRange): boolean {
return a.r0 <= b.r1 && a.r1 >= b.r0 && a.c0 <= b.c1 && a.c1 >= b.c0;
}
function rangeContains(outer: CellRange, inner: CellRange): boolean {
return (
outer.r0 <= inner.r0 && outer.r1 >= inner.r1 && outer.c0 <= inner.c0 && outer.c1 >= inner.c1
);
}
/** 붙일 자리가 기존 병합의 일부만 걸치면 막음(엑셀 「병합된 셀의 일부를 바꿀 수 없습니다」) — 병합을
* 통째로 덮거나(허용) · 병합 한 칸에 값 하나만 얹는 것(허용)은 됨. */
function findBlockingMerge(sheet: Sheet, target: CellRange): boolean {
const isSingleCell = target.r0 === target.r1 && target.c0 === target.c1;
for (const m of sheet.병합 ?? []) {
const mr = parseRange(m);
if (!mr || !rangesOverlap(target, mr)) continue;
if (rangeContains(target, mr)) continue;
if (isSingleCell && rangeContains(mr, target)) continue;
return true;
}
return false;
}
function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress): void {
const target: CellRange = {
r0: at.r,
c0: at.c,
r1: at.r + block.rows - 1,
c1: at.c + block.cols - 1,
};
const sheet = ctx.sheet();
if (findBlockingMerge(sheet, target)) {
showToast(st("ClipboardMergeBlocked"), "error");
return;
}
// 붙일 자리가 기존 병합을 통째로 덮으면 먼저 풂(엑셀도 큰 덩어리를 얹으면 병합이 풀리고 칸마다 값이 옴) —
// 안 풀면 A `applyCommand` 가 병합 속 왼위 아닌 칸에 값을 넣으려 할 때 막음.
const unmergeCmds: Command[] = (sheet.병합 ?? [])
.map((m) => parseRange(m))
.filter((m): m is CellRange => !!m && rangeContains(target, m))
.map((범위) => ({ 종류: "병합풀기", 시트: ctx.selection.시트, 범위 }));
const shift = block.원점 ? { dr: at.r - block.원점.r, dc: at.c - block.원점.c } : null;
const 칸: Record<string, Cell | null> = {};
const styleCmds: Command[] = [];
for (const [key, clip] of Object.entries(block.칸)) {
const [lr, lc] = key.split(",").map(Number);
const r = at.r + lr;
const c = at.c + lc;
const cell: Cell = {};
if (clip.값 !== undefined) cell.값 = clip.값;
if (clip.식 !== undefined) cell.식 = shift ? moveFormula(clip.식, shift.dr, shift.dc) : clip.식;
칸[toA1(r, c)] = Object.keys(cell).length ? cell : null;
if (clip.서식) {
styleCmds.push({
종류: "서식",
시트: ctx.selection.시트,
범위: [{ r0: r, c0: c, r1: r, c1: c }],
바꿀: clip.서식,
});
}
}
const commands: Command[] = [
...unmergeCmds,
{ 종류: "칸", 시트: ctx.selection.시트, 칸 },
...styleCmds,
];
for (const m of block.병합) {
commands.push({
종류: "병합",
시트: ctx.selection.시트,
범위: { r0: at.r + m.r0, c0: at.c + m.c0, r1: at.r + m.r1, c1: at.c + m.c1 },
});
}
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
}
/** A1 칸이 범위 안인가 */
function inside(rg: CellRange, a1: string): boolean {
const p = parseA1(a1);
return !!p && p.r >= rg.r0 && p.r <= rg.r1 && p.c >= rg.c0 && p.c <= rg.c1;
}
interface PendingCut {
token: string;
sheet: string;
range: CellRange;
/** 지우기 전 칸 — 같은 문서에 붙일 때 되살려 `옮기기` 로 넘김 */
cells: Record<string, Cell>;
}
/** 병합을 가르는지(통째로 든 병합은 됨) — `옮기기` 명령과 같은 규칙 */
function cutsMerge(sheet: Sheet, rg: CellRange): boolean {
return (sheet.병합 ?? [])
.map((m) => parseRange(m))
.some((m) => !!m && rangesOverlap(m, rg) && !rangeContains(rg, m));
}
/** `ctx.root` 의 copy · cut · paste 를 받음(root null) */
export function attachClipboard(ctx: SpreadsheetContext): PartHandle {
/** 마지막 잘라내기 — 같은 문서에 붙이면 칸을 옮김(그 칸을 가리키던 식도 따라감 · 엑셀) */
let cut: PendingCut | null = null;
function write(ev: ClipboardEvent, prefix = ""): void {
const range = activeRange(ctx);
const { html, text } = blockToClipboard(ctx.book, ctx.selection.시트, range, (r, c) =>
ctx.engine.value(ctx.selection.시트, r, c),
);
ev.clipboardData?.setData("text/html", prefix + html);
ev.clipboardData?.setData("text/plain", text);
ev.preventDefault();
}
function onCopy(ev: ClipboardEvent): void {
if (ctx.editor.editing()) return;
cut = null;
write(ev);
}
function onCut(ev: ClipboardEvent): void {
if (ctx.editor.editing() || ctx.readOnly) return;
const range = activeRange(ctx);
const sheet = ctx.sheet();
const cells: Record<string, Cell> = {};
for (const [a1, cell] of Object.entries(sheet.칸))
if (inside(range, a1)) cells[a1] = structuredClone(cell);
const token = Math.random().toString(36).slice(2);
write(ev, `<!--aislo-cut:${token}-->`);
cut = { token, sheet: sheet.id, range, cells };
ctx.dispatch({ 종류: "내용지움", 시트: sheet.id, 범위: [range] });
}
/** 잘라낸 칸을 같은 문서 안으로 — 되살린 뒤 `옮기기`(한 번에 되돌림) · 못 하면 false(보통 붙여넣기) */
function pasteCut(pending: PendingCut, at: CellAddress): boolean {
const src = ctx.book.시트.find((s) => s.id === pending.sheet);
if (!src) return false;
const { r0, c0, r1, c1 } = pending.range;
for (const [a1, cell] of Object.entries(src.칸))
if (inside(pending.range, a1) && (cell.값 !== undefined || cell.식 !== undefined))
return false; // 잘라낸 뒤 그 자리에 새로 씀
const dst = { r0: at.r, c0: at.c, r1: at.r + r1 - r0, c1: at.c + c1 - c0 };
if (cutsMerge(src, pending.range) || cutsMerge(ctx.sheet(), dst)) return false;
ctx.dispatch({
종류: "묶음",
명령: [
{ 종류: "칸", 시트: src.id, 칸: pending.cells },
{ 종류: "옮기기", 시트: src.id, 범위: pending.range, 대상시트: ctx.selection.시트, ...at },
],
});
return true;
}
function onPaste(ev: ClipboardEvent): void {
if (ctx.editor.editing() || ctx.readOnly) return;
const html = ev.clipboardData?.getData("text/html") ?? "";
const text = ev.clipboardData?.getData("text/plain") ?? "";
const at = ctx.selection.활성;
const pending = cut;
if (pending && html.includes(`<!--aislo-cut:${pending.token}-->`)) {
cut = null; // 엑셀처럼 한 번만 옮김 — 다음부터는 보통 붙여넣기
if (pasteCut(pending, at)) return void ev.preventDefault();
}
const block = parseClipboard(html, text, at);
if (!block) {
showToast(st("ClipboardPasteFailed"), "error");
return;
}
ev.preventDefault();
if (block.출처 === "excel" && hasNoFormula(block)) notifyNoFormulaOnce();
pasteBlock(ctx, block, at);
}
ctx.root.addEventListener("copy", onCopy);
ctx.root.addEventListener("cut", onCut);
ctx.root.addEventListener("paste", onPaste);
return {
root: null,
refresh() {},
destroy() {
ctx.root.removeEventListener("copy", onCopy);
ctx.root.removeEventListener("cut", onCut);
ctx.root.removeEventListener("paste", onPaste);
},
};
}
@@ -0,0 +1,665 @@
/* =============================================================================
* spreadsheet_commands.ts (주인 A)
* 명령 적용 — 문서를 그 자리에서 바꾸고 되돌림 짝 · 바뀐 칸 · 통째 다시 여부를 돌려줌.
* 병합 규칙(엑셀): 병합 일부를 가르는 칸 쓰기 · 옮기기는 막음(오류 던짐 → 화면이 알림) ·
* 병합 안에 행열을 넣으면 병합이 늘고 지우면 줆 · 병합하면 왼위 칸 값만 남김.
* 서식 표는 같은 서식을 한 번만 둠(칸은 번호) · 안 쓰는 서식 정리는 저장 전에(`compactStyles`).
* 행열 · 옮기기 · 시트 지우기는 바뀐 시트를 통째로 되돌림(`시트통째`) — 다른 시트 식 글까지 한 번에.
* ========================================================================== */
import {
colName,
inRange,
normRange,
parseA1,
parseRange,
rangeInside,
rangesOverlap,
rangeToA1,
toA1,
} from "./spreadsheet_address";
import {
addSheet,
moveSheet,
removeSheet,
renameSheet,
replaceSheet,
} from "./spreadsheet_commands_sheet";
import { shiftForCommand, shiftSpan } from "./spreadsheet_refshift";
import { applyFilter, applySort, shiftFilter } from "./spreadsheet_sortfilter";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
Cell,
CellRange,
CellStyle,
Command,
CommandEffect,
Sheet,
SheetCellAddress,
Workbook,
} from "./spreadsheet_types";
/** 막힌 명령(병합 가름 · 마지막 시트 지우기 · 같은 시트 이름 …) — 화면이 `message` 를 알림 */
export class CommandError extends Error {}
export type Of<K extends Command["종류"]> = Extract<Command, { 종류: K }>;
export const clone = <T>(x: T): T => structuredClone(x);
export function sheetOf(book: Workbook, id: string): Sheet {
const s = book.시트.find((x) => x.id === id);
if (!s) throw new CommandError(`없는 시트: ${id}`);
return s;
}
const mergesOf = (s: Sheet): CellRange[] =>
(s.병합 ?? []).map((t) => parseRange(t)).filter((r): r is CellRange => r !== null);
function setMerges(s: Sheet, list: CellRange[]) {
if (list.length) s.병합 = list.map(rangeToA1);
else delete s.병합;
}
/** 칸 열쇠 중 범위 안 것 */
function cellsIn(s: Sheet, ranges: CellRange[]): [string, number, number][] {
const out: [string, number, number][] = [];
for (const a1 of Object.keys(s.칸)) {
const at = parseA1(a1);
if (at && ranges.some((rg) => inRange(rg, at.r, at.c))) out.push([a1, at.r, at.c]);
}
return out;
}
const isFull = (rg: CellRange, rows: boolean) =>
rows ? rg.c0 === 0 && rg.c1 === MAX_COLS - 1 : rg.r0 === 0 && rg.r1 === MAX_ROWS - 1;
function sortKeys(v: unknown): unknown {
if (!v || typeof v !== "object" || Array.isArray(v)) return v;
const o = v as Record<string, unknown>;
return Object.fromEntries(
Object.keys(o)
.sort()
.map((k) => [k, sortKeys(o[k])]),
);
}
const styleKey = (s: CellStyle) => JSON.stringify(sortKeys(s));
function internStyle(book: Workbook, style: CellStyle): number {
const key = styleKey(style);
const i = book.서식.findIndex((s) => styleKey(s) === key);
if (i >= 0) return i;
book.서식.push(style);
return book.서식.length - 1;
}
function setComments(s: Sheet, notes: Record<string, string>) {
if (Object.keys(notes).length) s.comments = notes;
else delete s.comments;
}
/** 시트 통째 서식 */
const sheetStyle = (s: Sheet) => s.기본?.서식 ?? 0;
/** 칸 자리에서 물려받는 서식(칸 서식 없을 때) — 행 · 열 · 시트 · 0 */
const inherited = (s: Sheet, r: number, c: number) =>
s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? sheetStyle(s);
/** 시트 `기본` 한 칸 — undefined 면 지움 · 빈 `기본` 도 지움 */
function setSheetDefault(s: Sheet, key: "서식" | "열폭" | "행높이", value: number | undefined) {
const d = (s.기본 ??= {});
if (value === undefined) delete d[key];
else d[key] = value;
if (!Object.keys(d).length) delete s.기본;
}
const addr = (시트: string, r: number, c: number): SheetCellAddress => ({ 시트, r, c });
/** 바뀐 시트만 통째 되돌림 짝 */
export function restoreChanged(book: Workbook, old: Sheet[]): Command[] {
const out: Command[] = [];
for (const o of old) {
const now = book.시트.find((x) => x.id === o.id);
if (now && JSON.stringify(now) !== JSON.stringify(o)) out.push({ 종류: "시트통째", 시트: o });
}
return out;
}
/** 모든 식 글을 명령에 맞춰 고침 — 시트 이름을 바꾸기 전에 부름 */
export function rewriteAll(book: Workbook, command: Command) {
for (const s of book.시트)
for (const cell of Object.values(s.칸))
if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, command, book);
}
// ── 칸 ────────────────────────────────────────────────────────────────────
function setCells(book: Workbook, cmd: Of<"칸">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const merges = mergesOf(s);
const plan: [string, number, number, Cell | null][] = [];
for (const [key, cell] of Object.entries(cmd.칸)) {
const at = parseA1(key);
if (!at) throw new CommandError(`칸 주소 틀림: ${key}`);
if (cell && (cell.값 !== undefined || cell.식 !== undefined))
if (merges.some((m) => inRange(m, at.r, at.c) && (m.r0 !== at.r || m.c0 !== at.c)))
throw new CommandError("병합한 칸은 왼위 칸에만 값을 넣음");
plan.push([toA1(at.r, at.c), at.r, at.c, cell]);
}
const prev: Record<string, Cell | null> = {};
for (const [a1, , , cell] of plan) {
prev[a1] = s.칸[a1] ? clone(s.칸[a1]) : null;
if (cell) s.칸[a1] = clone(cell);
else delete s.칸[a1];
}
return {
undo: { 종류: "칸", 시트: s.id, 칸: prev },
cells: plan.map(([, r, c]) => addr(s.id, r, c)),
rebuild: false,
};
}
function clearContents(book: Workbook, cmd: Of<"내용지움">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const prev: Record<string, Cell | null> = {};
const cells: SheetCellAddress[] = [];
for (const [a1, r, c] of cellsIn(s, cmd.범위)) {
const cell = s.칸[a1];
if (cell.값 === undefined && cell.식 === undefined) continue;
prev[a1] = clone(cell);
if (cell.서식 === undefined) delete s.칸[a1];
else s.칸[a1] = { 서식: cell.서식 };
cells.push(addr(s.id, r, c));
}
return { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false };
}
// ── 서식 ────────────────────────────────────────────────────────────────────
/** 범위 서식 바꿈 — `change(지금 번호) → 새 번호`. 행 · 열 전체는 행 · 열 서식 + 든 칸. */
function restyle(
book: Workbook,
sheet: string,
ranges: CellRange[],
change: (i: number) => number,
): CommandEffect {
const s = sheetOf(book, sheet);
const before = clone(s);
const memo = new Map<number, number>();
const f = (i: number) => {
if (!memo.has(i)) memo.set(i, change(i));
return memo.get(i)!;
};
const prev: Record<string, Cell | null> = {};
const cells: SheetCellAddress[] = [];
let whole = false;
const setInfo = (axis: "행" | "열", key: string) => {
const table = (s[axis] ??= {});
const info = (table[key] ??= {});
const idx = f(info.서식 ?? sheetStyle(s));
if (idx !== sheetStyle(s)) info.서식 = idx;
else delete info.서식;
if (!Object.keys(info).length) delete table[key];
};
const touch = (r: number, c: number) => {
const a1 = toA1(r, c);
if (a1 in prev) return;
const cell = s.칸[a1];
prev[a1] = cell ? clone(cell) : null;
const next: Cell = { ...cell };
const idx = f(cell?.서식 ?? inherited(s, r, c));
if (idx === inherited(s, r, c)) delete next.서식;
else next.서식 = idx;
if (Object.keys(next).length) s.칸[a1] = next;
else delete s.칸[a1];
cells.push(addr(s.id, r, c));
};
for (const rg of ranges) {
const fullRows = isFull(rg, true);
const fullCols = isFull(rg, false);
if (fullRows && fullCols) {
whole = true;
const idx = f(sheetStyle(s));
setSheetDefault(s, "서식", idx || undefined);
for (const k of Object.keys(s.행 ?? {})) setInfo("행", k);
for (const k of Object.keys(s.열 ?? {})) setInfo("열", k);
} else if (fullRows || fullCols) {
whole = true;
for (let i = fullRows ? rg.r0 : rg.c0; i <= (fullRows ? rg.r1 : rg.c1); i++)
setInfo(fullRows ? "행" : "열", fullRows ? String(i + 1) : colName(i));
} else {
if ((rg.r1 - rg.r0 + 1) * (rg.c1 - rg.c0 + 1) > 1_000_000)
throw new CommandError("범위가 너무 큼");
for (let r = rg.r0; r <= rg.r1; r++) for (let c = rg.c0; c <= rg.c1; c++) touch(r, c);
continue;
}
for (const [, r, c] of cellsIn(s, [rg])) touch(r, c);
}
if (s.행 && !Object.keys(s.행).length) delete s.행;
if (s.열 && !Object.keys(s.열).length) delete s.열;
return whole
? { undo: { 종류: "시트통째", 시트: before }, cells, rebuild: true }
: { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false };
}
function patchStyle(book: Workbook, cmd: Of<"서식">): CommandEffect {
return restyle(book, cmd.시트, cmd.범위, (i) => {
const next: Record<string, unknown> = { ...book.서식[i] };
for (const [k, v] of Object.entries(cmd.바꿀)) {
if (v === null) delete next[k];
else if (v !== undefined) next[k] = clone(v);
}
return internStyle(book, next as CellStyle);
});
}
// ── 행열 넣기 · 지우기 ──────────────────────────────────────────────────────
function insertDelete(
book: Workbook,
cmd: Of<"행넣기" | "행지우기" | "열넣기" | "열지우기">,
): CommandEffect {
const s = sheetOf(book, cmd.시트);
const rows = cmd.종류.startsWith("행");
const insert = cmd.종류.endsWith("넣기");
const max = rows ? MAX_ROWS : MAX_COLS;
const { at, 수: n } = cmd;
if (n < 1 || at < 0 || at >= max || (!insert && at + n > max))
throw new CommandError("넣기 · 지우기 자리가 틀림");
const pos = (a1: string) => {
const p = parseA1(a1)!;
return rows ? p.r : p.c;
};
if (insert && Object.keys(s.칸).some((a1) => pos(a1) + n >= max))
throw new CommandError("격자 끝 칸이 밀려 나감 — 끝 쪽 칸을 먼저 지움");
const old = book.시트.map(clone);
rewriteAll(book, cmd);
const span = (i: number) => shiftSpan(i, i, insert, at, n, max)?.[0];
const cells: Record<string, Cell> = {};
for (const [a1, cell] of Object.entries(s.칸)) {
const p = parseA1(a1)!;
const i = span(rows ? p.r : p.c);
if (i !== undefined) cells[rows ? toA1(i, p.c) : toA1(p.r, i)] = cell;
}
s.칸 = cells;
if (s.comments) {
const notes: Record<string, string> = {};
for (const [a1, text] of Object.entries(s.comments)) {
const p = parseA1(a1);
const i = p && span(rows ? p.r : p.c);
if (p && i !== undefined && i !== null) notes[rows ? toA1(i, p.c) : toA1(p.r, i)] = text;
}
setComments(s, notes);
}
const shiftRange = (m: CellRange) => {
const sp = rows
? shiftSpan(m.r0, m.r1, insert, at, n, max)
: shiftSpan(m.c0, m.c1, insert, at, n, max);
return sp && (rows ? { ...m, r0: sp[0], r1: sp[1] } : { ...m, c0: sp[0], c1: sp[1] });
};
const merges: CellRange[] = [];
for (const m of mergesOf(s)) {
const next = shiftRange(m);
if (next && (next.r0 !== next.r1 || next.c0 !== next.c1)) merges.push(next);
}
setMerges(s, merges);
// 이름 범위도 따라감 · 범위가 통째로 지워진 이름은 뺌(엑셀은 `#REF!` 이름으로 남김 — 여기선 식이 `#NAME?`)
if (s.이름정의)
s.이름정의 = s.이름정의.flatMap((d) => {
const 범위 = shiftRange(d.범위);
return 범위 ? [{ ...d, 범위 }] : [];
});
const axis = rows ? "행" : "열";
const table = s[axis];
if (table) {
const next: Record<string, (typeof table)[string]> = {};
for (const [k, v] of Object.entries(table)) {
const i = span(rows ? Number(k) - 1 : parseA1(`${k}1`)!.c);
if (i !== undefined) next[rows ? String(i + 1) : colName(i)] = v;
}
if (Object.keys(next).length) s[axis] = next;
else delete s[axis];
}
// 넣은 줄은 위(왼) 줄 서식 · 폭 · 높이를 따라감(엑셀 「위와 같은 서식」) — 첫 줄 앞 넣기는 안 따라감
if (insert && at > 0) {
for (const [a1, cell] of Object.entries(s.칸)) {
const p = parseA1(a1)!;
if ((rows ? p.r : p.c) !== at - 1 || cell.서식 === undefined) continue;
for (let k = 0; k < n; k++)
s.칸[rows ? toA1(at + k, p.c) : toA1(p.r, at + k)] = { 서식: cell.서식 };
}
const info = s[axis]?.[rows ? String(at) : colName(at - 1)];
if (info) {
const { 숨김: _hidden, ...look } = info;
if (Object.keys(look).length)
for (let k = 0; k < n; k++)
s[axis]![rows ? String(at + k + 1) : colName(at + k)] = { ...look };
}
}
shiftFilter(s, rows, insert, at, n);
const key = rows ? "행" : "열";
const frozen = s.틀고정?.[key];
if (frozen && at < frozen)
s.틀고정![key] = insert ? frozen + n : frozen - (Math.min(frozen, at + n) - at);
return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true };
}
// ── 옮기기 (잘라 붙이기) ─────────────────────────────────────────────────────
function move(book: Workbook, cmd: Of<"옮기기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const t = sheetOf(book, cmd.대상시트);
const src = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1);
const dr = cmd.r - src.r0;
const dc = cmd.c - src.c0;
const dst: CellRange = { r0: cmd.r, c0: cmd.c, r1: src.r1 + dr, c1: src.c1 + dc };
if (dst.r0 < 0 || dst.c0 < 0 || dst.r1 >= MAX_ROWS || dst.c1 >= MAX_COLS)
throw new CommandError("옮길 자리가 격자 밖");
const cuts = (list: CellRange[], rg: CellRange) =>
list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg));
if (cuts(mergesOf(s), src) || cuts(mergesOf(t), dst))
throw new CommandError("병합 일부를 가르는 옮기기는 못 함");
if (s === t && dr === 0 && dc === 0)
return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false };
const old = book.시트.map(clone);
const moved: [number, number, Cell][] = [];
for (const [a1, r, c] of cellsIn(s, [src])) {
const cell = s.칸[a1];
if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, cmd, book, t.id);
moved.push([r + dr, c + dc, cell]);
delete s.칸[a1];
}
rewriteAll(book, cmd);
for (const [a1] of cellsIn(t, [dst])) delete t.칸[a1];
for (const [r, c, cell] of moved) t.칸[toA1(r, c)] = cell;
// 메모도 칸과 같이 — 덮은 자리 메모는 지움
const carriedNotes: [string, string][] = [];
const srcNotes = { ...s.comments };
for (const [a1, text] of Object.entries(srcNotes)) {
const p = parseA1(a1);
if (p && inRange(src, p.r, p.c)) {
carriedNotes.push([toA1(p.r + dr, p.c + dc), text]);
delete srcNotes[a1];
}
}
setComments(s, srcNotes);
const dstNotes = { ...t.comments };
for (const a1 of Object.keys(dstNotes)) {
const p = parseA1(a1);
if (p && inRange(dst, p.r, p.c)) delete dstNotes[a1];
}
setComments(t, { ...dstNotes, ...Object.fromEntries(carriedNotes) });
const sm = mergesOf(s);
const carried = sm.filter((m) => rangeInside(m, src));
setMerges(
s,
sm.filter((m) => !rangeInside(m, src)),
);
setMerges(t, [
...mergesOf(t).filter((m) => !rangeInside(m, dst)),
...carried.map((m) => ({ r0: m.r0 + dr, c0: m.c0 + dc, r1: m.r1 + dr, c1: m.c1 + dc })),
]);
return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true };
}
// ── 병합 ────────────────────────────────────────────────────────────────────
function merge(book: Workbook, cmd: Of<"병합">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const rg = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1);
if (rg.r0 === rg.r1 && rg.c0 === rg.c1)
return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false };
const list = mergesOf(s);
if (list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg)))
throw new CommandError("다른 병합과 걸침");
const absorbed = list.filter((m) => rangeInside(m, rg));
const prev: Record<string, Cell | null> = {};
const cells: SheetCellAddress[] = [];
for (const [a1, r, c] of cellsIn(s, [rg])) {
const cell = s.칸[a1];
if ((r === rg.r0 && c === rg.c0) || (cell.값 === undefined && cell.식 === undefined)) continue;
prev[a1] = clone(cell);
if (cell.서식 === undefined) delete s.칸[a1];
else s.칸[a1] = { 서식: cell.서식 };
cells.push(addr(s.id, r, c));
}
setMerges(s, [...list.filter((m) => !rangeInside(m, rg)), rg]);
const undo: Command[] = [
{ 종류: "병합풀기", 시트: s.id, 범위: rg },
...absorbed.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m })),
{ 종류: "칸", 시트: s.id, 칸: prev },
];
return { undo: { 종류: "묶음", 명령: undo }, cells, rebuild: false };
}
function unmerge(book: Workbook, cmd: Of<"병합풀기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const list = mergesOf(s);
const gone = list.filter((m) => rangesOverlap(m, cmd.범위));
setMerges(
s,
list.filter((m) => !rangesOverlap(m, cmd.범위)),
);
const undo = gone.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m }));
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: false };
}
// ── 행 · 열 모양 ─────────────────────────────────────────────────────────────
/** 행 · 열 정보 한 속성 바꿈 → 원래 값끼리 묶은 되돌림 짝 */
function setAxis<V>(
s: Sheet,
axis: "행" | "열",
idx: number[],
prop: "폭" | "높이" | "숨김" | "묶음",
value: V | null,
undoOf: (idx: number[], prev: V | null) => Command,
): Command {
const table = ((s as unknown as Record<string, Record<string, Record<string, unknown>>>)[axis] ??=
{});
const groups = new Map<string, number[]>();
for (const i of idx) {
const k = axis === "열" ? colName(i) : String(i + 1);
const prev = table[k]?.[prop];
const g = JSON.stringify(prev ?? null);
groups.set(g, [...(groups.get(g) ?? []), i]);
const info = (table[k] ??= {});
if (value === null || value === false) delete info[prop];
else info[prop] = value;
if (!Object.keys(info).length) delete table[k];
}
if (!Object.keys(table).length) delete s[axis];
return { 종류: "묶음", 명령: [...groups].map(([g, list]) => undoOf(list, JSON.parse(g))) };
}
/** 모두 고르기 폭 · 높이 — 시트 `기본` 에 두고 줄마다 값은 지움(엑셀 기본 폭 · 높이) */
function sheetSize(s: Sheet, axis: "행" | "열", value: number | null): CommandEffect {
const before = clone(s);
const prop = axis === "열" ? "폭" : "높이";
setSheetDefault(s, axis === "열" ? "열폭" : "행높이", value ?? undefined);
const table = s[axis] as Record<string, Record<string, unknown>> | undefined;
for (const [k, info] of Object.entries(table ?? {})) {
delete info[prop];
if (!Object.keys(info).length) delete table![k];
}
if (table && !Object.keys(table).length) delete s[axis];
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false };
}
function batch(book: Workbook, cmd: Of<"묶음">): CommandEffect {
const done: CommandEffect[] = [];
try {
for (const c of cmd.명령) done.push(applyCommand(book, c));
} catch (e) {
for (const d of done.reverse()) applyCommand(book, d.undo);
throw e;
}
return {
undo: { 종류: "묶음", 명령: done.map((d) => d.undo).reverse() },
cells: done.flatMap((d) => d.cells),
rebuild: done.some((d) => d.rebuild),
};
}
// ── 들머리 ──────────────────────────────────────────────────────────────────
export function applyCommand(book: Workbook, command: Command): CommandEffect {
const plain = (undo: Command): CommandEffect => ({ undo, cells: [], rebuild: false });
switch (command.종류) {
case "칸":
return setCells(book, command);
case "내용지움":
return clearContents(book, command);
case "서식":
return patchStyle(book, command);
case "서식지움":
return restyle(book, command.시트, command.범위, () => 0);
case "행넣기":
case "행지우기":
case "열넣기":
case "열지우기":
return insertDelete(book, command);
case "옮기기":
return move(book, command);
case "병합":
return merge(book, command);
case "병합풀기":
return unmerge(book, command);
case "열폭": {
const s = sheetOf(book, command.시트);
if (new Set(command.열).size >= MAX_COLS) return sheetSize(s, "열", command.폭);
return plain(
setAxis(s, "열", command.열, "폭", command.폭, (열, 폭) => ({
종류: "열폭",
시트: s.id,
열,
폭,
})),
);
}
case "행높이": {
const s = sheetOf(book, command.시트);
if (new Set(command.행).size >= MAX_ROWS) return sheetSize(s, "행", command.높이);
return plain(
setAxis(s, "행", command.행, "높이", command.높이, (행, 높이) => ({
종류: "행높이",
시트: s.id,
행,
높이,
})),
);
}
case "숨김": {
const s = sheetOf(book, command.시트);
const { 축 } = command;
return plain(
setAxis(s, 축, command.번호, "숨김", command.숨김, (번호, prev) => ({
종류: "숨김",
시트: s.id,
축,
번호,
숨김: prev ?? false,
})),
);
}
case "묶음설정": {
const s = sheetOf(book, command.시트);
const { 축 } = command;
return plain(
setAxis(s, 축, command.번호, "묶음", command.묶음, (번호, prev) => ({
종류: "묶음설정",
시트: s.id,
축,
번호,
묶음: prev,
})),
);
}
case "틀고정": {
const s = sheetOf(book, command.시트);
const prev = { 행: s.틀고정?.행 ?? 0, 열: s.틀고정?.열 ?? 0 };
const next: Sheet["틀고정"] = {};
if (command.행 > 0) next.행 = command.행;
if (command.열 > 0) next.열 = command.열;
if (Object.keys(next).length) s.틀고정 = next;
else delete s.틀고정;
return plain({ 종류: "틀고정", 시트: s.id, ...prev });
}
case "눈금선": {
const s = sheetOf(book, command.시트);
const prev = s.보기?.눈금선 ?? true;
if (command.보임) {
delete s.보기?.눈금선;
if (s.보기 && !Object.keys(s.보기).length) delete s.보기;
} else (s.보기 ??= {}).눈금선 = false;
return plain({ 종류: "눈금선", 시트: s.id, 보임: prev });
}
case "시트더하기":
return addSheet(book, command);
case "시트지우기":
return removeSheet(book, command);
case "시트이름":
return renameSheet(book, command);
case "시트옮기기":
return moveSheet(book, command);
case "시트통째":
return replaceSheet(book, command);
case "정렬":
return applySort(book, command);
case "필터":
return applyFilter(book, command);
case "묶음":
return batch(book, command);
}
}
/** 빈 통합문서 — 시트 하나(`s1` · 이름은 부른 쪽) · 서식 [{}] */
export function emptyWorkbook(column: string, sheetName: string): Workbook {
return {
종류: "통합문서",
판: 1,
열: column,
서식: [{}],
시트: [emptySheet("s1", sheetName)],
활성: "s1",
};
}
export function emptySheet(id: string, name: string): Sheet {
return { id, 이름: name, 칸: {} };
}
/** 안 쓰는 서식을 빼고 번호를 다시 매김 — 저장 직전(되돌리기 짝은 안 만듦) */
export function compactStyles(book: Workbook): void {
const next: CellStyle[] = [];
const byKey = new Map<string, number>();
const remap = new Map<number, number>();
const map = (i: number) => {
let j = remap.get(i);
if (j === undefined) {
const style = book.서식[i] ?? {};
const k = styleKey(style);
j = byKey.get(k);
if (j === undefined) {
j = next.push(style) - 1;
byKey.set(k, j);
}
remap.set(i, j);
}
return j;
};
map(0);
for (const s of book.시트) {
for (const cell of Object.values(s.칸)) if (cell.서식 !== undefined) cell.서식 = map(cell.서식);
for (const info of [...Object.values(s.행 ?? {}), ...Object.values(s.열 ?? {}), s.기본 ?? {}])
if (info.서식 !== undefined) info.서식 = map(info.서식);
}
book.서식 = next;
}
@@ -0,0 +1,69 @@
/* =============================================================================
* spreadsheet_commands_sheet.ts (주인 A)
* 시트 명령 — 더하기 · 지우기 · 이름 · 옮기기 · 통째 바꿔 넣기. 들머리는 `applyCommand`(commands).
* ========================================================================== */
import { clone, CommandError, restoreChanged, rewriteAll, sheetOf } from "./spreadsheet_commands";
import type { Of } from "./spreadsheet_commands";
import type { Command, CommandEffect, Workbook } from "./spreadsheet_types";
function checkSheetName(book: Workbook, name: string, self?: string) {
if (!name.trim() || name.length > 31) throw new CommandError("시트 이름은 1~31 글자");
if (/[[\]:*?/\\]/.test(name) || name.startsWith("'") || name.endsWith("'"))
throw new CommandError("시트 이름에 못 쓰는 글자: [ ] : * ? / \\ 와 앞뒤 '");
if (book.시트.some((s) => s.id !== self && s.이름.toLowerCase() === name.toLowerCase()))
throw new CommandError(`같은 이름 시트가 있음: ${name}`);
}
export function addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect {
if (book.시트.some((s) => s.id === cmd.시트.id))
throw new CommandError(`같은 id 시트가 있음: ${cmd.시트.id}`);
checkSheetName(book, cmd.시트.이름);
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, clone(cmd.시트));
return { undo: { 종류: "시트지우기", 시트: cmd.시트.id }, cells: [], rebuild: true };
}
export function removeSheet(book: Workbook, cmd: Of<"시트지우기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
if (book.시트.length === 1) throw new CommandError("마지막 시트는 못 지움");
const at = book.시트.indexOf(s);
const old = book.시트.map(clone);
rewriteAll(book, cmd);
book.시트.splice(at, 1);
if (book.활성 === s.id) book.활성 = book.시트[Math.min(at, book.시트.length - 1)].id;
const undo: Command[] = [
{ 종류: "시트더하기", 시트: old[at], 자리: at },
...restoreChanged(book, old),
];
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: true };
}
export function renameSheet(book: Workbook, cmd: Of<"시트이름">): CommandEffect {
const s = sheetOf(book, cmd.시트);
checkSheetName(book, cmd.이름, s.id);
const prev = s.이름;
rewriteAll(book, cmd);
s.이름 = cmd.이름;
return { undo: { 종류: "시트이름", 시트: s.id, 이름: prev }, cells: [], rebuild: true };
}
export function moveSheet(book: Workbook, cmd: Of<"시트옮기기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const from = book.시트.indexOf(s);
book.시트.splice(from, 1);
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, s);
return { undo: { 종류: "시트옮기기", 시트: s.id, 자리: from }, cells: [], rebuild: false };
}
export function replaceSheet(book: Workbook, cmd: Of<"시트통째">): CommandEffect {
const at = book.시트.findIndex((x) => x.id === cmd.시트.id);
if (at < 0) throw new CommandError(`없는 시트: ${cmd.시트.id}`);
const prev = book.시트[at];
book.시트[at] = clone(cmd.시트);
const undo: Of<"시트통째"> = { 종류: "시트통째", 시트: prev };
if (cmd.서식) {
undo.서식 = book.서식;
book.서식 = clone(cmd.서식);
}
return { undo, cells: [], rebuild: true };
}
@@ -0,0 +1,139 @@
/* =============================================================================
* spreadsheet_comments.ts (2단계 · 주인 sub_laptop_1)
* 메모(풍선 설명) · 칸 위쪽 빨간 세모 표시. 저장 자리 — 브레인 답(2026-09-27):
* `Sheet.comments?: Record<string, string>` (열쇠 = A1 주소 · 값 = 메모 글 · 빈 글이면 지움).
* 이 칸은 sub3 가 0 계약(spreadsheet_types.ts)에 보태는 중 — 여기서는 고치지 않고 모양만 가정
* (`SheetWithComments` 로 좁혀 씀 · sub3 커밋이 오면 구조가 그대로 맞음).
*
* 문서를 바꾸는 길은 명령뿐(view_types.ts 머리) — 「메모」 전용 명령이 아직 없어 `시트통째`
* (있는 시트를 통째로 바꿔 넣는 명령)로 얹음. 메모 전용 명령이 생기면 이 파일의 `applyComments`
* 한 곳만 고치면 됨.
*
* 행 · 열 넣기 · 지우기 때 메모 주소 옮김 — 지금은 안 함(사각지대로 남김). A 가
* `spreadsheet_refshift.ts` 를 완성하면 그 파일의 참조 옮김 함수(예: `shiftAddress` 류)를
* 이 파일 `remapComments` 자리에 불러 A1 주소들을 옮기게 잇기(지우는 행 · 열에 있던 메모는
* 참조 지움과 같은 규칙으로 버림). 지금은 `remapComments` 를 D · A 가 채울 자리로 비워 둠.
*
* 잇는 법(D · sub7 · C 격자) — `attachComments(ctx)` 를 한 번 붙이고:
* 1) C 가 칸을 그릴 때 `hasComment(sheet, r, c)` 가 참이면 칸 위 오른쪽에 빨간 세모(4~6px)를 그림.
* 2) 그 칸에 마우스가 올라가면(hover) `showBalloon(r, c)` · 벗어나면 `hideBalloon()`.
* 3) 우클릭 메뉴 [메모 삽입 · 메모 편집 · 메모 삭제] 는 `setComment` · `deleteComment` 를 부름.
* ========================================================================== */
import type { Sheet, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import { el } from "@ui/ui_template_elements";
/** 0 계약이 아직 안 실은 칸 — sub3 커밋 뒤엔 진짜 `Sheet` 가 이 모양을 그대로 가짐. */
export interface SheetWithComments extends Sheet {
comments?: Record<string, string>;
}
const a1 = (r: number, c: number): string => {
let col = c + 1;
let letters = "";
while (col > 0) {
const rem = (col - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
col = Math.floor((col - 1) / 26);
}
return `${letters}${r + 1}`;
};
/** 자리 이동(행열 넣기 · 지우기) 뒤 메모 주소를 옮김 — A `spreadsheet_refshift` 완성 뒤 채울 자리.
* `shift` 는 옛 주소 → 새 주소(지워진 자리면 null). 지금은 호출부가 없어 안 씀(사각지대 기록용). */
export function remapComments(
comments: Record<string, string>,
shift: (old: { r: number; c: number }) => { r: number; c: number } | null,
): Record<string, string> {
const next: Record<string, string> = {};
for (const [key, text] of Object.entries(comments)) {
const at = parseA1(key);
const moved = shift(at);
if (moved) next[a1(moved.r, moved.c)] = text;
}
return next;
}
function parseA1(key: string): { r: number; c: number } {
const m = /^([A-Z]+)(\d+)$/.exec(key);
if (!m) return { r: 0, c: 0 };
let col = 0;
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
return { r: Number(m[2]) - 1, c: col - 1 };
}
export function getComment(sheet: Sheet, r: number, c: number): string | null {
const comments = (sheet as SheetWithComments).comments;
return comments?.[a1(r, c)] ?? null;
}
export function hasComment(sheet: Sheet, r: number, c: number): boolean {
return getComment(sheet, r, c) !== null;
}
export function listComments(sheet: Sheet): { r: number; c: number; 글: string }[] {
const comments = (sheet as SheetWithComments).comments ?? {};
return Object.entries(comments).map(([key, 글]) => ({ ...parseA1(key), 글 }));
}
/** `시트통째` 명령으로 메모 칸만 바꿔 넣음 — 그 밖 시트 내용은 그대로. */
function applyComments(ctx: SpreadsheetContext, next: Record<string, string>): void {
const sheet = ctx.sheet() as SheetWithComments;
const cleaned: Record<string, string> = {};
for (const [key, text] of Object.entries(next)) if (text) cleaned[key] = text;
const nextSheet: SheetWithComments = { ...sheet, comments: cleaned };
ctx.dispatch({ 종류: "시트통째", 시트: nextSheet });
}
/** 글이 비면 메모를 지움(값과 같은 규칙 — `spreadsheet_types.ts` 「빈 칸 안 적음」). */
export function setComment(ctx: SpreadsheetContext, r: number, c: number, text: string): void {
const sheet = ctx.sheet() as SheetWithComments;
const next = { ...(sheet.comments ?? {}) };
if (text) next[a1(r, c)] = text;
else delete next[a1(r, c)];
applyComments(ctx, next);
}
export function deleteComment(ctx: SpreadsheetContext, r: number, c: number): void {
setComment(ctx, r, c, "");
}
export interface CommentsHandle extends PartHandle {
showBalloon(r: number, c: number): void;
hideBalloon(): void;
}
/** 풍선 뿌리 하나를 만들어 `ctx.root` 에 붙임 — 세모 그리기 자체는 C(격자)가 `hasComment` 로 판단해서 함. */
export function attachComments(ctx: SpreadsheetContext): CommentsHandle {
const balloon = el("div", { className: "spreadsheet-comment-balloon", attrs: { hidden: "" } });
ctx.root.append(balloon);
return {
root: balloon,
showBalloon(r: number, c: number): void {
const text = getComment(ctx.sheet(), r, c);
if (!text) return void this.hideBalloon();
balloon.textContent = text;
balloon.removeAttribute("hidden");
const box = ctx.grid.cellBox(r, c);
balloon.style.left = `${box.x + box.w}px`;
balloon.style.top = `${box.y}px`;
},
hideBalloon(): void {
balloon.setAttribute("hidden", "");
},
refresh(): void {
/* 문서가 바뀌면 지금 보이는 풍선 글도 새로 — 열린 풍선이 없으면 할 일 없음 */
},
destroy(): void {
balloon.remove();
},
};
}
/** 시트 전체 메모 개수(시험 · 실무 대조용) */
export function countComments(book: Workbook, sheetId: string): number {
const sheet = book.시트.find((s) => s.id === sheetId);
return sheet ? Object.keys((sheet as SheetWithComments).comments ?? {}).length : 0;
}
@@ -0,0 +1,272 @@
/* =============================================================================
* spreadsheet_condfmt.ts (3단계 · 주인 sub_laptop_4)
* 조건부 서식 — 칸 값이 규칙에 맞으면 채움색 · 글자색을 덮어씀. 저장 자리 `Sheet.조건부서식`
* (0 계약 `spreadsheet_types.ts` 에 방금 더함 · 계약 더함).
*
* 규칙 차례 = 우선순위(배열 앞쪽이 먼저 이김 · 엑셀 「규칙 관리」 위쪽과 같음). 스타일은 `서식` 표
* 번호를 안 씀 — 규칙마다 채움 · 글자색을 직접 가져 표 번호 조율(A 몫)이 필요 없음. 「수식이 참」
* 조건은 임의 수식 글을 파싱·계산해야 해서(엔진 A) 이 파일은 `evalFormula` 콜백만 받고 엔진을 모름.
*
* 잇는 법(A · C · D · sub7 — 지금은 하나도 안 이어짐):
* 1) 격자(C)가 칸을 그릴 때 `resolveCondFmtStyle(sheet, r, c, env)` 로 덮어쓸 스타일을 얻어
* 지금 셀 스타일 위에 얹음(env.value 는 `ctx.engine.value` 를 수 · 글 · 참거짓 · null 로 바꿔 줌).
* 2) `env.evalFormula(식, r, c)` 구현 — `spreadsheet_parser.parse(식)` 뒤 `spreadsheet_eval` 로
* 풀어 참거짓으로(A 가 만들 자리 · 지금 없음).
* 3) 행 · 열 넣기 · 지우기 뒤 `remapCondFmtRanges` 를 그 시트 `조건부서식` 에 적용해 범위를 옮김
* (`spreadsheet_commands.insertDelete` 가 지금 이 규칙을 모름 — refshift 참조 옮김과 같은 자리에
* 걸어야 함 · A 가 채울 자리).
* 4) 도구 모음 · 우클릭 메뉴에 [조건부 서식] 항목을 두고 `createCondFmtPanel(ctx)` 를 띄움.
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { inRange, rangeToA1 } from "./spreadsheet_address";
import type {
CellRange,
CondFmtCompareOp,
CondFmtCondition,
CondFmtRule,
CondFmtStyle,
Sheet,
} from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
export type { CondFmtCompareOp, CondFmtCondition, CondFmtRule, CondFmtStyle };
/** 칸 값을 이미 수 · 글 · 참거짓 · 빈칸으로 바꿔 넘김(Frac → 수는 호출부 몫 — 이 파일은 엔진을 모름). */
export type CondFmtValue = number | string | boolean | null;
function testCondition(
condition: CondFmtCondition,
value: CondFmtValue,
evalFormula: (식: string) => boolean,
): boolean {
switch (condition.종류) {
case "비교": {
if (typeof value !== "number") return false;
switch (condition.연산) {
case "보다큼":
return value > condition.값;
case "보다작음":
return value < condition.값;
case "크거나같음":
return value >= condition.값;
case "작거나같음":
return value <= condition.값;
case "같음":
return value === condition.값;
case "다름":
return value !== condition.값;
}
break;
}
case "사이":
return typeof value === "number" && value >= condition.아래 && value <= condition.위;
case "글포함":
return typeof value === "string" && value.includes(condition.글);
case "수식":
return evalFormula(condition.식);
}
return false;
}
export interface CondFmtEnv {
value(r: number, c: number): CondFmtValue;
/** 없으면 「수식이 참」 조건은 늘 거짓 */
evalFormula?(formula: string, r: number, c: number): boolean;
}
/** 칸 하나에 먹일 조건부 서식 — 처음 맞는 규칙(우선순위 위) · 없으면 null. */
export function resolveCondFmtStyle(
sheet: Sheet,
r: number,
c: number,
env: CondFmtEnv,
): CondFmtStyle | null {
for (const rule of sheet.조건부서식 ?? []) {
if (!rule.범위.some((range) => inRange(range, r, c))) continue;
const value = env.value(r, c);
const hit = testCondition(rule.조건, value, (식) => env.evalFormula?.(식, r, c) ?? false);
if (hit) return rule.스타일;
}
return null;
}
/** 행 · 열 넣기 · 지우기 뒤 범위 옮김 — `shift` 가 null 이면 그 범위는 버림(규칙이 범위를 다 잃으면 규칙째 버림). */
export function remapCondFmtRanges(
rules: CondFmtRule[],
shift: (range: CellRange) => CellRange | null,
): CondFmtRule[] {
const out: CondFmtRule[] = [];
for (const rule of rules) {
const moved = rule.범위.map(shift).filter((r): r is CellRange => r !== null);
if (moved.length) out.push({ ...rule, 범위: moved });
}
return out;
}
function applySheet(ctx: SpreadsheetContext, next: Sheet): void {
ctx.dispatch({ 종류: "시트통째", 시트: next });
}
let seq = 0;
const newId = (): string => `cf${Date.now().toString(36)}${seq++}`;
export function addCondFmtRule(ctx: SpreadsheetContext, rule: Omit<CondFmtRule, "id">): void {
const sheet = ctx.sheet();
applySheet(ctx, {
...sheet,
조건부서식: [...(sheet.조건부서식 ?? []), { ...rule, id: newId() }],
});
}
export function removeCondFmtRule(ctx: SpreadsheetContext, id: string): void {
const sheet = ctx.sheet();
applySheet(ctx, { ...sheet, 조건부서식: (sheet.조건부서식 ?? []).filter((r) => r.id !== id) });
}
/** 우선순위 한 칸 옮김(엑셀 「규칙 관리」 위 · 아래 화살표와 같음). */
export function moveCondFmtRule(ctx: SpreadsheetContext, id: string, dir: -1 | 1): void {
const sheet = ctx.sheet();
const list = [...(sheet.조건부서식 ?? [])];
const idx = list.findIndex((r) => r.id === id);
const swapWith = idx + dir;
if (idx < 0 || swapWith < 0 || swapWith >= list.length) return;
[list[idx], list[swapWith]] = [list[swapWith], list[idx]];
applySheet(ctx, { ...sheet, 조건부서식: list });
}
const conditionText = (c: CondFmtCondition): string => {
switch (c.종류) {
case "비교":
return `${c.연산} ${c.값}`;
case "사이":
return `${c.아래} ~ ${c.위} 사이`;
case "글포함":
return `「${c.글}」 포함`;
case "수식":
return `수식 =${c.식}`;
}
};
/** 규칙 관리 창 — 지금 시트 규칙 목록 + 고른 범위에 규칙 더하기. 잇는 법 머리 주석 참고. */
export function createCondFmtPanel(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-condfmt" });
const list = el("ul", { className: "ss-condfmt__list" });
const compareOps: CondFmtCompareOp[] = [
"보다큼",
"보다작음",
"크거나같음",
"작거나같음",
"같음",
"다름",
];
const kindSelect = createSelectField({
label: "조건",
options: [
{ value: "비교", text: "값 비교" },
{ value: "사이", text: "사이" },
{ value: "글포함", text: "글 포함" },
{ value: "수식", text: "수식이 참" },
],
});
const opSelect = createSelectField({
label: "연산",
options: compareOps.map((op) => ({ value: op, text: op })),
});
const val1 = createInputField({ label: "값(또는 아래 · 글 · 수식)" });
const val2 = createInputField({ label: "위(사이일 때만)" });
const fill = createInputField({ label: "채움색", type: "text", placeholder: "#RRGGBB" });
const fontColor = createInputField({ label: "글자색", type: "text", placeholder: "#RRGGBB" });
const addBtn = createButton({ label: "규칙 더하기" });
addBtn.addEventListener("click", () => {
const 범위 = ctx.selection.범위;
if (!범위.length) return;
const 조건 = buildCondition(kindSelect.select.value, opSelect.select.value, val1, val2);
if (!조건) return;
const 스타일: CondFmtStyle = {};
if (fill.input.value) 스타일.채움 = fill.input.value;
if (fontColor.input.value) 스타일.글자색 = fontColor.input.value;
addCondFmtRule(ctx, { 범위: [...범위], 조건, 스타일 });
render();
});
function buildCondition(
kind: string,
op: string,
a: ReturnType<typeof createInputField>,
b: ReturnType<typeof createInputField>,
): CondFmtCondition | null {
if (kind === "비교") {
const n = Number(a.input.value);
return Number.isFinite(n) ? { 종류: "비교", 연산: op as CondFmtCompareOp, 값: n } : null;
}
if (kind === "사이") {
const 아래 = Number(a.input.value);
const 위 = Number(b.input.value);
return Number.isFinite(아래) && Number.isFinite(위) ? { 종류: "사이", 아래, 위 } : null;
}
if (kind === "글포함") return a.input.value ? { 종류: "글포함", 글: a.input.value } : null;
if (kind === "수식") return a.input.value ? { 종류: "수식", 식: a.input.value } : null;
return null;
}
function render(): void {
const rules = ctx.sheet().조건부서식 ?? [];
list.replaceChildren(
...rules.map((rule, idx) => {
const li = el("li", { className: "ss-condfmt__row" });
const swatch = el("span", { className: "ss-condfmt__swatch" });
if (rule.스타일.채움) swatch.style.background = rule.스타일.채움;
const label = el("span", {
text: `${rule.범위.map(rangeToA1).join(", ")} — ${conditionText(rule.조건)}`,
});
const up = createButton({ label: "↑", variant: "ghost" });
up.disabled = idx === 0;
up.addEventListener("click", () => {
moveCondFmtRule(ctx, rule.id, -1);
render();
});
const down = createButton({ label: "↓", variant: "ghost" });
down.disabled = idx === rules.length - 1;
down.addEventListener("click", () => {
moveCondFmtRule(ctx, rule.id, 1);
render();
});
const del = createButton({ label: "지움", variant: "danger" });
del.addEventListener("click", () => {
removeCondFmtRule(ctx, rule.id);
render();
});
li.append(swatch, label, up, down, del);
return li;
}),
);
}
const form = el("div", {
className: "ss-condfmt__form",
children: [
kindSelect.root,
opSelect.root,
val1.root,
val2.root,
fill.root,
fontColor.root,
addBtn,
],
});
root.append(el("h4", { text: "조건부 서식 규칙" }), list, form);
render();
return {
root,
refresh: render,
destroy(): void {
root.remove();
},
};
}
@@ -0,0 +1,325 @@
/* =============================================================================
* spreadsheet_data_menu.ts (주인 D)
* 데이터 단추 · 메뉴 잇기 — 수식 입력줄 앞 [↑] [↓] [필터] [데이터 ▾].
* 정렬 · 자동 필터(sub3 `spreadsheet_sort_dialog` · `spreadsheet_filter`) ·
* 조건부 서식 · 데이터 유효성 · 이름 관리 · 시트 숨김/탭 색(sub4 패널 — 부품 오른위 창에 띄움) · 눈금선 ·
* 수식 분석(sub3 — 선행 · 종속 추적 화살표 · 화살표 지우기 · 수식 계산 · 오류 ⚠ 풍선) · 자동 합계(Σ · Alt+=) ·
* 이동 · 틀 고정 · 칸 넣기/지우기 · 데이터 도구 · 윤곽(`spreadsheet_actions.ts`).
* 유효성 목록 칸은 고르면 칸 옆 ▼ · Alt+↓ 로 값 목록(필터 머리 칸이면 필터 목록이 먼저).
* 누를 때만 쓰는 창(정렬 · 조건부 서식 · 시트)은 `import()` 로 늦게 — 크기 예산(열 때 받는 조각).
* ========================================================================== */
import "./spreadsheet_stage3.css"; // sub4 패널 스타일(잇는 쪽이 붙임)
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { el, showToast } from "@ui/ui_template_elements";
import { parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
import { autoSumRange } from "./spreadsheet_autosum";
import { isError } from "./spreadsheet_eval";
import type { FilterHandle } from "./spreadsheet_filter";
import { createCondFmtPanel } from "./spreadsheet_condfmt";
import { createSheetExtraPanel } from "./spreadsheet_sheet_extra";
import { mountActions } from "./spreadsheet_actions";
import type { ErrorTipHandle } from "./spreadsheet_errcheck";
import type { TraceHandle } from "./spreadsheet_trace";
import {
createValidationDropdown,
createValidationPanel,
findValidation,
} from "./spreadsheet_validation";
import type { KeyHooks } from "./spreadsheet_keys";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
asc: "오름차순 정렬(활성 칸 열 기준)",
desc: "내림차순 정렬(활성 칸 열 기준)",
filter: "자동 필터 켜기 · 끄기(Ctrl+Shift+L · 머리 칸에서 Alt+↓ 값 목록)",
menu: "데이터 ▾",
sort: "정렬…",
filterItem: "자동 필터 켜기 · 끄기",
condfmt: "조건부 서식…",
validation: "데이터 유효성…",
names: "이름 관리…",
grid: "눈금선 보이기 · 숨기기",
close: "닫기",
list: "값 목록(Alt+↓)",
sheets: "시트 숨기기 · 탭 색…",
audit: "수식 분석",
prec: "선행 참조 추적",
dep: "종속 참조 추적",
clear: "화살표 지우기",
steps: "수식 계산…",
noMore: "더 추적할 칸 없음",
sum: "자동 합계(Alt+=)",
};
export interface DataMenu {
/** 수식 입력줄 앞에 둘 단추들 */
buttons: HTMLElement[];
keys: Pick<KeyHooks, "filter" | "dropdown" | "autosum" | "more">;
part: PartHandle;
}
export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMenu {
// 자동 필터는 시트에 필터가 있거나 켤 때 불러옴(크기 예산)
let filter: FilterHandle | null = null;
let filterMod: Promise<typeof import("./spreadsheet_filter")> | null = null;
const loadFilter = () =>
(filterMod ??= import("./spreadsheet_filter").then((m) => {
filter = m.attachFilter(ctx);
filter.refresh();
return m;
}));
const toggleFilter = (): void => void loadFilter().then((m) => m.toggleFilter(ctx));
// 수식 분석(추적 · ⚠ 풍선 · 수식 계산)은 처음 쓸 때 — 메뉴 · 오류 칸 · 글 숫자 칸을 고르면
type Audit = { trace: TraceHandle; tip: ErrorTipHandle };
let audit: Audit | null = null;
let loading: Promise<Audit> | null = null;
const loadAudit = () =>
(loading ??= Promise.all([
import("./spreadsheet_trace"),
import("./spreadsheet_errcheck"),
]).then(([t, e]) => {
const trace = t.attachTrace(ctx);
audit = { trace, tip: e.attachErrorTip(ctx, trace) };
audit.tip.refresh();
return audit;
}));
const warnAt = (): boolean => {
const { r, c } = ctx.selection.활성;
const cell = ctx.sheet().칸[toA1(r, c)];
if (!cell) return false;
if (cell.식 !== undefined) return isError(ctx.engine.value(ctx.selection.시트, r, c));
return typeof cell.값 === "string" && /\d/.test(cell.값);
};
let closeSteps: (() => void) | null = null;
const traced = (more: boolean): void => {
if (!more) showToast(TEXT.noMore, "info");
back();
};
const act = mountActions(ctx, back);
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
let closeSort: (() => void) | null = null;
const button = (text: string, title: string, run: (b: HTMLElement) => void, keep = false) => {
const b = el("button", { className: "ss-brush", text, attrs: { type: "button", title } });
b.addEventListener("click", () => {
run(b);
if (!keep) back();
});
return b;
};
// ── 패널 창(조건부 서식 · 유효성 · 이름) — 한 번에 하나 ─────────────────────
const title = el("span", { className: "ss-dialog__title" });
const body = el("div", { className: "ss-dialog__body" });
const shut = el("button", {
className: "ss-find__btn",
text: "×",
attrs: { type: "button", title: TEXT.close },
});
const dialog = el("div", {
className: "ss-dialog",
attrs: { hidden: "" },
children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body],
});
ctx.root.append(dialog);
let panel: PartHandle | null = null;
const closePanel = (): void => {
panel?.destroy();
panel = null;
body.replaceChildren();
dialog.setAttribute("hidden", "");
};
shut.addEventListener("click", () => (closePanel(), back()));
dialog.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
e.stopPropagation();
closePanel();
back();
});
async function openPanel(
name: string,
load: () => Promise<(c: SpreadsheetContext) => PartHandle>,
): Promise<void> {
const make = await load();
closePanel();
panel = make(ctx);
title.textContent = name.replace("…", "");
if (panel.root) body.append(panel.root);
dialog.removeAttribute("hidden");
}
const sorting = () => import("./spreadsheet_sort_dialog");
async function sortQuick(desc: boolean): Promise<void> {
(await sorting()).sortSelection(ctx, desc);
back();
}
async function sortDialog(): Promise<void> {
const m = await sorting();
closeSort?.();
closeSort = m.openSortDialog(ctx);
}
async function evalSteps(): Promise<void> {
const m = await import("./spreadsheet_eval_steps");
closeSteps?.();
closeSteps = m.openEvalSteps(ctx);
}
function openMenu(anchor: HTMLElement): void {
const r = anchor.getBoundingClientRect();
const items: MapContextMenuItem[] = [
[TEXT.sort, () => void sortDialog()],
[TEXT.filterItem, () => (toggleFilter(), back())],
[TEXT.condfmt, () => void openPanel(TEXT.condfmt, async () => createCondFmtPanel)],
[TEXT.validation, () => void openPanel(TEXT.validation, async () => createValidationPanel)],
[
TEXT.names,
() =>
void openPanel(
TEXT.names,
async () => (await import("./spreadsheet_names")).createNameManagerPanel,
),
],
...(ctx.root.classList.contains("ss--notabs") // 시트 탭 없음(산출근거) — 시트 창도 없음
? []
: [
[
TEXT.sheets,
() => void openPanel(TEXT.sheets, async () => createSheetExtraPanel),
] as MapContextMenuItem,
]),
[TEXT.grid, () => (toggleGridlines(), back())],
...act.items,
{
label: TEXT.audit,
children: [
[TEXT.prec, () => void loadAudit().then((a) => traced(a.trace.precedents()))],
[TEXT.dep, () => void loadAudit().then((a) => traced(a.trace.dependents()))],
[TEXT.clear, () => (audit?.trace.clear(), back())],
[TEXT.steps, () => void evalSteps()],
],
},
];
if (!ctx.readOnly) menu.open(r.left, r.bottom, items);
}
function toggleGridlines(): void {
const s = ctx.sheet();
ctx.dispatch({ 종류: "눈금선", 시트: s.id, 보임: s.보기?.눈금선 === false });
}
// ── 유효성 목록 — ▼ 단추 · Alt+↓ ─────────────────────────────────────────
let drop: HTMLElement | null = null;
const closeDrop = (): void => {
drop?.remove();
drop = null;
};
const arrow = el("button", {
className: "ss-list-arrow",
text: "▼",
attrs: { type: "button", hidden: "", title: TEXT.list },
});
arrow.addEventListener("mousedown", (e) => e.preventDefault());
arrow.addEventListener("click", () => openList());
ctx.root.append(arrow);
/** 칸 자리(부품 뿌리 기준) */
const at = (r: number, c: number) => {
const { box } = ctx.grid.editorSlot(r, c);
const g = ctx.grid.root.getBoundingClientRect();
const o = ctx.root.getBoundingClientRect();
return { x: box.x + g.left - o.left, y: box.y + g.top - o.top, w: box.w, h: box.h };
};
function openList(): boolean {
const { r, c } = ctx.selection.활성;
const rule = findValidation(ctx.sheet(), r, c);
if (ctx.readOnly || !rule) return false;
closeDrop();
drop = createValidationDropdown(ctx, r, c, rule, (value) => {
closeDrop();
const key = toA1(r, c);
const n = Number(value);
const 값 = value !== "" && Number.isFinite(n) ? n : value;
const 서식 = ctx.sheet().칸[key]?.서식;
const cell = 서식 === undefined ? { 값 } : { 값, 서식 };
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: { [key]: cell } });
back();
});
if (!drop) return false;
const p = at(r, c);
drop.style.left = `${p.x}px`; // cellBox(스크롤 전 내용 좌표) 자리를 화면 자리로 고침
drop.style.top = `${p.y + p.h}px`;
ctx.root.append(drop);
return true;
}
const onOutside = (e: MouseEvent): void => {
if (drop && !drop.contains(e.target as Node) && e.target !== arrow) closeDrop();
};
document.addEventListener("mousedown", onOutside, true);
/** 자동 합계 — 엑셀처럼 =SUM(잡은 범위) 로 편집을 열고 Enter 는 사용자 몫 */
function autosum(): void {
const g = autoSumRange(ctx);
ctx.editor.begin(g ? `=SUM(${rangeToA1(g)})` : "=SUM()");
}
const buttons = ctx.readOnly
? []
: [
button("↑", TEXT.asc, () => void sortQuick(false), true),
button("↓", TEXT.desc, () => void sortQuick(true), true),
button("필터", TEXT.filter, toggleFilter),
button("Σ", TEXT.sum, autosum, true),
button(TEXT.menu, TEXT.menu, openMenu, true),
];
return {
buttons,
keys: {
filter: toggleFilter,
autosum,
more: act.key,
dropdown() {
const f = ctx.sheet().필터;
const g = f && parseRange(f.범위);
const { r, c } = ctx.selection.활성;
if (g && filter && r === g.r0 && c >= g.c0 && c <= g.c1) return (filter.open(c), true);
return openList();
},
},
part: {
root: null,
refresh() {
filter?.refresh();
if (!filter && ctx.sheet().필터) void loadFilter();
audit?.trace.refresh();
audit?.tip.refresh();
if (!audit && warnAt()) void loadAudit();
panel?.refresh();
closeDrop();
const { r, c } = ctx.selection.활성;
const list = !ctx.readOnly && findValidation(ctx.sheet(), r, c)?.규칙.종류 === "목록";
arrow.hidden = !list || ctx.editor.editing();
if (!list) return;
const p = at(r, c);
arrow.style.left = `${p.x + p.w + 1}px`;
arrow.style.top = `${p.y}px`;
arrow.style.height = `${p.h}px`;
},
destroy() {
document.removeEventListener("mousedown", onOutside, true);
closeSort?.();
closePanel();
closeDrop();
filter?.destroy();
closeSteps?.();
audit?.trace.destroy();
audit?.tip.destroy();
menu.element.remove();
dialog.remove();
arrow.remove();
buttons.forEach((b) => b.remove());
},
},
};
}
@@ -0,0 +1,424 @@
/* =============================================================================
* spreadsheet_data_tools.ts (2단계 · 주인 sub_laptop_1)
* 데이터 도구 셋 — ① 텍스트 나누기(구분 기호 · 고정 폭 · 미리보기) ② 중복된 항목 제거
* (열 고르기 · 지운 수 알림) ③ 채우기 연속 데이터(단계 값 · 끝 값 · 행/열).
* 문서를 바꾸는 길은 「칸」 명령 하나뿐 — 세 도구 모두 이것만 씀.
*
* 잇는 법(D · sub7) — 메뉴 [데이터] 아래 세 항목을 만들어 `openTextToColumnsDialog(ctx)` ·
* `openRemoveDuplicatesDialog(ctx)` · `openFillSeriesDialog(ctx)` 를 각각 부름
* (`spreadsheet_menu.ts` 우클릭 메뉴에도 [중복된 항목 제거]는 달아 둘 만함).
*
* 고정 폭 나누기는 실제 엑셀처럼 눈금자를 끌어 자르지 않고 자를 자리(문자 수)를
* 쉼표로 적는 입력 한 줄로 단순화함(미리보기로 바로 확인 가능 — 자리 표시만 다름).
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { fracToString } from "@ui/sheet/ui_template_sheet_frac";
import { colName, toA1 } from "./spreadsheet_address";
import type { Cell, CellAddress, CellInput, CellRange, Scalar } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
textToColumns: "텍스트 나누기",
byDelimiter: "구분 기호로",
byFixedWidth: "고정 폭으로",
delimiters: "구분 기호",
tab: "탭",
semicolon: "세미콜론",
comma: "쉼표",
space: "공백",
custom: "사용자 지정",
breaks: "자를 자리(문자 수, 쉼표로 구분)",
preview: "미리보기",
run: "실행",
cancel: "취소",
removeDuplicates: "중복된 항목 제거",
hasHeader: "머리글 포함",
keyColumns: "비교할 열",
removed: (n: number) => `중복 ${n}개 지움`,
fillSeries: "연속 데이터 채우기",
direction: "방향",
down: "아래로(열)",
right: "오른쪽으로(행)",
step: "단계 값",
stop: "끝 값(선택)",
};
// ═════════════════════════════════════════════════════════════════════════════
// 공용 — 값 ↔ 글, 작은 대화상자 뼈대
// ═════════════════════════════════════════════════════════════════════════════
function scalarText(v: Scalar): string {
if (v == null) return "";
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
if (typeof v === "string") return v;
if (typeof v === "object" && "error" in v) return v.error;
return fracToString(v);
}
function cellInputFrom(text: string): CellInput {
const n = Number(text);
return text !== "" && !Number.isNaN(n) ? n : text;
}
function row(label: string, ...controls: HTMLElement[]): HTMLElement {
const r = el("div", {
children: [el("span", { text: label }), ...controls],
});
r.style.cssText = "display:flex;align-items:center;gap:8px;margin-bottom:6px;";
return r;
}
/** `.ui-confirm` 배경 위에 제목 · 본문 · [실행]/[취소] 를 올린 작은 대화상자. */
function modal(title: string, body: HTMLElement[], onRun: () => void, runLabel = TEXT.run): void {
const overlay = el("div", { className: "ui-confirm" });
const panel = el("div", { className: "ui-confirm__panel" });
panel.style.minWidth = "360px";
const close = (): void => overlay.remove();
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) close();
});
panel.append(el("h3", { text: title }), ...body);
panel.append(
el("div", {
className: "ui-confirm__actions",
children: [
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
createButton({
label: runLabel,
variant: "filled",
onClick: () => {
onRun();
close();
},
}),
],
}),
);
overlay.append(panel);
document.body.append(overlay);
}
function readRange(ctx: SpreadsheetContext): CellRange | null {
return ctx.selection.범위[0] ?? null;
}
// ═════════════════════════════════════════════════════════════════════════════
// 1. 텍스트 나누기
// ═════════════════════════════════════════════════════════════════════════════
export type DelimiterKind = "탭" | "세미콜론" | "쉼표" | "공백" | "사용자";
const DELIM_CHARS: Record<Exclude<DelimiterKind, "사용자">, string> = {
탭: "\t",
세미콜론: ";",
쉼표: ",",
공백: " ",
};
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** 한 줄을 고른 구분 기호로 나눔 — 기호가 하나도 없으면 원문 그대로 한 칸. */
export function splitDelimited(line: string, kinds: DelimiterKind[], custom: string): string[] {
const chars = kinds
.map((k) => (k === "사용자" ? custom : DELIM_CHARS[k]))
.filter((c): c is string => !!c);
if (chars.length === 0) return [line];
return line.split(new RegExp(chars.map(escapeRegExp).join("|")));
}
/** 한 줄을 자를 자리(문자 수, 오름차순)로 나눔. */
export function splitFixedWidth(line: string, breaks: number[]): string[] {
const points = [0, ...breaks.filter((b) => b > 0 && b < line.length), line.length];
const sorted = [...new Set(points)].sort((a, b) => a - b);
const out: string[] = [];
for (let i = 0; i < sorted.length - 1; i++) out.push(line.slice(sorted[i], sorted[i + 1]));
return out;
}
/** 나뉜 줄들을 `anchor` 를 왼위로 삼아 칸에 씀(빈 글은 칸 지움). */
export function applyTextToColumns(
ctx: SpreadsheetContext,
anchor: CellAddress,
rows: string[][],
): void {
const cells: Record<string, Cell | null> = {};
rows.forEach((cols, ri) => {
cols.forEach((text, ci) => {
cells[toA1(anchor.r + ri, anchor.c + ci)] = text === "" ? null : { 값: cellInputFrom(text) };
});
});
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
export function openTextToColumnsDialog(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const range = readRange(ctx);
if (!range) return;
const lines: string[] = [];
for (let r = range.r0; r <= range.r1; r++) {
lines.push(scalarText(ctx.engine.value(ctx.selection.시트, r, range.c0)));
}
let mode: "구분" | "고정" = "구분";
let kinds: DelimiterKind[] = ["쉼표"];
let custom = "";
let breaksText = "";
const modeSelect = el("select", {
className: "ss-toolbar__select",
children: [
el("option", { attrs: { value: "구분" }, text: TEXT.byDelimiter }),
el("option", { attrs: { value: "고정" }, text: TEXT.byFixedWidth }),
],
}) as HTMLSelectElement;
const delimBox = el("div");
const kindChecks = (["탭", "세미콜론", "쉼표", "공백", "사용자"] as DelimiterKind[]).map((k) => {
const check = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
check.checked = kinds.includes(k);
check.addEventListener("change", () => {
kinds = check.checked ? [...kinds, k] : kinds.filter((x) => x !== k);
refreshPreview();
});
return row(
k === "탭"
? TEXT.tab
: k === "세미콜론"
? TEXT.semicolon
: k === "쉼표"
? TEXT.comma
: k === "공백"
? TEXT.space
: TEXT.custom,
check,
);
});
const customInput = createInputField({
label: TEXT.custom,
onInput: (v) => {
custom = v;
refreshPreview();
},
});
delimBox.append(...kindChecks, customInput.root);
const breaksField = createInputField({
label: TEXT.breaks,
placeholder: "5,10,20",
onInput: (v) => {
breaksText = v;
refreshPreview();
},
});
breaksField.root.hidden = true;
const previewBox = el("pre");
previewBox.style.cssText =
"max-height:160px;overflow:auto;background:var(--color-canvas);padding:6px;border-radius:4px;font-size:0.8rem;";
function splitAll(): string[][] {
if (mode === "고정") {
const breaks = breaksText
.split(",")
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n));
return lines.map((l) => splitFixedWidth(l, breaks));
}
return lines.map((l) => splitDelimited(l, kinds, custom));
}
function refreshPreview(): void {
previewBox.textContent = splitAll()
.slice(0, 8)
.map((cols) => cols.join(" | "))
.join("\n");
}
modeSelect.addEventListener("change", () => {
mode = modeSelect.value as "구분" | "고정";
delimBox.hidden = mode !== "구분";
breaksField.root.hidden = mode !== "고정";
refreshPreview();
});
refreshPreview();
modal(
TEXT.textToColumns,
[
row(TEXT.byDelimiter + "/" + TEXT.byFixedWidth, modeSelect),
delimBox,
breaksField.root,
el("div", { text: TEXT.preview }),
previewBox,
],
() => applyTextToColumns(ctx, { r: range.r0, c: range.c0 }, splitAll()),
);
}
// ═════════════════════════════════════════════════════════════════════════════
// 2. 중복된 항목 제거
// ═════════════════════════════════════════════════════════════════════════════
/** `keyCols`(범위 안 0 부터 상대 열)로 값이 같은 뒤쪽 행들의 자리(0 부터 상대 행)를 돌려줌. */
export function findDuplicateRowIndexes(rows: string[][], keyCols: number[]): number[] {
const seen = new Set<string>();
const dup: number[] = [];
rows.forEach((r, i) => {
const key = keyCols.map((c) => r[c] ?? "").join("\u0001");
if (seen.has(key)) dup.push(i);
else seen.add(key);
});
return dup;
}
/** 범위 안 값을 읽어 중복 행을 지우고 위로 당김(범위 밖 칸은 안 건드림) — 지운 행 수를 돌려줌. */
export function removeDuplicateRows(
ctx: SpreadsheetContext,
range: CellRange,
keyCols: number[],
skipHeader: boolean,
): number {
const width = range.c1 - range.c0 + 1;
const bodyR0 = range.r0 + (skipHeader ? 1 : 0);
const rows: string[][] = [];
for (let r = bodyR0; r <= range.r1; r++) {
const line: string[] = [];
for (let c = 0; c < width; c++)
line.push(scalarText(ctx.engine.value(ctx.selection.시트, r, range.c0 + c)));
rows.push(line);
}
const dupIdx = new Set(findDuplicateRowIndexes(rows, keyCols));
const kept = rows.filter((_, i) => !dupIdx.has(i));
const cells: Record<string, Cell | null> = {};
for (let i = 0; i < rows.length; i++) {
const r = bodyR0 + i;
for (let c = 0; c < width; c++) {
const text = i < kept.length ? kept[i][c] : "";
cells[toA1(r, range.c0 + c)] = text === "" ? null : { 값: cellInputFrom(text) };
}
}
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
return dupIdx.size;
}
export function openRemoveDuplicatesDialog(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const range = readRange(ctx);
if (!range) return;
const width = range.c1 - range.c0 + 1;
let hasHeader = width > 1 || range.r1 > range.r0;
const headerCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
headerCheck.checked = hasHeader;
const keyCols = new Set<number>(Array.from({ length: width }, (_, i) => i));
const colChecks = Array.from({ length: width }, (_, i) => {
const c = range.c0 + i;
const label = hasHeader
? scalarText(ctx.engine.value(ctx.selection.시트, range.r0, c)) || colName(c)
: colName(c);
const check = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
check.checked = true;
check.addEventListener("change", () => {
if (check.checked) keyCols.add(i);
else keyCols.delete(i);
});
return row(label, check);
});
headerCheck.addEventListener("change", () => {
hasHeader = headerCheck.checked;
});
modal(
TEXT.removeDuplicates,
[row(TEXT.hasHeader, headerCheck), el("div", { text: TEXT.keyColumns }), ...colChecks],
() => {
const removed = removeDuplicateRows(ctx, range, [...keyCols], hasHeader);
showToast(TEXT.removed(removed), "success");
},
);
}
// ═════════════════════════════════════════════════════════════════════════════
// 3. 채우기 연속 데이터
// ═════════════════════════════════════════════════════════════════════════════
export type SeriesDirection = "행" | "열";
/** 시작값 · 단계 · 끝값(넘으면 멈춤, 없으면 count 까지) · 개수로 등차수열을 지음. */
export function buildLinearSeries(
start: number,
step: number,
stop: number | null,
count: number,
): number[] {
const out: number[] = [];
for (let i = 0; i < count; i++) {
const v = start + step * i;
if (stop != null && ((step >= 0 && v > stop) || (step < 0 && v < stop))) break;
out.push(v);
}
return out;
}
export function applyFillSeries(
ctx: SpreadsheetContext,
anchor: CellAddress,
direction: SeriesDirection,
values: number[],
): void {
const cells: Record<string, Cell | null> = {};
values.forEach((v, i) => {
const r = direction === "열" ? anchor.r + i : anchor.r;
const c = direction === "행" ? anchor.c + i : anchor.c;
cells[toA1(r, c)] = { 값: v };
});
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
export function openFillSeriesDialog(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const range = readRange(ctx);
if (!range) return;
const at = ctx.selection.활성;
const startText = scalarText(ctx.engine.value(ctx.selection.시트, at.r, at.c));
const start = Number(startText) || 0;
let direction: SeriesDirection = range.r1 > range.r0 ? "열" : "행";
let step = 1;
let stop: number | null = null;
const dirSelect = el("select", {
className: "ss-toolbar__select",
children: [
el("option", { attrs: { value: "열" }, text: TEXT.down }),
el("option", { attrs: { value: "행" }, text: TEXT.right }),
],
}) as HTMLSelectElement;
dirSelect.value = direction;
dirSelect.addEventListener("change", () => (direction = dirSelect.value as SeriesDirection));
const stepField = createInputField({
label: TEXT.step,
type: "number",
value: "1",
onInput: (v) => (step = Number(v) || 0),
});
const stopField = createInputField({
label: TEXT.stop,
type: "number",
onInput: (v) => (stop = v === "" ? null : Number(v)),
});
modal(TEXT.fillSeries, [row(TEXT.direction, dirSelect), stepField.root, stopField.root], () => {
const extent = direction === "열" ? range.r1 - range.r0 + 1 : range.c1 - range.c0 + 1;
const values = buildLinearSeries(start, step, stop, extent);
applyFillSeries(ctx, at, direction, values);
});
}
@@ -0,0 +1,628 @@
/* =============================================================================
* spreadsheet_editor.ts (주인 D)
* 칸 편집기 · 수식 입력줄 · 주소 상자 · 한글 조합 · 참조 가리키기(식 편집 중 칸 · 범위를 눌러 넣기) ·
* 참조 칸 색 테두리.
* 한글: 숨은 textarea(`ta`) 가 늘 활성 칸 자리에서 초점을 쥠 → compositionstart · input 에서 편집을
* 시작하고 같은 textarea 가 그대로 칸 편집기가 됨(첫 글자 조합이 안 끊김 · keydown 글자 방식 금지).
* 방식(엑셀): enter = 바로 쳐서 시작(방향키가 확정 · 식이면 가리키기) · edit = F2 · 두 번 누르기 ·
* 수식 입력줄(방향키가 글자 사이를 옮김). F2 가 둘을 바꿈.
* ========================================================================== */
import { el, showToast } from "@ui/ui_template_elements";
import { moveFormula } from "./spreadsheet_refshift";
import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
import type { FuncHelp } from "./spreadsheet_func_help";
import { parseFormula, tokenize } from "./spreadsheet_parser";
import { cellsOf, move, selectCell } from "./spreadsheet_selection";
import { checkValue, findValidation } from "./spreadsheet_validation";
import { cellStyleAt } from "./spreadsheet_cellstyle";
import type { Cell, CellAddress, CellStyle, Command, Sheet } from "./spreadsheet_types";
import type {
CellBox,
EditorHandle,
RefHighlight,
Selection,
SpreadsheetContext,
} from "./spreadsheet_view_types";
export interface Editor extends EditorHandle {
/** 숨은 textarea = 칸 편집기(늘 초점) */
ta: HTMLTextAreaElement;
/** 주소 상자 + 수식 입력줄 한 줄 */
bar: HTMLElement;
formula: HTMLTextAreaElement;
mode(): "enter" | "edit";
toggleMode(): void;
/** 지금 글자 자리에 참조를 넣을 수 있나(식 · 연산자 뒤) */
pointable(): boolean;
/** 가리킨 고름을 식 글에 넣음(같은 가리키기면 바꿔 적음) */
point(sel: Selection): void;
/** 방향키 가리키기 — 지금 가리킨 곳(없으면 편집 칸)에서 */
pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void;
/** 함수 자동 완성 목록이 떠 있으면 고른 이름을 넣음(Tab) */
accept(): boolean;
/** 자동 완성 목록 안 위아래(목록이 없으면 false) */
suggestMove(d: number): boolean;
/** 고름 칸 전부에 같은 입력(Ctrl+Enter) — 식은 활성 칸 기준 상대 이동 */
commitAll(): void;
/** 초점 되찾기(스크롤 없이) */
focus(): void;
/** 편집 중이면 칸 자리 다시(스크롤 · 폭 바뀜) */
place(): void;
destroy(): void;
}
interface EditState {
sheet: string;
at: CellAddress;
mode: "enter" | "edit";
original: string;
/** 가리키기 중 — 식 글 [start, end) 가 가리킨 참조 */
point: { start: number; end: number; sel: Selection } | null;
}
/** 수 입력 — 1,234 · -1.5e3 · 50% (엑셀 입력 규칙) · 아니면 null */
export function parseNumber(text: string): number | null {
let t = text.trim();
const pct = t.endsWith("%");
if (pct) t = t.slice(0, -1);
if (!/\d/.test(t) || !/^[+-]?(\d{1,3}(,\d{3})+|\d*)(\.\d*)?([eE][+-]?\d+)?$/.test(t)) return null;
const n = Number(t.replace(/,/g, ""));
if (!Number.isFinite(n)) return null;
return pct ? n / 100 : n;
}
const looksTyped = (s: string): boolean =>
(s.length > 1 && s[0] === "=") ||
s[0] === "'" ||
parseNumber(s) !== null ||
/^(true|false)$/i.test(s);
/** 친 글 → 칸(서식은 두고) · 지울 칸이면 null */
export function textToCell(text: string, old?: Cell): Cell | null {
const base: Cell = old?.서식 !== undefined ? { 서식: old.서식 } : {};
if (text === "") return old?.서식 !== undefined ? base : null;
if (text.length > 1 && text[0] === "=") return { ...base, 식: text.slice(1) };
if (text[0] === "'") return { ...base, 값: text.slice(1) };
const n = parseNumber(text);
if (n !== null) return { ...base, 값: n };
if (/^(true|false)$/i.test(text)) return { ...base, 값: text.toUpperCase() === "TRUE" };
return { ...base, 값: text };
}
/** 친 모양대로 숫자 형식(엑셀) — `50%` → `0%` · `12.5%` → `0.0%` · `1,234.5` → `#,##0.0` · 아니면 null */
export function typedFormat(text: string): string | null {
const t = text.trim();
if (parseNumber(t) === null || /e/i.test(t)) return null;
const dec = /\.(\d+)/.exec(t)?.[1].length ?? 0;
const frac = dec ? "." + "0".repeat(dec) : "";
if (t.endsWith("%")) return `0${frac}%`;
if (t.includes(",")) return `#,##0${frac}`;
return null;
}
/** 식 글(앞 `=` 뺌) — 함수 · 참조 · 참거짓을 대문자로(엑셀) · 시트 이름 · 글 · 정의된 이름은 그대로 */
export function upperFormula(body: string): string {
return tokenize(body)
.map((t) => {
if (t.kind === "func" || t.kind === "bool") return t.text.toUpperCase();
if (t.kind !== "ref") return t.text;
const bang = t.text.lastIndexOf("!") + 1;
return t.text.slice(0, bang) + t.text.slice(bang).toUpperCase();
})
.join("");
}
/** 닫는 괄호만 모자란 식이면 채운 식(엑셀 자동 고침) · 아니면 null */
export function closeParens(value: string): string | null {
let depth = 0;
for (const t of tokenize(value.slice(1)))
if (t.kind === "paren") depth += t.text === "(" ? 1 : -1;
if (depth <= 0) return null;
const fixed = value + ")".repeat(depth);
return parseFormula(fixed).ok ? fixed : null;
}
/** 칸 → 편집 글(식은 `=` 붙임 · 수처럼 읽힐 글은 `'` 붙임) */
export function cellToText(cell: Cell | undefined): string {
if (!cell) return "";
if (cell.식 !== undefined) return "=" + cell.식;
const v = cell.값;
if (v === undefined) return "";
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
if (typeof v === "number") return String(v);
return looksTyped(v) ? "'" + v : v;
}
/** 참조 넣을 자리 — 식이고 caret 앞(빈칸 건너)이 연산자 · 괄호 · 쉼표 · `=` */
export function insertableAt(text: string, pos: number): boolean {
if (text[0] !== "=") return false;
if (pos < text.length && /[A-Za-z0-9$_.!']/.test(text[pos])) return false;
let i = pos - 1;
while (i >= 0 && text[i] === " ") i--;
return i >= 0 && "=(,+-*/^&<>:;".includes(text[i]);
}
/** F4 — caret 에 걸친 참조의 $ 를 A1 → $A$1 → A$1 → $A1 → A1 로(범위면 두 끝 같이) · caret 은 참조 끝 */
export function cycleAbs(text: string, pos: number): { text: string; caret: number } | null {
if (text[0] !== "=") return null;
const t = tokenize(text.slice(1)).find(
(t) => t.kind === "ref" && t.start < pos && pos <= t.end + 1,
);
if (!t) return null;
const bang = t.text.lastIndexOf("!") + 1;
const part = /(\$?)([A-Za-z]{1,3})(\$?)(\d+)/g;
const first = part.exec(t.text.slice(bang));
if (!first) return null;
const state = (first[1] ? 1 : 0) + (first[3] ? 2 : 0); // 0 없음 · 3 둘 다 · 2 행만 · 1 열만
const next = { 0: 3, 3: 2, 2: 1, 1: 0 }[state]!;
const ref =
t.text.slice(0, bang) +
t.text
.slice(bang)
.replace(
part,
(_m, _a, col, _b, row) => (next & 1 ? "$" : "") + col + (next & 2 ? "$" : "") + row,
);
const end = t.start + 1 + ref.length;
return { text: text.slice(0, t.start + 1) + ref + text.slice(t.end + 1), caret: end };
}
/** 식 글 참조 → 색 테두리 목록(같은 글이면 같은 색) */
export function refHighlights(
text: string,
host: string,
sheetId: (name: string) => string | null,
): RefHighlight[] {
if (text[0] !== "=") return [];
const out: RefHighlight[] = [];
const colors = new Map<string, number>();
for (const t of tokenize(text.slice(1))) {
if (t.kind !== "ref") continue;
const bang = t.text.lastIndexOf("!");
let sheet: string | null = host;
if (bang >= 0) {
const raw = t.text.slice(0, bang);
const name = raw[0] === "'" ? raw.slice(1, -1).replace(/''/g, "'") : raw;
sheet = sheetId(name);
}
const range = parseRange(t.text.slice(bang + 1).replace(/\$/g, ""));
if (!sheet || !range) continue;
const key = t.text.toUpperCase();
if (!colors.has(key)) colors.set(key, colors.size);
out.push({ 시트: sheet, 범위: range, 색번호: colors.get(key)! });
}
return out;
}
/** 칸 서식(칸 → 행 → 열 → 기본) */
function styleAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): CellStyle {
const idx =
sheet.칸[toA1(r, c)]?.서식 ??
sheet.행?.[String(r + 1)]?.서식 ??
sheet.열?.[colName(c)]?.서식 ??
0;
return ctx.book.서식[idx] ?? {};
}
/** 식 글 caret 앞 → 함수 이름 치는 중(prefix) · 함수 인자 안(func · 몇 번째 인자 0 부터) · 아님 null */
export function formulaHint(
pre: string,
): { prefix: string } | { func: string; arg: number } | null {
if (pre[0] !== "=") return null;
const m = /(?:^=|[(,+\-*/^&<>:;= ])([A-Za-z][A-Za-z0-9.]*)$/.exec(pre);
if (m && !/^\$?[A-Za-z]{1,3}\$?\d+$/.test(m[1])) return { prefix: m[1] };
let depth = 0;
let arg = 0;
let inStr = false;
for (let i = pre.length - 1; i > 0; i--) {
const ch = pre[i];
if (ch === '"') inStr = !inStr;
if (inStr) continue;
if (ch === ")") depth++;
else if (ch === "," && depth === 0) arg++;
else if (ch === "(") {
if (depth-- > 0) continue;
const n = /([A-Za-z][A-Za-z0-9.]*)$/.exec(pre.slice(0, i));
return n ? { func: n[1].toUpperCase(), arg } : null;
}
}
return null;
}
/** 편집기 · 미리보기 자리 — C `editorSlot` 의 box 는 격자 뿌리 기준이라 층(layer)이 뿌리 안에서
* 비켜 놓인 만큼 빼서 층 안 좌표로 맞춤. */
export function slotOf(
ctx: SpreadsheetContext,
r: number,
c: number,
): { layer: HTMLElement; box: CellBox } {
const { layer, box } = ctx.grid.editorSlot(r, c);
const a = layer.getBoundingClientRect();
const g = ctx.grid.root.getBoundingClientRect();
return { layer, box: { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) } };
}
/** 칸 자리 → 층 안 자리 */
export function boxStyle(node: HTMLElement, box: CellBox): void {
node.style.left = `${box.x}px`;
node.style.top = `${box.y}px`;
node.style.minWidth = `${box.w}px`;
node.style.minHeight = `${box.h}px`;
}
export function createEditor(ctx: SpreadsheetContext): Editor {
const ta = el("textarea", {
className: "ss-input ss-input--idle",
attrs: { spellcheck: "false", autocomplete: "off", rows: "1", "aria-label": "칸 입력" },
});
const addr = el("input", {
className: "ss-addr",
attrs: { spellcheck: "false", "aria-label": "주소 상자" },
});
const formula = el("textarea", {
className: "ss-formula",
attrs: { spellcheck: "false", rows: "1", "aria-label": "수식 입력줄" },
});
const bar = el("div", {
className: "ss-bar",
children: [addr, el("span", { className: "ss-fx", text: "fx" }), formula],
});
formula.readOnly = ctx.readOnly;
ta.readOnly = ctx.readOnly;
let st: EditState | null = null;
const src = (): HTMLTextAreaElement => (document.activeElement === formula ? formula : ta);
const text = (): string => src().value;
const sheetOf = (id: string): Sheet => ctx.book.시트.find((s) => s.id === id) ?? ctx.sheet();
const sheetId = (name: string): string | null =>
ctx.book.시트.find((s) => s.이름.toUpperCase() === name.toUpperCase())?.id ?? null;
function place(): void {
const at = st?.at ?? ctx.selection.활성;
const { layer, box } = slotOf(ctx, at.r, at.c);
if (ta.parentElement !== layer) {
const had = document.activeElement === ta;
layer.append(ta);
if (had) focus();
}
boxStyle(ta, box);
if (!st) return;
const s = styleAt(ctx, sheetOf(st.sheet), at.r, at.c);
ta.style.fontFamily = s.글꼴 ?? "";
ta.style.fontSize = s.크기 ? `${s.크기}pt` : "";
ta.style.fontWeight = s.굵게 ? "bold" : "";
ta.style.fontStyle = s.기울임 ? "italic" : "";
ta.style.textAlign = s.가로 === "right" || s.가로 === "center" ? s.가로 : "";
// 글이 길면 오른쪽 · 아래로 늘림(엑셀)
ta.style.width = "0";
ta.style.height = "0";
ta.style.width = `${Math.max(box.w, ta.scrollWidth + 4)}px`;
ta.style.height = `${Math.max(box.h, ta.scrollHeight)}px`;
}
function focus(): void {
if (document.activeElement !== ta) ta.focus({ preventScroll: true });
}
function highlight(): void {
const t = st ? text() : "";
ctx.grid.setRefHighlights(st ? refHighlights(t, st.sheet, sheetId) : []);
showHint();
}
// ── 함수 자동 완성 · 인자 도움말 (spreadsheet_func_help · sub1) ────────────────
const hint = el("div", { className: "ss-hint", attrs: { hidden: "" } });
let sugg: FuncHelp[] = [];
let pick = 0;
let typed = "";
let fh: typeof import("./spreadsheet_func_help") | null = null; // 식을 처음 칠 때 불러옴 — 크기 예산
function showHint(): void {
const node = src();
const h = st ? formulaHint(node.value.slice(0, node.selectionEnd)) : null;
if (h && !fh) return void import("./spreadsheet_func_help").then((m) => ((fh = m), showHint()));
sugg = h && "prefix" in h ? fh!.suggestFunctions(h.prefix).slice(0, 8) : [];
typed = h && "prefix" in h ? h.prefix : "";
const help = h && "func" in h ? fh!.funcHelp(h.func) : null;
if (!sugg.length && !help) return void hint.setAttribute("hidden", "");
pick = Math.min(pick, Math.max(0, sugg.length - 1));
hint.replaceChildren(
...(sugg.length
? sugg.map((f, i) => {
const row = el("div", {
className: i === pick ? "ss-hint__item is-on" : "ss-hint__item",
children: [el("b", { text: f.이름 }), ` ${f.뜻}`],
});
row.addEventListener("mousedown", (e) => {
e.preventDefault();
pick = i;
accept();
});
return row;
})
: [el("div", { className: "ss-hint__help", text: `${help!.꼴} — ${help!.뜻}` })]),
);
if (hint.parentElement !== ta.parentElement) ta.parentElement?.append(hint);
hint.style.left = ta.style.left;
hint.style.top = `${parseFloat(ta.style.top || "0") + ta.offsetHeight}px`;
hint.removeAttribute("hidden");
}
function accept(): boolean {
if (!st || !sugg.length) return false;
const node = src();
const pos = node.selectionEnd;
const name = `${sugg[pick].이름}(`;
node.setRangeText(name, pos - typed.length, pos, "end");
(node === ta ? formula : ta).value = node.value;
pick = 0;
place();
highlight();
return true;
}
/** 편집 시작 — `value` 없으면 textarea 에 이미 든 글(조합 · 바로 친 글자) 그대로 */
function start(mode: "enter" | "edit", value?: string, from: HTMLTextAreaElement = ta): void {
if (ctx.readOnly || st) return;
const sel = ctx.selection;
const sheet = ctx.sheet();
const original = cellToText(sheet.칸[toA1(sel.활성.r, sel.활성.c)]);
st = { sheet: sheet.id, at: { ...sel.활성 }, mode, original, point: null };
if (value !== undefined) from.value = value;
(from === ta ? formula : ta).value = from.value;
ta.classList.remove("ss-input--idle");
ctx.root.classList.add("ss--editing");
place();
highlight();
if (from === ta && value !== undefined) ta.setSelectionRange(ta.value.length, ta.value.length);
}
function end(): void {
st = null;
ta.value = "";
ta.classList.add("ss-input--idle");
ta.removeAttribute("style");
ctx.root.classList.remove("ss--editing");
ctx.grid.setRefHighlights([]);
hint.setAttribute("hidden", "");
sync();
focus();
}
/** 칸에 적음 — 식이 틀렸거나(인자 수 등) 유효성(sub4) 「막음」 을 어기면 안 적고 false(편집 그대로 · 엑셀처럼 다시 치게) */
function write(cells: CellAddress[], value: string, from: CellAddress): boolean {
if (!st) return true;
const isFormula = value.length > 1 && value[0] === "=";
if (isFormula && !parseFormula(value).ok) value = closeParens(value) ?? value;
const parsed = isFormula ? parseFormula(value) : null;
if (parsed?.ok) value = "=" + upperFormula(value.slice(1));
if (parsed && !parsed.ok) {
showToast(parsed.message, "error");
const node = src();
const at = Math.min(parsed.at + 1, node.value.length);
st.mode = "edit"; // 방향키가 글자 사이를 움직이게
st.point = null;
node.setSelectionRange(at, at);
return false;
}
const sheet = sheetOf(st.sheet);
const out: Record<string, Cell | null> = {};
const warn = new Set<string>();
for (const a of cells) {
let v = value;
if (v.length > 1 && v[0] === "=" && (a.r !== from.r || a.c !== from.c))
v = "=" + moveFormula(v.slice(1), a.r - from.r, a.c - from.c);
const key = toA1(a.r, a.c);
const next = textToCell(v, sheet.칸[key]);
if (next === null && !sheet.칸[key]) continue;
const rule = next?.값 !== undefined ? findValidation(sheet, a.r, a.c) : undefined;
const check = rule && checkValue(rule, next!.값!);
if (check && !check.ok) {
if (check.어기면 === "막음") return (showToast(check.메시지, "error"), false);
warn.add(check.메시지);
}
out[key] = next;
}
for (const m of warn) showToast(m, "warning");
if (!Object.keys(out).length) return true;
const cellCmd: Command = { 종류: "칸", 시트: sheet.id, 칸: out };
const fmt = isFormula ? null : typedFormat(value);
// `%` 는 글 형식(`@`) · 이미 % 형식만 빼고 바꿈 · 천 단위 쉼표는 일반 형식일 때만(엑셀)
const takes = (code?: string): boolean =>
!code ||
/^(general|g\/표준)$/i.test(code) ||
(fmt!.endsWith("%") && !code.includes("%") && code.trim() !== "@");
const fmtAt = fmt
? cells.filter((a) => takes(cellStyleAt(ctx.book, sheet, a.r, a.c).형식))
: [];
ctx.dispatch(
fmtAt.length
? {
종류: "묶음",
명령: [
cellCmd,
{
종류: "서식",
시트: sheet.id,
범위: fmtAt.map((a) => ({ r0: a.r, c0: a.c, r1: a.r, c1: a.c })),
바꿀: { 형식: fmt! },
},
],
}
: cellCmd,
);
return true;
}
function commit(): void {
if (!st) return;
const value = text();
if (value !== st.original && !write([st.at], value, st.at)) return;
end();
}
function commitAll(): void {
if (!st) return;
if (!write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at)) return;
end();
}
function cancel(): void {
if (!st) return;
end();
}
function sync(): void {
const sel = ctx.selection;
addr.value = toA1(sel.활성.r, sel.활성.c);
if (st) return;
formula.value = cellToText(ctx.sheet().칸[toA1(sel.활성.r, sel.활성.c)]);
place();
}
function pointable(): boolean {
if (!st) return false;
const node = src();
if (node.selectionStart !== node.selectionEnd) return false;
const pos = node.selectionEnd;
if (st.point && st.point.end === pos) return true;
st.point = null;
return insertableAt(node.value, pos);
}
function point(sel: Selection): void {
if (!st) return;
const node = src();
const other = node === ta ? formula : ta;
const pos = node.selectionEnd;
const p = st.point ?? { start: pos, end: pos, sel };
const g = sel.범위[0];
const ref = rangeToA1(g);
node.value = node.value.slice(0, p.start) + ref + node.value.slice(p.end);
other.value = node.value;
st.point = { start: p.start, end: p.start + ref.length, sel };
node.setSelectionRange(st.point.end, st.point.end);
place();
highlight();
ctx.grid.reveal(sel.활성.r === g.r0 ? g.r1 : g.r0, sel.활성.c === g.c0 ? g.c1 : g.c0);
}
function pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void {
if (!st) return;
const sheet = sheetOf(st.sheet);
const from = st.point?.sel ?? selectCell(sheet, st.at.r, st.at.c);
point(move(sheet, from, dr, dc, { extend, jump }));
}
// ── 한글 조합 · 바로 치기 → 편집 시작 ─────────────────────────────────────
ta.addEventListener("compositionstart", () => {
if (!st) start("enter");
});
ta.addEventListener("input", () => {
if (!st) return start("enter");
formula.value = ta.value;
if (st.point && ta.selectionEnd !== st.point.end) st.point = null;
place();
highlight();
});
// 수식 입력줄 — 누르면 edit 방식으로 시작
formula.addEventListener("focus", () => {
if (!st && !ctx.readOnly) start("edit", formula.value, formula);
});
formula.addEventListener("input", () => {
if (!st) return start("edit", undefined, formula);
ta.value = formula.value;
if (st.point && formula.selectionEnd !== st.point.end) st.point = null;
place();
highlight();
});
// 주소 상자 — `B5` · `A1:C3` · `시트!B5` 로 가기
addr.addEventListener("focus", () => addr.select());
addr.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
e.preventDefault();
sync();
focus();
}
if (e.key !== "Enter") return;
e.preventDefault();
goTo(addr.value.trim());
focus();
});
/** 이름 상자(sub4) — 있는 이름이면 그 범위로 · 없으면 지금 고름에 이름을 붙임(엑셀) */
async function nameBox(name: string): Promise<void> {
const { defineName, resolveName } = await import("./spreadsheet_names"); // 늦게 — 크기 예산
const hit = resolveName(ctx.book, ctx.selection.시트, name);
if (hit) {
if (hit.시트 !== ctx.selection.시트) ctx.showSheet(hit.시트);
const at = { r: hit.범위.r0, c: hit.범위.c0 };
return ctx.select({ 시트: hit.시트, 범위: [hit.범위], 활성: at, 기준: { ...at } }, true);
}
const why = ctx.readOnly ? null : defineName(ctx, name, ctx.selection.범위[0]);
if (why) showToast(why, "error");
sync();
}
function goTo(value: string): void {
const bang = value.lastIndexOf("!");
let sheet = ctx.sheet();
if (bang >= 0) {
const raw = value.slice(0, bang);
const id = sheetId(raw[0] === "'" ? raw.slice(1, -1).replace(/''/g, "'") : raw);
if (!id) return sync();
if (id !== sheet.id) ctx.showSheet(id);
sheet = ctx.sheet();
}
const body = value.slice(bang + 1).replace(/\$/g, "");
const one = parseA1(body);
const g = parseRange(body);
if (!g) return void nameBox(value.trim());
if (one) return ctx.select(selectCell(sheet, one.r, one.c), true);
const at = { r: g.r0, c: g.c0 };
ctx.select({ 시트: sheet.id, 범위: [g], 활성: at, 기준: { ...at } }, true);
}
return {
ta,
bar,
formula,
editing: () => st !== null,
begin(initial?: string) {
if (initial === undefined) {
const sel = ctx.selection;
start("edit", cellToText(ctx.sheet().칸[toA1(sel.활성.r, sel.활성.c)]));
} else start("enter", initial);
focus();
},
commit,
commitAll,
cancel,
sync,
mode: () => st?.mode ?? "enter",
toggleMode() {
if (st) ((st.mode = st.mode === "enter" ? "edit" : "enter"), (st.point = null));
},
pointable,
point,
pointMove,
accept,
suggestMove(d: number) {
if (!st || !sugg.length) return false;
pick = (pick + d + sugg.length) % sugg.length;
showHint();
return true;
},
focus,
place() {
if (st) place();
},
destroy() {
hint.remove();
ta.remove();
bar.remove();
},
};
}
@@ -0,0 +1,193 @@
/* =============================================================================
* spreadsheet_errcheck.ts (주인 A)
* 오류 검사(엑셀 초록 세모) — 칸마다 까닭을 냄: 순환 참조 · 식 결과가 오류(#DIV/0! 등) · 숫자처럼 보이는 글.
* 활성 칸에 까닭이 있으면 칸 왼쪽에 ⚠ 단추 · 올리면 까닭 풍선 · 누르면 메뉴(숫자로 변환 · 계산 단계 보기 ·
* 선행 참조 추적 · 오류 무시). 「오류 무시」는 이 화면 동안만(문서에 안 적음).
*
* 잇는 법(C · sub6 · D · sub7):
* · 부품 붙이기: `const tip = attachErrorTip(ctx, trace?)` — ⚠ 단추를 격자 overlay(editorSlot 층)에 얹음.
* grid.render() · 고름 바뀜 뒤마다 `tip.refresh()`. `trace`(spreadsheet_trace 의 손잡이)를 주면 메뉴에 「선행 참조 추적」.
* · 격자(C): 보이는 칸마다 `tip.warning(r, c)` 가 null 이 아니면 칸 왼쪽 위에 초록 세모(6px · `--color-success`).
* 시트 전체를 훑지 말고 그리는 칸만 물음(값 하나 · 칸 하나 읽는 값싼 부름).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { toA1 } from "./spreadsheet_address";
import { isError } from "./spreadsheet_eval";
import { openEvalSteps } from "./spreadsheet_eval_steps";
import type { TraceHandle } from "./spreadsheet_trace";
import type { CalcEngine, Cell, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_analysis.css";
export type WarningKind = "순환" | "오류" | "글숫자";
export interface CellWarning {
종류: WarningKind;
까닭: string;
}
/** 엑셀 오류 값 뜻(엔진 까닭이 없을 때) */
const MEANING: Record<string, [string, string]> = {
"#DIV/0!": ["0 으로 나눔", "Divide by zero"],
"#VALUE!": ["값의 종류가 맞지 않음", "Wrong type of value"],
"#REF!": ["지워진 칸을 가리킴", "Invalid cell reference"],
"#NAME?": ["모르는 이름 · 함수", "Unrecognized name"],
"#N/A": ["찾는 값이 없음", "Value not available"],
"#NUM!": ["수가 맞지 않음", "Invalid number"],
"#NULL!": ["겹치지 않는 범위", "Ranges do not intersect"],
};
const TEXT = {
Cycle: ["순환 참조", "Circular reference"],
TextNumber: ["숫자가 글로 저장됨", "Number stored as text"],
Convert: ["숫자로 변환", "Convert to number"],
Steps: ["계산 단계 보기", "Show calculation steps"],
Trace: ["선행 참조 추적", "Trace precedents"],
Ignore: ["오류 무시", "Ignore error"],
};
const t = (k: keyof typeof TEXT) => TEXT[k][currentLanguageIndex] ?? TEXT[k][0];
/** 숫자로 읽히는 글(앞뒤 빈칸 · 천 단위 쉼표 · 지수 허용) */
const NUMBER_LIKE = /^[+-]?(\d{1,3}(,\d{3})+|\d+)?(\.\d+)?(e[+-]?\d+)?$/i;
export function numberLike(text: string): number | null {
const s = text.trim();
if (!/\d/.test(s) || !NUMBER_LIKE.test(s)) return null;
const n = Number(s.replace(/,/g, ""));
return Number.isFinite(n) ? n : null;
}
/** 칸 하나 까닭 — 없으면 null */
export function cellWarning(
book: Workbook,
engine: CalcEngine,
sheet: string,
r: number,
c: number,
): CellWarning | null {
const cell: Cell | undefined = book.시트.find((s) => s.id === sheet)?.칸[toA1(r, c)];
if (!cell) return null;
if (cell.식 !== undefined) {
const v = engine.value(sheet, r, c);
if (!isError(v)) return null;
if (v.error === "#CYCLE!") return { 종류: "순환", 까닭: v.why ?? t("Cycle") };
const m = MEANING[v.error];
const head = m ? `${v.error} ${m[currentLanguageIndex] ?? m[0]}` : v.error;
return { 종류: "오류", 까닭: v.why ? `${v.error} ${v.why}` : head };
}
if (typeof cell.값 === "string" && numberLike(cell.값) !== null)
return { 종류: "글숫자", 까닭: t("TextNumber") };
return null;
}
export interface ErrorTipHandle extends PartHandle {
/** 격자 세모용 — 「오류 무시」한 칸은 null */
warning(r: number, c: number): CellWarning | null;
}
export function attachErrorTip(ctx: SpreadsheetContext, trace?: TraceHandle): ErrorTipHandle {
const layer = el("div", { className: "ss-errtip" });
const ignored = new Set<string>();
let menu: HTMLElement | null = null;
const warning = (r: number, c: number) =>
ignored.has(`${ctx.sheet().id}!${toA1(r, c)}`)
? null
: cellWarning(ctx.book, ctx.engine, ctx.sheet().id, r, c);
function closeMenu() {
menu?.remove();
menu = null;
document.removeEventListener("mousedown", onOutside, true);
}
const onOutside = (ev: MouseEvent) => {
if (menu && !layer.contains(ev.target as Node)) closeMenu();
};
/** 고른 칸 중 글숫자를 모두 수로(서식은 그대로) */
function convert() {
const s = ctx.sheet();
const cells: Record<string, Cell> = {};
for (const g of ctx.selection.범위)
for (let r = g.r0; r <= g.r1; r++)
for (let c = g.c0; c <= g.c1; c++) {
const a1 = toA1(r, c);
const cell = s.칸[a1];
const n =
typeof cell?.값 === "string" && cell.식 === undefined ? numberLike(cell.값) : null;
if (n !== null) cells[a1] = { ...cell, 값: n };
}
if (Object.keys(cells).length) ctx.dispatch({ 종류: "칸", 시트: s.id, 칸: cells });
}
function place() {
closeMenu();
const { r, c } = ctx.selection.활성;
const { layer: host, box } = ctx.grid.editorSlot(r, c);
if (layer.parentElement !== host) host.append(layer);
const w = warning(r, c);
const v = ctx.grid.visibleRange();
if (!w || !box.w || r < v.r0 || r > v.r1 || c < v.c0 || c > v.c1) {
layer.replaceChildren();
return;
}
const a = host.getBoundingClientRect();
const g = ctx.grid.root.getBoundingClientRect();
layer.style.left = `${box.x - (a.left - g.left) - 20}px`;
layer.style.top = `${box.y - (a.top - g.top)}px`;
const btn = el("button", {
className: "ss-errtip__btn",
text: "⚠",
attrs: { type: "button", "aria-label": w.까닭 },
});
const balloon = el("div", { className: "ss-errtip__balloon", text: w.까닭 });
btn.addEventListener("mousedown", (ev) => ev.stopPropagation());
btn.addEventListener("click", () => (menu ? closeMenu() : openMenu(w, r, c)));
layer.replaceChildren(btn, balloon);
}
function openMenu(w: CellWarning, r: number, c: number) {
const item = (label: string, run: () => void) => {
const b = el("button", {
className: "ss-errtip__item",
text: label,
attrs: { type: "button" },
});
b.addEventListener("click", () => {
closeMenu();
run();
});
return b;
};
const items: HTMLElement[] = [el("div", { className: "ss-errtip__head", text: w.까닭 })];
if (w.종류 === "글숫자" && !ctx.readOnly) items.push(item(t("Convert"), convert));
if (w.종류 !== "글숫자") items.push(item(t("Steps"), () => openEvalSteps(ctx)));
if (w.종류 !== "글숫자" && trace) items.push(item(t("Trace"), () => trace.precedents()));
items.push(
item(t("Ignore"), () => {
ignored.add(`${ctx.sheet().id}!${toA1(r, c)}`);
ctx.grid.render();
place();
}),
);
menu = el("div", { className: "ss-errtip__menu", attrs: { role: "menu" }, children: items });
layer.append(menu);
document.addEventListener("mousedown", onOutside, true);
}
const onScroll = () => place();
ctx.grid.root.addEventListener("scroll", onScroll, true);
place();
return {
root: layer,
refresh: place,
warning,
destroy() {
closeMenu();
ctx.grid.root.removeEventListener("scroll", onScroll, true);
layer.remove();
},
};
}
+300
View File
@@ -0,0 +1,300 @@
/* =============================================================================
* spreadsheet_eval.ts (주인 A)
* 나무 풀기 · 오류 값 전파 · 형 바꾸기(엑셀 규칙). B 의 함수들이 형 바꾸기 도우미를 씀.
* 빈 칸 = 수에서 0 · 글에서 "" · `"12"+1 = 13` · `TRUE+1 = 2` · 글이 수로 안 읽히면 `#VALUE!`.
* 무리수(SQRT · 삼각 · PI · 소수 거듭제곱)는 double → 유효 15 자리 십진 → 분수(B · A 같이 `fromDouble`).
* 오류 전파 = 왼쪽부터 처음 만난 오류. 비교 = 수 < 글 < 참거짓 · 글은 대소문자 가리지 않음.
* ========================================================================== */
import {
add,
cmp,
div,
frac,
fracToString,
mul,
parseDecimal,
roundAt,
sub,
toFrac,
ZERO,
} from "@ui/sheet/ui_template_sheet_frac";
import { normRange } from "./spreadsheet_address";
import { getFunction } from "./spreadsheet_functions";
import type {
CalcValue,
ErrorCode,
ErrorValue,
EvalContext,
EvalResult,
FormulaNode,
Frac,
RangeValue,
Scalar,
} from "./spreadsheet_types";
export function err(code: ErrorCode, why?: string): ErrorValue {
return why === undefined ? { error: code } : { error: code, why };
}
export function isError(value: unknown): value is ErrorValue {
return typeof value === "object" && value !== null && "error" in value;
}
export function isFrac(value: unknown): value is Frac {
return typeof value === "object" && value !== null && "n" in value && "d" in value;
}
export function isRange(value: unknown): value is RangeValue {
return typeof value === "object" && value !== null && (value as RangeValue).kind === "range";
}
const ONE = frac(1n);
const HUNDRED = frac(100n);
const BIG_D = 10n ** 40n;
/** 분모가 너무 커지면(나눗셈 사슬) 30 자리 십진으로 굳힘 */
export function tame(x: Frac): Frac {
return x.d > BIG_D ? parseDecimal(fracToString(x)) : x;
}
/** 범위 → 칸 하나(1×1 일 때만) · 아니면 `#VALUE!` */
function single(value: EvalResult): Scalar {
if (!isRange(value)) return value;
return value.rows === 1 && value.cols === 1
? value.at(0, 0)
: err("#VALUE!", "범위를 값 하나로 못 씀");
}
/** 엔진이 만든 범위 — 적힌 자리(끝 자름 전) · 암묵 교차에 씀 */
export interface PlacedRange extends RangeValue {
r0: number;
c0: number;
r1: number;
c1: number;
}
/** 범위 → 칸 하나 — 1×1 이면 그 칸 · 아니면 식 든 행(한 열 범위) · 열(한 행 범위)과 겹친 칸(엑셀 암묵 교차) */
export function intersect(value: EvalResult, at: { r: number; c: number }): Scalar {
if (!isRange(value) || (value.rows === 1 && value.cols === 1)) return single(value);
const p = value as Partial<PlacedRange>;
if (p.r0 !== undefined && p.c0 !== undefined && p.r1 !== undefined && p.c1 !== undefined) {
if (p.c0 === p.c1 && at.r >= p.r0 && at.r <= p.r1) return value.at(at.r - p.r0, 0);
if (p.r0 === p.r1 && at.c >= p.c0 && at.c <= p.c1) return value.at(0, at.c - p.c0);
}
return err("#VALUE!", "범위가 식 든 행 · 열과 안 겹침");
}
/** 수로 — 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` · 범위는 칸 하나일 때만 */
export function asNumber(value: EvalResult): Frac | ErrorValue {
const v = single(value);
if (v === null) return ZERO;
if (typeof v === "boolean") return v ? ONE : ZERO;
if (typeof v === "string") {
const t = v.trim();
const pct = t.endsWith("%");
try {
const n = parseDecimal(pct ? t.slice(0, -1) : t);
return pct ? div(n, HUNDRED) : n;
} catch {
return err("#VALUE!", `수로 못 읽는 글: "${v}"`);
}
}
return v;
}
/** 일반 형식 글 — 유효 15 자리 · 1e15 넘거나 1e-9 밑이면 `1.5E+18` 꼴(엑셀 `&` 와 같게) */
export function numberText(x: Frac): string {
if (x.n === 0n) return "0";
const d = parseFloat(fracToString(x));
const e = Math.floor(Math.log10(Math.abs(d)));
if (e >= 15 || e < -9) {
const [m, ex] = d.toExponential(14).split("e");
const mant = m.replace(/\.?0+$/, "");
const n = Number(ex);
return `${mant}E${n < 0 ? "-" : "+"}${String(Math.abs(n)).padStart(2, "0")}`;
}
return fracToString(roundAt(x, 14 - e, "round"));
}
/** 글로 — 수는 일반 형식 글(엑셀 `&` 와 같게) · 참거짓 `TRUE`/`FALSE` */
export function asText(value: EvalResult): string | ErrorValue {
const v = single(value);
if (v === null) return "";
if (typeof v === "string" || isError(v)) return v;
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
return numberText(v);
}
export function asBool(value: EvalResult): boolean | ErrorValue {
const v = single(value);
if (v === null) return false;
if (typeof v === "boolean" || isError(v)) return v;
if (typeof v === "string") {
const t = v.toUpperCase();
return t === "TRUE"
? true
: t === "FALSE"
? false
: err("#VALUE!", `참거짓으로 못 읽는 글: "${v}"`);
}
return v.n !== 0n;
}
/** double → 유효 15 자리 십진 → 분수 · NaN · 무한은 `#NUM!` */
export function fromDouble(x: number): Frac | ErrorValue {
if (!Number.isFinite(x)) return err("#NUM!", "수가 너무 크거나 뜻이 없음");
return x === 0 ? ZERO : parseDecimal(x.toPrecision(15));
}
export const toDouble = (x: Frac): number => parseFloat(fracToString(x));
/** 저장 모양 — 수는 십진 글(30 자리까지) */
export function toCalcValue(value: Scalar): CalcValue {
if (value === null) return { 수: "0" };
if (typeof value === "string") return { 글: value };
if (typeof value === "boolean") return { 참: value };
if (isError(value))
return value.why ? { 오류: value.error, 까닭: value.why } : { 오류: value.error };
return { 수: fracToString(value) };
}
// ── 연산 ────────────────────────────────────────────────────────────────────
function power(a: Frac, b: Frac): Frac | ErrorValue {
if (a.n === 0n) {
if (b.n === 0n) return err("#NUM!", "0^0");
return b.n < 0n ? err("#DIV/0!", "0 의 음수 거듭제곱") : ZERO;
}
if (b.d === 1n) {
const e = b.n < 0n ? -b.n : b.n;
const bits = BigInt(a.n.toString(2).length + a.d.toString(2).length);
// ponytail: 4,000 비트 넘는 정확 거듭제곱은 double 로 — 실무 식엔 없음
if (bits * e <= 4000n) {
const r = frac(a.n ** e, a.d ** e);
return tame(b.n < 0n ? div(ONE, r) : r);
}
} else if (a.n < 0n) return err("#NUM!", "음수의 소수 거듭제곱");
return fromDouble(Math.pow(toDouble(a), toDouble(b)));
}
function arith(op: string, a: Frac, b: Frac): Frac | ErrorValue {
switch (op) {
case "+":
return tame(add(a, b));
case "-":
return tame(sub(a, b));
case "*":
return tame(mul(a, b));
case "/":
return b.n === 0n ? err("#DIV/0!", "0 으로 나눔") : tame(div(a, b));
default:
return power(a, b);
}
}
const RANK = (v: Scalar) => (isFrac(v) ? 0 : typeof v === "string" ? 1 : 2);
/** 엑셀 비교 — 빈 칸은 맞은편 형의 빈 값(0 · "" · FALSE) · 형이 다르면 수 < 글 < 참거짓 */
export function compareValues(a: Scalar, b: Scalar): number {
const blank = (other: Scalar): Scalar =>
isFrac(other)
? ZERO
: typeof other === "string"
? ""
: typeof other === "boolean"
? false
: ZERO;
if (a === null) a = blank(b);
if (b === null) b = blank(a);
const ra = RANK(a);
const rb = RANK(b);
if (ra !== rb) return ra < rb ? -1 : 1;
if (isFrac(a)) return cmp(a, b as Frac);
if (typeof a === "string") {
const x = a.toLowerCase();
const y = (b as string).toLowerCase();
return x === y ? 0 : x < y ? -1 : 1;
}
return a === b ? 0 : a ? 1 : -1;
}
function compareOp(op: string, a: Scalar, b: Scalar): boolean {
const c = compareValues(a, b);
return op === "="
? c === 0
: op === "<>"
? c !== 0
: op === "<"
? c < 0
: op === "<="
? c <= 0
: op === ">"
? c > 0
: c >= 0;
}
// ── 풀기 ────────────────────────────────────────────────────────────────────
export function evaluate(node: FormulaNode, ctx: EvalContext): EvalResult {
switch (node.type) {
case "number":
return node.value;
case "string":
return node.value;
case "bool":
return node.value;
case "error":
return err(node.code);
case "name":
return err("#NAME?", `모르는 이름: ${node.name}`);
case "cell":
case "range": {
const sheet = node.sheet === null ? ctx.sheet : ctx.sheetId(node.sheet);
if (sheet === null) return err("#REF!", `없는 시트: ${node.sheet}`);
if (node.type === "cell") return ctx.cell(sheet, node.ref.r, node.ref.c);
return ctx.range(sheet, normRange(node.from.r, node.from.c, node.to.r, node.to.c));
}
case "unary": {
const v = intersect(evaluate(node.arg, ctx), ctx);
if (node.op === "+") return v;
const n = asNumber(v);
if (isError(n)) return n;
return node.op === "-" ? frac(-n.n, n.d) : div(n, HUNDRED);
}
case "binary": {
const left = intersect(evaluate(node.left, ctx), ctx);
const right = intersect(evaluate(node.right, ctx), ctx);
if (node.op === "&") {
const a = asText(left);
if (isError(a)) return a;
const b = asText(right);
return isError(b) ? b : a + b;
}
if (PLAIN_OPS.has(node.op)) {
const a = asNumber(left);
if (isError(a)) return a;
const b = asNumber(right);
return isError(b) ? b : arith(node.op, a, b);
}
if (isError(left)) return left;
return isError(right) ? right : compareOp(node.op, left, right);
}
case "call": {
const fn = getFunction(node.name);
if (!fn) return err("#NAME?", `모르는 함수: ${node.name}`);
// 인자 수는 파서가 막음(spreadsheet_parser)
return fn.call(
node.args.map((a) => () => evaluate(a, ctx)),
ctx,
);
}
}
}
const PLAIN_OPS = new Set(["+", "-", "*", "/", "^"]);
/** 수로 온 입력 값 → 풀이 값 */
export function inputValue(v: number | string | boolean): Scalar {
return typeof v === "number" ? toFrac(v) : v;
}
@@ -0,0 +1,261 @@
/* =============================================================================
* spreadsheet_eval_steps.ts (주인 A)
* 수식 계산 단계 보기(엑셀 [수식 계산] 창) — 식을 왼쪽부터 한 마디씩 값으로 바꿔 보여 줌.
* 엑셀 규칙: 칸 참조 → 값(빈 칸은 0 · 글은 따옴표) · 연산 · 함수는 인자가 다 값이 된 뒤 · 범위는 함수가 통째로 풂 ·
* IF · IFERROR · CHOOSE 류는 첫 인자만 먼저 풀고 고른 쪽은 함수 한 걸음으로 · ROW(A1) 같은 참조 인자는 그대로.
* 마디 값은 늘 엔진 풀이(`evalSafe`)로 냄 — 보이는 글은 보여 주기만.
*
* 잇는 법(D · sub7):
* · 도구 모음 [수식 분석] 「수식 계산」 → `openEvalSteps(ctx)` — 활성 칸 식의 창을 `ctx.root` 에 띄우고 닫는 함수를 돌려줌.
* · 식 없는 칸이면 토스트로 알리고 창을 안 띄움. 들어가기(참조 칸 식 속으로)는 아직 없음.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
import { isError, isRange, numberText } from "./spreadsheet_eval";
import type { PlacedRange } from "./spreadsheet_eval";
import { bindNames, evalSafe } from "./spreadsheet_graph";
import { formulaToText, parseFormula } from "./spreadsheet_parser";
import type {
CalcEngine,
CellRange,
EvalContext,
FormulaNode,
Scalar,
Workbook,
} from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_analysis.css";
/** 한 걸음 — 글과 다음에 풀 곳(밑줄 · 글 안 [시작, 끝)) */
export interface EvalStep {
글: string;
밑줄: [number, number] | null;
}
/** 첫 인자만 먼저 풂 */
const LAZY = new Set(["IF", "IFS", "IFERROR", "IFNA", "CHOOSE", "SWITCH"]);
/** 칸 참조 인자를 값으로 안 바꿈 */
const REF_ARGS = new Set([
"ROW",
"COLUMN",
"ROWS",
"COLUMNS",
"OFFSET",
"INDEX",
"ISREF",
"ISBLANK",
"CELL",
"ISFORMULA",
"FORMULATEXT",
"SUMIF",
"SUMIFS",
"COUNTIF",
"COUNTIFS",
"AVERAGEIF",
"AVERAGEIFS",
"COUNTBLANK",
]);
/** 푸는 차례(왼쪽 먼저 · 아래 먼저) */
function order(node: FormulaNode, out: FormulaNode[], refArg = false): void {
switch (node.type) {
case "cell":
if (!refArg) out.push(node);
return;
case "name":
out.push(node);
return;
case "unary":
if (node.op !== "%" && node.arg.type === "number") return; // -5 는 글 그대로
order(node.arg, out);
out.push(node);
return;
case "binary":
order(node.left, out);
order(node.right, out);
out.push(node);
return;
case "call": {
const args = LAZY.has(node.name) ? node.args.slice(0, 1) : node.args;
for (const a of args) order(a, out, REF_ARGS.has(node.name));
out.push(node);
}
}
}
export function valueText(v: Scalar): string {
if (v === null) return "0";
if (typeof v === "string") return `"${v.replace(/"/g, '""')}"`;
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
if (isError(v)) return v.error;
return numberText(v);
}
/** 엔진 없이 쓸 풀이 문맥 — 칸 값은 엔진에서 · 범위는 값 든 끝까지(엔진과 같게) */
function contextFor(book: Workbook, engine: CalcEngine, sheet: string, r: number, c: number) {
const ext = new Map<string, { r: number; c: number }>();
const extent = (id: string) => {
let e = ext.get(id);
if (!e) {
e = { r: 0, c: 0 };
for (const [a1, cell] of Object.entries(book.시트.find((s) => s.id === id)?.칸 ?? {})) {
const at = cell.식 !== undefined || cell.값 !== undefined ? parseA1(a1) : null;
if (at) e = { r: Math.max(e.r, at.r), c: Math.max(e.c, at.c) };
}
ext.set(id, e);
}
return e;
};
const ctx: EvalContext = {
sheet,
r,
c,
cell: (s, rr, cc) => engine.value(s, rr, cc),
range(s, rg: CellRange): PlacedRange {
const e = extent(s);
const r1 = Math.min(rg.r1, Math.max(rg.r0, e.r));
const c1 = Math.min(rg.c1, Math.max(rg.c0, e.c));
return {
kind: "range",
rows: r1 - rg.r0 + 1,
cols: c1 - rg.c0 + 1,
at: (i, j) => engine.value(s, rg.r0 + i, rg.c0 + j),
...rg,
};
},
sheetId: (name) =>
book.시트.find((s) => s.이름.toLowerCase() === name.toLowerCase())?.id ?? null,
};
return ctx;
}
/** 칸 식의 모든 걸음(첫 글 → … → 값) · 식이 없으면 null */
export function evalSteps(
book: Workbook,
engine: CalcEngine,
sheet: string,
r: number,
c: number,
): EvalStep[] | null {
const text = book.시트.find((s) => s.id === sheet)?.칸[toA1(r, c)]?.식;
if (text === undefined) return null;
const p = parseFormula(text);
if (!p.ok)
return [
{ 글: text, 밑줄: null },
{ 글: valueText(engine.value(sheet, r, c)), 밑줄: null },
];
const ctx = contextFor(book, engine, sheet, r, c);
const nodes: FormulaNode[] = [];
order(p.node, nodes);
// 식 전체가 범위(`=A1:A3`)면 마지막 걸음으로 엔진 값(암묵 교차)
if (nodes[nodes.length - 1] !== p.node && p.node.type === "range") nodes.push(p.node);
const vals = new Map<FormulaNode, Scalar>();
const steps: FormulaNode[] = [];
for (const n of nodes) {
if (n === p.node) vals.set(n, engine.value(sheet, r, c));
else {
const v = evalSafe(bindNames(n, book, sheet), ctx);
if (isRange(v)) continue; // 범위 값(INDEX 등)은 글 그대로
vals.set(n, v);
}
steps.push(n);
}
const done = new Map<FormulaNode, string>();
const out: EvalStep[] = [];
for (let i = 0; i <= steps.length; i++) {
const raw = formulaToText(p.node, (n) => done.get(n), steps[i]);
const a = raw.indexOf("\u0001");
const b = raw.indexOf("\u0002");
out.push(
a < 0
? { 글: raw, 밑줄: null }
: { 글: raw.replace(/[\u0001\u0002]/g, ""), 밑줄: [a, b - 1] },
);
if (i < steps.length) done.set(steps[i], valueText(vals.get(steps[i]) ?? null));
}
return out;
}
// ── 창 ──────────────────────────────────────────────────────────────────────
const TEXT = {
Title: ["수식 계산", "Evaluate Formula"],
Ref: ["참조", "Reference"],
Hint: [
"밑줄 친 식의 값을 보려면 [계산] 을 누름",
"To show the result of the underlined expression, click Evaluate.",
],
Eval: ["계산", "Evaluate"],
Restart: ["다시 시작", "Restart"],
Close: ["닫기", "Close"],
NoFormula: ["식이 없는 칸", "The cell has no formula"],
};
const t = (k: keyof typeof TEXT) => TEXT[k][currentLanguageIndex] ?? TEXT[k][0];
export function openEvalSteps(ctx: SpreadsheetContext): () => void {
const sheet = ctx.sheet();
const { r, c } = ctx.selection.활성;
const steps = evalSteps(ctx.book, ctx.engine, sheet.id, r, c);
if (!steps) {
showToast(t("NoFormula"), "info");
return () => {};
}
let i = 0;
const view = el("div", { className: "ss-steps__text", attrs: { "aria-live": "polite" } });
const next = createButton({
label: t("Eval"),
onClick: () => go(i + 1 < steps.length ? i + 1 : 0),
});
const box = el("div", {
className: "ss-steps__box",
attrs: { role: "dialog", "aria-label": t("Title") },
});
const shade = el("div", { className: "ss-steps", children: [box] });
function go(to: number) {
i = to;
const s = steps![i];
if (!s.밑줄) view.replaceChildren(s.글);
else {
const [a, b] = s.밑줄;
view.replaceChildren(s.글.slice(0, a), el("u", { text: s.글.slice(a, b) }), s.글.slice(b));
}
next.textContent = i + 1 < steps!.length ? t("Eval") : t("Restart");
}
const close = () => {
shade.remove();
document.removeEventListener("keydown", onKey, true);
};
const onKey = (ev: KeyboardEvent) => {
if (ev.key === "Escape") close();
else if (ev.key === "Enter") next.click();
else return;
ev.preventDefault();
ev.stopPropagation();
};
box.append(
el("div", { className: "ss-steps__title", text: t("Title") }),
el("div", {
className: "ss-steps__meta",
text: `${t("Ref")}: ${ctx.book.시트.length > 1 ? `${quoteSheet(sheet.이름)}!` : ""}${toA1(r, c)}`,
}),
view,
el("div", { className: "ss-steps__meta", text: t("Hint") }),
el("div", {
className: "ss-steps__foot",
children: [next, createButton({ label: t("Close"), variant: "ghost", onClick: close })],
}),
);
shade.addEventListener("mousedown", (ev) => {
if (ev.target === shade) close();
});
document.addEventListener("keydown", onKey, true);
go(0);
ctx.root.append(shade);
next.focus();
return close;
}
@@ -0,0 +1,221 @@
/* =============================================================================
* spreadsheet_extras.ts (주인 D)
* 2단계 부품 잇기(sub1 파일들의 「잇는 법」) — 찾기 패널 · 메모 풍선(마우스 올림) · 메모 편집(Shift+F2) ·
* 셀 서식 창(Ctrl+1) · 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) ·
* 도구 모음을 누른 뒤 초점을 격자로 되돌림 · 데이터 단추 · 메뉴는 `spreadsheet_data_menu.ts`.
* 빨간 세모는 C 격자 · 우클릭 [메모 …] 는 E 메뉴 몫(브레인 배정).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { watchClose } from "./spreadsheet_actions";
import { parseClipboard } from "./spreadsheet_clipboard";
import { attachComments, getComment, setComment } from "./spreadsheet_comments";
import { mountDataMenu } from "./spreadsheet_data_menu";
import type { Editor } from "./spreadsheet_editor";
import type { FindPanelHandle } from "./spreadsheet_find_panel";
import type { FormatPainterHandle } from "./spreadsheet_format_painter";
import type { KeyHooks } from "./spreadsheet_keys";
import type { Memo } from "./spreadsheet_memo";
import type { MouseHooks } from "./spreadsheet_mouse";
import type { PasteSpecialMode } from "./spreadsheet_paste_special";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
format: "셀 서식(Ctrl+1)",
painter: "서식 붓 — 한 번 누르면 한 번 · 두 번 누르면 Esc 까지 계속",
pasteTitle: "골라 붙여넣기",
modes: { 값: "값만", 서식: "서식만", 수식: "수식만" } as Record<PasteSpecialMode, string>,
};
export interface Extras {
keys: KeyHooks;
mouse: MouseHooks;
parts: PartHandle[];
}
export function attachExtras(
ctx: SpreadsheetContext,
ed: Editor,
toolbarRoot: HTMLElement | null,
): Extras {
const back = (): void => ed.focus();
/** 칸 오른쪽 자리(부품 뿌리 기준 px) — C editorSlot box 는 격자 뿌리 기준 */
const beside = (r: number, c: number): { x: number; y: number } => {
const { box } = ctx.grid.editorSlot(r, c);
const g = ctx.grid.root.getBoundingClientRect();
const o = ctx.root.getBoundingClientRect();
return { x: box.x + box.w + g.left - o.left + 4, y: box.y + g.top - o.top };
};
const put = (node: HTMLElement, r: number, c: number): void => {
const p = beside(r, c);
node.style.left = `${p.x}px`;
node.style.top = `${p.y}px`;
};
// ── 찾기(처음 열 때 불러옴 — 크기 예산) ─────────────────────────────────────
let find: FindPanelHandle | null = null;
async function openFind(replace: boolean): Promise<void> {
find ??= (await import("./spreadsheet_find_panel")).mountFindPanel(ctx, back);
find.open(replace);
}
// ── 메모 — 풍선 · 편집 ─────────────────────────────────────────────────
const comments = attachComments(ctx);
let hovered = "";
let memo: Memo | null = null; // 처음 쓸 때 불러옴 — 크기 예산
async function editMemo(): Promise<void> {
if (ctx.readOnly) return;
const { r, c } = ctx.selection.활성;
memo ??= (await import("./spreadsheet_memo")).createMemo(ctx, back, {
get: getComment,
set: setComment,
});
comments.hideBalloon();
memo.open(r, c, (node) => put(node, r, c));
}
// ── 서식 붓 ─────────────────────────────────────────────────────────────
let painter: FormatPainterHandle | null = null; // 처음 누를 때 불러옴 — 크기 예산
let sticky = false;
const brush = el("button", {
className: "ss-brush",
text: "붓",
attrs: { type: "button", title: TEXT.painter },
});
const showBrush = (): void => {
const on = !!painter?.active();
brush.classList.toggle("is-on", on);
ctx.root.classList.toggle("ss--painting", on);
};
brush.addEventListener("click", async (e) => {
painter ??= (await import("./spreadsheet_format_painter")).createFormatPainter(ctx);
if (e.detail === 2) sticky = true;
else if (painter.active()) (painter.clear(), (sticky = false));
else (painter.pick(), (sticky = false));
showBrush();
back();
});
// 셀 서식 창 — Ctrl+1 을 브라우저 · ORCA 가 가로챌 때 누를 자리
const fmt = el("button", {
className: "ss-brush",
text: "서식…",
attrs: { type: "button", title: TEXT.format },
});
/** 창이 닫히면(확인 · 취소) 초점을 격자로 */
async function openFormat(): Promise<void> {
if (ctx.readOnly) return;
(await import("./spreadsheet_format_dialog")).openFormatDialog(ctx); // 늦게 — 크기 예산
watchClose(back);
}
fmt.addEventListener("click", () => void openFormat());
if (!ctx.readOnly) ed.bar.prepend(fmt, brush);
// ── 데이터 단추 · 메뉴(정렬 · 필터 · 조건부 서식 · 유효성 · 이름 · 눈금선) ─────────
const data = mountDataMenu(ctx, back);
brush.after(...data.buttons);
// ── 골라 붙여넣기 ─────────────────────────────────────────────────────────
let armed = false;
const picker = el("div", { className: "ss-pick", attrs: { hidden: "", title: TEXT.pasteTitle } });
ctx.root.append(picker);
const onPaste = (e: ClipboardEvent): void => {
if (!armed || ed.editing()) return;
armed = false;
e.preventDefault();
e.stopPropagation(); // E 클립보드의 보통 붙여넣기를 막음
const at = { ...ctx.selection.활성 };
const data = e.clipboardData;
const block = parseClipboard(
data?.getData("text/html") ?? "",
data?.getData("text/plain") ?? "",
at,
);
if (!block) return;
// ponytail: 우리 복사면 원본을 같은 시트로 봄(ClipBlock 에 시트가 없음) — 값만 때 식 칸 계산값용
const source =
block.출처 === "aislo" && block.원점
? { 시트: ctx.selection.시트, ...block.원점 }
: undefined;
picker.replaceChildren(
...(Object.keys(TEXT.modes) as PasteSpecialMode[]).map((mode) => {
const b = el("button", {
text: TEXT.modes[mode],
attrs: { type: "button", "data-mode": mode },
});
b.addEventListener("click", () => {
picker.setAttribute("hidden", "");
// 늦게 — 크기 예산
void import("./spreadsheet_paste_special").then((m) => {
m.pasteSpecial(ctx, block, at, mode, source);
back();
});
});
return b;
}),
);
picker.removeAttribute("hidden");
put(picker, at.r, at.c);
(picker.firstElementChild as HTMLElement).focus();
};
picker.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
e.preventDefault();
picker.setAttribute("hidden", "");
back();
});
ctx.root.addEventListener("paste", onPaste, true);
// ── 도구 모음 뒤 초점 ───────────────────────────────────────────────────
const onToolbarClick = (e: Event): void => {
if ((e.target as HTMLElement).closest("button")) back();
};
toolbarRoot?.addEventListener("click", onToolbarClick);
toolbarRoot?.addEventListener("change", back);
const self: PartHandle = {
root: null,
refresh: () => showBrush(),
destroy() {
ctx.root.removeEventListener("paste", onPaste, true);
toolbarRoot?.removeEventListener("click", onToolbarClick);
toolbarRoot?.removeEventListener("change", back);
find?.destroy();
memo?.destroy();
picker.remove();
brush.remove();
fmt.remove();
},
};
return {
keys: {
find: (replace) => void openFind(replace),
pasteSpecial: () => (armed = true),
comment: () => void editMemo(),
formatDialog: () => void openFormat(),
...data.keys,
escape() {
painter?.clear();
sticky = false;
showBrush();
},
},
mouse: {
selected() {
if (!painter?.active()) return;
painter.paint(ctx.selection.범위[0], sticky);
showBrush();
},
hover(hit) {
const key = hit?.kind === "cell" ? `${hit.r},${hit.c}` : "";
if (key === hovered) return;
hovered = key;
if (!hit || !key || memo?.busy() || !getComment(ctx.sheet(), hit.r, hit.c))
return comments.hideBalloon();
comments.showBalloon(hit.r, hit.c);
put(comments.root!, hit.r, hit.c); // showBalloon 의 cellBox 자리 → 화면 자리로 고침
},
},
parts: [comments, data.part, self],
};
}
@@ -0,0 +1,90 @@
/* =============================================================================
* spreadsheet_fill.ts (주인 B)
* 채우기 핸들 — 원본 범위를 대상 범위로 늘림: 수 하나 = 복사 · 수 둘 이상 = 등차 연속 ·
* 글 끝 번호(`구-01` → `구-02`) · 식 = 상대 참조 옮김(A `moveFormula`) · 서식 같이.
* 결과는 `{ 종류: "칸" }` 명령에 넣을 칸 뭉치.
* ========================================================================== */
import type { Cell, CellRange, FillDirection, Workbook } from "./spreadsheet_types";
import { moveFormula } from "./spreadsheet_refshift";
import { toA1 } from "./spreadsheet_address";
function detectDirection(source: CellRange, target: CellRange): FillDirection {
if (target.r1 > source.r1 && target.c0 === source.c0 && target.c1 === source.c1) return "down";
if (target.r0 < source.r0 && target.c0 === source.c0 && target.c1 === source.c1) return "up";
if (target.c1 > source.c1 && target.r0 === source.r0 && target.r1 === source.r1) return "right";
return "left";
}
/** 글 끝 숫자를 옮김(`구-01`,step 1 → `구-02`) · 끝 숫자가 없으면 그대로 */
function incrementTrailingNumber(text: string, step: number): string {
const m = /^(.*?)(\d+)$/.exec(text);
if (!m) return text;
const [, prefix, digits] = m;
const next = BigInt(digits) + BigInt(step);
const clamped = next < 0n ? 0n : next;
return prefix + clamped.toString().padStart(digits.length, "0");
}
/** `target` 은 `source` 를 한 방향으로 늘린 범위(방향은 둘을 견줘 앎) · A1 → 칸(null = 비움) */
export function fillCells(
book: Workbook,
sheet: string,
source: CellRange,
target: CellRange,
): Record<string, Cell | null> {
const sheetObj = book.시트.find((s) => s.id === sheet);
const out: Record<string, Cell | null> = {};
if (!sheetObj) return out;
const direction = detectDirection(source, target);
const vertical = direction === "down" || direction === "up";
const lineCount = vertical ? source.c1 - source.c0 + 1 : source.r1 - source.r0 + 1;
for (let li = 0; li < lineCount; li++) {
const fixed = vertical ? source.c0 + li : source.r0 + li;
const sourceStart = vertical ? source.r0 : source.c0;
const sourceEnd = vertical ? source.r1 : source.c1;
const targetStart = vertical ? target.r0 : target.c0;
const targetEnd = vertical ? target.r1 : target.c1;
const lineLen = sourceEnd - sourceStart + 1;
const key = (pos: number): string => (vertical ? toA1(pos, fixed) : toA1(fixed, pos));
const sourceCells: (Cell | undefined)[] = [];
for (let p = sourceStart; p <= sourceEnd; p++) sourceCells.push(sheetObj.칸[key(p)]);
const allNumeric = sourceCells.every(
(c) => c !== undefined && c.식 === undefined && typeof c.값 === "number",
);
const nums = allNumeric ? sourceCells.map((c) => c!.값 as number) : [];
const step = nums.length > 1 ? (nums[nums.length - 1] - nums[0]) / (nums.length - 1) : 0;
for (let p = targetStart; p <= targetEnd; p++) {
if (p >= sourceStart && p <= sourceEnd) continue;
const rel = p - sourceStart;
const slot = ((rel % lineLen) + lineLen) % lineLen;
const period = (rel - slot) / lineLen;
const template = sourceCells[slot];
const outKey = key(p);
if (template === undefined) {
out[outKey] = null;
continue;
}
const cell: Cell = {};
if (template.서식 !== undefined) cell.서식 = template.서식;
if (allNumeric) {
cell.값 = nums[0] + step * rel;
} else if (template.식 !== undefined) {
const dr = vertical ? period * lineLen : 0;
const dc = vertical ? 0 : period * lineLen;
cell.식 = moveFormula(template.식, dr, dc);
} else if (typeof template.값 === "string" && lineLen === 1) {
cell.값 = incrementTrailingNumber(template.값, rel);
} else if (template.값 !== undefined) {
cell.값 = template.값;
}
out[outKey] = cell;
}
}
return out;
}
@@ -0,0 +1,232 @@
/* =============================================================================
* spreadsheet_filter.ts (주인 A · 화면)
* 자동 필터 — 머리 행 칸마다 ▼ 단추 · 값 목록 창(모두 선택 · 값마다 체크 · 이 열 정렬 · 이 열 필터 지움).
* 걸러진 행은 엔진이 `행.걸러짐` 으로 적음(사용자 숨김과 따로) · 명령은 `필터`(spreadsheet_sortfilter.ts).
*
* 잇는 법(D · sub7 · C · sub6):
* · 부품 붙이기: `const filter = attachFilter(ctx)` — 단추 층을 격자 overlay(editorSlot 층)에 얹음.
* grid.render() 뒤마다 `filter.refresh()`(단추 자리 · 켜짐 표시) · 스크롤은 스스로 따라감.
* · 도구 단추 「필터」 · Ctrl+Shift+L → `toggleFilter(ctx)`(켜기 = 고른 범위 · 칸 하나면 현재 영역 / 끄기).
* · Alt+↓(활성 칸이 머리 행이면) → `filter.open(활성 열)`.
* · 격자(C): 행 `걸러짐` 을 `숨김` 처럼 높이 0 으로 — 머리의 숨김 표시(두 줄)는 사용자 `숨김` 만.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { colName, parseRange, rangeToA1 } from "./spreadsheet_address";
import { CommandError } from "./spreadsheet_commands";
import { currentRegion, filterCommand, filterItems, sortCommand } from "./spreadsheet_sortfilter";
import type { AutoFilter, Command } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_sortfilter.css";
const TEXT = {
Button: ["필터", "Filter"],
All: ["(모두 선택)", "(Select all)"],
Blank: ["(빈 칸)", "(Blanks)"],
SortAsc: ["오름차순 정렬", "Sort ascending"],
SortDesc: ["내림차순 정렬", "Sort descending"],
Clear: ["이 열 필터 지우기", "Clear filter from column"],
Ok: ["확인", "OK"],
Cancel: ["취소", "Cancel"],
};
const t = (key: keyof typeof TEXT) => TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
function send(ctx: SpreadsheetContext, make: () => Command): void {
try {
ctx.dispatch(make());
} catch (e) {
if (!(e instanceof CommandError)) throw e;
showToast(e.message, "error");
}
}
/** 필터 켜기 · 끄기 */
export function toggleFilter(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const s = ctx.sheet();
if (s.필터) return send(ctx, () => filterCommand(ctx.book, ctx.engine, s.id, null));
const g = ctx.selection.범위[0];
const rg = g.r0 === g.r1 && g.c0 === g.c1 ? currentRegion(ctx.book, s.id, g.r0, g.c0) : g;
send(ctx, () => ({ 종류: "필터", 시트: s.id, 필터: { 범위: rangeToA1(rg) }, 걸러짐: [] }));
}
export interface FilterHandle extends PartHandle {
/** 열 `c` 값 목록 창 열기(필터 범위 안일 때) */
open(c: number): void;
}
export function attachFilter(ctx: SpreadsheetContext): FilterHandle {
const layer = el("div", { className: "ss-filter__layer" });
let panel: HTMLElement | null = null;
const filterOf = () => {
const f = ctx.sheet().필터;
const rg = f && parseRange(f.범위);
return f && rg ? { f, rg } : null;
};
/** 칸 자리 → 단추 층 안 자리(editorSlot 상자는 격자 뿌리 기준 — 층 원점과 다를 수 있어 실제 자리로 맞춤) */
function boxIn(r: number, c: number) {
const { layer: host, box } = ctx.grid.editorSlot(r, c);
const a = host.getBoundingClientRect();
const g = ctx.grid.root.getBoundingClientRect();
return { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) };
}
/** 머리 칸이 지금 보이나(틀고정 판이면 늘 보임) */
function shown(r: number, c: number): boolean {
const v = ctx.grid.visibleRange();
const fz = ctx.sheet().틀고정 ?? {};
const rowOk = r < (fz.행 ?? 0) || (r >= v.r0 && r <= v.r1);
const colOk = c < (fz.열 ?? 0) || (c >= v.c0 && c <= v.c1);
return rowOk && colOk;
}
function place(): void {
const { layer: host } = ctx.grid.editorSlot(0, 0);
if (layer.parentElement !== host) host.append(layer);
const cur = filterOf();
const btns: HTMLElement[] = [];
if (cur)
for (let c = cur.rg.c0; c <= cur.rg.c1; c++) {
const box = boxIn(cur.rg.r0, c);
if (!box.w || !box.h || !shown(cur.rg.r0, c)) continue;
const on = !!cur.f.조건?.[colName(c)];
const b = el("button", {
className: on ? "ss-filter__btn is-on" : "ss-filter__btn",
text: "▼",
attrs: {
type: "button",
"aria-label": `${t("Button")} ${colName(c)}`,
"data-col": String(c),
},
});
b.style.left = `${box.x + box.w - 17}px`;
b.style.top = `${box.y + box.h - 17}px`;
b.addEventListener("mousedown", (ev) => ev.stopPropagation());
b.addEventListener("click", () => open(c));
btns.push(b);
}
layer.replaceChildren(...btns, ...(panel ? [panel] : []));
}
function close(): void {
panel?.remove();
panel = null;
document.removeEventListener("mousedown", onOutside, true);
}
const onOutside = (ev: MouseEvent) => {
if (panel && !panel.contains(ev.target as Node)) close();
};
function apply(next: AutoFilter): void {
close();
send(ctx, () => filterCommand(ctx.book, ctx.engine, ctx.sheet().id, next));
}
function open(c: number): void {
const cur = filterOf();
if (!cur || c < cur.rg.c0 || c > cur.rg.c1 || ctx.readOnly) return;
close();
const sheetId = ctx.sheet().id;
const items = filterItems(ctx.book, ctx.engine, sheetId, c);
const boxes = items.map((it) => {
const cb = el("input", { attrs: { type: "checkbox" } });
cb.checked = it.켬;
return cb;
});
const all = el("input", { attrs: { type: "checkbox" } });
const ok = createButton({
label: t("Ok"),
onClick: () => {
const conds = { ...cur.f.조건 };
if (boxes.every((b) => b.checked)) delete conds[colName(c)];
else conds[colName(c)] = items.filter((_, i) => boxes[i].checked).map((it) => it.글);
apply({ ...cur.f, 조건: conds });
},
});
const sync = () => {
all.checked = boxes.every((b) => b.checked);
all.indeterminate = !all.checked && boxes.some((b) => b.checked);
ok.disabled = !boxes.some((b) => b.checked);
};
all.addEventListener("change", () => {
for (const b of boxes) b.checked = all.checked;
sync();
});
for (const b of boxes) b.addEventListener("change", sync);
const link = (label: string, run: () => void) => {
const a = el("button", {
className: "ss-filter__link",
text: label,
attrs: { type: "button" },
});
a.addEventListener("click", run);
return a;
};
const sortBy = (desc: boolean) => () => {
close();
send(ctx, () =>
sortCommand(ctx.book, ctx.engine, sheetId, cur.rg, {
머리: true,
기준: [{ 열: c, 내림: desc }],
}),
);
};
const rest = { ...cur.f.조건 };
delete rest[colName(c)];
panel = el("div", {
className: "ss-filter__panel",
attrs: { role: "dialog", "aria-label": `${t("Button")} ${colName(c)}` },
children: [
link(t("SortAsc"), sortBy(false)),
link(t("SortDesc"), sortBy(true)),
link(t("Clear"), () => apply({ ...cur.f, 조건: rest })),
el("div", {
className: "ss-filter__list",
children: [
el("label", { children: [all, t("All")] }),
...items.map((it, i) =>
el("label", { children: [boxes[i], it.글 === "" ? t("Blank") : it.글] }),
),
],
}),
el("div", {
className: "ss-filter__foot",
children: [createButton({ label: t("Cancel"), variant: "ghost", onClick: close }), ok],
}),
],
});
panel.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") close();
ev.stopPropagation();
});
const box = boxIn(cur.rg.r0, c);
panel.style.left = `${box.x}px`;
panel.style.top = `${box.y + box.h}px`;
sync();
layer.append(panel);
document.addEventListener("mousedown", onOutside, true);
}
const onScroll = () => place();
ctx.grid.root.addEventListener("scroll", onScroll, true);
place();
return {
root: layer,
refresh() {
close();
place();
},
open,
destroy() {
close();
ctx.grid.root.removeEventListener("scroll", onScroll, true);
layer.remove();
},
};
}
+179
View File
@@ -0,0 +1,179 @@
/* =============================================================================
* spreadsheet_find.ts (2단계 · 주인 sub_laptop_1)
* 찾기 · 바꾸기 — 대소문자 구분 · 전체 칸 일치 · 「수식 안」(식 글 속 글자도 뒤짐) 옵션.
* 값 칸은 `값`(수 · 참거짓은 글로 바꿔 견줌) · 식 칸은 기본으로 식 글(`=` 뺀 것)을 뒤짐(계산값은
* 이 계층에서 모름 — CalcEngine 은 C·D 쪽에만 있음). 바꾸기는 `dispatch({종류:"칸", …})` 하나로만.
*
* 잇는 법(D · sub7) — 1단계 격자 · 편집기가 서면:
* 1) 도구 모음이나 Ctrl+F 단축키로 작은 패널을 띄우고 `createFindPanel(ctx)` 를 그 안에 붙임.
* 2) 패널의 「다음 찾기」 는 `findNext` 결과로 `ctx.select` + `ctx.grid.reveal` 호출.
* 3) 「모두 바꾸기」 버튼은 `replaceAll` 을 부르고 끝나면 바뀐 수를 토스트로.
* ========================================================================== */
import type { CellInput, SheetCellAddress, Workbook } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
export interface FindOptions {
대소문자?: boolean;
전체칸?: boolean;
/** 식 칸은 식 글(수식) 속에서도 찾음 — 꺼지면 값 칸만 뒤짐(식 칸은 건너뜀) */
수식안?: boolean;
}
const norm = (text: string, opts: FindOptions | undefined): string =>
opts?.대소문자 ? text : text.toLowerCase();
/** 칸 하나를 글로 — 식 칸은 `수식안` 이 있어야 식 글, 없으면 후보에서 빠짐(null). */
function cellText(
book: Workbook,
sheetId: string,
r: number,
c: number,
opts?: FindOptions,
): string | null {
const sheet = book.시트.find((s) => s.id === sheetId);
const cell = sheet?.칸[a1(r, c)];
if (!cell) return null;
if (cell.식 !== undefined) return opts?.수식안 ? cell.식 : null;
const v = cell.값;
if (v === undefined) return null;
return typeof v === "boolean" ? (v ? "TRUE" : "FALSE") : String(v);
}
function a1(r: number, c: number): string {
let col = c + 1;
let letters = "";
while (col > 0) {
const rem = (col - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
col = Math.floor((col - 1) / 26);
}
return `${letters}${r + 1}`;
}
const matches = (haystack: string, query: string, opts: FindOptions | undefined): boolean => {
const h = norm(haystack, opts);
const q = norm(query, opts);
return opts?.전체칸 ? h === q : h.includes(q);
};
/** 시트 하나 안 찾는 순서 — 왼위부터 오른아래로(행 우선). */
function* cellsInOrder(book: Workbook, sheetId: string): Generator<SheetCellAddress> {
const sheet = book.시트.find((s) => s.id === sheetId);
if (!sheet) return;
const addrs = Object.keys(sheet.칸)
.map((key) => parseA1(key))
.sort((x, y) => x.r - y.r || x.c - y.c);
for (const { r, c } of addrs) yield { 시트: sheetId, r, c };
}
function parseA1(key: string): { r: number; c: number } {
const m = /^([A-Z]+)(\d+)$/.exec(key);
if (!m) return { r: 0, c: 0 };
let col = 0;
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
return { r: Number(m[2]) - 1, c: col - 1 };
}
/** 통합문서 전체(활성 시트 먼저 뒤지고 싶으면 `sheetOrder` 로 순서를 줌)에서 다 찾음. */
export function findAll(
book: Workbook,
query: string,
opts?: FindOptions,
sheetOrder?: string[],
): SheetCellAddress[] {
if (!query) return [];
const ids = sheetOrder ?? book.시트.map((s) => s.id);
const out: SheetCellAddress[] = [];
for (const id of ids) {
for (const at of cellsInOrder(book, id)) {
const text = cellText(book, at.시트, at.r, at.c, opts);
if (text !== null && matches(text, query, opts)) out.push(at);
}
}
return out;
}
/** `from` 다음 칸부터 한 바퀴 돌아 처음 걸리는 것(순환) · 없으면 null. */
export function findNext(
book: Workbook,
from: SheetCellAddress,
query: string,
opts?: FindOptions,
): SheetCellAddress | null {
const all = findAll(book, query, opts, [
from.시트,
...book.시트.map((s) => s.id).filter((id) => id !== from.시트),
]);
if (all.length === 0) return null;
const idx = all.findIndex((m) => m.시트 === from.시트 && m.r === from.r && m.c === from.c);
return all[(idx + 1) % all.length];
}
/** 한 칸 바꾸기 — 식 칸(`수식안`)이면 식 글 속 글자를 바꿈 · 값 칸이면 값 전체(전체칸)나 부분(글만)을 바꿈. */
export function replaceOne(
ctx: SpreadsheetContext,
at: SheetCellAddress,
query: string,
replacement: string,
opts?: FindOptions,
): void {
const sheet = ctx.book.시트.find((s) => s.id === at.시트);
const cell = sheet?.칸[a1(at.r, at.c)];
if (!sheet || !cell) return;
const next: { 값?: CellInput; 식?: string; 서식?: number } = { 서식: cell.서식 };
if (cell.식 !== undefined && opts?.수식안) {
next.식 = replaceText(cell.식, query, replacement, opts);
} else if (cell.값 !== undefined) {
const text = typeof cell.값 === "boolean" ? (cell.값 ? "TRUE" : "FALSE") : String(cell.값);
next.값 = replaceText(text, query, replacement, opts);
} else {
return;
}
ctx.dispatch({ 종류: "칸", 시트: at.시트, 칸: { [a1(at.r, at.c)]: next } });
}
function replaceText(
text: string,
query: string,
replacement: string,
opts: FindOptions | undefined,
): string {
if (opts?.전체칸) return matches(text, query, opts) ? replacement : text;
if (opts?.대소문자) return text.split(query).join(replacement);
const re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
return text.replace(re, replacement);
}
/** 모두 바꾸기 — 한 번의 되돌리기로 묶어 적용 · 바뀐 칸 수를 돌려줌. */
export function replaceAll(
ctx: SpreadsheetContext,
query: string,
replacement: string,
opts?: FindOptions,
): number {
const hits = findAll(ctx.book, query, opts);
if (hits.length === 0) return 0;
const bySheet = new Map<string, SheetCellAddress[]>();
for (const at of hits) bySheet.set(at.시트, [...(bySheet.get(at.시트) ?? []), at]);
const commands = [...bySheet.entries()].map(([sheetId, ats]) => {
const sheet = ctx.book.시트.find((s) => s.id === sheetId)!;
const 칸: Record<string, { 값?: CellInput; 식?: string; 서식?: number }> = {};
for (const at of ats) {
const cell = sheet.칸[a1(at.r, at.c)];
if (!cell) continue;
if (cell.식 !== undefined && opts?.수식안) {
칸[a1(at.r, at.c)] = {
식: replaceText(cell.식, query, replacement, opts),
서식: cell.서식,
};
} else if (cell.값 !== undefined) {
const text = typeof cell.값 === "boolean" ? (cell.값 ? "TRUE" : "FALSE") : String(cell.값);
칸[a1(at.r, at.c)] = { 값: replaceText(text, query, replacement, opts), 서식: cell.서식 };
}
}
return { 종류: "칸" as const, 시트: sheetId, 칸 };
});
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
return hits.length;
}
@@ -0,0 +1,131 @@
/* =============================================================================
* spreadsheet_find_panel.ts (주인 D)
* 찾기(Ctrl+F) · 바꾸기(Ctrl+H) 작은 패널 — 부품 오른위에 뜸. 찾기 규칙은 `spreadsheet_find.ts`(sub1).
* Enter = 다음 · Esc = 닫고 격자로. 바꾸기 = 활성 칸이 맞으면 바꾸고 다음으로(엑셀).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { findAll, findNext, replaceAll, replaceOne, type FindOptions } from "./spreadsheet_find";
import { selectCell } from "./spreadsheet_selection";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
find: "찾을 내용",
replace: "바꿀 내용",
next: "다음",
one: "바꾸기",
all: "모두 바꾸기",
close: "닫기",
case: "대소문자",
whole: "전체 칸",
formula: "수식 안",
none: "찾을 수 없음",
replaced: "{n} 개 바꿈",
};
export interface FindPanelHandle extends PartHandle {
open(replace: boolean): void;
close(): void;
}
export function mountFindPanel(ctx: SpreadsheetContext, back: () => void): FindPanelHandle {
const query = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.find } });
const repl = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.replace } });
const status = el("span", { className: "ss-find__status" });
const button = (text: string, onClick: () => void): HTMLButtonElement => {
const b = el("button", { className: "ss-find__btn", text, attrs: { type: "button" } });
b.addEventListener("click", onClick);
return b;
};
const check = (text: string): HTMLInputElement => {
const box = el("input", { attrs: { type: "checkbox" } });
options.append(el("label", { className: "ss-find__opt", children: [box, text] }));
return box;
};
const closeBtn = button("×", close);
closeBtn.title = TEXT.close;
const options = el("div", { className: "ss-find__row" });
const caseBox = check(TEXT.case);
const wholeBox = check(TEXT.whole);
const formulaBox = check(TEXT.formula);
const replaceRow = el("div", {
className: "ss-find__row",
children: [repl, button(TEXT.one, one), button(TEXT.all, all)],
});
const root = el("div", {
className: "ss-find",
attrs: { hidden: "" },
children: [
el("div", {
className: "ss-find__row",
children: [query, button(TEXT.next, next), closeBtn],
}),
replaceRow,
options,
status,
],
});
ctx.root.append(root);
const opts = (): FindOptions => ({
대소문자: caseBox.checked,
전체칸: wholeBox.checked,
수식안: formulaBox.checked,
});
const here = () => ({ 시트: ctx.selection.시트, ...ctx.selection.활성 });
function next(): void {
status.textContent = "";
if (!query.value) return;
const hit = findNext(ctx.book, here(), query.value, opts());
if (!hit) return void (status.textContent = TEXT.none);
if (hit.시트 !== ctx.selection.시트) ctx.showSheet(hit.시트);
ctx.select(selectCell(ctx.sheet(), hit.r, hit.c), true);
}
function one(): void {
if (ctx.readOnly || !query.value) return;
const at = here();
const onIt = findAll(ctx.book, query.value, opts(), [at.시트]).some(
(h) => h.r === at.r && h.c === at.c,
);
if (onIt) replaceOne(ctx, at, query.value, repl.value, opts());
next();
}
function all(): void {
if (ctx.readOnly || !query.value) return;
const n = replaceAll(ctx, query.value, repl.value, opts());
status.textContent = n ? TEXT.replaced.replace("{n}", String(n)) : TEXT.none;
}
function open(replace: boolean): void {
replaceRow.hidden = !replace || ctx.readOnly;
root.removeAttribute("hidden");
status.textContent = "";
query.focus();
query.select();
}
function close(): void {
root.setAttribute("hidden", "");
back();
}
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") close();
else if (e.key === "Enter") e.target === repl ? one() : next();
else return;
e.preventDefault();
e.stopPropagation();
};
root.addEventListener("keydown", onKey);
return {
root,
open,
close,
refresh() {},
destroy: () => root.remove(),
};
}
@@ -0,0 +1,58 @@
/* =============================================================================
* spreadsheet_format_code.ts (2단계 · 주인 sub_laptop_1)
* 셀 서식 대화상자(표시 형식 탭)의 범주 → 형식 코드 짓기 — DOM · CSS 없음(시험이 바로 부름).
* 실무 자주 쓰는 모양만(일반 · 숫자 · 통화 · 회계 · 백분율 · 텍스트 · 사용자 지정).
* ========================================================================== */
export const CATEGORIES = [
"일반",
"숫자",
"통화",
"회계",
"백분율",
"텍스트",
"사용자 지정",
] as const;
export type Category = (typeof CATEGORIES)[number];
export type NegStyle = "minus" | "paren" | "red-minus" | "red-paren";
/** 범주 · 소수 자릿수 · 천단위 · 음수 표시로 형식 코드를 지음. */
export function buildCode(
cat: Category,
decimals: number,
thousands: boolean,
neg: NegStyle,
): string {
if (cat === "일반") return "";
if (cat === "텍스트") return "@";
const digits = decimals > 0 ? `0.${"0".repeat(decimals)}` : "0";
const grouped = thousands ? `#,##${digits}` : digits;
if (cat === "백분율") return `${grouped}%`;
const prefix = cat === "통화" ? '"₩"' : "";
const positive = `${prefix}${grouped}`;
if (cat === "회계") {
const acc = decimals > 0 ? "0.00" : "0";
return `_-* #,##${acc}_-;-* #,##${acc}_-;_-* "-"_-;_-@_-`;
}
switch (neg) {
case "minus":
return positive;
case "paren":
return `${positive};(${positive})`;
case "red-minus":
return `${positive};[Red]-${positive}`;
case "red-paren":
return `${positive};[Red](${positive})`;
}
}
/** 있는 형식 코드를 보고 범주를 어림(대화상자를 열 때 초기 범주 고르기). */
export function guessCategory(code: string | undefined): Category {
if (!code) return "일반";
if (code === "@") return "텍스트";
if (code.includes("%")) return "백분율";
if (code.includes("_-*")) return "회계";
if (code.includes("₩")) return "통화";
if (/[0#]/.test(code)) return "숫자";
return "사용자 지정";
}
@@ -0,0 +1,146 @@
/* =============================================================================
* spreadsheet_format_dialog.css (2단계 · 주인 sub_laptop_1)
* 셀 서식 대화상자 — 뿌리 배경(`.ui-confirm`)은 공용 확인 모달과 같은 것을 그대로 씀.
* 색 · 간격은 테마 토큰만(`ui_template_theme.css`).
* ========================================================================== */
.ss-fmtdlg__panel {
display: flex;
flex-direction: column;
width: min(560px, 92vw);
max-height: 84vh;
padding: var(--spacing-16);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-fmtdlg__title {
margin: 0 0 var(--spacing-8);
font-size: 1rem;
color: var(--color-text-body);
}
.ss-fmtdlg__tabs {
display: flex;
flex-wrap: wrap;
gap: 2px;
border-bottom: 1px solid var(--color-border);
}
.ss-fmtdlg__tabbtn {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-bottom: 0;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
background: var(--color-paper);
color: var(--color-text-secondary);
font-size: 0.82rem;
cursor: pointer;
}
.ss-fmtdlg__tabbtn.is-active {
background: var(--color-canvas);
color: var(--color-text-body);
font-weight: 600;
}
.ss-fmtdlg__body {
flex: 1;
overflow-y: auto;
padding: var(--spacing-12) 2px;
}
.ss-fmtdlg__tab {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.ss-fmtdlg__row {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.ss-fmtdlg__label {
width: 140px;
flex-shrink: 0;
color: var(--color-text-secondary);
font-size: 0.82rem;
}
.ss-fmtdlg__row select,
.ss-fmtdlg__row input[type="text"],
.ss-fmtdlg__row input:not([type]),
.ss-fmtdlg__row input[type="number"],
.ss-fmtdlg__code {
height: 28px;
padding: 0 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font-size: 0.82rem;
}
.ss-fmtdlg__code {
flex: 1;
}
.ss-fmtdlg__subrows {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding-left: var(--spacing-16);
}
.ss-fmtdlg__preview-text {
padding: 4px 8px;
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm);
font-family: monospace;
}
.ss-fmtdlg__font-sample {
margin-top: var(--spacing-8);
padding: var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
}
.ss-fmtdlg__border-presets {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
}
.ss-fmtdlg__border-preview {
width: 96px;
height: 64px;
margin-top: var(--spacing-8);
background: var(--color-canvas);
}
.ss-fmtdlg__fill-swatch {
width: 96px;
height: 40px;
margin-top: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.ss-fmtdlg__note {
margin: 0 0 var(--spacing-8);
color: var(--color-text-muted);
font-size: 0.8rem;
}
.ss-fmtdlg__actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
padding-top: var(--spacing-12);
border-top: 1px solid var(--color-border);
}
@@ -0,0 +1,514 @@
/* =============================================================================
* spreadsheet_format_dialog.ts (2단계 · 주인 sub_laptop_1)
* 셀 서식 대화상자(Ctrl+1) — 엑셀과 같은 탭 여섯: 표시 형식 · 맞춤 · 글꼴 · 테두리 · 채우기 · 보호.
* [확인] 을 누를 때까지 아무 것도 안 바꿈(도구 모음처럼 칸마다 바로 안 바꿈) — 눌러야
* 「서식」 명령 하나(+ 병합이 바뀌었으면 「병합」·「병합풀기」)로 한 번에 나감.
*
* 들여쓰기 · 보호(잠금·숨김)는 화면 자리만 — 0 계약 `CellStyle`·`Command` 에 그 칸이 없어
* 실제로는 안 바뀜(보호는 브레인 지시대로 「자리만」).
*
* 잇는 법(D · sub7) — Ctrl+1(`spreadsheet_shortcuts.ts` 의 `openFormatDialog`) 을
* `spreadsheet_keys.ts` navKey 에 새 case 로 더해 `openFormatDialog(ctx)` 를 부름.
* 우클릭 메뉴 [셀 서식] 도 같은 함수를 부르면 됨(`spreadsheet_menu.ts`).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { toFrac } from "@ui/sheet/ui_template_sheet_frac";
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
import {
buildCode,
CATEGORIES,
guessCategory,
type Category,
type NegStyle,
} from "./spreadsheet_format_code";
import { isAutoInk } from "./spreadsheet_cellstyle";
import { formatValue } from "./spreadsheet_numfmt";
import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_format_dialog.css";
const TEXT = {
title: "셀 서식",
tabNumber: "표시 형식",
tabAlign: "맞춤",
tabFont: "글꼴",
tabBorder: "테두리",
tabFill: "채우기",
tabProtect: "보호",
ok: "확인",
cancel: "취소",
category: "범주",
decimals: "소수 자릿수",
thousands: "천 단위 구분 기호 사용",
negative: "음수 표시 형식",
customCode: "사용자 지정 형식 코드",
preview: "보기",
hAlign: "가로",
vAlign: "세로",
wrap: "줄 바꿈",
merge: "셀 병합",
indent: "들여쓰기",
fontName: "글꼴",
fontSize: "크기",
bold: "굵게",
italic: "기울임",
underline: "밑줄",
strike: "취소선",
fontColor: "글자색",
lineStyle: "선 모양",
lineColor: "선 색",
none: "없음",
all: "모두",
top: "위",
bottom: "아래",
left: "왼",
right: "오른",
fillColor: "채움색",
clear: "지움",
protectNote: "칸 잠금 · 숨김은 자리만 둠(시트 보호 자체가 아직 없음)",
lock: "잠금",
hidden: "수식 숨김",
};
/** 활성 칸의 실제 서식 — 칸 → 행 → 열 → 시트 기본 → 0(계약 주석과 같은 차례). */
function styleAt(ctx: SpreadsheetContext, r: number, c: number): CellStyle {
const sheet = ctx.sheet();
const cell = sheet.칸[toA1(r, c)];
const idx = cell?.서식 ?? sheet.행?.[String(r + 1)]?.서식 ?? sheet.열?.[colName(c)]?.서식 ?? 0;
return ctx.book.서식[idx] ?? {};
}
function row(label: string, ...controls: HTMLElement[]): HTMLElement {
return el("div", {
className: "ss-fmtdlg__row",
children: [el("span", { className: "ss-fmtdlg__label", text: label }), ...controls],
});
}
function borderCss(side?: BorderSide): string {
if (!side) return "none";
const color = isAutoInk(side.색) ? "var(--color-text-body)" : (side.색 as string);
const width = side.선 === "medium" ? 2 : side.선 === "thick" || side.선 === "double" ? 3 : 1;
const style = side.선 === "hair" ? "dotted" : side.선 === "double" ? "double" : side.선;
return `${width}px ${style} ${color}`;
}
export function openFormatDialog(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const range = ctx.selection.범위[0];
if (!range) return;
const at = ctx.selection.활성;
const base = styleAt(ctx, at.r, at.c);
const draft: CellStyle = structuredClone(base);
const spansMany = range.r0 !== range.r1 || range.c0 !== range.c1;
let mergeWanted = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
const overlay = el("div", { className: "ui-confirm ss-fmtdlg" });
const panel = el("div", { className: "ss-fmtdlg__panel" });
const tabsBar = el("div", { className: "ss-fmtdlg__tabs" });
const body = el("div", { className: "ss-fmtdlg__body" });
overlay.append(panel);
panel.append(el("h2", { className: "ss-fmtdlg__title", text: TEXT.title }), tabsBar, body);
const close = (): void => overlay.remove();
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) close();
});
// ── 표시 형식 ────────────────────────────────────────────────────────
let category: Category = guessCategory(draft.형식);
let decimals = (draft.형식?.match(/\.0+/)?.[0].length ?? 1) - 1;
let thousands = draft.형식?.includes(",") ?? false;
let negStyle: NegStyle = draft.형식?.includes("[Red]")
? draft.형식.includes("(")
? "red-paren"
: "red-minus"
: draft.형식?.includes(";(")
? "paren"
: "minus";
let customCode = draft.형식 ?? "";
let useCustom = category === "사용자 지정";
function numberTab(): HTMLElement {
const catSelect = el("select", {
children: CATEGORIES.map((c) => el("option", { attrs: { value: c }, text: c })),
}) as HTMLSelectElement;
catSelect.value = category;
const decimalsInput = el("input", {
attrs: { type: "number", min: "0", max: "10" },
}) as HTMLInputElement;
decimalsInput.value = String(Math.max(0, decimals));
const thousandsCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
thousandsCheck.checked = thousands;
const negSelect = el("select", {
children: [
el("option", { attrs: { value: "minus" }, text: "-1,234.00" }),
el("option", { attrs: { value: "red-minus" }, text: "-1,234.00(빨강)" }),
el("option", { attrs: { value: "paren" }, text: "(1,234.00)" }),
el("option", { attrs: { value: "red-paren" }, text: "(1,234.00)(빨강)" }),
],
}) as HTMLSelectElement;
negSelect.value = negStyle;
const customInput = el("input", { className: "ss-fmtdlg__code" }) as HTMLInputElement;
customInput.value = customCode;
const previewText = el("span", { className: "ss-fmtdlg__preview-text" });
const numberRows = el("div", { className: "ss-fmtdlg__subrows" });
const customRow = row(TEXT.customCode, customInput);
function syncControls(): void {
const showsNumberControls =
category === "숫자" || category === "통화" || category === "백분율";
numberRows.hidden = !showsNumberControls;
customRow.hidden = category !== "사용자 지정";
decimalsInput.disabled = category === "회계" ? false : !showsNumberControls;
}
function refreshPreview(): void {
const code = useCustom ? customCode : buildCode(category, decimals, thousands, negStyle);
draft.형식 = code || undefined;
const cellValue = ctx.engine.value(ctx.selection.시트, at.r, at.c);
const sample = cellValue ?? toFrac(-1234.5);
previewText.textContent = formatValue(sample, code).글;
}
catSelect.addEventListener("change", () => {
category = catSelect.value as Category;
useCustom = category === "사용자 지정";
syncControls();
refreshPreview();
});
decimalsInput.addEventListener("input", () => {
decimals = Math.max(0, Math.min(10, Number(decimalsInput.value) || 0));
refreshPreview();
});
thousandsCheck.addEventListener("change", () => {
thousands = thousandsCheck.checked;
refreshPreview();
});
negSelect.addEventListener("change", () => {
negStyle = negSelect.value as NegStyle;
refreshPreview();
});
customInput.addEventListener("input", () => {
customCode = customInput.value;
refreshPreview();
});
numberRows.append(
row(TEXT.decimals, decimalsInput),
row(TEXT.thousands, thousandsCheck),
row(TEXT.negative, negSelect),
);
syncControls();
refreshPreview();
return el("div", {
className: "ss-fmtdlg__tab",
children: [
row(TEXT.category, catSelect),
numberRows,
customRow,
row(TEXT.preview, previewText),
],
});
}
// ── 맞춤 ────────────────────────────────────────────────────────────
function alignTab(): HTMLElement {
const hSelect = el("select", {
children: [
["general", "일반"],
["left", "왼쪽"],
["center", "가운데"],
["right", "오른쪽"],
["distributed", "균등 분할"],
["centerContinuous", "선택 영역 가운데"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement;
hSelect.value = draft.가로 ?? "general";
hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign));
const vSelect = el("select", {
children: [
["top", "위"],
["center", "가운데"],
["bottom", "아래"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement;
vSelect.value = draft.세로 ?? "bottom";
vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign));
const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
wrapCheck.checked = !!draft.줄바꿈;
wrapCheck.addEventListener("change", () => (draft.줄바꿈 = wrapCheck.checked));
const mergeCheck = el("input", {
attrs: { type: "checkbox", ...(spansMany ? {} : { disabled: "true" }) },
}) as HTMLInputElement;
mergeCheck.checked = mergeWanted;
mergeCheck.addEventListener("change", () => (mergeWanted = mergeCheck.checked));
const indentInput = el("input", {
attrs: { type: "number", min: "0", max: "15", disabled: "true", title: "0 계약 밖 — 자리만" },
}) as HTMLInputElement;
indentInput.value = "0";
return el("div", {
className: "ss-fmtdlg__tab",
children: [
row(TEXT.hAlign, hSelect),
row(TEXT.vAlign, vSelect),
row(TEXT.wrap, wrapCheck),
row(TEXT.merge, mergeCheck),
row(TEXT.indent, indentInput),
],
});
}
// ── 글꼴 ────────────────────────────────────────────────────────────
function fontTab(): HTMLElement {
const nameInput = el("input") as HTMLInputElement;
nameInput.value = draft.글꼴 ?? "";
const sizeInput = el("input", {
attrs: { type: "number", min: "1", max: "409", step: "0.5" },
}) as HTMLInputElement;
sizeInput.value = draft.크기 != null ? String(draft.크기) : "";
const boldCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
boldCheck.checked = !!draft.굵게;
const italicCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
italicCheck.checked = !!draft.기울임;
const underlineCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
underlineCheck.checked = !!draft.밑줄;
const strikeCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
strikeCheck.checked = !!draft.취소선;
const colorInput = el("input", { attrs: { type: "color" } }) as HTMLInputElement;
colorInput.value = draft.글자색 ?? "#000000";
const sample = el("div", { className: "ss-fmtdlg__font-sample", text: "가나다 ABC 123" });
function refreshSample(): void {
sample.style.fontFamily = nameInput.value || "";
sample.style.fontSize = sizeInput.value ? `${sizeInput.value}pt` : "";
sample.style.fontWeight = boldCheck.checked ? "700" : "400";
sample.style.fontStyle = italicCheck.checked ? "italic" : "normal";
sample.style.textDecorationLine = [
underlineCheck.checked && "underline",
strikeCheck.checked && "line-through",
]
.filter(Boolean)
.join(" ");
sample.style.color = isAutoInk(colorInput.value) ? "" : colorInput.value;
}
nameInput.addEventListener("input", () => {
draft.글꼴 = nameInput.value || undefined;
refreshSample();
});
sizeInput.addEventListener("input", () => {
const n = Number(sizeInput.value);
draft.크기 = sizeInput.value && n > 0 ? n : undefined;
refreshSample();
});
boldCheck.addEventListener("change", () => {
draft.굵게 = boldCheck.checked || undefined;
refreshSample();
});
italicCheck.addEventListener("change", () => {
draft.기울임 = italicCheck.checked || undefined;
refreshSample();
});
underlineCheck.addEventListener("change", () => {
draft.밑줄 = underlineCheck.checked || undefined;
refreshSample();
});
strikeCheck.addEventListener("change", () => {
draft.취소선 = strikeCheck.checked || undefined;
refreshSample();
});
colorInput.addEventListener("input", () => {
draft.글자색 = colorInput.value;
refreshSample();
});
refreshSample();
return el("div", {
className: "ss-fmtdlg__tab",
children: [
row(TEXT.fontName, nameInput),
row(TEXT.fontSize, sizeInput),
row(TEXT.bold, boldCheck),
row(TEXT.italic, italicCheck),
row(TEXT.underline, underlineCheck),
row(TEXT.strike, strikeCheck),
row(TEXT.fontColor, colorInput),
sample,
],
});
}
// ── 테두리 ────────────────────────────────────────────────────────────
function borderTab(): HTMLElement {
const lineSelect = el("select", {
children: (
["hair", "thin", "medium", "thick", "double", "dotted", "dashed"] as BorderLine[]
).map((v) => el("option", { attrs: { value: v }, text: v })),
}) as HTMLSelectElement;
const colorInput = el("input", { attrs: { type: "color" } }) as HTMLInputElement;
colorInput.value = "#000000";
const preview = el("div", { className: "ss-fmtdlg__border-preview" });
function refreshPreview(): void {
const t = draft.테두리 ?? {};
preview.style.borderTop = borderCss(t.위);
preview.style.borderBottom = borderCss(t.아래);
preview.style.borderLeft = borderCss(t.왼);
preview.style.borderRight = borderCss(t.오른);
}
refreshPreview();
const set = (sides: ("위" | "아래" | "왼" | "오른")[] | null): void => {
if (sides === null) {
draft.테두리 = undefined;
} else {
const cur = { ...(draft.테두리 ?? {}) };
const side: BorderSide = { 선: lineSelect.value as BorderLine, 색: colorInput.value };
for (const s of sides) cur[s] = side;
draft.테두리 = cur;
}
refreshPreview();
};
// 「바깥(outline)」은 범위 가장자리 칸만 따로 골라야 해서(토대는 toolbar.ts cellsFor) 여기선 뺌 —
// 여러 칸을 한 서식 값으로 통째로 바꾸는 이 대화상자는 위·아래·왼·오른·모두만 (모두 = 칸마다 네 변).
const presets: [string, ("위" | "아래" | "왼" | "오른")[] | null][] = [
[TEXT.none, null],
[TEXT.top, ["위"]],
[TEXT.bottom, ["아래"]],
[TEXT.left, ["왼"]],
[TEXT.right, ["오른"]],
[TEXT.all, ["위", "아래", "왼", "오른"]],
];
const presetRow = el("div", {
className: "ss-fmtdlg__border-presets",
children: presets.map(([label, sides]) =>
createButton({ label, variant: "ghost", onClick: () => set(sides) }),
),
});
return el("div", {
className: "ss-fmtdlg__tab",
children: [
row(TEXT.lineStyle, lineSelect),
row(TEXT.lineColor, colorInput),
presetRow,
preview,
],
});
}
// ── 채우기 ────────────────────────────────────────────────────────────
function fillTab(): HTMLElement {
const colorInput = el("input", { attrs: { type: "color" } }) as HTMLInputElement;
colorInput.value = draft.채움 ?? "#ffffff";
const swatch = el("div", { className: "ss-fmtdlg__fill-swatch" });
function refresh(): void {
swatch.style.background = draft.채움 ?? "transparent";
}
colorInput.addEventListener("input", () => {
draft.채움 = colorInput.value;
refresh();
});
const clearBtn = createButton({
label: TEXT.clear,
variant: "ghost",
onClick: () => {
draft.채움 = undefined;
refresh();
},
});
refresh();
return el("div", {
className: "ss-fmtdlg__tab",
children: [row(TEXT.fillColor, colorInput, clearBtn), swatch],
});
}
// ── 보호(자리만) ────────────────────────────────────────────────────
function protectTab(): HTMLElement {
const lockCheck = el("input", {
attrs: { type: "checkbox", checked: "true", disabled: "true" },
});
const hiddenCheck = el("input", { attrs: { type: "checkbox", disabled: "true" } });
return el("div", {
className: "ss-fmtdlg__tab",
children: [
el("p", { className: "ss-fmtdlg__note", text: TEXT.protectNote }),
row(TEXT.lock, lockCheck),
row(TEXT.hidden, hiddenCheck),
],
});
}
const tabs: [string, () => HTMLElement][] = [
[TEXT.tabNumber, numberTab],
[TEXT.tabAlign, alignTab],
[TEXT.tabFont, fontTab],
[TEXT.tabBorder, borderTab],
[TEXT.tabFill, fillTab],
[TEXT.tabProtect, protectTab],
];
function showTab(index: number): void {
body.replaceChildren(tabs[index][1]());
tabsBar.querySelectorAll(".ss-fmtdlg__tabbtn").forEach((btn, i) => {
btn.classList.toggle("is-active", i === index);
});
}
tabs.forEach(([label], i) => {
const btn = el("button", {
className: "ss-fmtdlg__tabbtn",
text: label,
attrs: { type: "button" },
});
btn.addEventListener("click", () => showTab(i));
tabsBar.append(btn);
});
const actions = el("div", {
className: "ss-fmtdlg__actions",
children: [
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
createButton({
label: TEXT.ok,
variant: "filled",
onClick: () => {
ctx.dispatch({
종류: "서식",
시트: ctx.selection.시트,
범위: ctx.selection.범위,
바꿀: draft,
});
const isMerged = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
if (mergeWanted !== isMerged) {
ctx.dispatch({
종류: mergeWanted ? "병합" : "병합풀기",
시트: ctx.selection.시트,
범위: range,
});
}
close();
},
}),
],
});
panel.append(actions);
showTab(0);
document.body.append(overlay);
}
@@ -0,0 +1,95 @@
/* =============================================================================
* spreadsheet_format_painter.ts (2단계 · 주인 sub_laptop_1)
* 서식 붓 — 활성 칸의 서식을 묻혀 다른 범위에 바름. 한 번 누르면 한 번만 · 두 번 누르면(sticky)
* 끌 때까지 계속. 서식 찾기는 칸 → 행 → 열 → 기본(0) 차례(`spreadsheet_types.ts` `Cell.서식` 규칙).
*
* 잇는 법(D · sub7 · E 도구 모음) — 도구 모음에 버튼을 두고 `createFormatPainter(ctx)` 하나를 붙임.
* 한 번 누르면 `pick()` → 커서를 붓 모양으로 → 범위를 고르면(mouseup) `paint(range)`.
* 두 번 누르면 `pick()` 뒤 `sticky=true` 로 두고 Esc 나 버튼을 다시 누르면 꺼짐(`active()` 로 표시).
* ========================================================================== */
import type { CellRange, CellStyle, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const EMPTY_STYLE: CellStyle = {};
/** 칸 하나의 실제 서식 — 칸 자기 서식 없으면 행 서식 · 없으면 열 서식 · 없으면 기본(0). */
export function resolveCellStyle(book: Workbook, sheetId: string, r: number, c: number): CellStyle {
const sheet = book.시트.find((s) => s.id === sheetId);
if (!sheet) return EMPTY_STYLE;
const idx = cellStyleIndex(sheet, r, c);
return book.서식[idx] ?? EMPTY_STYLE;
}
function cellStyleIndex(
sheet: {
칸: Record<string, { 서식?: number }>;
열?: Record<string, { 서식?: number }>;
행?: Record<string, { 서식?: number }>;
},
r: number,
c: number,
): number {
const key = a1(r, c);
const cellIdx = sheet.칸[key]?.서식;
if (cellIdx !== undefined) return cellIdx;
const rowIdx = sheet.행?.[String(r + 1)]?.서식;
if (rowIdx !== undefined) return rowIdx;
const colIdx = sheet.열?.[colLetters(c)]?.서식;
if (colIdx !== undefined) return colIdx;
return 0;
}
function colLetters(c: number): string {
let col = c + 1;
let letters = "";
while (col > 0) {
const rem = (col - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
col = Math.floor((col - 1) / 26);
}
return letters;
}
const a1 = (r: number, c: number): string => `${colLetters(c)}${r + 1}`;
export interface FormatPainterHandle extends PartHandle {
/** 지금 활성 칸의 서식을 붓에 묻힘 */
pick(): void;
/** 묻힌 서식이 있으면 범위에 바름 · `sticky` 가 아니면 한 번 쓰고 스스로 끔 */
paint(range: CellRange, sticky?: boolean): void;
active(): boolean;
clear(): void;
}
export function createFormatPainter(ctx: SpreadsheetContext): FormatPainterHandle {
let picked: CellStyle | null = null;
let sticky = false;
return {
root: null,
pick(): void {
const at = ctx.selection.활성;
picked = resolveCellStyle(ctx.book, ctx.selection.시트, at.r, at.c);
},
paint(range: CellRange, keepOn = false): void {
if (!picked) return;
sticky = keepOn;
ctx.dispatch({ 종류: "서식", 시트: ctx.selection.시트, 범위: [range], 바꿀: { ...picked } });
if (!sticky) picked = null;
},
active(): boolean {
return picked !== null;
},
clear(): void {
picked = null;
sticky = false;
},
refresh(): void {
/* 상태 없는 표시만 씀 — 버튼 눌림은 active() 로 E 가 그림 */
},
destroy(): void {
picked = null;
},
};
}
@@ -0,0 +1,99 @@
/* =============================================================================
* spreadsheet_func_help.ts (2단계 · 주인 sub_laptop_1 · 2단계 묶음은 B 가 대신 고침 2026-09-27)
* 함수 자동 완성 목록 · 인자 도움말 — 1단계 함수 48 개(`spreadsheet_types.ts` 머리 「1단계 함수」) +
* B 의 2단계 묶음(`spreadsheet_functions.ts` 의 `FUNC_HELP_BATCH2` · `FUNC_HELP_BATCH3`)을 이어 붙임.
* 이름 · 인자 모양 · 한 줄 뜻. B `spreadsheet_functions.ts`(실제 풀이)와는 따로 — 이 표는 화면 도움말 전용
* 정적 자료라 B 가 아직 없어도 씀. B 가 완성돼도 이름 목록이 갈리지 않게 이 표를 그대로 둠.
*
* 잇는 법(D · sub7 · 편집기) — 식 입력 중 `=` 뒤 낱말을 `suggestFunctions` 로 목록을 보여주고
* `(` 를 친 뒤엔 `funcHelp(name)` 로 인자 도움말 풍선을 씀(지금 몇 번째 인자인지는 D 가 콤마를 세어 넘김).
* ========================================================================== */
import { FUNC_HELP_BATCH2, FUNC_HELP_BATCH3 } from "./spreadsheet_functions";
export interface FuncHelp {
이름: string;
/** 인자 모양 — 대괄호는 생략 가능 */
꼴: string;
뜻: string;
}
const TABLE: FuncHelp[] = [
{ 이름: "SUM", 꼴: "SUM(수1, [수2], …)", 뜻: "수를 더함" },
{ 이름: "PRODUCT", 꼴: "PRODUCT(수1, [수2], …)", 뜻: "수를 곱함" },
{ 이름: "SUMPRODUCT", 꼴: "SUMPRODUCT(범위1, [범위2], …)", 뜻: "같은 자리끼리 곱한 뒤 더함" },
{ 이름: "ROUND", 꼴: "ROUND(수, 자릿수)", 뜻: "반올림" },
{ 이름: "ROUNDUP", 꼴: "ROUNDUP(수, 자릿수)", 뜻: "올림" },
{ 이름: "ROUNDDOWN", 꼴: "ROUNDDOWN(수, 자릿수)", 뜻: "내림" },
{ 이름: "INT", 꼴: "INT(수)", 뜻: "정수로 내림(음수는 작은 쪽)" },
{ 이름: "TRUNC", 꼴: "TRUNC(수, [자릿수])", 뜻: "소수점을 버림(0 쪽으로)" },
{ 이름: "CEILING", 꼴: "CEILING(수, 배수)", 뜻: "배수 단위로 올림" },
{ 이름: "FLOOR", 꼴: "FLOOR(수, 배수)", 뜻: "배수 단위로 내림" },
{ 이름: "ABS", 꼴: "ABS(수)", 뜻: "절댓값" },
{ 이름: "MOD", 꼴: "MOD(수, 나눌수)", 뜻: "나눈 나머지" },
{ 이름: "POWER", 꼴: "POWER(수, 지수)", 뜻: "거듭제곱" },
{ 이름: "SQRT", 꼴: "SQRT(수)", 뜻: "제곱근" },
{ 이름: "PI", 꼴: "PI()", 뜻: "원주율" },
{ 이름: "SIN", 꼴: "SIN(각도값)", 뜻: "사인(라디안)" },
{ 이름: "COS", 꼴: "COS(각도값)", 뜻: "코사인(라디안)" },
{ 이름: "TAN", 꼴: "TAN(각도값)", 뜻: "탄젠트(라디안)" },
{ 이름: "ASIN", 꼴: "ASIN(수)", 뜻: "아크사인(라디안)" },
{ 이름: "ACOS", 꼴: "ACOS(수)", 뜻: "아크코사인(라디안)" },
{ 이름: "ATAN", 꼴: "ATAN(수)", 뜻: "아크탄젠트(라디안)" },
{ 이름: "RADIANS", 꼴: "RADIANS(각도)", 뜻: "도 → 라디안" },
{ 이름: "DEGREES", 꼴: "DEGREES(라디안)", 뜻: "라디안 → 도" },
{ 이름: "MIN", 꼴: "MIN(수1, [수2], …)", 뜻: "가장 작은 수" },
{ 이름: "MAX", 꼴: "MAX(수1, [수2], …)", 뜻: "가장 큰 수" },
{ 이름: "AVERAGE", 꼴: "AVERAGE(수1, [수2], …)", 뜻: "평균" },
{ 이름: "IF", 꼴: "IF(조건, 참일때, [거짓일때])", 뜻: "조건에 따라 값을 고름" },
{ 이름: "AND", 꼴: "AND(조건1, [조건2], …)", 뜻: "모두 참이면 참" },
{ 이름: "OR", 꼴: "OR(조건1, [조건2], …)", 뜻: "하나라도 참이면 참" },
{ 이름: "NOT", 꼴: "NOT(조건)", 뜻: "참 · 거짓을 뒤집음" },
{ 이름: "IFERROR", 꼴: "IFERROR(식, 오류일때)", 뜻: "오류면 대신 값을 씀" },
{ 이름: "CONCATENATE", 꼴: "CONCATENATE(글1, [글2], …)", 뜻: "글을 이어 붙임" },
{ 이름: "FIXED", 꼴: "FIXED(수, [소수자리], [쉼표뺌])", 뜻: "소수 자리를 정해 글로 바꿈" },
{ 이름: "TEXT", 꼴: "TEXT(값, 형식)", 뜻: "형식 코드로 값을 글로 바꿈" },
{ 이름: "LEN", 꼴: "LEN(글)", 뜻: "글자 수" },
{ 이름: "LEFT", 꼴: "LEFT(글, [글자수])", 뜻: "왼쪽부터 글자를 뗌" },
{ 이름: "RIGHT", 꼴: "RIGHT(글, [글자수])", 뜻: "오른쪽부터 글자를 뗌" },
{ 이름: "MID", 꼴: "MID(글, 시작, 글자수)", 뜻: "가운데 글자를 뗌" },
{ 이름: "VALUE", 꼴: "VALUE(글)", 뜻: "숫자 모양 글을 수로 바꿈" },
{
이름: "VLOOKUP",
꼴: "VLOOKUP(찾을값, 표범위, 열번호, [정확히])",
뜻: "표 왼쪽에서 찾아 그 행의 값",
},
{
이름: "HLOOKUP",
꼴: "HLOOKUP(찾을값, 표범위, 행번호, [정확히])",
뜻: "표 위쪽에서 찾아 그 열의 값",
},
{ 이름: "INDEX", 꼴: "INDEX(범위, 행번호, [열번호])", 뜻: "범위 안 자리 값" },
{ 이름: "MATCH", 꼴: "MATCH(찾을값, 범위, [맞춤꼴])", 뜻: "범위 안 자리 번호" },
{ 이름: "CHOOSE", 꼴: "CHOOSE(순번, 값1, [값2], …)", 뜻: "순번에 맞는 값을 고름" },
{ 이름: "COUNT", 꼴: "COUNT(값1, [값2], …)", 뜻: "수가 든 칸 개수" },
{ 이름: "COUNTA", 꼴: "COUNTA(값1, [값2], …)", 뜻: "비어 있지 않은 칸 개수" },
{ 이름: "COUNTIF", 꼴: "COUNTIF(범위, 조건)", 뜻: "조건에 맞는 칸 개수" },
{ 이름: "SUMIF", 꼴: "SUMIF(범위, 조건, [더할범위])", 뜻: "조건에 맞는 칸(또는 짝 칸)의 합" },
...FUNC_HELP_BATCH2,
...FUNC_HELP_BATCH3,
];
const BY_NAME = new Map(TABLE.map((f) => [f.이름, f]));
/** `=` 뒤 지금 치는 낱말(대소문자 안 가림)로 시작하는 함수 이름 — 가나다(알파벳)순. */
export function suggestFunctions(prefix: string): FuncHelp[] {
const p = prefix.toUpperCase();
if (!p) return [];
return TABLE.filter((f) => f.이름.startsWith(p));
}
/** 함수 하나의 도움말(없으면 null) — 이름은 대소문자 안 가림. */
export function funcHelp(name: string): FuncHelp | null {
return BY_NAME.get(name.toUpperCase()) ?? null;
}
/** 아는 함수 전부 개수(1단계 48 + 2단계 묶음 · 시험용) */
export function knownFunctionCount(): number {
return TABLE.length;
}
@@ -0,0 +1,154 @@
/* =============================================================================
* spreadsheet_functions.ts (주인 B)
* 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` ·
* `_stat.ts` 에 두고 여기서 모음. 1단계 약 45 + 2단계 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2` ·
* `FUNC_HELP_BATCH3`(sub1 의 `spreadsheet_func_help.ts` 가 읽어 이름 목록에 둘 다 더할 것 —
* 그 파일은 sub1 몫이라 안 고침).
* 없는 이름 = `#NAME?` · 인자 수가 min · max 밖이면 A 의 eval 이 `#VALUE!` + 까닭(엑셀은 입력을 막음).
* ========================================================================== */
import type { SheetFunction } from "./spreadsheet_types";
import { MATH_FUNCTIONS } from "./spreadsheet_functions_math";
import { MISC_FUNCTIONS } from "./spreadsheet_functions_misc";
import { LOOKUP_FUNCTIONS } from "./spreadsheet_functions_lookup";
import { STAT_FUNCTIONS } from "./spreadsheet_functions_stat";
const TABLE: Record<string, SheetFunction> = {
...MATH_FUNCTIONS,
...MISC_FUNCTIONS,
...LOOKUP_FUNCTIONS,
...STAT_FUNCTIONS,
};
export function getFunction(name: string): SheetFunction | undefined {
return TABLE[name.toUpperCase()];
}
/** 아는 함수 이름 전부(가나다 · 자동 완성은 2단계) */
export function functionNames(): string[] {
return Object.keys(TABLE).sort();
}
/** 2단계 첫 묶음(2026-09-27) — sub1 `spreadsheet_func_help.ts` 의 `TABLE` 에 이 모양대로 더할 것.
* `이름` · `꼴`(인자 모양 · 대괄호 = 생략 가능) · `뜻`(한 줄). */
export interface FuncHelpEntry {
이름: string;
꼴: string;
뜻: string;
}
export const FUNC_HELP_BATCH2: FuncHelpEntry[] = [
{ 이름: "MROUND", 꼴: "MROUND(수, 배수)", 뜻: "배수의 가장 가까운 정수 배로 반올림" },
{ 이름: "ODD", 꼴: "ODD(수)", 뜻: "홀수로 올림(0 에서 먼 쪽)" },
{ 이름: "EVEN", 꼴: "EVEN(수)", 뜻: "짝수로 올림(0 에서 먼 쪽)" },
{ 이름: "LOG", 꼴: "LOG(수, [밑=10])", 뜻: "로그(밑 지정)" },
{ 이름: "LOG10", 꼴: "LOG10(수)", 뜻: "상용로그" },
{ 이름: "LN", 꼴: "LN(수)", 뜻: "자연로그" },
{ 이름: "EXP", 꼴: "EXP(수)", 뜻: "e 의 거듭제곱" },
{
이름: "SUMIFS",
꼴: "SUMIFS(합할범위, 조건범위1, 조건1, [조건범위2, 조건2], …)",
뜻: "여러 조건에 다 맞는 칸의 합",
},
{
이름: "COUNTIFS",
꼴: "COUNTIFS(조건범위1, 조건1, [조건범위2, 조건2], …)",
뜻: "여러 조건에 다 맞는 칸 개수",
},
{
이름: "AVERAGEIF",
꼴: "AVERAGEIF(범위, 조건, [평균범위])",
뜻: "조건에 맞는 칸(또는 짝 칸)의 평균",
},
{
이름: "AVERAGEIFS",
꼴: "AVERAGEIFS(평균범위, 조건범위1, 조건1, …)",
뜻: "여러 조건에 다 맞는 칸의 평균",
},
{
이름: "MAXIFS",
꼴: "MAXIFS(최대범위, 조건범위1, 조건1, …)",
뜻: "여러 조건에 다 맞는 칸의 최댓값",
},
{
이름: "MINIFS",
꼴: "MINIFS(최소범위, 조건범위1, 조건1, …)",
뜻: "여러 조건에 다 맞는 칸의 최솟값",
},
{
이름: "IFS",
꼴: "IFS(조건1, 값1, [조건2, 값2], …)",
뜻: "처음으로 참인 조건의 값",
},
{
이름: "SWITCH",
꼴: "SWITCH(식, 값1, 결과1, [값2, 결과2], …, [기본값])",
뜻: "식과 같은 값을 찾아 그 결과",
},
{
이름: "XLOOKUP",
꼴: "XLOOKUP(찾을값, 찾을범위, 돌려줄범위, [못찾으면], [맞춤꼴], [찾는방향])",
뜻: "찾을범위에서 찾아 돌려줄범위의 같은 자리 값",
},
{
이름: "TEXTJOIN",
꼴: "TEXTJOIN(구분자, 빈칸건너뜀, 글1, [글2], …)",
뜻: "구분자로 여러 글을 이어 붙임",
},
{ 이름: "TRIM", 꼴: "TRIM(글)", 뜻: "앞뒤 · 겹친 빈칸을 뺌" },
{ 이름: "UPPER", 꼴: "UPPER(글)", 뜻: "대문자로" },
{ 이름: "LOWER", 꼴: "LOWER(글)", 뜻: "소문자로" },
{
이름: "SUBSTITUTE",
꼴: "SUBSTITUTE(글, 옛글, 새글, [몇번째])",
뜻: "글 안 옛글을 새글로 바꿈",
},
{ 이름: "REPLACE", 꼴: "REPLACE(글, 시작, 글자수, 새글)", 뜻: "자리로 콕 집어 바꿈" },
{ 이름: "FIND", 꼴: "FIND(찾을글, 안글, [시작])", 뜻: "글자 그대로 찾음(대소문자 가림)" },
{
이름: "SEARCH",
꼴: "SEARCH(찾을글, 안글, [시작])",
뜻: "찾음(대소문자 안 가림 · * ? 자리표시)",
},
{ 이름: "REPT", 꼴: "REPT(글, 번)", 뜻: "글을 번 만큼 되풀이" },
{ 이름: "ISNUMBER", 꼴: "ISNUMBER(값)", 뜻: "수면 참" },
{ 이름: "ISTEXT", 꼴: "ISTEXT(값)", 뜻: "글이면 참" },
{ 이름: "ISBLANK", 꼴: "ISBLANK(값)", 뜻: "빈 칸이면 참" },
{ 이름: "ISERROR", 꼴: "ISERROR(값)", 뜻: "오류 값이면 참" },
{ 이름: "NA", 꼴: "NA()", 뜻: "#N/A 오류를 만듦" },
{ 이름: "TODAY", 꼴: "TODAY()", 뜻: "오늘 날짜(일련번호)" },
{ 이름: "DATE", 꼴: "DATE(년, 월, 일)", 뜻: "날짜 일련번호를 만듦" },
{ 이름: "YEAR", 꼴: "YEAR(일련번호)", 뜻: "연도" },
{ 이름: "MONTH", 꼴: "MONTH(일련번호)", 뜻: "월" },
{ 이름: "DAY", 꼴: "DAY(일련번호)", 뜻: "일" },
];
/** 2단계 셋째 묶음(2026-09-27) — 위 `FUNC_HELP_BATCH2` 와 같은 모양 · 같이 sub1 표에 더할 것. */
export const FUNC_HELP_BATCH3: FuncHelpEntry[] = [
{ 이름: "MEDIAN", 꼴: "MEDIAN(수1, [수2], …)", 뜻: "가운데 값" },
{ 이름: "MODE", 꼴: "MODE(수1, [수2], …)", 뜻: "가장 자주 나오는 값" },
{ 이름: "STDEV", 꼴: "STDEV(수1, [수2], …)", 뜻: "표본 표준편차" },
{ 이름: "STDEV.S", 꼴: "STDEV.S(수1, [수2], …)", 뜻: "표본 표준편차" },
{ 이름: "STDEV.P", 꼴: "STDEV.P(수1, [수2], …)", 뜻: "모집단 표준편차" },
{ 이름: "VAR", 꼴: "VAR(수1, [수2], …)", 뜻: "표본 분산" },
{ 이름: "RANK", 꼴: "RANK(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" },
{ 이름: "RANK.EQ", 꼴: "RANK.EQ(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" },
{ 이름: "LARGE", 꼴: "LARGE(범위, k)", 뜻: "k 번째로 큰 값" },
{ 이름: "SMALL", 꼴: "SMALL(범위, k)", 뜻: "k 번째로 작은 값" },
{ 이름: "PERCENTILE", 꼴: "PERCENTILE(범위, k)", 뜻: "k(0~1) 자리 백분위 값" },
{ 이름: "QUARTILE", 꼴: "QUARTILE(범위, 사분위수)", 뜻: "사분위수(0~4)" },
{ 이름: "COUNTBLANK", 꼴: "COUNTBLANK(범위)", 뜻: "빈 칸 개수" },
{ 이름: "NOW", 꼴: "NOW()", 뜻: "지금 날짜 · 시각(일련번호)" },
{ 이름: "WEEKDAY", 꼴: "WEEKDAY(일련번호, [유형=1])", 뜻: "요일 번호" },
{ 이름: "EDATE", 꼴: "EDATE(시작일, 개월수)", 뜻: "개월 뒤(앞) 같은 날" },
{ 이름: "EOMONTH", 꼴: "EOMONTH(시작일, 개월수)", 뜻: "개월 뒤(앞) 달의 말일" },
{ 이름: "DATEDIF", 꼴: "DATEDIF(시작일, 종료일, 단위)", 뜻: "두 날짜 차(단위 Y·M·D·YM·MD·YD)" },
{
이름: "NETWORKDAYS",
꼴: "NETWORKDAYS(시작일, 종료일, [휴일범위])",
뜻: "평일 일수(토·일 · 휴일 뺌)",
},
{ 이름: "HOUR", 꼴: "HOUR(일련번호)", 뜻: "시" },
{ 이름: "MINUTE", 꼴: "MINUTE(일련번호)", 뜻: "분" },
{ 이름: "TIME", 꼴: "TIME(시, 분, 초)", 뜻: "시각 일련번호를 만듦" },
];
@@ -0,0 +1,536 @@
/* =============================================================================
* spreadsheet_functions_lookup.ts (주인 B · misc 700 줄 넘어 2026-09-27 쪼갬)
* 찾기 · 조건부 세기·합·평균·최대·최소 함수. `spreadsheet_functions_misc.ts` 에서
* VLOOKUP · HLOOKUP · INDEX · MATCH · CHOOSE · COUNT · COUNTA · COUNTIF · SUMIF ·
* SUMIFS · COUNTIFS · AVERAGEIF(S) · MAXIFS · MINIFS · IFS · SWITCH · XLOOKUP 를 옮겨 옴.
* 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
* ========================================================================== */
import {
ZERO,
add,
cmp,
div,
frac,
parseDecimal,
type Frac,
} from "@ui/sheet/ui_template_sheet_frac";
import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types";
import {
bool,
collectNumbers,
fracToDouble,
isErr,
isFrac,
isRange,
mkErr,
num,
toNumber,
toScalar,
} from "./spreadsheet_functions_math";
const ONE = frac(1n);
// ── 값 견줌(찾기 · 세기 함수 공용) ───────────────────────────────────────────
function typeRank(v: Scalar): number {
if (v === null) return -1;
if (isFrac(v)) return 0;
if (typeof v === "string") return 1;
if (typeof v === "boolean") return 2;
return 3;
}
function compareScalars(a: Scalar, b: Scalar): number {
if (isFrac(a) && isFrac(b)) return cmp(a, b);
if (typeof a === "string" && typeof b === "string") {
const la = a.toUpperCase();
const lb = b.toUpperCase();
return la < lb ? -1 : la > lb ? 1 : 0;
}
if (typeof a === "boolean" && typeof b === "boolean") return a === b ? 0 : a ? 1 : -1;
return 0;
}
function equalScalars(a: Scalar, b: Scalar): boolean {
return typeRank(a) === typeRank(b) && compareScalars(a, b) === 0;
}
function wrapScalar(v: Scalar): RangeValue {
return { kind: "range", rows: 1, cols: 1, at: () => v };
}
// ── 조건 글(`>10` · `<=5` · `사과*`) ─────────────────────────────────────────
function wildcardToRegex(text: string): RegExp {
const escaped = text
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${escaped}$`, "i");
}
function parseCriteria(crit: Scalar): (cell: Scalar) => boolean {
if (crit === null) return (cell) => cell === null;
if (typeof crit === "boolean") return (cell) => typeof cell === "boolean" && cell === crit;
if (isFrac(crit)) return (cell) => isFrac(cell) && cmp(cell, crit) === 0;
if (isErr(crit)) return () => false;
const m = /^(<=|>=|<>|<|>|=)?(.*)$/.exec(crit);
const op = m?.[1] ?? "";
const rest = m?.[2] ?? crit;
let numRest: Frac | null = null;
try {
numRest = rest.trim() === "" ? null : parseDecimal(rest);
} catch {
numRest = null;
}
if (numRest !== null) {
const nr = numRest;
return (cell) => {
if (!isFrac(cell)) return false;
const c = cmp(cell, nr);
if (op === "<=") return c <= 0;
if (op === ">=") return c >= 0;
if (op === "<>") return c !== 0;
if (op === "<") return c < 0;
if (op === ">") return c > 0;
return c === 0;
};
}
const pat = wildcardToRegex(rest);
if (op === "<>") return (cell) => !(typeof cell === "string" && pat.test(cell));
return (cell) => typeof cell === "string" && pat.test(cell);
}
/** `args[from]` 부터 (범위,조건) 짝을 이어가며 모두 맞는 자리만 참 — *IFS 함수 공용.
* `shape` 범위와 크기가 다른 조건 범위가 있으면 `#VALUE!`. */
function criteriaMask(args: LazyArg[], from: number, shape: RangeValue): boolean[] | ErrorValue {
const mask = new Array<boolean>(shape.rows * shape.cols).fill(true);
for (let i = from; i + 1 < args.length; i += 2) {
const rangeArg = args[i]();
if (!isRange(rangeArg)) return mkErr("#VALUE!");
if (rangeArg.rows !== shape.rows || rangeArg.cols !== shape.cols) {
return mkErr("#VALUE!", "조건 범위 크기가 다름");
}
const crit = toScalar(args[i + 1]());
if (isErr(crit)) return crit;
const pred = parseCriteria(crit);
let k = 0;
for (let r = 0; r < shape.rows; r++) {
for (let c = 0; c < shape.cols; c++) {
if (mask[k] && !pred(rangeArg.at(r, c))) mask[k] = false;
k++;
}
}
}
return mask;
}
// ── 함수 표 ──────────────────────────────────────────────────────────────
export const LOOKUP_FUNCTIONS: Record<string, SheetFunction> = {
VLOOKUP: {
min: 3,
max: 4,
call: (args) => {
const lookup = toScalar(args[0]());
if (isErr(lookup)) return lookup;
const table = args[1]();
if (!isRange(table)) return mkErr("#VALUE!");
const colN = num(args[2]());
if (isErr(colN)) return colN;
const col = Math.trunc(fracToDouble(colN)) - 1;
if (col < 0 || col >= table.cols) return mkErr("#REF!");
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
if (isErr(approxRaw)) return approxRaw;
if (approxRaw) {
let best = -1;
for (let r = 0; r < table.rows; r++) {
const key = table.at(r, 0);
if (typeRank(key) !== typeRank(lookup)) {
if (best >= 0) break;
continue;
}
if (compareScalars(key, lookup) <= 0) best = r;
else break;
}
if (best < 0) return mkErr("#N/A");
return table.at(best, col);
}
for (let r = 0; r < table.rows; r++) {
if (equalScalars(table.at(r, 0), lookup)) return table.at(r, col);
}
return mkErr("#N/A");
},
},
HLOOKUP: {
min: 3,
max: 4,
call: (args) => {
const lookup = toScalar(args[0]());
if (isErr(lookup)) return lookup;
const table = args[1]();
if (!isRange(table)) return mkErr("#VALUE!");
const rowN = num(args[2]());
if (isErr(rowN)) return rowN;
const row = Math.trunc(fracToDouble(rowN)) - 1;
if (row < 0 || row >= table.rows) return mkErr("#REF!");
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
if (isErr(approxRaw)) return approxRaw;
if (approxRaw) {
let best = -1;
for (let c = 0; c < table.cols; c++) {
const key = table.at(0, c);
if (typeRank(key) !== typeRank(lookup)) {
if (best >= 0) break;
continue;
}
if (compareScalars(key, lookup) <= 0) best = c;
else break;
}
if (best < 0) return mkErr("#N/A");
return table.at(row, best);
}
for (let c = 0; c < table.cols; c++) {
if (equalScalars(table.at(0, c), lookup)) return table.at(row, c);
}
return mkErr("#N/A");
},
},
INDEX: {
min: 2,
max: 3,
call: (args) => {
const rangeArg = args[0]();
if (isErr(rangeArg)) return rangeArg;
const range = isRange(rangeArg) ? rangeArg : wrapScalar(rangeArg);
const rowN = num(args[1]());
if (isErr(rowN)) return rowN;
const row = Math.trunc(fracToDouble(rowN));
const colN = args.length > 2 ? num(args[2]()) : frac(range.cols === 1 ? 1n : 0n);
if (isErr(colN)) return colN;
const col = Math.trunc(fracToDouble(colN));
if (row === 0 && col === 0) return mkErr("#VALUE!");
if (row === 0) {
if (col < 1 || col > range.cols) return mkErr("#REF!");
const c = col - 1;
return { kind: "range", rows: range.rows, cols: 1, at: (r: number) => range.at(r, c) };
}
if (col === 0) {
if (row < 1 || row > range.rows) return mkErr("#REF!");
const r = row - 1;
return {
kind: "range",
rows: 1,
cols: range.cols,
at: (_r: number, c: number) => range.at(r, c),
};
}
if (row < 1 || row > range.rows || col < 1 || col > range.cols) return mkErr("#REF!");
return range.at(row - 1, col - 1);
},
},
MATCH: {
min: 2,
max: 3,
call: (args) => {
const lookup = toScalar(args[0]());
if (isErr(lookup)) return lookup;
const rangeArg = args[1]();
if (!isRange(rangeArg)) return mkErr("#VALUE!");
if (rangeArg.rows !== 1 && rangeArg.cols !== 1) {
return mkErr("#VALUE!", "한 줄 · 한 칸짜리 범위만");
}
const n = rangeArg.rows === 1 ? rangeArg.cols : rangeArg.rows;
const at = (i: number): Scalar =>
rangeArg.rows === 1 ? rangeArg.at(0, i) : rangeArg.at(i, 0);
const modeN = args.length > 2 ? num(args[2]()) : ONE;
if (isErr(modeN)) return modeN;
const mode = Math.trunc(fracToDouble(modeN));
if (mode === 0) {
for (let i = 0; i < n; i++) if (equalScalars(at(i), lookup)) return frac(BigInt(i + 1));
return mkErr("#N/A");
}
let best = -1;
for (let i = 0; i < n; i++) {
const key = at(i);
if (typeRank(key) !== typeRank(lookup)) {
if (best >= 0) break;
continue;
}
const c = compareScalars(key, lookup);
if (mode > 0 ? c <= 0 : c >= 0) best = i;
else break;
}
if (best < 0) return mkErr("#N/A");
return frac(BigInt(best + 1));
},
},
CHOOSE: {
min: 2,
call: (args) => {
const idxN = num(args[0]());
if (isErr(idxN)) return idxN;
const idx = Math.trunc(fracToDouble(idxN));
if (idx < 1 || idx > args.length - 1) return mkErr("#VALUE!");
return args[idx]();
},
},
COUNT: {
min: 0,
call: (args) => {
const values = collectNumbers(args, false, false);
return isErr(values) ? values : frac(BigInt(values.length));
},
},
COUNTA: {
min: 0,
call: (args) => {
let count = 0;
for (const a of args) {
const v = a();
if (isRange(v)) {
for (let r = 0; r < v.rows; r++) {
for (let c = 0; c < v.cols; c++) if (v.at(r, c) !== null) count++;
}
} else if (v !== null) {
count++;
}
}
return frac(BigInt(count));
},
},
COUNTIF: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const crit = toScalar(args[1]());
if (isErr(crit)) return crit;
const pred = parseCriteria(crit);
let count = 0;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) if (pred(range.at(r, c))) count++;
}
return frac(BigInt(count));
},
},
SUMIF: {
min: 2,
max: 3,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const crit = toScalar(args[1]());
if (isErr(crit)) return crit;
const pred = parseCriteria(crit);
const sumRange = args.length > 2 ? args[2]() : range;
if (!isRange(sumRange)) return mkErr("#VALUE!");
if (sumRange.rows !== range.rows || sumRange.cols !== range.cols) {
return mkErr("#VALUE!", "합 범위 크기가 다름");
}
let total = ZERO;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) {
if (!pred(range.at(r, c))) continue;
const n = toNumber(sumRange.at(r, c));
if (isErr(n)) return n;
total = add(total, n);
}
}
return total;
},
},
SUMIFS: {
min: 3,
call: (args) => {
const sumRange = args[0]();
if (!isRange(sumRange)) return mkErr("#VALUE!");
const mask = criteriaMask(args, 1, sumRange);
if (isErr(mask)) return mask;
let total = ZERO;
let k = 0;
for (let r = 0; r < sumRange.rows; r++) {
for (let c = 0; c < sumRange.cols; c++) {
if (mask[k]) {
const n = toNumber(sumRange.at(r, c));
if (isErr(n)) return n;
total = add(total, n);
}
k++;
}
}
return total;
},
},
COUNTIFS: {
min: 2,
call: (args) => {
const first = args[0]();
if (!isRange(first)) return mkErr("#VALUE!");
const mask = criteriaMask(args, 0, first);
if (isErr(mask)) return mask;
return frac(BigInt(mask.filter(Boolean).length));
},
},
AVERAGEIF: {
min: 2,
max: 3,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const crit = toScalar(args[1]());
if (isErr(crit)) return crit;
const pred = parseCriteria(crit);
const avgRange = args.length > 2 ? args[2]() : range;
if (!isRange(avgRange)) return mkErr("#VALUE!");
if (avgRange.rows !== range.rows || avgRange.cols !== range.cols) {
return mkErr("#VALUE!", "평균 범위 크기가 다름");
}
let total = ZERO;
let count = 0;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) {
if (!pred(range.at(r, c))) continue;
const n = toNumber(avgRange.at(r, c));
if (isErr(n)) return n;
total = add(total, n);
count++;
}
}
return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count)));
},
},
AVERAGEIFS: {
min: 3,
call: (args) => {
const avgRange = args[0]();
if (!isRange(avgRange)) return mkErr("#VALUE!");
const mask = criteriaMask(args, 1, avgRange);
if (isErr(mask)) return mask;
let total = ZERO;
let count = 0;
let k = 0;
for (let r = 0; r < avgRange.rows; r++) {
for (let c = 0; c < avgRange.cols; c++) {
if (mask[k]) {
const n = toNumber(avgRange.at(r, c));
if (isErr(n)) return n;
total = add(total, n);
count++;
}
k++;
}
}
return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count)));
},
},
MAXIFS: {
min: 3,
call: (args) => {
const targetRange = args[0]();
if (!isRange(targetRange)) return mkErr("#VALUE!");
const mask = criteriaMask(args, 1, targetRange);
if (isErr(mask)) return mask;
let best: Frac | null = null;
let k = 0;
for (let r = 0; r < targetRange.rows; r++) {
for (let c = 0; c < targetRange.cols; c++) {
if (mask[k]) {
const cell = targetRange.at(r, c);
if (isFrac(cell) && (best === null || cmp(cell, best) > 0)) best = cell;
}
k++;
}
}
return best ?? ZERO;
},
},
MINIFS: {
min: 3,
call: (args) => {
const targetRange = args[0]();
if (!isRange(targetRange)) return mkErr("#VALUE!");
const mask = criteriaMask(args, 1, targetRange);
if (isErr(mask)) return mask;
let best: Frac | null = null;
let k = 0;
for (let r = 0; r < targetRange.rows; r++) {
for (let c = 0; c < targetRange.cols; c++) {
if (mask[k]) {
const cell = targetRange.at(r, c);
if (isFrac(cell) && (best === null || cmp(cell, best) < 0)) best = cell;
}
k++;
}
}
return best ?? ZERO;
},
},
IFS: {
min: 2,
call: (args) => {
for (let i = 0; i + 1 < args.length; i += 2) {
const c = bool(args[i]());
if (isErr(c)) return c;
if (c) return args[i + 1]();
}
return mkErr("#N/A");
},
},
SWITCH: {
min: 3,
call: (args) => {
const target = toScalar(args[0]());
if (isErr(target)) return target;
let i = 1;
for (; i + 1 < args.length; i += 2) {
const cand = toScalar(args[i]());
if (isErr(cand)) return cand;
if (equalScalars(target, cand)) return args[i + 1]();
}
return i < args.length ? args[i]() : mkErr("#N/A");
},
},
XLOOKUP: {
min: 3,
max: 6,
call: (args) => {
const lookup = toScalar(args[0]());
if (isErr(lookup)) return lookup;
const lookupRange = args[1]();
const returnRange = args[2]();
if (!isRange(lookupRange) || !isRange(returnRange)) return mkErr("#VALUE!");
const n = lookupRange.rows * lookupRange.cols;
if (n !== returnRange.rows * returnRange.cols) {
return mkErr("#VALUE!", "찾을 범위 · 돌려줄 범위 크기가 다름");
}
const at = (rv: RangeValue, i: number): Scalar => (rv.rows === 1 ? rv.at(0, i) : rv.at(i, 0));
const modeN = args.length > 4 ? num(args[4]()) : ZERO;
if (isErr(modeN)) return modeN;
const mode = Math.trunc(fracToDouble(modeN));
const searchN = args.length > 5 ? num(args[5]()) : ONE;
if (isErr(searchN)) return searchN;
const reverse = Math.trunc(fracToDouble(searchN)) < 0;
let best = -1;
for (let step = 0; step < n; step++) {
const i = reverse ? n - 1 - step : step;
const key = at(lookupRange, i);
if (typeRank(key) !== typeRank(lookup)) continue;
const c = compareScalars(key, lookup);
if (c === 0) {
best = i;
break;
}
if (mode === 1 && c > 0) {
if (best < 0 || compareScalars(key, at(lookupRange, best)) < 0) best = i;
} else if (mode === -1 && c < 0) {
if (best < 0 || compareScalars(key, at(lookupRange, best)) > 0) best = i;
}
}
if (best < 0) return args.length > 3 ? args[3]() : mkErr("#N/A");
return at(returnRange, best);
},
},
};
@@ -0,0 +1,490 @@
/* =============================================================================
* spreadsheet_functions_math.ts (주인 B)
* 수학 · 삼각 · 끝수 함수 + 함수 표 전체가 쓰는 형 바꾸기 도우미(공용은 여기서 export).
* 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만 씀 · 속에 안 넣음).
* 무리수(SQRT · 삼각 · PI · 소수 거듭제곱)는 double → 유효 15 자리 십진 → 분수.
* ========================================================================== */
import {
ZERO,
add,
cmp,
div,
frac,
mul,
parseDecimal,
roundAt,
sub,
toInteger,
type Frac,
} from "@ui/sheet/ui_template_sheet_frac";
import type {
ErrorCode,
ErrorValue,
EvalResult,
LazyArg,
RangeValue,
Scalar,
SheetFunction,
} from "./spreadsheet_types";
const ONE = frac(1n);
// ── 공용 도우미(math · misc 둘 다 씀) ────────────────────────────────────────
export function mkErr(code: ErrorCode, why?: string): ErrorValue {
return why === undefined ? { error: code } : { error: code, why };
}
export function isErr(v: unknown): v is ErrorValue {
return typeof v === "object" && v !== null && "error" in (v as Record<string, unknown>);
}
export function isFrac(v: unknown): v is Frac {
return typeof v === "object" && v !== null && "n" in (v as Record<string, unknown>);
}
export function isRange(v: EvalResult): v is RangeValue {
return typeof v === "object" && v !== null && (v as RangeValue).kind === "range";
}
/** 범위면 1×1 일 때만 칸 하나로 · 아니면 `#VALUE!` */
export function toScalar(v: EvalResult): Scalar | ErrorValue {
if (!isRange(v)) return v;
if (v.rows === 1 && v.cols === 1) return v.at(0, 0);
return mkErr("#VALUE!", "범위를 칸 하나 자리에 씀");
}
/** 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` */
export function toNumber(v: Scalar): Frac | ErrorValue {
if (v === null) return ZERO;
if (isErr(v)) return v;
if (typeof v === "boolean") return v ? ONE : ZERO;
if (typeof v === "string") {
try {
return parseDecimal(v);
} catch {
return mkErr("#VALUE!", `글을 수로 못 읽음: ${v}`);
}
}
return v;
}
/** 인자 하나(범위는 1×1 만) → 수 */
export function num(v: EvalResult): Frac | ErrorValue {
const s = toScalar(v);
return isErr(s) ? s : toNumber(s);
}
/** 글로 — 수는 일반 형식(간단 십진) · 참거짓 TRUE/FALSE */
export function toText(v: Scalar): string | ErrorValue {
if (v === null) return "";
if (isErr(v)) return v;
if (typeof v === "string") return v;
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
return generalNumberText(v);
}
export function str(v: EvalResult): string | ErrorValue {
const s = toScalar(v);
return isErr(s) ? s : toText(s);
}
export function toBool(v: Scalar): boolean | ErrorValue {
if (v === null) return false;
if (isErr(v)) return v;
if (typeof v === "boolean") return v;
if (typeof v === "string") {
const up = v.trim().toUpperCase();
if (up === "TRUE") return true;
if (up === "FALSE") return false;
return mkErr("#VALUE!", `글을 참거짓으로 못 읽음: ${v}`);
}
return cmp(v, ZERO) !== 0;
}
export function bool(v: EvalResult): boolean | ErrorValue {
const s = toScalar(v);
return isErr(s) ? s : toBool(s);
}
/** double → 유효 15 자리 십진 → 분수 · NaN · 무한은 `#NUM!` */
export function fromDouble(x: number): Frac | ErrorValue {
if (!Number.isFinite(x)) return mkErr("#NUM!");
if (x === 0) return ZERO;
return parseDecimal(x.toPrecision(15));
}
export function fracToDouble(x: Frac): number {
return Number(x.n) / Number(x.d);
}
/** 엑셀 「일반」 형식과 같은 십진 글(수식 `&` · TEXT 안 형식 자리가 없을 때) */
export function generalNumberText(x: Frac): string {
const scaled = toInteger(mul(x, frac(10n ** 15n)), "round");
const negative = scaled < 0n;
const digits = (negative ? -scaled : scaled).toString().padStart(16, "0");
const whole = digits.slice(0, -15).replace(/^0+(?=\d)/, "");
const fraction = digits.slice(-15).replace(/0+$/, "");
const body = fraction ? `${whole}.${fraction}` : whole;
return negative && body !== "0" ? `-${body}` : body;
}
/** 인자 목록에서 수만 모음 — 범위는 수 아닌 값 조용히 건너뜀 · 낱값은 형 바꿈(실패시 strict 면 오류)
* `propagateErrors` = 범위 · 낱값 오류를 그대로 돌려줄지(COUNT 는 false) */
export function collectNumbers(
args: LazyArg[],
propagateErrors: boolean,
strictScalar: boolean,
): Frac[] | ErrorValue {
const out: Frac[] = [];
for (const a of args) {
const v = a();
if (isRange(v)) {
for (let r = 0; r < v.rows; r++) {
for (let c = 0; c < v.cols; c++) {
const cell = v.at(r, c);
if (isErr(cell)) {
if (propagateErrors) return cell;
continue;
}
if (isFrac(cell)) out.push(cell);
}
}
} else if (isErr(v)) {
if (propagateErrors) return v;
} else if (v !== null) {
const n = toNumber(v);
if (isErr(n)) {
if (strictScalar) return n;
} else {
out.push(n);
}
}
}
return out;
}
function digitsArg(args: LazyArg[], index: number, fallback: number): number | ErrorValue {
if (index >= args.length) return fallback;
const n = num(args[index]());
return isErr(n) ? n : Math.trunc(fracToDouble(n));
}
function unaryDouble(fn: (x: number) => number): SheetFunction {
return {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
return fromDouble(fn(fracToDouble(n)));
},
};
}
// ── 함수 표 ──────────────────────────────────────────────────────────────
export const MATH_FUNCTIONS: Record<string, SheetFunction> = {
SUM: {
min: 0,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
return values.reduce(add, ZERO);
},
},
PRODUCT: {
min: 0,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
return values.length === 0 ? ZERO : values.reduce(mul, ONE);
},
},
SUMPRODUCT: {
min: 1,
call: (args) => {
const arrays = args.map((a) => a());
const grids = arrays.map((v) =>
isRange(v) ? v : { rows: 1, cols: 1, at: () => (isErr(v) ? v : (v as Scalar)) },
);
const rows = grids[0].rows;
const cols = grids[0].cols;
for (const g of grids) {
if (g.rows !== rows || g.cols !== cols) return mkErr("#VALUE!", "배열 크기가 다름");
}
let sum = ZERO;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
let prod = ONE;
for (const g of grids) {
const n = toNumber(g.at(r, c));
if (isErr(n)) return n;
prod = mul(prod, n);
}
sum = add(sum, prod);
}
}
return sum;
},
},
ROUND: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const d = digitsArg(args, 1, 0);
if (isErr(d)) return d;
return roundAt(n, d, "round");
},
},
ROUNDUP: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const d = digitsArg(args, 1, 0);
if (isErr(d)) return d;
return roundAt(n, d, "away");
},
},
ROUNDDOWN: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const d = digitsArg(args, 1, 0);
if (isErr(d)) return d;
return roundAt(n, d, "trunc");
},
},
INT: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
return frac(toInteger(n, "floor"));
},
},
TRUNC: {
min: 1,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const d = digitsArg(args, 1, 0);
if (isErr(d)) return d;
return roundAt(n, d, "trunc");
},
},
CEILING: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const sig = num(args[1]());
if (isErr(sig)) return sig;
if (cmp(sig, ZERO) === 0) return ZERO;
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(sig, ZERO) > 0) {
return mkErr("#NUM!", "수 · 기준의 부호가 다름");
}
return mul(frac(toInteger(div(n, sig), "away")), sig);
},
},
FLOOR: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const sig = num(args[1]());
if (isErr(sig)) return sig;
if (cmp(sig, ZERO) === 0) return ZERO;
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(sig, ZERO) > 0) {
return mkErr("#NUM!", "수 · 기준의 부호가 다름");
}
return mul(frac(toInteger(div(n, sig), "trunc")), sig);
},
},
ABS: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
return frac(n.n < 0n ? -n.n : n.n, n.d);
},
},
MOD: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const d = num(args[1]());
if (isErr(d)) return d;
if (cmp(d, ZERO) === 0) return mkErr("#DIV/0!");
const q = frac(toInteger(div(n, d), "floor"));
return sub(n, mul(d, q));
},
},
POWER: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const p = num(args[1]());
if (isErr(p)) return p;
if (p.d === 1n) {
const exp = p.n;
if (exp >= 0n) return frac(n.n ** exp, n.d ** exp);
if (n.n === 0n) return mkErr("#DIV/0!");
return frac(n.d ** -exp, n.n ** -exp);
}
return fromDouble(Math.pow(fracToDouble(n), fracToDouble(p)));
},
},
SQRT: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
if (cmp(n, ZERO) < 0) return mkErr("#NUM!");
return fromDouble(Math.sqrt(fracToDouble(n)));
},
},
PI: { min: 0, max: 0, call: () => fromDouble(Math.PI) },
SIN: unaryDouble(Math.sin),
COS: unaryDouble(Math.cos),
TAN: unaryDouble(Math.tan),
ASIN: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const x = fracToDouble(n);
if (x < -1 || x > 1) return mkErr("#NUM!");
return fromDouble(Math.asin(x));
},
},
ACOS: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const x = fracToDouble(n);
if (x < -1 || x > 1) return mkErr("#NUM!");
return fromDouble(Math.acos(x));
},
},
ATAN: unaryDouble(Math.atan),
RADIANS: unaryDouble((x) => (x * Math.PI) / 180),
DEGREES: unaryDouble((x) => (x * 180) / Math.PI),
MIN: {
min: 0,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
return values.length === 0 ? ZERO : values.reduce((a, b) => (cmp(a, b) <= 0 ? a : b));
},
},
MAX: {
min: 0,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
return values.length === 0 ? ZERO : values.reduce((a, b) => (cmp(a, b) >= 0 ? a : b));
},
},
AVERAGE: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
if (values.length === 0) return mkErr("#DIV/0!");
return div(values.reduce(add, ZERO), frac(BigInt(values.length)));
},
},
MROUND: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const m = num(args[1]());
if (isErr(m)) return m;
if (cmp(m, ZERO) === 0) return ZERO;
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(m, ZERO) > 0) {
return mkErr("#NUM!", "수 · 배수의 부호가 다름");
}
return mul(frac(toInteger(div(n, m), "round")), m);
},
},
ODD: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
let v = toInteger(n, "away");
if (v === 0n) v = 1n;
else if (v % 2n === 0n) v += v < 0n ? -1n : 1n;
return frac(v);
},
},
EVEN: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
let v = toInteger(n, "away");
if (v % 2n !== 0n) v += v < 0n ? -1n : 1n;
return frac(v);
},
},
LOG: {
min: 1,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
const base = args.length > 1 ? num(args[1]()) : frac(10n);
if (isErr(base)) return base;
if (cmp(base, ZERO) <= 0 || cmp(base, ONE) === 0) return mkErr("#NUM!");
return fromDouble(Math.log(fracToDouble(n)) / Math.log(fracToDouble(base)));
},
},
LOG10: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
return fromDouble(Math.log10(fracToDouble(n)));
},
},
LN: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
return fromDouble(Math.log(fracToDouble(n)));
},
},
EXP: unaryDouble(Math.exp),
};
@@ -0,0 +1,656 @@
/* =============================================================================
* spreadsheet_functions_misc.ts (주인 B)
* 논리 · 글 · 정보 · 날짜 함수. 찾기 · 조건부 세기/합/평균/최대/최소는
* `spreadsheet_functions_lookup.ts` 로 옮김(700 줄 규칙 · 2026-09-27).
* 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
* ========================================================================== */
import {
ZERO,
cmp,
frac,
mul,
parseDecimal,
roundAt,
toInteger,
type Frac,
} from "@ui/sheet/ui_template_sheet_frac";
import type { ErrorValue, LazyArg, SheetFunction } from "./spreadsheet_types";
import { formatValue } from "./spreadsheet_numfmt";
import {
bool,
fracToDouble,
fromDouble,
isErr,
isFrac,
isRange,
mkErr,
num,
str,
toBool,
toScalar,
toText,
} from "./spreadsheet_functions_math";
const ONE = frac(1n);
/** SEARCH — 통짜 맞춤이 아니라 안 어디서든 찾음(자리 없이 그대로 씀). */
function wildcardToSearchRegex(text: string): RegExp {
const escaped = text
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(escaped, "i");
}
// ── 숫자 → 고정 소수 글(FIXED) ───────────────────────────────────────────────
function groupThousands(digits: string): string {
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function formatFixed(n: Frac, decimals: number, commas: boolean): string {
const d = Math.max(0, decimals);
const rounded = roundAt(n, d, "round");
const negative = cmp(rounded, ZERO) < 0;
const magnitude = negative ? frac(-rounded.n, rounded.d) : rounded;
const scaled = toInteger(mul(magnitude, frac(10n ** BigInt(d))), "round");
const digits = scaled.toString().padStart(d + 1, "0");
let whole = d > 0 ? digits.slice(0, -d) : digits;
const fraction = d > 0 ? digits.slice(-d) : "";
if (commas) whole = groupThousands(whole);
const body = fraction ? `${whole}.${fraction}` : whole;
return negative ? `-${body}` : body;
}
function collectBools(args: LazyArg[]): boolean[] | ErrorValue {
const out: boolean[] = [];
for (const a of args) {
const v = a();
if (isRange(v)) {
for (let r = 0; r < v.rows; r++) {
for (let c = 0; c < v.cols; c++) {
const cell = v.at(r, c);
if (isErr(cell)) return cell;
if (typeof cell === "boolean") out.push(cell);
else if (isFrac(cell)) out.push(cmp(cell, ZERO) !== 0);
}
}
} else if (isErr(v)) {
return v;
} else if (v !== null) {
const b = toBool(v);
if (isErr(b)) return b;
out.push(b);
}
}
return out;
}
// ── 날짜 일련번호(엑셀 1900 체계 · 1970-01-01 = 25569) ───────────────────────
// 1900 을 윤년 취급하는 옛 버그는 그대로 두지 않음(1900 년대 자체를 실무가 안 씀) —
// 유닉스 자정 기준으로 바로 셈해 1900 년 2 월 안쪽만 어긋남(1단계 밖).
const MS_PER_DAY = 86400000;
const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569;
function serialToDate(serial: number): Date {
return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY));
}
function dateToSerial(d: Date): number {
return d.getTime() / MS_PER_DAY + UNIX_EPOCH_AS_EXCEL_SERIAL;
}
/** DATEDIF 낱자리(Y·M·D·YM·MD·YD) — 끝이 처음보다 이르면 `#NUM!` */
function dateDiff(startSerial: number, endSerial: number, unit: string): number | ErrorValue {
const start = serialToDate(startSerial);
const end = serialToDate(endSerial);
if (end.getTime() < start.getTime()) return mkErr("#NUM!");
const u = unit.toUpperCase();
const sy = start.getUTCFullYear();
const sm = start.getUTCMonth();
const sd = start.getUTCDate();
const ey = end.getUTCFullYear();
const em = end.getUTCMonth();
const ed = end.getUTCDate();
if (u === "Y") {
let y = ey - sy;
if (em < sm || (em === sm && ed < sd)) y--;
return y;
}
if (u === "M") {
let m = (ey - sy) * 12 + (em - sm);
if (ed < sd) m--;
return m;
}
if (u === "D") return Math.round((end.getTime() - start.getTime()) / MS_PER_DAY);
if (u === "YM") {
let m = em - sm;
if (ed < sd) m--;
return ((m % 12) + 12) % 12;
}
if (u === "MD") {
let d = ed - sd;
if (d < 0) d += new Date(Date.UTC(ey, em, 0)).getUTCDate();
return d;
}
if (u === "YD") {
let days = Math.round((end.getTime() - Date.UTC(ey, sm, sd)) / MS_PER_DAY);
if (days < 0) days = Math.round((end.getTime() - Date.UTC(ey - 1, sm, sd)) / MS_PER_DAY);
return days;
}
return mkErr("#NUM!");
}
/** 일련번호의 소수부(하루 안 시각) → 시 · 분 · 초 */
function timeParts(serial: number): { h: number; mnt: number; s: number } {
const dayFrac = serial - Math.floor(serial);
const totalSeconds = Math.round(dayFrac * 86400);
return {
h: Math.floor(totalSeconds / 3600) % 24,
mnt: Math.floor(totalSeconds / 60) % 60,
s: totalSeconds % 60,
};
}
// ── 함수 표 ──────────────────────────────────────────────────────────────
export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
IF: {
min: 2,
max: 3,
call: (args) => {
const c = bool(args[0]());
if (isErr(c)) return c;
if (c) return args[1]();
return args.length > 2 ? args[2]() : false;
},
},
AND: {
min: 1,
call: (args) => {
const bs = collectBools(args);
if (isErr(bs)) return bs;
if (bs.length === 0) return mkErr("#VALUE!");
return bs.every(Boolean);
},
},
OR: {
min: 1,
call: (args) => {
const bs = collectBools(args);
if (isErr(bs)) return bs;
if (bs.length === 0) return mkErr("#VALUE!");
return bs.some(Boolean);
},
},
NOT: {
min: 1,
max: 1,
call: (args) => {
const b = bool(args[0]());
return isErr(b) ? b : !b;
},
},
IFERROR: {
min: 2,
max: 2,
call: (args) => {
const v = args[0]();
if (!isRange(v) && isErr(v)) return args[1]();
return v;
},
},
CONCATENATE: {
min: 1,
call: (args) => {
let out = "";
for (const a of args) {
const v = a();
if (isRange(v)) {
for (let r = 0; r < v.rows; r++) {
for (let c = 0; c < v.cols; c++) {
const t = toText(v.at(r, c));
if (isErr(t)) return t;
out += t;
}
}
} else {
const t = str(v);
if (isErr(t)) return t;
out += t;
}
}
return out;
},
},
FIXED: {
min: 1,
max: 3,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const decArg = args.length > 1 ? num(args[1]()) : frac(2n);
if (isErr(decArg)) return decArg;
const decimals = Math.trunc(fracToDouble(decArg));
const noCommaArg = args.length > 2 ? bool(args[2]()) : false;
if (isErr(noCommaArg)) return noCommaArg;
return formatFixed(n, decimals, !noCommaArg);
},
},
TEXT: {
min: 2,
max: 2,
call: (args) => {
const v = toScalar(args[0]());
if (isErr(v)) return v;
const code = str(args[1]());
if (isErr(code)) return code;
return formatValue(v, code).글;
},
},
LEN: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
return isErr(s) ? s : frac(BigInt(s.length));
},
},
LEFT: {
min: 1,
max: 2,
call: (args) => {
const s = str(args[0]());
if (isErr(s)) return s;
const nArg = args.length > 1 ? num(args[1]()) : ONE;
if (isErr(nArg)) return nArg;
const n = Math.max(0, Math.trunc(fracToDouble(nArg)));
return s.slice(0, n);
},
},
RIGHT: {
min: 1,
max: 2,
call: (args) => {
const s = str(args[0]());
if (isErr(s)) return s;
const nArg = args.length > 1 ? num(args[1]()) : ONE;
if (isErr(nArg)) return nArg;
const n = Math.max(0, Math.trunc(fracToDouble(nArg)));
return n === 0 ? "" : s.slice(-n);
},
},
MID: {
min: 3,
max: 3,
call: (args) => {
const s = str(args[0]());
if (isErr(s)) return s;
const startN = num(args[1]());
if (isErr(startN)) return startN;
const lenN = num(args[2]());
if (isErr(lenN)) return lenN;
const start = Math.trunc(fracToDouble(startN));
const len = Math.max(0, Math.trunc(fracToDouble(lenN)));
if (start < 1) return mkErr("#VALUE!");
return s.slice(start - 1, start - 1 + len);
},
},
VALUE: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
if (isErr(s)) return s;
const trimmed = s.trim();
const pct = trimmed.endsWith("%");
const body = pct ? trimmed.slice(0, -1) : trimmed;
try {
const v = parseDecimal(body);
return pct ? { n: v.n, d: v.d * 100n } : v;
} catch {
return mkErr("#VALUE!", `글을 수로 못 읽음: ${s}`);
}
},
},
TEXTJOIN: {
min: 3,
call: (args) => {
const delim = str(args[0]());
if (isErr(delim)) return delim;
const ignoreEmpty = bool(args[1]());
if (isErr(ignoreEmpty)) return ignoreEmpty;
const parts: string[] = [];
for (let i = 2; i < args.length; i++) {
const v = args[i]();
if (isRange(v)) {
for (let r = 0; r < v.rows; r++) {
for (let c = 0; c < v.cols; c++) {
const t = toText(v.at(r, c));
if (isErr(t)) return t;
if (!(ignoreEmpty && t === "")) parts.push(t);
}
}
} else {
const t = str(v);
if (isErr(t)) return t;
if (!(ignoreEmpty && t === "")) parts.push(t);
}
}
return parts.join(delim);
},
},
TRIM: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
return isErr(s) ? s : s.split(" ").filter(Boolean).join(" ");
},
},
UPPER: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
return isErr(s) ? s : s.toUpperCase();
},
},
LOWER: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
return isErr(s) ? s : s.toLowerCase();
},
},
SUBSTITUTE: {
min: 3,
max: 4,
call: (args) => {
const text = str(args[0]());
if (isErr(text)) return text;
const oldT = str(args[1]());
if (isErr(oldT)) return oldT;
const newT = str(args[2]());
if (isErr(newT)) return newT;
if (oldT === "") return text;
if (args.length <= 3) return text.split(oldT).join(newT);
const nthArg = num(args[3]());
if (isErr(nthArg)) return nthArg;
const nth = Math.trunc(fracToDouble(nthArg));
let idx = -1;
let count = 0;
let pos = 0;
for (;;) {
const found = text.indexOf(oldT, pos);
if (found < 0) break;
count++;
if (count === nth) {
idx = found;
break;
}
pos = found + oldT.length;
}
return idx < 0 ? text : text.slice(0, idx) + newT + text.slice(idx + oldT.length);
},
},
REPLACE: {
min: 4,
max: 4,
call: (args) => {
const text = str(args[0]());
if (isErr(text)) return text;
const startN = num(args[1]());
if (isErr(startN)) return startN;
const lenN = num(args[2]());
if (isErr(lenN)) return lenN;
const newText = str(args[3]());
if (isErr(newText)) return newText;
const start = Math.trunc(fracToDouble(startN));
const len = Math.max(0, Math.trunc(fracToDouble(lenN)));
if (start < 1) return mkErr("#VALUE!");
return text.slice(0, start - 1) + newText + text.slice(start - 1 + len);
},
},
FIND: {
min: 2,
max: 3,
call: (args) => {
const findT = str(args[0]());
if (isErr(findT)) return findT;
const within = str(args[1]());
if (isErr(within)) return within;
const startArg = args.length > 2 ? num(args[2]()) : ONE;
if (isErr(startArg)) return startArg;
const start = Math.trunc(fracToDouble(startArg));
if (start < 1) return mkErr("#VALUE!");
const idx = within.indexOf(findT, start - 1);
return idx < 0 ? mkErr("#VALUE!") : frac(BigInt(idx + 1));
},
},
SEARCH: {
min: 2,
max: 3,
call: (args) => {
const findT = str(args[0]());
if (isErr(findT)) return findT;
const within = str(args[1]());
if (isErr(within)) return within;
const startArg = args.length > 2 ? num(args[2]()) : ONE;
if (isErr(startArg)) return startArg;
const start = Math.trunc(fracToDouble(startArg));
if (start < 1) return mkErr("#VALUE!");
const pat = wildcardToSearchRegex(findT);
const m = pat.exec(within.slice(start - 1));
return m ? frac(BigInt(m.index + start)) : mkErr("#VALUE!");
},
},
REPT: {
min: 2,
max: 2,
call: (args) => {
const s = str(args[0]());
if (isErr(s)) return s;
const nArg = num(args[1]());
if (isErr(nArg)) return nArg;
const n = Math.trunc(fracToDouble(nArg));
return n < 0 ? mkErr("#VALUE!") : s.repeat(n);
},
},
ISNUMBER: { min: 1, max: 1, call: (args) => isFrac(toScalar(args[0]())) },
ISTEXT: { min: 1, max: 1, call: (args) => typeof toScalar(args[0]()) === "string" },
ISBLANK: { min: 1, max: 1, call: (args) => toScalar(args[0]()) === null },
ISERROR: { min: 1, max: 1, call: (args) => isErr(toScalar(args[0]())) },
NA: { min: 0, max: 0, call: () => mkErr("#N/A") },
TODAY: {
min: 0,
max: 0,
call: () => {
const now = new Date();
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
return fromDouble(dateToSerial(new Date(midnight)));
},
},
DATE: {
min: 3,
max: 3,
call: (args) => {
const y = num(args[0]());
if (isErr(y)) return y;
const m = num(args[1]());
if (isErr(m)) return m;
const d = num(args[2]());
if (isErr(d)) return d;
const date = new Date(
Date.UTC(
Math.trunc(fracToDouble(y)),
Math.trunc(fracToDouble(m)) - 1,
Math.trunc(fracToDouble(d)),
),
);
return fromDouble(dateToSerial(date));
},
},
YEAR: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCFullYear()));
},
},
MONTH: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCMonth() + 1));
},
},
DAY: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCDate()));
},
},
NOW: { min: 0, max: 0, call: () => fromDouble(dateToSerial(new Date())) },
WEEKDAY: {
min: 1,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const typeArg = args.length > 1 ? num(args[1]()) : ONE;
if (isErr(typeArg)) return typeArg;
const type = Math.trunc(fracToDouble(typeArg));
const dow = serialToDate(fracToDouble(n)).getUTCDay(); // 0=일 .. 6=토
if (type === 2) return frac(BigInt(((dow + 6) % 7) + 1));
if (type === 3) return frac(BigInt((dow + 6) % 7));
return frac(BigInt(dow + 1));
},
},
EDATE: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const mArg = num(args[1]());
if (isErr(mArg)) return mArg;
const months = Math.trunc(fracToDouble(mArg));
const d = serialToDate(fracToDouble(n));
const targetMonth = d.getUTCMonth() + months;
// 달 끝을 넘는 날짜(1/31 + 1개월 등)는 다음 달로 넘기지 않고 그 달 말일로 눌러 담음(엑셀과 같음).
const lastDay = new Date(Date.UTC(d.getUTCFullYear(), targetMonth + 1, 0)).getUTCDate();
const day = Math.min(d.getUTCDate(), lastDay);
const date = new Date(Date.UTC(d.getUTCFullYear(), targetMonth, day));
return fromDouble(dateToSerial(date));
},
},
EOMONTH: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const mArg = num(args[1]());
if (isErr(mArg)) return mArg;
const months = Math.trunc(fracToDouble(mArg));
const d = serialToDate(fracToDouble(n));
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months + 1, 0));
return fromDouble(dateToSerial(date));
},
},
DATEDIF: {
min: 3,
max: 3,
call: (args) => {
const s = num(args[0]());
if (isErr(s)) return s;
const e = num(args[1]());
if (isErr(e)) return e;
const unit = str(args[2]());
if (isErr(unit)) return unit;
const result = dateDiff(fracToDouble(s), fracToDouble(e), unit);
return isErr(result) ? result : frac(BigInt(result));
},
},
NETWORKDAYS: {
min: 2,
max: 3,
call: (args) => {
const s = num(args[0]());
if (isErr(s)) return s;
const e = num(args[1]());
if (isErr(e)) return e;
let startSerial = Math.round(fracToDouble(s));
let endSerial = Math.round(fracToDouble(e));
let sign = 1;
if (startSerial > endSerial) {
[startSerial, endSerial] = [endSerial, startSerial];
sign = -1;
}
const holidays = new Set<number>();
if (args.length > 2) {
const holidayArg = args[2]();
if (isRange(holidayArg)) {
for (let r = 0; r < holidayArg.rows; r++) {
for (let c = 0; c < holidayArg.cols; c++) {
const cell = holidayArg.at(r, c);
if (isFrac(cell)) holidays.add(Math.round(fracToDouble(cell)));
}
}
} else if (isFrac(holidayArg)) {
holidays.add(Math.round(fracToDouble(holidayArg)));
}
}
let count = 0;
for (let day = startSerial; day <= endSerial; day++) {
const dow = serialToDate(day).getUTCDay();
if (dow === 0 || dow === 6) continue;
if (holidays.has(day)) continue;
count++;
}
return frac(BigInt(sign * count));
},
},
HOUR: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).h));
},
},
MINUTE: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).mnt));
},
},
TIME: {
min: 3,
max: 3,
call: (args) => {
const hArg = num(args[0]());
if (isErr(hArg)) return hArg;
const mArg = num(args[1]());
if (isErr(mArg)) return mArg;
const sArg = num(args[2]());
if (isErr(sArg)) return sArg;
const totalSeconds =
Math.trunc(fracToDouble(hArg)) * 3600 +
Math.trunc(fracToDouble(mArg)) * 60 +
Math.trunc(fracToDouble(sArg));
const dayFraction = ((totalSeconds % 86400) + 86400) % 86400;
return fromDouble(dayFraction / 86400);
},
},
};
@@ -0,0 +1,228 @@
/* =============================================================================
* spreadsheet_functions_stat.ts (주인 B · 3차 · 2026-09-27)
* 통계 함수 — MEDIAN · MODE · STDEV(.S/.P) · VAR · RANK(.EQ) · LARGE · SMALL ·
* PERCENTILE · QUARTILE · COUNTBLANK. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
* ========================================================================== */
import { ZERO, add, cmp, div, frac, mul, sub, type Frac } from "@ui/sheet/ui_template_sheet_frac";
import type {
ErrorValue,
EvalContext,
EvalResult,
LazyArg,
RangeValue,
SheetFunction,
} from "./spreadsheet_types";
import {
collectNumbers,
fracToDouble,
fromDouble,
isErr,
isFrac,
isRange,
mkErr,
num,
} from "./spreadsheet_functions_math";
function rangeNumbers(range: RangeValue): Frac[] | ErrorValue {
const out: Frac[] = [];
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) {
const cell = range.at(r, c);
if (isErr(cell)) return cell;
if (isFrac(cell)) out.push(cell);
}
}
return out;
}
function meanAndSumSq(values: Frac[]): { mean: Frac; sumSq: Frac } {
const n = frac(BigInt(values.length));
const mean = div(values.reduce(add, ZERO), n);
const sumSq = values.reduce((acc, v) => add(acc, mul(sub(v, mean), sub(v, mean))), ZERO);
return { mean, sumSq };
}
function sampleVariance(values: Frac[]): Frac | ErrorValue {
if (values.length < 2) return mkErr("#DIV/0!");
return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length - 1)));
}
function populationVariance(values: Frac[]): Frac | ErrorValue {
if (values.length < 1) return mkErr("#DIV/0!");
return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length)));
}
function stdevOf(variance: Frac | ErrorValue): Frac | ErrorValue {
if (isErr(variance)) return variance;
return fromDouble(Math.sqrt(fracToDouble(variance)));
}
/** 정렬한 값에서 0 ≤ k ≤ 1 자리의 백분위(직선 보간 · 엑셀 PERCENTILE.INC 와 같음) */
function percentileOf(values: Frac[], k: number): Frac | ErrorValue {
if (values.length === 0) return mkErr("#NUM!");
if (k < 0 || k > 1) return mkErr("#NUM!");
const sorted = [...values].sort((a, b) => cmp(a, b));
const pos = k * (sorted.length - 1);
const lowIdx = Math.floor(pos);
const highIdx = Math.ceil(pos);
if (lowIdx === highIdx) return sorted[lowIdx];
const weight = fromDouble(pos - lowIdx);
if (isErr(weight)) return weight;
return add(sorted[lowIdx], mul(weight, sub(sorted[highIdx], sorted[lowIdx])));
}
function nthOrderStat(range: RangeValue, k: number, largest: boolean): Frac | ErrorValue {
const values = rangeNumbers(range);
if (isErr(values)) return values;
if (k < 1 || k > values.length) return mkErr("#NUM!");
const sorted = [...values].sort((a, b) => cmp(a, b));
return sorted[largest ? sorted.length - k : k - 1];
}
function rankCall(args: LazyArg[], _ctx: EvalContext): EvalResult {
const target = num(args[0]());
if (isErr(target)) return target;
const range = args[1]();
if (!isRange(range)) return mkErr("#VALUE!");
const orderArg = args.length > 2 ? num(args[2]()) : ZERO;
if (isErr(orderArg)) return orderArg;
const ascending = cmp(orderArg, ZERO) !== 0;
let rank = 1;
let found = false;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) {
const cell = range.at(r, c);
if (!isFrac(cell)) continue;
if (cmp(cell, target) === 0) found = true;
if (ascending ? cmp(cell, target) < 0 : cmp(cell, target) > 0) rank++;
}
}
return found ? frac(BigInt(rank)) : mkErr("#N/A");
}
export const STAT_FUNCTIONS: Record<string, SheetFunction> = {
MEDIAN: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
if (values.length === 0) return mkErr("#NUM!");
const sorted = [...values].sort((a, b) => cmp(a, b));
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) return sorted[mid];
return div(add(sorted[mid - 1], sorted[mid]), frac(2n));
},
},
MODE: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
let bestIdx = -1;
let bestCount = 1;
for (let i = 0; i < values.length; i++) {
let count = 0;
for (const v of values) if (cmp(values[i], v) === 0) count++;
if (count > bestCount) {
bestCount = count;
bestIdx = i;
}
}
return bestIdx < 0 ? mkErr("#N/A") : values[bestIdx];
},
},
STDEV: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : stdevOf(sampleVariance(values));
},
},
"STDEV.S": {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : stdevOf(sampleVariance(values));
},
},
"STDEV.P": {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : stdevOf(populationVariance(values));
},
},
VAR: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : sampleVariance(values);
},
},
RANK: { min: 2, max: 3, call: rankCall },
"RANK.EQ": { min: 2, max: 3, call: rankCall },
LARGE: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const kArg = num(args[1]());
if (isErr(kArg)) return kArg;
return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), true);
},
},
SMALL: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const kArg = num(args[1]());
if (isErr(kArg)) return kArg;
return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), false);
},
},
PERCENTILE: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const kArg = num(args[1]());
if (isErr(kArg)) return kArg;
const values = rangeNumbers(range);
if (isErr(values)) return values;
return percentileOf(values, fracToDouble(kArg));
},
},
QUARTILE: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const qArg = num(args[1]());
if (isErr(qArg)) return qArg;
const q = Math.trunc(fracToDouble(qArg));
if (q < 0 || q > 4) return mkErr("#NUM!");
const values = rangeNumbers(range);
if (isErr(values)) return values;
return percentileOf(values, q / 4);
},
},
COUNTBLANK: {
min: 1,
max: 1,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
let count = 0;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) if (range.at(r, c) === null) count++;
}
return frac(BigInt(count));
},
},
};
+399
View File
@@ -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;
},
};
}
+265
View File
@@ -0,0 +1,265 @@
/* =============================================================================
* spreadsheet_grid.css (주인 C)
* 격자 4 판 · 머리 · 칸 기본 모양. 색 · 간격은 여기 변수만(다른 파일은 이 클래스만 씀).
* ========================================================================== */
.aislo-grid {
/* 앱 전역 테마 토큰(ui_template_theme.css · :root[data-theme])을 그대로 물려받음 —
* 여기서 값을 다시 안 적음(다크 전환 시 앱 토큰이 바뀌면 같이 바뀜). */
--aislo-grid-bg: var(--color-surface-raised);
--aislo-grid-fg: var(--color-text-body);
--aislo-grid-line: var(--color-border);
--aislo-grid-head-bg: var(--color-surface);
--aislo-grid-head-fg: var(--color-text-secondary);
--aislo-grid-head-border: var(--color-border);
--aislo-grid-freeze-shadow: rgba(0, 0, 0, 0.12);
--aislo-grid-selected: var(--color-accent);
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
font:
10pt "굴림",
sans-serif;
background: var(--aislo-grid-bg);
color: var(--aislo-grid-fg);
user-select: none;
-webkit-user-select: none;
}
.aislo-grid__corner,
.aislo-grid__colhead-frozen,
.aislo-grid__colhead-scroll,
.aislo-grid__rowhead-frozen,
.aislo-grid__rowhead-scroll,
.aislo-grid__pane {
position: absolute;
overflow: hidden;
}
.aislo-grid__pane--br {
overflow: auto;
}
.aislo-grid__corner {
z-index: 5;
background: var(--aislo-grid-head-bg);
border-right: 1px solid var(--aislo-grid-head-border);
border-bottom: 1px solid var(--aislo-grid-head-border);
}
.aislo-grid__colhead-frozen,
.aislo-grid__colhead-scroll {
z-index: 4;
background: var(--aislo-grid-head-bg);
border-bottom: 1px solid var(--aislo-grid-head-border);
}
.aislo-grid__rowhead-frozen,
.aislo-grid__rowhead-scroll {
z-index: 4;
background: var(--aislo-grid-head-bg);
border-right: 1px solid var(--aislo-grid-head-border);
}
.aislo-grid__colhead-frozen,
.aislo-grid__rowhead-frozen {
z-index: 6;
box-shadow: 2px 2px 4px var(--aislo-grid-freeze-shadow);
}
.aislo-grid__colhead-inner,
.aislo-grid__rowhead-inner {
position: absolute;
top: 0;
left: 0;
will-change: transform;
}
.aislo-grid__colhead,
.aislo-grid__rowhead {
position: absolute;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.82em;
font-weight: 600;
color: var(--aislo-grid-head-fg);
border-right: 1px solid var(--aislo-grid-head-border);
border-bottom: 1px solid var(--aislo-grid-head-border);
top: 0;
}
.aislo-grid__rowhead {
left: 0;
top: auto;
}
.aislo-grid__colhead--hidden-before,
.aislo-grid__rowhead--hidden-before {
border-left: 3px double var(--aislo-grid-head-fg);
}
.aislo-grid__head-label {
pointer-events: none;
overflow: hidden;
text-overflow: ellipsis;
}
.aislo-grid__resize {
position: absolute;
z-index: 2;
}
.aislo-grid__resize--col {
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
}
.aislo-grid__outline-btn {
position: absolute;
z-index: 2;
box-sizing: border-box;
padding: 0;
border: 1px solid var(--aislo-grid-head-border);
background: var(--aislo-grid-head-bg);
color: var(--aislo-grid-head-fg);
font-size: 0.7em;
line-height: 1;
cursor: pointer;
}
.aislo-grid__resize--row {
left: 0;
bottom: -3px;
width: 100%;
height: 6px;
cursor: row-resize;
}
.aislo-grid__pane-inner {
position: absolute;
top: 0;
left: 0;
will-change: transform;
}
.aislo-grid__pane--br .aislo-grid__spacer {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.aislo-cell {
position: absolute;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0 2px;
background: var(--aislo-grid-bg);
cursor: cell;
}
.aislo-grid--gridlines .aislo-cell {
border-right-style: solid;
border-bottom-style: solid;
border-right-width: var(--aislo-grid-gridline-w, 1px);
border-bottom-width: var(--aislo-grid-gridline-w, 1px);
border-right-color: var(--aislo-grid-line);
border-bottom-color: var(--aislo-grid-line);
}
.aislo-cell__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: inherit;
}
.aislo-cell.is-error {
color: var(--color-danger, #c00);
}
/* 오류 칸 — 엑셀 오류 검사 초록 세모(왼 위 · sub3 오류 까닭은 title 풍선). */
.aislo-cell.is-error::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 6px 6px 0 0;
border-color: #2e7d32 transparent transparent transparent;
}
.aislo-cell.is-selected {
outline: 2px solid var(--aislo-grid-selected);
outline-offset: -1px;
z-index: 1;
}
/* 메모 칸 — 엑셀처럼 오른 위 모서리 빨간 세모. */
.aislo-cell.has-comment::after {
content: "";
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0 6px 6px 0;
border-color: transparent #c00 transparent transparent;
}
.aislo-grid__ref-highlight {
position: absolute;
box-sizing: border-box;
pointer-events: none;
border: 1.5px solid;
z-index: 3;
}
.aislo-grid__fill-handle {
position: absolute;
width: 6px;
height: 6px;
background: var(--aislo-grid-selected);
border: 1px solid var(--aislo-grid-bg);
cursor: crosshair;
z-index: 3;
}
.aislo-grid__overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 7;
}
.aislo-grid__overlay > * {
pointer-events: auto;
}
/* 긴 글 넘침(`spreadsheet_spill.ts`) — 옆 빈 칸 위로 글을 폄 · 덮은 칸 사이 눈금선은 감춤(엑셀) ·
* 고치는 중에는 활성 칸 넘침을 감춤(편집기와 안 겹치게). */
.aislo-cell.is-spill {
overflow: visible;
z-index: 1;
}
.aislo-grid--gridlines .aislo-cell.is-spill-r {
border-right-color: transparent;
}
.ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text {
visibility: hidden;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,255 @@
/* =============================================================================
* spreadsheet_headers.ts (주인 C)
* 열 글자 · 행 번호 머리 · 폭 · 높이 재기(숨김 = 0) · 끌기 · 숨김 표시.
* `LineGeometry` = 자리 하나 재기 — 자리 대부분 기본값이라(성긴 `열`·`행` 표) 겹친 만큼만
* 어긋난 표로 쥐고 이분 탐색으로 자리 ↔ 픽셀을 오간다(1만 행이어도 가벼움).
* 0 계약 머리 — 몸은 C 가 채움. 계약은 `spreadsheet_types.ts`.
* ========================================================================== */
import { colIndex, colName } from "./spreadsheet_address";
import type { RowInfo, Sheet, Workbook } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
export const DEFAULT_COL_CHARS = 8.43;
export const DEFAULT_ROW_PT = 15;
/** 엑셀 글자 폭 → px 근사(굴림 10pt 기준 · Calibri 공식과 같은 꼴). */
const CHAR_PX = 7;
const CHAR_PAD_PX = 5;
const PT_PX = 4 / 3;
/** 엑셀 열 폭 상한(글자 단위) — 이 위로 재는 값이 들어와도 격자는 여기서 막음(자동 맞춤 폭주 방어). */
export const MAX_COL_CHARS = 255;
export const colCharsToPx = (chars: number): number =>
Math.round(Math.min(chars, MAX_COL_CHARS) * CHAR_PX + CHAR_PAD_PX);
export const pxToColChars = (px: number): number => Math.max(0, (px - CHAR_PAD_PX) / CHAR_PX);
export const ptToPx = (pt: number): number => Math.round(pt * PT_PX);
export const pxToPt = (px: number): number => px / PT_PX;
export const colLabel = (c: number): string => colName(c);
export const rowLabel = (r: number): string => String(r + 1);
/** 자리 하나 재기 — 성긴 기본값 어긋남 표(정렬된 색인)로 자리 ↔ 픽셀 변환. */
export interface LineGeometry {
/** 자리 `i` 폭(px) — 숨겼으면 0. */
size(i: number): number;
/** 자리 `i` 가 0 부터 시작하는 픽셀 자리(내용 좌표). */
offset(i: number): number;
/** 자리 `count` 개(0..count-1)를 합친 픽셀 길이. */
total(count: number): number;
/** 픽셀 자리 `px` 가 든 자리 번호(0 부터). */
indexAt(px: number): number;
}
class SparseGeometry implements LineGeometry {
private readonly idx: number[] = [];
private readonly prefix: number[] = [];
constructor(
private readonly base: number,
overrides: Map<number, number>,
) {
const keys = [...overrides.keys()].sort((a, b) => a - b);
let acc = 0;
for (const k of keys) {
acc += overrides.get(k)! - base;
this.idx.push(k);
this.prefix.push(acc);
}
}
/** 어긋난 표에서 `i` 가 들어갈 자리(첫 idx[k] >= i). */
private lowerBound(i: number): number {
let lo = 0;
let hi = this.idx.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (this.idx[mid] < i) lo = mid + 1;
else hi = mid;
}
return lo;
}
size(i: number): number {
const at = this.lowerBound(i);
if (this.idx[at] === i) {
const before = at > 0 ? this.prefix[at - 1] : 0;
return this.base + (this.prefix[at] - before);
}
return this.base;
}
offset(i: number): number {
const at = this.lowerBound(i);
const acc = at > 0 ? this.prefix[at - 1] : 0;
return i * this.base + acc;
}
total(count: number): number {
return this.offset(count);
}
indexAt(px: number): number {
if (px <= 0) return 0;
let hi = Math.max(1, Math.ceil(px / Math.max(this.base, 1)) + this.idx.length + 2);
while (this.offset(hi) <= px) hi *= 2;
let lo = 0;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (this.offset(mid) <= px) lo = mid;
else hi = mid - 1;
}
return lo;
}
}
export function buildColGeometry(book: Workbook, sheet: Sheet): LineGeometry {
const base = colCharsToPx(sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS);
const overrides = new Map<number, number>();
for (const [key, info] of Object.entries(sheet.열 ?? {})) {
const c = colIndex(key);
if (c < 0) continue;
if (info.숨김 || info.묶음?.접힘) overrides.set(c, 0);
else if (info.폭 !== undefined) overrides.set(c, colCharsToPx(info.폭));
}
return new SparseGeometry(base, overrides);
}
export function buildRowGeometry(book: Workbook, sheet: Sheet): LineGeometry {
const base = ptToPx(sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT);
const overrides = new Map<number, number>();
for (const [key, info] of Object.entries(sheet.행 ?? {}) as [string, RowInfo][]) {
const r = Number(key) - 1;
if (!Number.isInteger(r) || r < 0) continue;
if (info.숨김 || info.걸러짐 || info.묶음?.접힘) overrides.set(r, 0);
else if (info.높이 !== undefined) overrides.set(r, ptToPx(info.높이));
}
return new SparseGeometry(base, overrides);
}
/** 접힌 윤곽 묶음(연속 자리) — 묶음 있는 자리를 모아 붙은 것끼리 한 덩이로(같은 접힘 값끼리만). */
export interface OutlineRun {
from: number;
to: number;
collapsed: boolean;
}
export function buildOutlineRuns<T extends { 묶음?: { 층: number; 접힘?: boolean } }>(
records: Record<string, T> | undefined,
indexOf: (key: string) => number,
): OutlineRun[] {
const items = Object.entries(records ?? {})
.map(([key, info]) => ({ i: indexOf(key), collapsed: !!info.묶음?.접힘, has: !!info.묶음 }))
.filter((x) => x.has && x.i >= 0)
.sort((a, b) => a.i - b.i);
const runs: OutlineRun[] = [];
for (const it of items) {
const last = runs[runs.length - 1];
if (last && last.to === it.i - 1 && last.collapsed === it.collapsed) last.to = it.i;
else runs.push({ from: it.i, to: it.i, collapsed: it.collapsed });
}
return runs;
}
/** 그 자리가 숨김 · 걸러짐(사용자 · 필터) 인가 — 묶음 접힘은 빼고(화면 표시가 다름). */
export function isExplicitHidden(info: { 숨김?: boolean; 걸러짐?: boolean } | undefined): boolean {
return !!info?.숨김 || !!info?.걸러짐;
}
/** `from..to` 안에서 숨기지 않은 자리만. */
export function visibleIndices(geo: LineGeometry, from: number, to: number): number[] {
const list: number[] = [];
for (let i = from; i <= to; i += 1) if (geo.size(i) > 0) list.push(i);
return list;
}
export interface HeaderCell {
el: HTMLElement;
index: number;
}
/** 열 머리 칸 — 글자 · 폭 끌기 손잡이 · 숨김 표시(왼쪽에 건너뛴 열이 있으면). */
export function makeColHeaderCell(
c: number,
x: number,
w: number,
hiddenBefore: boolean,
): HeaderCell {
const cellEl = document.createElement("div");
cellEl.className = "aislo-grid__colhead";
if (hiddenBefore) cellEl.classList.add("aislo-grid__colhead--hidden-before");
cellEl.style.left = `${x}px`;
cellEl.style.width = `${w}px`;
cellEl.dataset.col = String(c);
const label = document.createElement("span");
label.className = "aislo-grid__head-label";
label.textContent = colLabel(c);
cellEl.append(label);
const handle = document.createElement("div");
handle.className = "aislo-grid__resize aislo-grid__resize--col";
cellEl.append(handle);
return { el: cellEl, index: c };
}
/** 행 머리 칸 — 번호 · 높이 끌기 손잡이 · 숨김 표시(위에 건너뛴 행이 있으면). */
export function makeRowHeaderCell(
r: number,
y: number,
h: number,
hiddenBefore: boolean,
): HeaderCell {
const cellEl = document.createElement("div");
cellEl.className = "aislo-grid__rowhead";
if (hiddenBefore) cellEl.classList.add("aislo-grid__rowhead--hidden-before");
cellEl.style.top = `${y}px`;
cellEl.style.height = `${h}px`;
cellEl.dataset.row = String(r);
const label = document.createElement("span");
label.className = "aislo-grid__head-label";
label.textContent = rowLabel(r);
cellEl.append(label);
const handle = document.createElement("div");
handle.className = "aislo-grid__resize aislo-grid__resize--row";
cellEl.append(handle);
return { el: cellEl, index: r };
}
export interface ResizeDragOptions {
handle: HTMLElement;
axis: "col" | "row";
currentPx: () => number;
onPreview: (px: number) => void;
onCommit: (px: number) => void;
}
/** 폭 · 높이 끌기 — 누르는 순간 시작 픽셀을 쥐고 포인터가 옮긴 만큼 더함. */
export function startDragResize(opts: ResizeDragOptions): void {
opts.handle.addEventListener("pointerdown", (ev) => {
ev.preventDefault();
ev.stopPropagation();
const startClient = opts.axis === "col" ? ev.clientX : ev.clientY;
const startPx = opts.currentPx();
opts.handle.setPointerCapture(ev.pointerId);
const next = (e: PointerEvent): number => {
const cur = opts.axis === "col" ? e.clientX : e.clientY;
return Math.max(4, startPx + (cur - startClient));
};
const move = (e: PointerEvent): void => opts.onPreview(next(e));
const up = (e: PointerEvent): void => {
opts.handle.removeEventListener("pointermove", move);
opts.handle.removeEventListener("pointerup", up);
opts.onCommit(next(e));
};
opts.handle.addEventListener("pointermove", move);
opts.handle.addEventListener("pointerup", up);
});
}
/** 폭 끌기 다 됨 → `열폭` 명령(글자 단위로 되돌려 보냄). */
export function commitColWidth(ctx: SpreadsheetContext, c: number, px: number): void {
ctx.dispatch({ 종류: "열폭", 시트: ctx.sheet().id, 열: [c], 폭: pxToColChars(px) });
}
/** 높이 끌기 다 됨 → `행높이` 명령(pt 단위로 되돌려 보냄). */
export function commitRowHeight(ctx: SpreadsheetContext, r: number, px: number): void {
ctx.dispatch({ 종류: "행높이", 시트: ctx.sheet().id, 행: [r], 높이: pxToPt(px) });
}
@@ -0,0 +1,38 @@
/* =============================================================================
* spreadsheet_history.ts (주인 A)
* 되돌리기 · 다시 — 명령과 되돌림 짝을 쌓음(메모리만 · 캐시에 안 넣음).
* undo() 는 되돌림 짝을 · redo() 는 원래 명령을 돌려줌 — 적용은 부른 쪽(`applyCommand`).
* ========================================================================== */
import type { Command, History } from "./spreadsheet_types";
/** `limit` = 쌓을 수(넘으면 오래된 것부터 버림) */
export function createHistory(limit = 100): History {
let done: [Command, Command][] = [];
let undone: [Command, Command][] = [];
return {
push(command, undo) {
done.push([command, undo]);
if (done.length > limit) done.shift();
undone = [];
},
undo() {
const pair = done.pop();
if (!pair) return null;
undone.push(pair);
return pair[1];
},
redo() {
const pair = undone.pop();
if (!pair) return null;
done.push(pair);
return pair[0];
},
canUndo: () => done.length > 0,
canRedo: () => undone.length > 0,
clear() {
done = [];
undone = [];
},
};
}
@@ -0,0 +1,240 @@
/* =============================================================================
* spreadsheet_insert_dialog.ts (2단계 · 주인 sub_laptop_1)
* 칸 삽입(Ctrl+Shift++) · 칸 삭제(Ctrl+-) 대화상자 — 엑셀처럼 네 갈래:
* 삽입 = 셀을 오른쪽으로 밀기 · 셀을 아래로 밀기 · 행 전체 · 열 전체
* 삭제 = 셀을 왼쪽으로 밀기 · 셀을 위로 밀기 · 행 전체 · 열 전체
* 「행 전체」·「열 전체」는 A 의 행넣기 · 행지우기 · 열넣기 · 열지우기 명령을 그대로 씀
* (참조 옮김까지 A 가 다 함). 「오른쪽 · 아래로 · 왼쪽 · 위로 밀기」는 0 계약에 전용 명령이
* 없어(부분 범위 이동 명령 없음) 「칸」 명령으로 손수 값 · 식 글 · 서식 번호를 옮김 —
* ⚠ 식의 상대 참조는 안 고쳐짐 · 행 · 열 단위 기본서식(`행`·`열` 레코드)도 안 옮김
* (부분 이동은 칸 레코드만). 온전한 참조 옮김이 필요하면 「행 전체」·「열 전체」를 쓰거나
* A 의 `spreadsheet_refshift.ts` 가 부분 범위까지 다루게 된 뒤 이 파일의 shift* 함수만 고치면 됨.
*
* 잇는 법(D · sub7) — Ctrl+Shift+= → `openInsertCellsDialog(ctx)` · Ctrl+- →
* `openDeleteCellsDialog(ctx)` 를 `spreadsheet_keys.ts` navKey 에 더함. 우클릭 메뉴
* [삽입]·[삭제] 도 같은 함수를 부르면 됨.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { parseA1, toA1 } from "./spreadsheet_address";
import type { Cell, CellRange, Sheet } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
insertTitle: "삽입",
deleteTitle: "삭제",
shiftRight: "셀을 오른쪽으로 밀기",
shiftDown: "셀을 아래로 밀기",
shiftLeft: "셀을 왼쪽으로 밀기",
shiftUp: "셀을 위로 밀기",
wholeRow: "행 전체",
wholeCol: "열 전체",
ok: "확인",
cancel: "취소",
};
function readRange(ctx: SpreadsheetContext): CellRange | null {
return ctx.selection.범위[0] ?? null;
}
/** 시트 `칸` 레코드 안에서 실제 값이 있는 가장 큰 행 · 열(없으면 -1). */
function sheetUsedBounds(sheet: Sheet): { maxR: number; maxC: number } {
let maxR = -1;
let maxC = -1;
for (const key of Object.keys(sheet.칸)) {
const at = parseA1(key);
if (!at) continue;
if (at.r > maxR) maxR = at.r;
if (at.c > maxC) maxC = at.c;
}
return { maxR, maxC };
}
// ═════════════════════════════════════════════════════════════════════════════
// 부분 범위 밀기 — 「칸」 명령으로 값 · 식 · 서식 번호만 옮김(위 머리 주석의 한계 참고)
// ═════════════════════════════════════════════════════════════════════════════
export function insertShiftRight(ctx: SpreadsheetContext, range: CellRange): void {
const sheet = ctx.sheet();
const width = range.c1 - range.c0 + 1;
const lastCol = Math.max(sheetUsedBounds(sheet).maxC + width, range.c1);
const cells: Record<string, Cell | null> = {};
for (let r = range.r0; r <= range.r1; r++) {
for (let c = lastCol; c >= range.c0; c--) {
const src = c - width;
cells[toA1(r, c)] = src >= range.c0 ? (sheet.칸[toA1(r, src)] ?? null) : null;
}
}
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
export function insertShiftDown(ctx: SpreadsheetContext, range: CellRange): void {
const sheet = ctx.sheet();
const height = range.r1 - range.r0 + 1;
const lastRow = Math.max(sheetUsedBounds(sheet).maxR + height, range.r1);
const cells: Record<string, Cell | null> = {};
for (let c = range.c0; c <= range.c1; c++) {
for (let r = lastRow; r >= range.r0; r--) {
const src = r - height;
cells[toA1(r, c)] = src >= range.r0 ? (sheet.칸[toA1(src, c)] ?? null) : null;
}
}
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
export function deleteShiftLeft(ctx: SpreadsheetContext, range: CellRange): void {
const sheet = ctx.sheet();
const width = range.c1 - range.c0 + 1;
const lastCol = Math.max(sheetUsedBounds(sheet).maxC, range.c1);
const cells: Record<string, Cell | null> = {};
for (let r = range.r0; r <= range.r1; r++) {
for (let c = range.c0; c <= lastCol; c++) {
const src = c + width;
cells[toA1(r, c)] = src <= lastCol ? (sheet.칸[toA1(r, src)] ?? null) : null;
}
}
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
export function deleteShiftUp(ctx: SpreadsheetContext, range: CellRange): void {
const sheet = ctx.sheet();
const height = range.r1 - range.r0 + 1;
const lastRow = Math.max(sheetUsedBounds(sheet).maxR, range.r1);
const cells: Record<string, Cell | null> = {};
for (let c = range.c0; c <= range.c1; c++) {
for (let r = range.r0; r <= lastRow; r++) {
const src = r + height;
cells[toA1(r, c)] = src <= lastRow ? (sheet.칸[toA1(src, c)] ?? null) : null;
}
}
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
// ═════════════════════════════════════════════════════════════════════════════
// 대화상자
// ═════════════════════════════════════════════════════════════════════════════
function radioGroup(
name: string,
options: [string, string][],
): { root: HTMLElement; value(): string } {
const inputs: HTMLInputElement[] = [];
const root = el("div", {
children: options.map(([value, label], i) => {
const input = el("input", {
attrs: { type: "radio", name, value, ...(i === 0 ? { checked: "true" } : {}) },
}) as HTMLInputElement;
inputs.push(input);
const wrap = el("label", { children: [input, el("span", { text: label })] });
wrap.style.cssText = "display:flex;align-items:center;gap:6px;margin-bottom:4px;";
return wrap;
}),
});
return { root, value: () => inputs.find((i) => i.checked)?.value ?? options[0][0] };
}
function modal(
title: string,
body: HTMLElement,
onOk: (choice: string) => void,
choice: () => string,
): void {
const overlay = el("div", { className: "ui-confirm" });
const panel = el("div", { className: "ui-confirm__panel" });
const close = (): void => overlay.remove();
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) close();
});
panel.append(
el("h3", { text: title }),
body,
el("div", {
className: "ui-confirm__actions",
children: [
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
createButton({
label: TEXT.ok,
variant: "filled",
onClick: () => {
onOk(choice());
close();
},
}),
],
}),
);
overlay.append(panel);
document.body.append(overlay);
}
export function openInsertCellsDialog(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const range = readRange(ctx);
if (!range) return;
const group = radioGroup("insertMode", [
["right", TEXT.shiftRight],
["down", TEXT.shiftDown],
["row", TEXT.wholeRow],
["col", TEXT.wholeCol],
]);
modal(
TEXT.insertTitle,
group.root,
(choice) => {
if (choice === "right") insertShiftRight(ctx, range);
else if (choice === "down") insertShiftDown(ctx, range);
else if (choice === "row") {
ctx.dispatch({
종류: "행넣기",
시트: ctx.selection.시트,
at: range.r0,
수: range.r1 - range.r0 + 1,
});
} else {
ctx.dispatch({
종류: "열넣기",
시트: ctx.selection.시트,
at: range.c0,
수: range.c1 - range.c0 + 1,
});
}
},
group.value,
);
}
export function openDeleteCellsDialog(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const range = readRange(ctx);
if (!range) return;
const group = radioGroup("deleteMode", [
["left", TEXT.shiftLeft],
["up", TEXT.shiftUp],
["row", TEXT.wholeRow],
["col", TEXT.wholeCol],
]);
modal(
TEXT.deleteTitle,
group.root,
(choice) => {
if (choice === "left") deleteShiftLeft(ctx, range);
else if (choice === "up") deleteShiftUp(ctx, range);
else if (choice === "row") {
ctx.dispatch({
종류: "행지우기",
시트: ctx.selection.시트,
at: range.r0,
수: range.r1 - range.r0 + 1,
});
} else {
ctx.dispatch({
종류: "열지우기",
시트: ctx.selection.시트,
at: range.c0,
수: range.c1 - range.c0 + 1,
});
}
},
group.value,
);
}
+251
View File
@@ -0,0 +1,251 @@
/* =============================================================================
* spreadsheet_keys.ts (주인 D)
* 엑셀 단축키 지도 — 숨은 textarea · 수식 입력줄 keydown 하나로 받음.
* 편집 중: Enter/Tab 확정 이동 · Alt+Enter 줄바꿈 · Ctrl+Enter 고름 전부 · Esc · F2 방식 바꿈 ·
* enter 방식 방향키 = 확정 이동(식 참조 자리면 가리키기).
* 고름 중: 방향(Shift 늘림 · Ctrl 끝) · Home · Ctrl+Home/End · PgUp/PgDn · Enter/Tab(고름 안 돌기) ·
* F2 · Delete · Backspace · Ctrl+A · Shift/Ctrl+Space · Ctrl+Z/Y · Ctrl+D/R · 그 밖은 hooks.more.
* 식 편집 중 F4 = 참조 $ 돌리기(A1 → $A$1 → A$1 → $A1).
* 글자 키는 막지 않음 — textarea 의 input · compositionstart 가 편집을 엶(한글 첫 글자).
* 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음.
* ========================================================================== */
import { cycleAbs } from "./spreadsheet_editor";
import { fillCells } from "./spreadsheet_fill";
import {
cycle,
LAST_R,
move,
selectAll,
selectCell,
selectCols,
selectRows,
selectSpan,
usedEnd,
} from "./spreadsheet_selection";
import type { Editor } from "./spreadsheet_editor";
import type { Selection, SpreadsheetContext } from "./spreadsheet_view_types";
const ARROWS: Record<string, [number, number]> = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
ArrowLeft: [0, -1],
ArrowRight: [0, 1],
};
/** 2단계 잇기 자리 — 부른 쪽(spreadsheet.ts)이 채움 */
export interface KeyHooks {
/** Ctrl+F · Shift+F5 찾기 · Ctrl+H 바꾸기 */
find?(replace: boolean): void;
/** Ctrl+Shift+V — 곧 올 paste 사건을 골라 붙여넣기로 */
pasteSpecial?(): void;
/** Shift+F2 메모 편집 */
comment?(): void;
/** 고름 중 Esc(서식 붓 끄기 등) */
escape?(): void;
/** Ctrl+1 셀 서식 창 */
formatDialog?(): void;
/** Alt+= 자동 합계 */
autosum?(): void;
/** Ctrl+Shift+L 필터 켜기 · 끄기 */
filter?(): void;
/** Alt+↓ — 필터 머리 칸이면 값 목록을 열고 true */
dropdown?(): boolean;
/** 고름 중 그 밖 단축키(`spreadsheet_actions.ts`) — 받았으면 true */
more?(e: KeyboardEvent): boolean;
}
export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks = {}): () => void {
const go = (sel: Selection): void => ctx.select(sel, true);
/** 확정 뒤 한 칸 — 고름이 여러 칸이면 그 안에서 돎 */
function next(dr: number, dc: number): void {
const sheet = ctx.sheet();
go(cycle(sheet, ctx.selection, dr, dc) ?? move(sheet, ctx.selection, dr, dc));
}
function editingKey(e: KeyboardEvent, inBar: boolean): boolean {
const ctrl = e.ctrlKey || e.metaKey;
switch (e.key) {
case "Enter": {
const node = e.target as HTMLTextAreaElement;
if (e.altKey) {
node.setRangeText("\n", node.selectionStart, node.selectionEnd, "end");
node.dispatchEvent(new Event("input"));
return true;
}
if (ctrl) return (ed.commitAll(), true);
ed.commit();
if (ed.editing()) return true; // 유효성에 막힘
next(e.shiftKey ? -1 : 1, 0);
return true;
}
case "Tab":
if (!e.shiftKey && ed.accept()) return true; // 함수 자동 완성
ed.commit();
if (ed.editing()) return true;
next(0, e.shiftKey ? -1 : 1);
return true;
case "Escape":
ed.cancel();
return true;
case "F2":
ed.toggleMode();
return true;
case "F4": {
const node = e.target as HTMLTextAreaElement;
const got = cycleAbs(node.value, node.selectionStart);
if (!got) return false;
node.value = got.text;
node.setSelectionRange(got.caret, got.caret);
node.dispatchEvent(new Event("input"));
return true;
}
}
const arrow = ARROWS[e.key];
if (arrow && !arrow[1] && !e.shiftKey && ed.suggestMove(arrow[0])) return true;
if (!arrow || inBar || ed.mode() === "edit") return false;
if (ed.pointable()) {
ed.pointMove(arrow[0], arrow[1], e.shiftKey, ctrl);
return true;
}
if (e.shiftKey || ctrl) return false; // 글자 고르기 · 낱말 건너기는 textarea 에 맡김
ed.commit();
if (!ed.editing()) go(move(ctx.sheet(), ctx.selection, arrow[0], arrow[1]));
return true;
}
function navKey(e: KeyboardEvent): boolean {
const ctrl = e.ctrlKey || e.metaKey;
const sheet = ctx.sheet();
const sel = ctx.selection;
const arrow = ARROWS[e.key];
if (e.altKey && e.key === "ArrowDown" && hooks.dropdown?.()) return true;
if (hooks.more?.(e)) return true;
if (e.altKey && e.key === "=" && hooks.autosum && !ctx.readOnly) return (hooks.autosum(), true);
if (arrow && !e.altKey) {
go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl }));
return true;
}
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (ctrl && !e.altKey) {
switch (k) {
case "z":
e.shiftKey ? ctx.redo() : ctx.undo();
return true;
case "y":
ctx.redo();
return true;
case "a":
go(selectAll(sheet, sel.활성));
return true;
case "Home":
go(e.shiftKey ? selectSpan(sheet, sel.기준, { r: 0, c: 0 }) : selectCell(sheet, 0, 0));
return true;
case "End": {
const end = usedEnd(sheet);
go(e.shiftKey ? selectSpan(sheet, sel.기준, end) : selectCell(sheet, end.r, end.c));
return true;
}
case " ": {
const g = sel.범위[0];
go(selectCols(sheet, g.c0, g.c1, sel.활성.c));
return true;
}
case "d":
case "r":
fill(k === "d");
return true;
case "f":
case "h":
if (!hooks.find) return false;
hooks.find(k === "h");
return true;
case "1":
if (!hooks.formatDialog) return false;
hooks.formatDialog();
return true;
case "l":
if (!e.shiftKey || !hooks.filter) return false;
hooks.filter();
return true;
case "v":
if (e.shiftKey) hooks.pasteSpecial?.();
return false; // paste 사건은 그대로 — E 클립보드 · 골라 붙여넣기가 받음
}
return false;
}
switch (e.key) {
case "Enter":
next(e.shiftKey ? -1 : 1, 0);
return true;
case "Tab":
next(0, e.shiftKey ? -1 : 1);
return true;
case "F5": // Shift+F5 = 찾기(엑셀 · Ctrl+F 를 브라우저가 가로챌 때)
if (!e.shiftKey || !hooks.find) return false;
hooks.find(false);
return true;
case "F2":
if (e.shiftKey) hooks.comment?.();
else ed.begin();
return true;
case "Escape":
hooks.escape?.();
return !!hooks.escape;
case "Delete":
if (!ctx.readOnly) ctx.dispatch({ 종류: "내용지움", 시트: sheet.id, 범위: sel.범위 });
return true;
case "Backspace":
ed.begin("");
return true;
case "Home":
go(
e.shiftKey
? selectSpan(sheet, sel.기준, { r: sel.기준.r, c: 0 })
: selectCell(sheet, sel.활성.r, 0),
);
return true;
case "PageDown":
case "PageUp": {
const v = ctx.grid.visibleRange();
const n = Math.max(1, v.r1 - v.r0) * (e.key === "PageDown" ? 1 : -1);
const r = Math.min(LAST_R, Math.max(0, sel.활성.r + n));
go(selectCell(sheet, r, sel.활성.c));
return true;
}
case " ":
if (!e.shiftKey) return false;
go(selectRows(sheet, sel.범위[0].r0, sel.범위[0].r1, sel.활성.r));
return true;
}
return false;
}
/** Ctrl+D 아래로 · Ctrl+R 오른쪽으로 — 고름 첫 행(열)을 나머지에 */
function fill(down: boolean): void {
if (ctx.readOnly) return;
const sheet = ctx.sheet();
const g = ctx.selection.범위[0];
const src = down ? { ...g, r1: g.r0 } : { ...g, c1: g.c0 };
if ((down ? g.r1 : g.c1) === (down ? src.r1 : src.c1)) return;
ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: fillCells(ctx.book, sheet.id, src, g) });
}
function onKey(e: KeyboardEvent): void {
if (e.isComposing || e.keyCode === 229) return; // 한글 조합 중 — 브라우저 몫
const inBar = e.target === ed.formula;
const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e);
if (done) {
e.preventDefault();
e.stopPropagation();
}
}
ed.ta.addEventListener("keydown", onKey);
ed.formula.addEventListener("keydown", onKey);
return () => {
ed.ta.removeEventListener("keydown", onKey);
ed.formula.removeEventListener("keydown", onKey);
};
}
@@ -0,0 +1,138 @@
/* spreadsheet_links.css (주인 D) — 위 알약 막대 · 연결 칸 세모 · 자재 고르기 창 */
.ss-links {
display: flex;
flex: none;
flex-wrap: wrap;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.ss-link {
display: inline-flex;
align-items: baseline;
gap: var(--spacing-4);
padding: 2px 10px;
border: 1px solid var(--color-accent);
border-radius: 999px;
background: var(--color-surface-raised);
color: var(--color-text-body);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.ss-link small {
color: var(--color-text-secondary);
}
.ss-link code {
font-size: 11px;
color: var(--color-accent);
}
/* 연결됨 — 흐림(연결 현황) */
.ss-link.is-linked {
opacity: 0.5;
border-style: dashed;
}
.ss-link.is-linked:hover {
opacity: 0.8;
}
.ss-link--add {
border-style: dashed;
color: var(--color-text-secondary);
}
/* 연결 칸 왼아래 세모(초록 오류 세모는 왼위 · 빨강 메모는 오른위) */
.ss-link-dot {
position: absolute;
z-index: 3;
width: 0;
height: 0;
border-top: 6px solid transparent;
border-left: 6px solid var(--color-accent);
pointer-events: none;
}
.ss-links__pick {
position: absolute;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 320px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-links__search {
padding: 4px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
}
.ss-links__list {
display: flex;
flex-direction: column;
max-height: 240px;
overflow: auto;
}
.ss-links__row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: var(--spacing-8);
padding: 4px 6px;
border: none;
background: none;
color: var(--color-text-body);
font: inherit;
text-align: left;
cursor: pointer;
}
.ss-links__row:hover {
background: var(--color-mist-violet);
}
.ss-links__note {
padding: 4px 6px;
color: var(--color-text-muted);
}
/* 알약으로 이동한 칸 — 굵은 테두리 + 채움 깜빡임(1.6초 · 끝나면 스스로 지워짐) */
.ss-link-flash {
position: absolute;
z-index: 4;
box-sizing: border-box;
border: 3px solid var(--color-accent);
pointer-events: none;
animation: ss-link-flash 1.6s ease-out 1 both;
}
@keyframes ss-link-flash {
0%,
40%,
80% {
background: color-mix(in srgb, var(--color-accent) 40%, transparent);
}
20%,
60% {
background: transparent;
}
100% {
background: transparent;
opacity: 0;
}
}
+400
View File
@@ -0,0 +1,400 @@
/* =============================================================================
* spreadsheet_links.ts (주인 D)
* 위 알약 막대 — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장).
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결(알약 흐려짐 + 칸 주소 · 칸 왼아래 작은 세모) ·
* 흐린 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
* 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] ·
* 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음).
* 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가
* 연결 칸을 옮김(지운 칸이면 빠짐). `createSpreadsheet` 가 `links` 선택 또는 연결 있는 문서일 때만 불러옴(크기 예산).
* ========================================================================== */
import "./spreadsheet_links.css";
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { el, showToast } from "@ui/ui_template_elements";
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
import { applyCommand } from "./spreadsheet_commands";
import type { slotOf } from "./spreadsheet_editor";
import { shiftSpan } from "./spreadsheet_refshift";
import { selectCell } from "./spreadsheet_selection";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
CellLink,
Command,
CommandEffect,
LinkMaterial,
Sheet,
Workbook,
} from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
/** 알약 한 개(입력값) — `키` 는 부른 쪽(일위대가 입력값 키) */
export interface LinkItem {
키: string;
이름: string;
단위?: string;
/** 자재 알약 — 연결하면 이 정보가 칸 연결에 들어감 */
자재?: LinkMaterial;
}
export interface LinkOptions {
항목: LinkItem[];
/** 입력 = 항목이 알약 · 자재 = 항목(자재 정보 포함 · 호스트 목록) + [자재 더하기] 로 더한 자재 */
모드: "입력" | "자재";
/** 자재 모드에서 목록이 바뀔 때(더하기 · 빼기) 새 목록을 알림 — 저장은 부른 쪽 몫 */
onItemsChange?: (항목: LinkItem[]) => void;
/** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */
자재찾기?: (글: string) => Promise<LinkMaterial[]>;
}
export interface Links extends PartHandle {
/** `applyCommand` + 연결 따라가기 */
apply(book: Workbook, command: Command): CommandEffect;
}
const TEXT = {
bar: "알약 — 칸을 고른 채 누르면 연결 · 흐린 알약은 누르면 연결 칸으로 · 우클릭 풀기",
free: "칸을 고른 채 누르면 이 칸에 연결",
linked: "누르면 연결 칸으로 · 우클릭 연결 풀기",
unlink: "연결 풀기",
remove: "목록에서 빼기",
removed: "목록에서 뺌 — 연결도 풀림",
add: "+ 자재 더하기",
search: "자재 이름 · 규격 · 코드",
searching: "찾는 중…",
none: "맞는 자재 없음",
fail: "자재 목록을 못 받음",
picked: "칸을 고른 뒤 새 알약을 누르면 연결",
dup: "이미 목록에 있는 자재",
};
type RowCol = Extract<Command, { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기" }>;
const isRowCol = (c: Command): c is RowCol =>
c.종류 === "행넣기" || c.종류 === "행지우기" || c.종류 === "열넣기" || c.종류 === "열지우기";
const flat = (c: Command): Command[] => (c.종류 === "묶음" ? c.명령.flatMap(flat) : [c]);
/** 행열 넣기 · 지우기 한 번에 맞춰 연결 칸을 옮김 — 지운 칸 연결은 뺌(순수 · 시험이 부름) */
export function shiftLinks(links: CellLink[], cmd: RowCol): CellLink[] {
const rows = cmd.종류.startsWith("행");
const insert = cmd.종류.endsWith("넣기");
return links.flatMap((link) => {
const p = parseA1(link.칸);
if (!p) return [link];
const i = rows ? p.r : p.c;
const to = shiftSpan(i, i, insert, cmd.at, cmd.수, rows ? MAX_ROWS : MAX_COLS)?.[0];
if (to === undefined) return [];
return [{ ...link, 칸: rows ? toA1(to, p.c) : toA1(p.r, to) }];
});
}
const setLinks = (s: Sheet, list: CellLink[]): void => {
if (list.length) s.연결 = list;
else delete s.연결;
};
/** 명령 적용 — 행열 명령이 연결 있는 시트를 건드리면 연결도 옮기고 되돌림 짝 끝에 명령 전 시트를 더함
* (A 의 되돌림은 바뀐 시트만 통째로 — 연결만 바뀐 시트도 돌아오게) */
export function applyLinked(book: Workbook, command: Command): CommandEffect {
const ops = flat(command).filter(isRowCol);
const hit = new Set(ops.map((o) => o.시트));
const before = book.시트
.filter((s) => hit.has(s.id) && s.연결?.length)
.map((s) => structuredClone(s));
const eff = applyCommand(book, command);
if (!before.length) return eff;
for (const op of ops) {
const s = book.시트.find((x) => x.id === op.시트);
if (s?.연결) setLinks(s, shiftLinks(s.연결, op));
}
const restore = before.map((시트): Command => ({ 종류: "시트통째", 시트 }));
return { ...eff, undo: { 종류: "묶음", 명령: [eff.undo, ...restore] } };
}
/** 자재 → 알약(키 = 자재 코드) */
const ofMaterial = (m: LinkMaterial): LinkItem => ({
키: m.코드,
이름: m.규격 ? `${m.이름} ${m.규격}` : m.이름,
단위: m.단위,
자재: m,
});
export function mountLinks(
ctx: SpreadsheetContext,
opts: LinkOptions | undefined,
back: () => void,
/** 편집기 `slotOf` — 부른 쪽이 넘김(여기서 끌어오면 편집기가 따로 쪼개져 크기가 늚) */
slot: typeof slotOf,
): Links {
const bar = el("div", { className: "ss-links", attrs: { title: TEXT.bar } });
if (opts) ctx.root.prepend(bar);
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
/** 알약 목록 — 호스트가 준 항목 + 더한 자재(자재 모드 · 바뀌면 호스트에 알림) */
const items: LinkItem[] = opts ? [...opts.항목] : [];
let dots: HTMLElement[] = [];
let pick: HTMLElement | null = null;
/** 키 → 연결(시트 · 줄) */
function found(): Map<string, { sheet: Sheet; link: CellLink }> {
const out = new Map<string, { sheet: Sheet; link: CellLink }>();
for (const sheet of ctx.book.시트)
for (const link of sheet.연결 ?? []) out.set(link.키, { sheet, link });
return out;
}
/** 시트 연결을 바꿔 넣음 — `시트통째` 라 되돌리기 한 번 */
function put(sheet: Sheet, list: CellLink[]): void {
const next = structuredClone(sheet);
setLinks(next, list);
ctx.dispatch({ 종류: "시트통째", 시트: next });
}
function link(item: LinkItem): void {
if (ctx.readOnly) return;
const sheet = ctx.sheet();
const 칸 = toA1(ctx.selection.활성.r, ctx.selection.활성.c);
const rest = (sheet.연결 ?? []).filter((l) => l.칸 !== 칸 && l.키 !== item.키);
const one: CellLink = { 키: item.키, 이름: item.이름, 칸 };
if (item.단위) one.단위 = item.단위;
if (item.자재) one.자재 = item.자재;
put(sheet, [...rest, one]);
back();
}
/** 그 칸으로 가서 눈에 띄게 깜빡임(굵은 테두리 + 채움 · 테마 색 · 끝나면 스스로 사라짐) */
function goTo(sheet: Sheet, a1: string): void {
const p = parseA1(a1);
if (!p) return;
if (sheet.id !== ctx.selection.시트) ctx.showSheet(sheet.id);
ctx.select(selectCell(ctx.sheet(), p.r, p.c), true);
back();
requestAnimationFrame(() => {
const { layer, box } = slot(ctx, p.r, p.c);
if (!box.w || !box.h) return;
const f = el("div", { className: "ss-link-flash" });
f.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
f.addEventListener("animationend", () => f.remove());
layer.append(f);
});
}
const commit = (): void => opts?.onItemsChange?.(items.map((i) => ({ ...i })));
function pill(item: LinkItem, hit: { sheet: Sheet; link: CellLink } | undefined) {
const where = hit
? hit.sheet.id === ctx.selection.시트
? hit.link.칸
: `${quoteSheet(hit.sheet.이름)}!${hit.link.칸}`
: "";
const b = el("button", {
className: hit ? "ss-link is-linked" : "ss-link",
attrs: { type: "button", title: hit ? TEXT.linked : TEXT.free, "data-key": item.키 },
children: [
el("span", { text: item.이름 }),
...(item.단위 ? [el("small", { text: item.단위 })] : []),
...(where ? [el("code", { text: where })] : []),
],
});
b.addEventListener("mousedown", (e) => e.preventDefault()); // 초점은 격자에 둠
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item)));
const unlink = (): void => {
if (hit)
put(
hit.sheet,
(hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키),
);
};
b.addEventListener("contextmenu", (e) => {
e.preventDefault();
const inList = opts?.모드 === "자재" && items.some((i) => i.키 === item.키);
if (ctx.readOnly || !(hit || inList)) return;
const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 안 절대 위치 — 누른 자리에 뜨게
menu.open(e.clientX - o.left, e.clientY - o.top, [
...(hit ? [[TEXT.unlink, () => (unlink(), back())] as [string, () => void]] : []),
...(inList
? [
[
TEXT.remove,
() => {
unlink();
items.splice(0, items.length, ...items.filter((i) => i.키 !== item.키));
commit();
renderBar();
if (hit) showToast(TEXT.removed, "info");
back();
},
] as [string, () => void],
]
: []),
]);
});
return b;
}
/** 옛 키(로직 키 등)로 저장된 연결 — 키가 알약 목록에 없으면 이름 + 단위가 같은 알약으로 이어
* 연결 키를 새 키로 바꿔 둠(다음 저장 때 새 키로 나감 · 안 맞으면 그대로 두어 늘 보임) */
function align(): void {
if (opts?.모드 !== "입력") return;
const keys = new Set(items.map((i) => i.키));
const taken = new Set<string>();
for (const sheet of ctx.book.시트)
for (const l of sheet.연결 ?? []) if (keys.has(l.키)) taken.add(l.키);
for (const sheet of ctx.book.시트)
for (const l of sheet.연결 ?? []) {
if (keys.has(l.키)) continue;
const hit = items.find(
(i) => !taken.has(i.키) && i.이름 === l.이름 && (i.단위 ?? "") === (l.단위 ?? ""),
);
if (!hit) continue;
l.키 = hit.키;
taken.add(hit.키);
}
}
function renderBar(): void {
if (!opts) return;
align();
const map = found();
const shown: LinkItem[] = [];
const seen = new Set<string>();
const add = (item: LinkItem) => {
if (seen.has(item.키)) return;
seen.add(item.키);
shown.push(item);
};
for (const item of items) add(item);
// 문서에 있는 연결(목록에서 빠진 것) — 풀 수 있게 늘 보임
for (const { link } of map.values())
add(
link.자재
? ofMaterial(link.자재)
: { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 },
);
const pills = shown.map((item) => pill(item, map.get(item.키)));
if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) {
const more = el("button", {
className: "ss-link ss-link--add",
text: TEXT.add,
attrs: { type: "button" },
});
more.addEventListener("click", () => openPick(more));
pills.push(more);
}
bar.replaceChildren(...pills);
}
/** 칸 왼아래 작은 세모 — 칸이 든 판 층에 얹어 스크롤을 따라감 */
function placeDots(): void {
for (const d of dots) d.remove();
dots = [];
for (const l of ctx.sheet().연결 ?? []) {
const p = parseA1(l.칸);
if (!p) continue;
const { layer, box } = slot(ctx, p.r, p.c);
if (!box.w || !box.h) continue; // 숨긴 행열
const dot = el("div", { className: "ss-link-dot" });
dot.style.left = `${box.x}px`;
dot.style.top = `${box.y + box.h - 7}px`;
layer.append(dot);
dots.push(dot);
}
}
// ── 자재 고르기 창 ──────────────────────────────────────────────────────
function closePick(): void {
pick?.remove();
pick = null;
document.removeEventListener("mousedown", onOutside, true);
}
const onOutside = (e: MouseEvent): void => {
if (pick && !pick.contains(e.target as Node)) closePick();
};
function openPick(anchor: HTMLElement): void {
closePick();
const input = el("input", {
className: "ss-links__search",
attrs: { type: "search", placeholder: TEXT.search },
}) as HTMLInputElement;
const list = el("div", { className: "ss-links__list" });
const note = (text: string) => el("div", { className: "ss-links__note", text });
pick = el("div", { className: "ss-links__pick", children: [input, list] });
const a = anchor.getBoundingClientRect();
const o = ctx.root.getBoundingClientRect();
pick.style.left = `${a.left - o.left}px`;
pick.style.top = `${a.bottom - o.top + 4}px`;
ctx.root.append(pick);
document.addEventListener("mousedown", onOutside, true);
let seq = 0;
let timer = 0;
const choose = (m: LinkMaterial) => {
closePick();
if (items.some((i) => i.키 === m.코드)) showToast(TEXT.dup, "info");
else {
items.push(ofMaterial(m));
commit();
renderBar();
showToast(TEXT.picked, "info");
}
back();
};
const search = async (): Promise<void> => {
const mine = ++seq;
list.replaceChildren(note(TEXT.searching));
try {
const got = await opts!.자재찾기!(input.value.trim());
if (mine !== seq) return;
list.replaceChildren(
...(got.length
? got.map((m) => {
const row = el("button", {
className: "ss-links__row",
attrs: { type: "button", title: m.코드 },
children: [
el("span", { text: m.이름 }),
el("span", { text: m.규격 }),
el("small", { text: m.단위 }),
],
});
row.addEventListener("click", () => choose(m));
return row;
})
: [note(TEXT.none)]),
);
} catch {
if (mine === seq) list.replaceChildren(note(TEXT.fail));
}
};
input.addEventListener("input", () => {
clearTimeout(timer);
timer = window.setTimeout(() => void search(), 250);
});
input.addEventListener("keydown", (e) => {
if (e.key === "Escape") (e.stopPropagation(), closePick(), back());
if (e.key === "Enter") list.querySelector<HTMLElement>(".ss-links__row")?.click();
});
input.focus();
void search();
}
const onZoom = (): void => placeDots();
ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom);
return {
root: opts ? bar : null,
apply: applyLinked,
refresh() {
renderBar();
placeDots();
},
destroy() {
ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom);
closePick();
for (const d of dots) d.remove();
menu.element.remove();
bar.remove();
},
};
}
@@ -0,0 +1,59 @@
/* =============================================================================
* spreadsheet_memo.ts (주인 D)
* 메모 편집(Shift+F2) — 칸 옆 글상자 · Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소.
* 처음 쓸 때 `spreadsheet_extras.ts` 가 불러옴(크기 예산).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import type { getComment, setComment } from "./spreadsheet_comments";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const TITLE = "메모 — Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소";
export interface Memo {
/** (r, c) 메모를 열고 `place` 로 칸 옆에 둠 */
open(r: number, c: number, place: (node: HTMLElement) => void): void;
/** 편집 중인가 — 풍선을 띄우지 않음 */
busy(): boolean;
destroy(): void;
}
/** 메모 읽기 · 쓰기는 부른 쪽이 넘김 — 여기서 끌어오면 메모 조각이 따로 쪼개져 크기가 늘어남 */
export function createMemo(
ctx: SpreadsheetContext,
back: () => void,
io: { get: typeof getComment; set: typeof setComment },
): Memo {
const memo = el("textarea", { className: "ss-memo", attrs: { hidden: "", title: TITLE } });
ctx.root.append(memo);
let memoAt: { r: number; c: number } | null = null;
const close = (save: boolean): void => {
if (!memoAt) return;
const at = memoAt;
memoAt = null;
memo.setAttribute("hidden", "");
if (save && memo.value !== (io.get(ctx.sheet(), at.r, at.c) ?? ""))
io.set(ctx, at.r, at.c, memo.value.trim());
back();
};
memo.addEventListener("keydown", (e) => {
if (e.key === "Escape" || (e.key === "Enter" && e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
close(e.key === "Enter");
}
});
memo.addEventListener("blur", () => close(true));
return {
open(r, c, place) {
memoAt = { r, c };
memo.value = io.get(ctx.sheet(), r, c) ?? "";
memo.removeAttribute("hidden");
place(memo);
memo.focus();
},
busy: () => !!memoAt,
destroy: () => memo.remove(),
};
}
+257
View File
@@ -0,0 +1,257 @@
/* =============================================================================
* spreadsheet_menu.ts (주인 E)
* 우클릭 메뉴 — 칸 · 행 머리 · 열 머리마다: 잘라내기 · 복사 · (붙여넣기 = 「Ctrl+V 를 쓸 것」 안내) ·
* 행열 넣기 · 지우기 · 숨기기 · 보이기 · 내용 지우기 · 서식 지우기 · 병합 · 풀기 · 메모 삽입/편집/삭제.
* `ui_template_context_menu.ts` 재사용 — 우클릭 밖 자리는 그냥 눌러 선택부터 옮김.
* 메모는 `spreadsheet_comments.ts`(sub1 · 브레인 답 2026-09-27)의 `setComment` · `deleteComment` ·
* `getComment` 를 부름 — 안에서 「시트통째」 명령 · 되돌리기 짝까지 만듦. 글 입력 창은 이 파일이
* 직접 띄움(작은 오버레이 · `showConfirmDialog` 와 같은 결의 자체 모달 · native prompt 안 씀).
*
* 이음(D `spreadsheet.ts` 잇는 자리) — `attachMenu(ctx)` 한 번 부름(반환 `root` = 메뉴 껍데기 ·
* 안 붙여도 됨 · `ctx.root` 안에 스스로 넣음). `ctx.grid.hitTest` 가 준비된 뒤(C 격자 붙은 뒤) 불러야 함.
* ========================================================================== */
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { rangeToA1 } from "./spreadsheet_address";
import { deleteComment, getComment, setComment } from "./spreadsheet_comments";
import { st } from "./spreadsheet_text";
import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_toolbar.css";
function within(range: CellRange, r: number, c: number): boolean {
return r >= range.r0 && r <= range.r1 && c >= range.c0 && c <= range.c1;
}
/** `ctx.root` 의 contextmenu 를 받음(root null) */
export function attachMenu(ctx: SpreadsheetContext): PartHandle {
if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} };
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
function isMerged(range: CellRange): boolean {
return (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
}
function cutCopy(kind: "cut" | "copy"): void {
document.execCommand(kind);
}
/** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는
* 문제는 `showConfirmDialog` 머리 주석과 같은 까닭). 저장을 누르면 `onSave`. */
function openCommentEditor(initial: string, onSave: (text: string) => void): void {
const overlay = el("div", { className: "ss-comment-editor" });
const panel = el("div", { className: "ss-comment-editor__panel" });
const textarea = el("textarea", {
className: "ss-comment-editor__input",
}) as HTMLTextAreaElement;
textarea.value = initial;
const done = (): void => overlay.remove();
const actions = el("div", {
className: "ss-comment-editor__actions",
children: [
createButton({ label: "취소", variant: "ghost", onClick: done }),
createButton({
label: "저장",
variant: "filled",
onClick: () => {
onSave(textarea.value);
done();
},
}),
],
});
panel.append(textarea, actions);
overlay.append(panel);
document.body.append(overlay);
textarea.focus();
textarea.select();
}
function cellMenuItems(range: CellRange, r: number, c: number): MapContextMenuItem[] {
const note = getComment(ctx.sheet(), r, c);
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
st("MenuClearContent"),
() => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }),
],
[
st("MenuClearFormat"),
() => ctx.dispatch({ 종류: "서식지움", 시트: ctx.selection.시트, 범위: [range] }),
],
[
isMerged(range) ? st("MenuUnmerge") : st("MenuMerge"),
() =>
ctx.dispatch(
isMerged(range)
? { 종류: "병합풀기", 시트: ctx.selection.시트, 범위: range }
: { 종류: "병합", 시트: ctx.selection.시트, 범위: range },
),
],
[
note ? st("MenuCommentEdit") : st("MenuCommentInsert"),
() => openCommentEditor(note ?? "", (text) => setComment(ctx, r, c, text)),
],
...(note
? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[])
: []),
];
}
function rowMenuItems(rows: number[]): MapContextMenuItem[] {
const at = Math.min(...rows);
const 수 = rows.length;
return [
[st("MenuCopy"), () => cutCopy("copy")],
[
st("MenuInsertRow"),
() => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }),
],
[
st("MenuDeleteRow"),
() => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }),
],
[
st("MenuHide"),
() =>
ctx.dispatch({
종류: "숨김",
시트: ctx.selection.시트,
축: "행",
번호: rows,
숨김: true,
}),
],
[
st("MenuUnhide"),
() =>
ctx.dispatch({
종류: "숨김",
시트: ctx.selection.시트,
축: "행",
번호: rows,
숨김: false,
}),
],
];
}
function colMenuItems(cols: number[]): MapContextMenuItem[] {
const at = Math.min(...cols);
const 수 = cols.length;
return [
[st("MenuCopy"), () => cutCopy("copy")],
[
st("MenuInsertCol"),
() => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }),
],
[
st("MenuDeleteCol"),
() => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }),
],
[
st("MenuHide"),
() =>
ctx.dispatch({
종류: "숨김",
시트: ctx.selection.시트,
축: "열",
번호: cols,
숨김: true,
}),
],
[
st("MenuUnhide"),
() =>
ctx.dispatch({
종류: "숨김",
시트: ctx.selection.시트,
축: "열",
번호: cols,
숨김: false,
}),
],
];
}
function range(r0: number, c0: number, r1: number, c1: number): CellRange {
return { r0, c0, r1, c1 };
}
function onContextMenu(ev: MouseEvent): void {
const hit = ctx.grid.hitTest(ev.clientX, ev.clientY);
if (!hit) return;
ev.preventDefault();
const box = ctx.root.getBoundingClientRect();
const x = ev.clientX - box.left;
const y = ev.clientY - box.top;
if (hit.kind === "rowHead") {
const covered = ctx.selection.범위.find(
(rg) => rg.c0 === 0 && rg.c1 >= MAX_COLS - 1 && within(rg, hit.r, 0),
);
const rows = covered
? Array.from({ length: covered.r1 - covered.r0 + 1 }, (_, i) => covered.r0 + i)
: [hit.r];
if (!covered)
ctx.select({
시트: ctx.selection.시트,
범위: [range(hit.r, 0, hit.r, MAX_COLS - 1)],
활성: { r: hit.r, c: 0 },
기준: { r: hit.r, c: 0 },
});
menu.open(x, y, rowMenuItems(rows));
return;
}
if (hit.kind === "colHead") {
const covered = ctx.selection.범위.find(
(rg) => rg.r0 === 0 && rg.r1 >= MAX_ROWS - 1 && within(rg, 0, hit.c),
);
const cols = covered
? Array.from({ length: covered.c1 - covered.c0 + 1 }, (_, i) => covered.c0 + i)
: [hit.c];
if (!covered)
ctx.select({
시트: ctx.selection.시트,
범위: [range(0, hit.c, MAX_ROWS - 1, hit.c)],
활성: { r: 0, c: hit.c },
기준: { r: 0, c: hit.c },
});
menu.open(x, y, colMenuItems(cols));
return;
}
if (hit.kind === "cell") {
const covered = ctx.selection.범위.some((rg) => within(rg, hit.r, hit.c));
if (!covered) {
ctx.select({
시트: ctx.selection.시트,
범위: [range(hit.r, hit.c, hit.r, hit.c)],
활성: { r: hit.r, c: hit.c },
기준: { r: hit.r, c: hit.c },
});
}
const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c);
menu.open(x, y, cellMenuItems(active, hit.r, hit.c));
}
}
ctx.root.addEventListener("contextmenu", onContextMenu);
return {
root: menu.element,
refresh() {},
destroy() {
ctx.root.removeEventListener("contextmenu", onContextMenu);
menu.close();
menu.element.remove();
},
};
}
+270
View File
@@ -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);
};
}
+201
View File
@@ -0,0 +1,201 @@
/* =============================================================================
* spreadsheet_names.ts (3단계 · 주인 sub_laptop_4)
* 이름 정의 — 범위에 이름을 붙이고 수식에서 `=단가*수량` 처럼 씀. 저장 자리 `Sheet.이름정의`(0 계약에
* 방금 더함) — 이 이름을 정의한 시트 안 범위만 가리킴(다른 시트 참조는 3단계 밖).
*
* 수식 나무는 이미 `{type:"name", name}` 갈래를 가짐(계약 `spreadsheet_types.ts` 3절 · 정의 없으면
* `#NAME?`). 이 파일은 이름을 저장 · 관리만 하고 실제로 식을 풀 때 이름을 찾는 일은 A 몫.
*
* 잇는 법(A · D · sub7 — 지금은 하나도 안 이어짐):
* 1) `spreadsheet_eval.ts` 가 `{type:"name"}` 나무를 만나면 `resolveName(book, 식이든시트, 이름)` 을
* 불러 범위를 얻고, 범위가 칸 하나면 그 칸 값 · 여러 칸이면 `RangeValue` 로 바꿔 씀(없으면 지금처럼 `#NAME?`).
* 2) 편집기 이름 상자(주소 상자)에서 범위를 고른 채 이름을 치고 Enter 하면 `defineName(ctx, 이름, 범위)`.
* 3) 도구 모음 · 메뉴에 [이름 관리] 항목을 두고 `createNameManagerPanel(ctx)` 를 띄움.
* ========================================================================== */
import { createButton, createInputField, el } from "@ui/ui_template_elements";
import { rangeToA1 } from "./spreadsheet_address";
import type { CellRange, NamedRange, Sheet, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
export type { NamedRange };
const CELL_LIKE = /^[A-Za-z]{1,3}\d+$/;
const RESERVED = /^(TRUE|FALSE)$/i;
const NAME_RE = /^[\p{L}_][\p{L}\p{N}_.]*$/u;
/** 이름 규칙 — 글자 · 밑줄로 시작 · 칸 주소처럼 안 보임 · 겹치지 않음. 어기면 까닭 글 · 되면 null. */
export function invalidNameReason(name: string, existing: string[]): string | null {
if (!name) return "이름이 비었음";
if (!NAME_RE.test(name)) return "글자 · 숫자 · 밑줄만(첫 글자는 글자 · 밑줄)";
if (CELL_LIKE.test(name)) return "칸 주소처럼 보이는 이름은 못 씀";
if (RESERVED.test(name)) return "TRUE · FALSE 는 못 씀";
if (existing.some((n) => n.toLowerCase() === name.toLowerCase())) return "이미 있는 이름";
return null;
}
export function listNames(book: Workbook): { 이름: string; 시트: string; 범위: CellRange }[] {
return book.시트.flatMap((sheet) =>
(sheet.이름정의 ?? []).map((n) => ({ 이름: n.이름, 시트: sheet.id, 범위: n.범위 })),
);
}
/** 식이 든 시트의 이름부터 찾고(자기 시트 우선) · 없으면 다른 시트 이름도(느슨한 전역 대체). */
export function resolveName(
book: Workbook,
sheetId: string,
name: string,
): { 시트: string; 범위: CellRange } | null {
const lower = name.toLowerCase();
const own = book.시트.find((s) => s.id === sheetId);
const local = own?.이름정의?.find((n) => n.이름.toLowerCase() === lower);
if (local) return { 시트: sheetId, 범위: local.범위 };
for (const sheet of book.시트) {
const found = sheet.이름정의?.find((n) => n.이름.toLowerCase() === lower);
if (found) return { 시트: sheet.id, 범위: found.범위 };
}
return null;
}
function applySheet(ctx: SpreadsheetContext, sheetId: string, next: Sheet): void {
ctx.dispatch({ 종류: "시트통째", 시트: { ...next, id: sheetId } });
}
/** 지금 활성 시트에 이름을 더함 — 규칙 어기면 아무 일도 안 하고 까닭을 돌려줌(호출부가 토스트로). */
export function defineName(ctx: SpreadsheetContext, name: string, 범위: CellRange): string | null {
const reason = invalidNameReason(
name,
listNames(ctx.book).map((n) => n.이름),
);
if (reason) return reason;
const sheet = ctx.sheet();
applySheet(ctx, sheet.id, {
...sheet,
이름정의: [...(sheet.이름정의 ?? []), { 이름: name, 범위 }],
});
return null;
}
/** 이름을 지움 — 그 이름을 쓰던 식은 그대로 남아 다음 풀이 때 `#NAME?`(계약대로). */
export function deleteName(ctx: SpreadsheetContext, name: string): void {
const lower = name.toLowerCase();
for (const sheet of ctx.book.시트) {
if (!sheet.이름정의?.some((n) => n.이름.toLowerCase() === lower)) continue;
applySheet(ctx, sheet.id, {
...sheet,
이름정의: sheet.이름정의!.filter((n) => n.이름.toLowerCase() !== lower),
});
return;
}
}
/** 이름을 바꿈 — 정의뿐 아니라 모든 시트 식 글 속 이름 글자도 같이 바꿈(시트이름 바꾸기와 같은 규칙). */
export function renameName(
ctx: SpreadsheetContext,
oldName: string,
newName: string,
): string | null {
const reason = invalidNameReason(
newName,
listNames(ctx.book)
.map((n) => n.이름)
.filter((n) => n.toLowerCase() !== oldName.toLowerCase()),
);
if (reason) return reason;
const commands = ctx.book.시트
.map((sheet) => renamedSheet(sheet, oldName, newName))
.filter((s): s is Sheet => s !== null)
.map((sheet) => ({ 종류: "시트통째" as const, 시트: sheet }));
if (!commands.length) return null;
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
return null;
}
function renamedSheet(sheet: Sheet, oldName: string, newName: string): Sheet | null {
// 한글 이름은 `\w` 밖이라 `\b` 가 안 먹음 — 글자 · 숫자 · 밑줄이 아닌 자리를 경계로 직접 잡음.
const re = new RegExp(`(?<![\\p{L}\\p{N}_])${escapeReg(oldName)}(?![\\p{L}\\p{N}_])`, "giu");
let changed = false;
const 칸: Sheet["칸"] = { ...sheet.칸 };
for (const [key, cell] of Object.entries(칸)) {
if (cell.식 !== undefined && re.test(cell.식)) {
re.lastIndex = 0;
칸[key] = { ...cell, 식: cell.식.replace(re, newName) };
changed = true;
}
}
const 이름정의 = sheet.이름정의?.map((n) =>
n.이름.toLowerCase() === oldName.toLowerCase() ? { ...n, 이름: newName } : n,
);
if (이름정의 && JSON.stringify(이름정의) !== JSON.stringify(sheet.이름정의)) changed = true;
return changed ? { ...sheet, 칸, ...(이름정의 ? { 이름정의 } : {}) } : null;
}
function escapeReg(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** 이름 관리 창 — 통합문서 전체 이름 목록 + 고른 범위에 새 이름. */
export function createNameManagerPanel(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-names" });
const list = el("ul", { className: "ss-names__list" });
const nameInput = createInputField({ label: "새 이름", placeholder: "단가" });
const addBtn = createButton({ label: "이름 더하기" });
let error: HTMLElement | null = null;
const showError = (msg: string | null): void => {
error?.remove();
error = null;
if (!msg) return;
error = el("p", { text: msg });
error.style.color = "var(--color-danger, red)";
root.append(error);
};
addBtn.addEventListener("click", () => {
const 범위 = ctx.selection.범위[0];
if (!범위) return;
const reason = defineName(ctx, nameInput.input.value.trim(), 범위);
showError(reason);
if (!reason) {
nameInput.input.value = "";
render();
}
});
function render(): void {
list.replaceChildren(
...listNames(ctx.book).map(({ 이름, 시트, 범위 }) => {
const li = el("li", { className: "ss-names__row" });
const label = el("span", { text: `${이름} = ${시트}!${rangeToA1(범위)}` });
const rename = createButton({ label: "이름 바꾸기", variant: "ghost" });
rename.addEventListener("click", () => {
const next = window.prompt("새 이름", 이름);
if (!next || next === 이름) return;
showError(renameName(ctx, 이름, next));
render();
});
const del = createButton({ label: "지움", variant: "danger" });
del.addEventListener("click", () => {
deleteName(ctx, 이름);
render();
});
li.append(label, rename, del);
return li;
}),
);
}
const form = el("div", { className: "ss-names__form", children: [nameInput.root, addBtn] });
root.append(el("h4", { text: "이름 관리" }), list, form);
render();
return {
root,
refresh: render,
destroy(): void {
root.remove();
},
};
}
@@ -0,0 +1,631 @@
/* =============================================================================
* spreadsheet_numfmt.ts (주인 B)
* 숫자 형식 코드 → 보이는 글 — 엑셀 코드 부분 집합: 일반 · `0` `#` `?` `.` `,`(천 단위 · 끝 쉼표 = 천으로 나눔) ·
* 구역 `;`(양 · 음 · 0 · 글) · `"글"` · `\x` · `_x`(폭만큼 띄움) · `*x`(채움) · `[Red]` 등 색 · `%` · `@` · `E+00`.
* 실무 형식(`0.00` · `_-* #,##0.00_-;\-* #,##0.00_-;_-* "-"_-;_-@_-` · `0.00_);[Red]\(0.00\)` …)은 모두 맞아야 함.
* 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`)도 봄
* (3차 · 2026-09-27) — 시간(`h:mm:ss`)은 아직 밖(글 그대로).
* ========================================================================== */
import {
ZERO,
cmp,
div,
frac,
fracToString,
mul,
roundAt,
sub,
toInteger,
type Frac,
} from "@ui/sheet/ui_template_sheet_frac";
import type { ErrorValue, FormattedText, Scalar } from "./spreadsheet_types";
function isErrorScalar(v: Scalar): v is ErrorValue {
return (
typeof v === "object" && v !== null && "error" in (v as unknown as Record<string, unknown>)
);
}
function groupThousands(digits: string): string {
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
// ── 구역 나누기(`;` · 따옴표 · `\` 뒤는 안 나눔) ─────────────────────────────
function splitSections(code: string): string[] {
const sections: string[] = [];
let cur = "";
let inQuote = false;
for (let i = 0; i < code.length; i++) {
const ch = code[i];
if (ch === '"') {
inQuote = !inQuote;
cur += ch;
continue;
}
if (ch === "\\") {
// 형식 끝에 홀로 남은 역슬래시 · 구역 나눔(`;`) 바로 앞 역슬래시는 깨진 자투리로 보고 버림
// (엑셀 붙여넣기가 잘려 남긴 것 — 2026-09-28 실측 rv.json). 그 밖엔 원래대로 다음 글자를 이스케이프.
if (i + 1 >= code.length || code[i + 1] === ";") continue;
cur += ch + code[i + 1];
i++;
continue;
}
if (ch === ";" && !inQuote) {
sections.push(cur);
cur = "";
continue;
}
cur += ch;
}
sections.push(cur);
return sections;
}
const COLOR_NAMES = ["black", "white", "red", "green", "blue", "yellow", "magenta", "cyan"];
function extractColor(section: string): { rest: string; color?: string } {
let color: string | undefined;
const rest = section.replace(/\[([^\]]+)\]/g, (_m, inner: string) => {
const lower = inner.toLowerCase();
if (COLOR_NAMES.includes(lower)) {
color = lower;
return "";
}
const currency = /^\$([^-\]]*)(-[0-9A-Fa-f]+)?$/.exec(inner);
if (currency) return currency[1]; // `[$₩-412]` 등 통화 태그 → 글자만 남김
return ""; // 조건 태그(`[>100]` 등)는 1단계에서 무시
});
return { rest, color };
}
// ── 낱말 나누기 ──────────────────────────────────────────────────────────
type Tok =
| { k: "digit"; ch: "0" | "#" | "?" }
| { k: "comma" }
| { k: "point" }
| { k: "percent" }
| { k: "text" }
| { k: "general" }
| { k: "lit"; ch: string };
function tokenize(s: string): { toks: Tok[]; fillChar?: string } {
const toks: Tok[] = [];
let fillChar: string | undefined;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
// 코드 속에 섞인 맨 `General`(따옴표 없이) — 실무 흔한 `General"개"` 같은 단위 접미 버릇.
if (s.slice(i, i + 7).toLowerCase() === "general") {
toks.push({ k: "general" });
i += 6;
continue;
}
if (ch === '"') {
let j = i + 1;
while (j < s.length && s[j] !== '"') {
toks.push({ k: "lit", ch: s[j] });
j++;
}
i = j;
continue;
}
if (ch === "\\") {
// 홀로 남은 역슬래시(뒤에 이스케이프할 글자가 없음)는 굴림 등 글꼴에서 ₩ 모양으로 보여 버림.
if (i + 1 < s.length) toks.push({ k: "lit", ch: s[i + 1] });
i++;
continue;
}
if (ch === "_" && i + 1 < s.length) {
toks.push({ k: "lit", ch: " " });
i++;
continue;
}
if (ch === "*" && i + 1 < s.length) {
fillChar = s[i + 1];
i++;
continue;
}
if (ch === "0" || ch === "#" || ch === "?") {
toks.push({ k: "digit", ch });
continue;
}
if (ch === ",") {
toks.push({ k: "comma" });
continue;
}
if (ch === ".") {
toks.push({ k: "point" });
continue;
}
if (ch === "%") {
toks.push({ k: "percent" });
continue;
}
if (ch === "@") {
toks.push({ k: "text" });
continue;
}
toks.push({ k: "lit", ch });
}
return { toks, fillChar };
}
// ── 수 구역 그리기 ───────────────────────────────────────────────────────
function renderNumericSection(toks: Tok[], magnitude: Frac): string {
const pointIdx = toks.findIndex((t) => t.k === "point");
const hasPercent = toks.some((t) => t.k === "percent");
const scaledMag = hasPercent ? mul(magnitude, frac(100n)) : magnitude;
let lastDigit = -1;
toks.forEach((t, i) => {
if (t.k === "digit") lastDigit = i;
});
let scale = 0;
for (let i = lastDigit + 1; i < toks.length; i++) if (toks[i].k === "comma") scale++;
let hasGrouping = false;
const introEnd = pointIdx >= 0 ? pointIdx : toks.length;
for (let i = 0; i < introEnd && i <= lastDigit; i++)
if (toks[i].k === "comma") hasGrouping = true;
const intRun = pointIdx >= 0 ? toks.slice(0, pointIdx) : toks;
const fracRun = pointIdx >= 0 ? toks.slice(pointIdx + 1) : [];
const intDigitToks = intRun.filter((t): t is Extract<Tok, { k: "digit" }> => t.k === "digit");
const fracDigitToks = fracRun.filter((t): t is Extract<Tok, { k: "digit" }> => t.k === "digit");
const scaleDiv = scale > 0 ? 1000n ** BigInt(scale) : 1n;
const adjusted = scaleDiv === 1n ? scaledMag : div(scaledMag, frac(scaleDiv));
const rounded = roundAt(adjusted, fracDigitToks.length, "round");
const scaleUp = frac(10n ** BigInt(fracDigitToks.length));
const scaledInt = toInteger(mul(rounded, scaleUp), "round");
const allDigits = scaledInt.toString().padStart(fracDigitToks.length + 1, "0");
const rawIntStr =
fracDigitToks.length > 0 ? allDigits.slice(0, -fracDigitToks.length) : allDigits;
const rawFracStr = fracDigitToks.length > 0 ? allDigits.slice(-fracDigitToks.length) : "";
let intStr = rawIntStr;
// 정수부가 온통 `?`(회계 형식 0 구역의 `"-"??` 자리맞춤 버릇)뿐이고 값이 정말 0 이면
// 자리마다 빈칸(엑셀은 `?` 를 「있으면 숫자 · 없으면 빈칸」으로 봄 — `0` 처럼 0 을 안 채움).
if (intDigitToks.length > 0 && rawIntStr === "0" && intDigitToks.every((t) => t.ch === "?")) {
intStr = " ".repeat(intDigitToks.length);
} else if (intDigitToks.length > intStr.length) {
const need = intDigitToks.length - intStr.length;
let pad = "";
for (let i = 0; i < need; i++) pad += intDigitToks[i].ch === "0" ? "0" : "";
intStr = pad + intStr;
}
if (hasGrouping) intStr = groupThousands(intStr);
let keepUntil = fracDigitToks.length;
for (let i = fracDigitToks.length - 1; i >= 0; i--) {
if (fracDigitToks[i].ch === "0") break;
if (rawFracStr[i] !== "0") break;
keepUntil = i;
}
const fracStr = rawFracStr.slice(0, keepUntil);
let out = "";
let intEmitted = false;
let fracEmitted = false;
for (let i = 0; i < toks.length; i++) {
const t = toks[i];
if (t.k === "digit" || t.k === "comma") {
const inFrac = pointIdx >= 0 && i > pointIdx;
if (inFrac) {
if (!fracEmitted) {
out += fracStr;
fracEmitted = true;
}
} else if (!intEmitted) {
out += intStr;
intEmitted = true;
}
continue;
}
if (t.k === "point") {
if (fracStr.length > 0) out += ".";
continue;
}
if (t.k === "percent") {
out += "%";
continue;
}
if (t.k === "text") continue; // 수 구역엔 의미 없음(방어)
if (t.k === "general") {
out += generalFormat(magnitude);
continue;
}
out += t.ch;
}
return out;
}
// ── 분수 형식(`0/0` · `# ?/?` · `# ??/??` · 분모 고정 `# ?/8`) ───────────────
interface FracPlan {
prefix: Tok[];
wholeDigits: number; // 0 = 정수부 자리 없음(가분수로 통째)
sepToks: Tok[]; // 정수부 · 분자 사이 리터럴(보통 칸 하나)
fixedDenom: bigint | null; // null = 분모 자리표시(자동 찾기) · 있으면 그 값 고정
denomDigits: number; // 자동일 때 자리표시 개수(최대 분모 = 10^자리 - 1)
suffix: Tok[];
}
/** 슬래시 앞뒤 자리표시(0/#/?)·고정 숫자 자리를 찾음 — 없으면 분수 형식이 아님(null). */
function detectFraction(toks: Tok[]): FracPlan | null {
if (toks.some((t) => t.k === "point")) return null;
const slashIdx = toks.findIndex((t) => t.k === "lit" && t.ch === "/");
if (slashIdx < 0) return null;
let i = slashIdx - 1;
while (i >= 0 && toks[i].k === "digit") i--;
const numeratorStart = i + 1;
if (numeratorStart === slashIdx) return null; // 분자 자리표시 없음
let j = slashIdx + 1;
let denomDigits = 0;
let fixedDigits = "";
let mode: "auto" | "fixed" | null = null;
while (j < toks.length) {
const t = toks[j];
if (t.k === "digit") {
if (mode === "fixed") break;
mode = "auto";
denomDigits++;
j++;
continue;
}
if (t.k === "lit" && /[0-9]/.test(t.ch)) {
if (mode === "auto") break;
mode = "fixed";
fixedDigits += t.ch;
j++;
continue;
}
break;
}
if (mode === null) return null;
const denomEnd = j;
const sepToks: Tok[] = [];
let k = numeratorStart - 1;
while (k >= 0 && toks[k].k !== "digit") {
sepToks.unshift(toks[k]);
k--;
}
let wholeDigits = 0;
while (k >= 0 && toks[k].k === "digit") {
wholeDigits++;
k--;
}
const prefix = wholeDigits > 0 ? toks.slice(0, k + 1) : toks.slice(0, numeratorStart);
if (wholeDigits === 0) sepToks.length = 0;
return {
prefix,
wholeDigits,
sepToks,
fixedDenom: mode === "fixed" ? BigInt(fixedDigits) : null,
denomDigits,
suffix: toks.slice(denomEnd),
};
}
/** 분모가 `maxDenom` 이하인 가장 가까운 분수(0 ≤ x) — 다 훑어봐도 999 번 안쪽(자리표시 최대 3 개). */
function bestFraction(x: Frac, maxDenom: bigint): { num: bigint; den: bigint } {
let bestNum = 0n;
let bestDen = 1n;
let bestDiff: Frac | null = null;
for (let den = 1n; den <= maxDenom; den++) {
const num = toInteger(mul(x, frac(den)), "round");
const gap = sub(x, frac(num, den));
const diff = frac(gap.n < 0n ? -gap.n : gap.n, gap.d);
if (bestDiff === null || cmp(diff, bestDiff) < 0) {
bestDiff = diff;
bestNum = num;
bestDen = den;
}
}
return { num: bestNum, den: bestDen };
}
function renderFractionSection(plan: FracPlan, magnitude: Frac): string {
let whole = 0n;
let remainder = magnitude;
if (plan.wholeDigits > 0) {
whole = toInteger(magnitude, "trunc");
remainder = sub(magnitude, frac(whole));
}
let num: bigint;
let den: bigint;
if (plan.fixedDenom !== null) {
den = plan.fixedDenom;
num = toInteger(mul(remainder, frac(den)), "round");
} else {
const maxDenom = 10n ** BigInt(plan.denomDigits) - 1n;
const best = bestFraction(remainder, maxDenom);
num = best.num;
den = best.den;
}
if (plan.wholeDigits > 0 && den > 0n && num >= den) {
whole += num / den;
num %= den;
}
const fracText = num !== 0n ? `${num}/${den}` : "";
let out = "";
for (const t of plan.prefix) if (t.k === "lit") out += t.ch;
if (plan.wholeDigits > 0) {
if (whole !== 0n || fracText === "") out += whole.toString();
if (fracText !== "") for (const t of plan.sepToks) if (t.k === "lit") out += t.ch;
}
out += fracText;
for (const t of plan.suffix) if (t.k === "lit") out += t.ch;
return out;
}
function generalFormat(value: Frac): string {
return fracToString(value);
}
// ── 지수(과학) 형식 `0.00E+00` ───────────────────────────────────────────────
interface SciPlan {
mantissaToks: Tok[]; // `E` 앞 가수 자리(자릿수·소수점은 수 구역과 같은 규칙)
expDigits: number; // `E+`·`E-` 뒤 자리표시 개수(모자라면 0 으로 채움)
expSign: "+" | "-"; // `E-00` 은 양의 지수에서 부호를 안 보임(엑셀과 같음)
}
/** `E`(부호 있음) 뒤 숫자 자리표시가 끝까지 이어질 때만 지수 형식 — 아니면 null. */
function detectScientific(toks: Tok[]): SciPlan | null {
for (let i = 0; i < toks.length; i++) {
const t = toks[i];
if (t.k !== "lit" || (t.ch !== "E" && t.ch !== "e")) continue;
const signTok = toks[i + 1];
if (!signTok || signTok.k !== "lit" || (signTok.ch !== "+" && signTok.ch !== "-")) continue;
let j = i + 2;
let digits = 0;
while (j < toks.length && toks[j].k === "digit") {
digits++;
j++;
}
if (digits === 0 || j !== toks.length) continue;
return { mantissaToks: toks.slice(0, i), expDigits: digits, expSign: signTok.ch };
}
return null;
}
function renderScientificSection(plan: SciPlan, magnitude: Frac): string {
const value = toDouble(magnitude);
const pointIdx = plan.mantissaToks.findIndex((t) => t.k === "point");
const fracDigits = plan.mantissaToks
.slice(pointIdx >= 0 ? pointIdx + 1 : plan.mantissaToks.length)
.filter((t) => t.k === "digit").length;
let exp = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)));
let mantissaText = (value / Math.pow(10, exp)).toFixed(fracDigits);
if (Math.abs(Number(mantissaText)) >= 10) {
exp += 1;
mantissaText = (value / Math.pow(10, exp)).toFixed(fracDigits);
}
const expSign = exp < 0 ? "-" : plan.expSign === "+" ? "+" : "";
const expText = String(Math.abs(exp)).padStart(plan.expDigits, "0");
return `${mantissaText}E${expSign}${expText}`;
}
// ── 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`) ───────────────────────
// 엑셀 일련번호(1970-01-01 = 25569) → 달력. 1900 윤년 버그는 misc.ts 와 같이 안 다룸.
const MS_PER_DAY = 86400000;
const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569;
const WEEKDAY_SHORT = ["일", "월", "화", "수", "목", "금", "토"];
function toDouble(x: Frac): number {
return Number(x.n) / Number(x.d);
}
function serialToDateUTC(serial: number): Date {
return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY));
}
type DatePart =
{ k: "lit"; text: string } | { k: "y" | "m" | "d"; width: 1 | 2 | 4 } | { k: "a"; width: 3 | 4 };
/** `y/m/d/a` 낱말 뜀(자리수가 아는 폭이 아니면 그대로 글로) — 없으면 날짜 형식 아님(null). */
function detectDateFormat(s: string): DatePart[] | null {
const parts: DatePart[] = [];
let hasDateToken = false;
let i = 0;
while (i < s.length) {
const ch = s[i];
if (ch === '"') {
let j = i + 1;
let text = "";
while (j < s.length && s[j] !== '"') {
text += s[j];
j++;
}
parts.push({ k: "lit", text });
i = j + 1;
continue;
}
if (ch === "\\") {
if (i + 1 < s.length) parts.push({ k: "lit", text: s[i + 1] });
i += 2;
continue;
}
const lower = ch.toLowerCase();
if (lower === "y" || lower === "m" || lower === "d" || lower === "a") {
let j = i;
while (j < s.length && s[j].toLowerCase() === lower) j++;
const width = j - i;
// 앞뒤가 영문 알파벳이면 딴 낱말 속에 섞인 우연한 글자(`Fixed` 의 d · `Currency` 의 y 등) —
// 자리표시로 안 봄(2026-09-28 — 그 우연 때문에 이름 형식이 날짜로 깨지던 것 고침).
const before = i > 0 ? s[i - 1] : "";
const after = j < s.length ? s[j] : "";
const boundaryOk = !/[A-Za-z]/.test(before) && !/[A-Za-z]/.test(after);
if (boundaryOk && lower === "a" && (width === 3 || width === 4)) {
parts.push({ k: "a", width });
hasDateToken = true;
} else if (
boundaryOk &&
lower !== "a" &&
(width === 1 || width === 2 || (lower === "y" && width === 4))
) {
parts.push({ k: lower as "y" | "m" | "d", width: width as 1 | 2 | 4 });
hasDateToken = true;
} else {
parts.push({ k: "lit", text: s.slice(i, j) });
}
i = j;
continue;
}
parts.push({ k: "lit", text: ch });
i++;
}
return hasDateToken ? parts : null;
}
function renderDatePart(part: DatePart, date: Date): string {
if (part.k === "lit") return part.text;
if (part.k === "y") {
const y = date.getUTCFullYear();
return part.width === 4 ? String(y) : String(y % 100).padStart(2, "0");
}
if (part.k === "m") {
const m = date.getUTCMonth() + 1;
return part.width === 1 ? String(m) : String(m).padStart(2, "0");
}
if (part.k === "d") {
const d = date.getUTCDate();
return part.width === 1 ? String(d) : String(d).padStart(2, "0");
}
const wd = WEEKDAY_SHORT[date.getUTCDay()];
return part.width === 3 ? wd : `${wd}요일`;
}
function renderDateSection(parts: DatePart[], value: Frac): string {
const date = serialToDateUTC(toDouble(value));
return parts.map((p) => renderDatePart(p, date)).join("");
}
function chooseSection(sections: string[], value: Frac): { code: string; forceMinus: boolean } {
const isNeg = cmp(value, ZERO) < 0;
const isZero = cmp(value, ZERO) === 0;
if (sections.length === 1) return { code: sections[0], forceMinus: isNeg };
if (sections.length === 2) {
return isNeg
? { code: sections[1], forceMinus: false }
: { code: sections[0], forceMinus: false };
}
if (isZero) return { code: sections[2] ?? sections[0], forceMinus: false };
if (isNeg) return { code: sections[1], forceMinus: false };
return { code: sections[0], forceMinus: false };
}
// ── 엑셀 「칸 형식 이름」(코드 대신) — `mso-number-format:Fixed` 처럼 클립보드가 이름을 넘김 ─────
// 엑셀 붙여넣기 · 일부 자리에서 형식 코드 대신 이름(Fixed·Standard·Currency·Percent·General 등)이
// 옴 — 그대로 코드로 읽으면 `y·m·d` 낱자가 날짜 자리표시로 잘못 잡혀 글자가 깨짐(2026-09-28 실측:
// `Fixed`→`Fixe11`). 우리 코드로 바꿔치기 · 모르는 이름은 일반(General)으로.
const EXCEL_NAMED_FORMATS: Record<string, string> = {
general: "General",
"general number": "General",
fixed: "0.00",
standard: "#,##0.00",
currency: "#,##0.00", // 이 문서 실측 — 통화 기호가 안 보임(사용자 확인)
percent: "0.00%",
scientific: "0.00E+00",
"short date": "yyyy-mm-dd",
"long date": "yyyy-mm-dd aaaa",
};
const BARE_WORD_RE = /^[A-Za-z][A-Za-z ]*$/;
/** 따옴표 한 겹 · 흔한 `\x` 이스케이프까지 방어적으로 풀어(대조용) 이름표와 맞대 봄 —
* 맞으면 우리 코드로 · 알파벳 낱말뿐인 모르는 이름이면 일반으로 · 아니면 원래 코드 그대로. */
function resolveExcelStyleName(code: string): string {
let key = code.trim();
if (key.length >= 2 && key.startsWith('"') && key.endsWith('"')) key = key.slice(1, -1);
key = key.replace(/\\(.)/g, "$1").trim().toLowerCase().replace(/\s+/g, " ");
const mapped = EXCEL_NAMED_FORMATS[key];
if (mapped !== undefined) return mapped;
// 알파벳 낱말뿐이면서 진짜 날짜 자리표시(`aaa` · `yyyy` 등)도 아니면 모르는 이름 — 일반으로.
if (BARE_WORD_RE.test(key) && detectDateFormat(code) === null) return "General";
return code;
}
function formatNumber(value: Frac, code: string | undefined): FormattedText {
const resolved = code === undefined ? undefined : resolveExcelStyleName(code);
if (resolved === undefined || resolved === "" || resolved.trim().toLowerCase() === "general") {
return { 글: generalFormat(value), 정렬: "right" };
}
const sections = splitSections(resolved);
const { code: chosen, forceMinus } = chooseSection(sections, value);
const { rest, color } = extractColor(chosen);
const datePlan = detectDateFormat(rest);
if (datePlan) {
const extra: Partial<FormattedText> = {};
if (color) extra.색 = color;
return { 글: renderDateSection(datePlan, value), 정렬: "right", ...extra };
}
const { toks, fillChar } = tokenize(rest);
const extra: Partial<FormattedText> = {};
if (color) extra.색 = color;
if (fillChar) extra.채움글 = fillChar;
const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value;
const sci = detectScientific(toks);
if (sci) {
const body = renderScientificSection(sci, magnitude);
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
}
const fraction = detectFraction(toks);
if (fraction) {
const body = renderFractionSection(fraction, magnitude);
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
}
if (!toks.some((t) => t.k === "digit" || t.k === "general")) {
let out = "";
for (const t of toks) if (t.k === "lit") out += t.ch;
return { 글: out, 정렬: "right", ...extra };
}
const body = renderNumericSection(toks, magnitude);
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
}
function formatText(value: string, code: string | undefined): FormattedText {
if (code === undefined || code === "") return { 글: value, 정렬: "left" };
const resolved = resolveExcelStyleName(code);
const sections = splitSections(resolved);
// 구역 하나뿐(`;` 없음)에 `@` 가 있을 때만 그 구역을 씀(순수 수 형식 하나뿐이면 글엔 안 씀) ·
// 넷째 구역은 명시적 글 자리라 `@` 없어도 그대로(고정 라벨 버릇) · 2~3 구역엔 글 자리가 없어 그대로.
let textCode: string | undefined;
if (sections.length >= 4) textCode = sections[3];
else if (sections.length === 1 && sections[0].includes("@")) textCode = sections[0];
if (textCode === undefined) return { 글: value, 정렬: "left" };
const { rest, color } = extractColor(textCode);
const { toks, fillChar } = tokenize(rest);
const extra: Partial<FormattedText> = {};
if (color) extra.색 = color;
if (fillChar) extra.채움글 = fillChar;
let out = "";
for (const t of toks) {
if (t.k === "text") out += value;
else if (t.k === "lit") out += t.ch;
else if (t.k === "percent") out += "%";
}
return { 글: out, 정렬: "left", ...extra };
}
/** `code` 없음 = 일반(수는 유효 11 자리 안쪽 · 칸 폭은 격자 몫) · 오류 값은 코드 글 그대로 */
export function formatValue(value: Scalar, code: string | undefined): FormattedText {
if (isErrorScalar(value)) return { 글: value.error, 정렬: "center" };
if (value === null) return { 글: "", 정렬: "right" };
if (typeof value === "boolean") return { 글: value ? "TRUE" : "FALSE", 정렬: "center" };
if (typeof value === "string") return formatText(value, code);
return formatNumber(value, code);
}
@@ -0,0 +1,342 @@
/* =============================================================================
* spreadsheet_parser.ts (주인 A)
* 수식 글 → 낱말 · 나무 · 나무 → 글. `eval` 금지 — 직접 짠 파서만.
* 우선순위는 엑셀과 같음: 범위 `:` > 부호 `-` `+` > `%` > `^` > `* /` > `+ -` > `&` > 비교
* (부호가 `^` 보다 먼저 — `-2^2 = 4`). 같은 단은 왼쪽부터(`^` 도 왼쪽부터 · 엑셀과 같음).
* 글 `"…"` 안 `""` = 따옴표 하나 · 시트 `'이름'!A1` · `이름!A1` · 오류 값 글자 · TRUE/FALSE.
* 참조 낱말 풀기(`parseRef` · `renderRef`)는 참조 옮김(`spreadsheet_refshift.ts`)이 같이 씀.
* ========================================================================== */
import { parseDecimal, ZERO } from "@ui/sheet/ui_template_sheet_frac";
import { colIndex, colName, quoteSheet, rowIndex } from "./spreadsheet_address";
import { getFunction } from "./spreadsheet_functions";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type { ErrorCode, FormulaNode, ParseResult, RefCell, Token } from "./spreadsheet_types";
// ── 참조 낱말 ────────────────────────────────────────────────────────────────
const IDC = "[\\p{L}\\p{N}_.]";
const END = `(?!${IDC}|\\()`;
const CELL = "(\\$?)([A-Za-z]{1,3})(\\$?)(\\d+)";
const RE_PREFIX = /^(?:'((?:[^']|'')+)'|([\p{L}\p{N}_.]+))!/u;
const RE_AREA = new RegExp(`^${CELL}(?::${CELL})?${END}`, "u");
const RE_COLS = new RegExp(`^(\\$?)([A-Za-z]{1,3}):(\\$?)([A-Za-z]{1,3})${END}`, "u");
const RE_ROWS = new RegExp(`^(\\$?)(\\d+):(\\$?)(\\d+)${END}`, "u");
const RE_ERROR = /^#(NULL!|DIV\/0!|VALUE!|REF!|NAME\?|NUM!|N\/A|CYCLE!)/i;
const RE_IDENT = /^[\p{L}_\\][\p{L}\p{N}_.]*/u;
/** 참조 낱말 하나 — `kind` cell = 칸 · area = `A1:B2` · cols = `A:C` · rows = `3:5`. */
export interface RefParts {
/** 적힌 시트 머리 글 그대로(`'돌-골막이'!`) · 없으면 "" */
prefix: string;
sheet: string | null;
kind: "cell" | "area" | "cols" | "rows";
a: RefCell;
b: RefCell;
}
/** 참조 글 → 조각 · 참조가 아니면 null(주소가 격자 밖이어도 null → 이름) */
export function parseRef(text: string): RefParts | null {
const pm = RE_PREFIX.exec(text);
const prefix = pm ? pm[0] : "";
const sheet = pm ? (pm[1] !== undefined ? pm[1].replace(/''/g, "'") : pm[2]) : null;
const body = text.slice(prefix.length);
const pt = (sr: string, col: number, sc: string, row: number): RefCell => ({
r: row,
c: col,
absR: sc === "$",
absC: sr === "$",
});
let m = RE_AREA.exec(body);
if (m && m[0].length === body.length) {
const a = pt(m[1], colIndex(m[2]), m[3], rowIndex(m[4]));
const b = m[5] === undefined ? { ...a } : pt(m[5], colIndex(m[6]), m[7], rowIndex(m[8]));
if (a.r < 0 || a.c < 0 || b.r < 0 || b.c < 0) return null;
return { prefix, sheet, kind: m[5] === undefined ? "cell" : "area", a, b };
}
m = RE_COLS.exec(body);
if (m && m[0].length === body.length) {
const c0 = colIndex(m[2]);
const c1 = colIndex(m[4]);
if (c0 < 0 || c1 < 0) return null;
return {
prefix,
sheet,
kind: "cols",
a: pt(m[1], c0, "", 0),
b: pt(m[3], c1, "", MAX_ROWS - 1),
};
}
m = RE_ROWS.exec(body);
if (m && m[0].length === body.length) {
const r0 = rowIndex(m[2]);
const r1 = rowIndex(m[4]);
if (r0 < 0 || r1 < 0) return null;
return {
prefix,
sheet,
kind: "rows",
a: pt("", 0, m[1], r0),
b: pt("", MAX_COLS - 1, m[3], r1),
};
}
return null;
}
const cellText = (p: RefCell): string =>
`${p.absC ? "$" : ""}${colName(p.c)}${p.absR ? "$" : ""}${p.r + 1}`;
/** 조각 → 참조 글(시트 머리는 `prefix` 그대로) */
export function renderRef(p: RefParts): string {
if (p.kind === "cols")
return `${p.prefix}${p.a.absC ? "$" : ""}${colName(p.a.c)}:${p.b.absC ? "$" : ""}${colName(p.b.c)}`;
if (p.kind === "rows")
return `${p.prefix}${p.a.absR ? "$" : ""}${p.a.r + 1}:${p.b.absR ? "$" : ""}${p.b.r + 1}`;
return p.prefix + cellText(p.a) + (p.kind === "area" ? `:${cellText(p.b)}` : "");
}
// ── 낱말 나누기 ───────────────────────────────────────────────────────────────
/** 앞 `=` 뺀 식 글 → 낱말(빈칸 낱말 포함 · 끝까지 · 못 읽는 글은 `unknown`) — 편집기 색칠 · 가리키기용.
* 이름(정의 안 된 글자 묶음)도 `unknown` — 파서가 이름 나무로 읽음. */
export function tokenize(formula: string): Token[] {
const out: Token[] = [];
let i = 0;
let ref: string | null;
const push = (kind: Token["kind"], len: number) => {
out.push({ kind, text: formula.slice(i, i + len), start: i, end: i + len });
i += len;
};
while (i < formula.length) {
const rest = formula.slice(i);
const ch = rest[0];
let m: RegExpExecArray | null;
if ((m = /^\s+/.exec(rest))) push("space", m[0].length);
else if (ch === '"') {
const s = /^"(?:[^"]|"")*"?/.exec(rest)!;
push("string", s[0].length);
} else if ((m = /^([\p{L}_][\p{L}\p{N}_.]*)\(/u.exec(rest)) && !/^(TRUE|FALSE)$/i.test(m[1]))
push("func", m[1].length);
else if ((ref = refAt(rest))) push(/#REF!$/i.test(ref) ? "error" : "ref", ref.length);
else if ((m = /^(TRUE|FALSE)(?![\p{L}\p{N}_.(])/iu.exec(rest))) push("bool", m[0].length);
else if ((m = RE_ERROR.exec(rest))) push("error", m[0].length);
else if ((m = /^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec(rest))) push("number", m[0].length);
else if ((m = RE_IDENT.exec(rest))) push("unknown", m[0].length);
else if ((m = /^(<>|<=|>=|[-+*/^&=<>%:])/.exec(rest))) push("op", m[0].length);
else if (ch === "(" || ch === ")") push("paren", 1);
else if (ch === ",") push("comma", 1);
else push("unknown", 1);
}
return out;
}
/** 머리 이 자리에서 시작하는 참조(또는 `시트!#REF!`) 글 */
function refAt(rest: string): string | null {
const head = RE_PREFIX.exec(rest)?.[0] ?? "";
const body = rest.slice(head.length);
if (head && /^#REF!/i.test(body)) return head + body.slice(0, 5);
for (const re of [RE_AREA, RE_COLS, RE_ROWS]) {
const m = re.exec(body);
if (m && parseRef(head + m[0])) return head + m[0];
}
return null;
}
// ── 나무 ────────────────────────────────────────────────────────────────────
class Fail {
constructor(
readonly message: string,
readonly at: number,
) {}
}
const COMPARE = ["=", "<>", "<", "<=", ">", ">="];
// ponytail: B 함수 표 min 이 엑셀보다 낮은 것만 덧댐 — sub5 가 표를 고치면 지움
const EXCEL_MIN: Record<string, number> = {
SUM: 1,
MIN: 1,
MAX: 1,
COUNT: 1,
COUNTA: 1,
PRODUCT: 1,
};
/** 앞 `=` 뺀 식 글 → 나무. 앞에 `=` 가 붙어 와도 됨. 함수 이름은 대문자로. */
export function parseFormula(formula: string): ParseResult {
const text = formula.startsWith("=") ? formula.slice(1) : formula;
const toks = tokenize(text).filter((t) => t.kind !== "space");
let p = 0;
const peek = (): Token | undefined => toks[p];
const isOp = (...ops: string[]) => {
const t = toks[p];
return !!t && t.kind === "op" && ops.includes(t.text);
};
const where = () => (toks[p] ? toks[p].start : text.length);
const binary = (ops: string[], next: () => FormulaNode) => (): FormulaNode => {
let left = next();
while (isOp(...ops)) {
const op = toks[p++].text as "+";
left = { type: "binary", op, left, right: next() };
}
return left;
};
const primary = (): FormulaNode => {
const t = peek();
if (!t) throw new Fail("식이 덜 끝남", text.length);
p++;
switch (t.kind) {
case "number":
return { type: "number", value: parseDecimal(t.text), text: t.text };
case "string":
if (t.text.length < 2 || !t.text.endsWith('"')) throw new Fail("따옴표가 안 닫힘", t.start);
return { type: "string", value: t.text.slice(1, -1).replace(/""/g, '"') };
case "bool":
return { type: "bool", value: t.text.toUpperCase() === "TRUE" };
case "error":
return {
type: "error",
code: t.text.slice(t.text.lastIndexOf("#")).toUpperCase() as ErrorCode,
};
case "ref": {
const r = parseRef(t.text)!;
return r.kind === "cell"
? { type: "cell", sheet: r.sheet, ref: r.a }
: { type: "range", sheet: r.sheet, from: r.a, to: r.b };
}
case "func": {
const name = t.text.toUpperCase().replace(/^_XLFN\./, "");
p++; // `(`
const args: FormulaNode[] = [];
// 인자 수는 엑셀처럼 입력 때 막음(편집기가 이 실패로 Enter 를 막음) · 모르는 함수는 풀 때 #NAME?
const done = (): FormulaNode => {
const fn = getFunction(name);
const lo = Math.max(fn?.min ?? 0, EXCEL_MIN[name] ?? 0);
if (fn && args.length < lo)
throw new Fail(`${name} 인수가 너무 적음(${lo} 개부터)`, t.start);
if (fn?.max !== undefined && args.length > fn.max)
throw new Fail(`${name} 인수가 너무 많음(${fn.max} 개까지)`, t.start);
return { type: "call", name, args };
};
if (peek()?.text === ")") {
p++;
return done();
}
for (;;) {
const k = peek();
// 빈 인자(`IF(A1,,1)`) = 0 · 글은 비움
if (k && (k.kind === "comma" || k.text === ")"))
args.push({ type: "number", value: ZERO, text: "" });
else args.push(compare());
const s = toks[p++];
if (s?.kind === "comma") continue;
if (s?.text === ")") return done();
throw new Fail("함수 괄호가 안 닫힘", s ? s.start : text.length);
}
}
case "paren": {
if (t.text !== "(") break;
const inner = compare();
if (peek()?.text !== ")") throw new Fail("괄호가 안 닫힘", where());
p++;
return inner;
}
case "unknown":
if (RE_IDENT.exec(t.text)?.[0] === t.text) return { type: "name", name: t.text };
}
throw new Fail(`읽지 못한 글: ${t.text}`, t.start);
};
const unary = (): FormulaNode => {
if (isOp("-", "+")) {
const op = toks[p++].text as "-";
return { type: "unary", op, arg: unary() };
}
return primary();
};
const percent = (): FormulaNode => {
let node = unary();
while (isOp("%")) {
p++;
node = { type: "unary", op: "%", arg: node };
}
return node;
};
const power = binary(["^"], percent);
const mul = binary(["*", "/"], power);
const add = binary(["+", "-"], mul);
const concat = binary(["&"], add);
const compare = binary(COMPARE, concat);
try {
if (!toks.length) throw new Fail("빈 식", 0);
const node = compare();
if (p < toks.length) throw new Fail(`남은 글: ${toks[p].text}`, toks[p].start);
return { ok: true, node };
} catch (e) {
if (e instanceof Fail) return { ok: false, message: e.message, at: e.at };
throw e;
}
}
// ── 나무 → 글 ─────────────────────────────────────────────────────────────────
const PREC: Record<string, number> = { "&": 2, "+": 3, "-": 3, "*": 4, "/": 4, "^": 5 };
for (const op of COMPARE) PREC[op] = 1;
function prec(node: FormulaNode): number {
if (node.type === "binary") return PREC[node.op];
if (node.type === "unary") return node.op === "%" ? 6 : 7;
return 8;
}
const sheetHead = (sheet: string | null) => (sheet === null ? "" : `${quoteSheet(sheet)}!`);
/** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 · 괄호는 필요한 곳만.
* 계산 단계 보기용: `sub` 가 글을 주는 마디는 그 글(푼 값)로 갈음 · `mark` 마디는 \u0001 … \u0002 로 감쌈 */
export function formulaToText(
node: FormulaNode,
sub?: (node: FormulaNode) => string | undefined,
mark?: FormulaNode,
): string {
const own = sub?.(node);
if (own !== undefined) return own;
if (node === mark) return `\u0001${formulaToText(node, sub)}\u0002`;
const text = (child: FormulaNode) => formulaToText(child, sub, mark);
const wrap = (child: FormulaNode, need: boolean) =>
need && sub?.(child) === undefined ? `(${text(child)})` : text(child);
switch (node.type) {
case "number":
return node.text;
case "string":
return `"${node.value.replace(/"/g, '""')}"`;
case "bool":
return node.value ? "TRUE" : "FALSE";
case "error":
return node.code;
case "name":
return node.name;
case "cell":
return sheetHead(node.sheet) + cellText(node.ref);
case "range": {
const { from, to } = node;
const kind =
from.r === 0 && to.r === MAX_ROWS - 1
? "cols"
: from.c === 0 && to.c === MAX_COLS - 1
? "rows"
: "area";
return renderRef({ prefix: sheetHead(node.sheet), sheet: node.sheet, kind, a: from, b: to });
}
case "unary":
return node.op === "%"
? `${wrap(node.arg, prec(node.arg) < 6)}%`
: `${node.op}${wrap(node.arg, prec(node.arg) < 7)}`;
case "binary": {
const p = PREC[node.op];
return `${wrap(node.left, prec(node.left) < p)}${node.op}${wrap(node.right, prec(node.right) <= p)}`;
}
case "call":
return `${node.name}(${node.args.map(text).join(",")})`;
}
}
@@ -0,0 +1,140 @@
/* =============================================================================
* spreadsheet_paste_special.ts (2단계 · 주인 sub_laptop_1)
* 붙여넣기 골라서 — 값만 · 서식만 · 수식만(Ctrl+Shift+V). `ClipBlock`(0 계약) 을 받아
* 대상 왼위(`at`)에 맞게 명령을 만듦 — 서식은 `서식` 명령(칸마다 하나 · A 가 표 번호를 매김) ·
* 값 · 식은 `칸` 명령(대상 칸의 지금 서식 번호는 그대로 둠). 참조 옮김은 「원점」 이 있을 때만
* `$` 없는 A1 참조를 얕게 옮김(진짜 옮김은 A `spreadsheet_refshift` 몫 — 여긴 메뉴용 편의).
*
* 잇는 법(D · sub7) — 클립보드(E)가 만든 `ClipBlock` 을 들고:
* 1) 우클릭 메뉴 · Ctrl+Shift+V 로 모드 고르는 작은 팝업을 띄움(값만 · 서식만 · 수식만).
* 2) 고른 모드로 `pasteSpecial(ctx, block, ctx.selection.활성, mode, sourceOrigin?)` 호출.
* 3) 「값만」 은 원본이 아직 화면에 있을 때만 계산값을 살릴 수 있음 — `sourceOrigin` 을 줌(없으면 식 칸은 건너뜀).
* ========================================================================== */
import type {
Cell,
CellAddress,
CellRange,
CellStyle,
ClipBlock,
ClipCell,
Command,
Frac,
Scalar,
} from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
import { fracToString } from "@ui/sheet/ui_template_sheet_frac";
export type PasteSpecialMode = "값" | "서식" | "수식";
const a1 = (r: number, c: number): string => {
let col = c + 1;
let letters = "";
while (col > 0) {
const rem = (col - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
col = Math.floor((col - 1) / 26);
}
return `${letters}${r + 1}`;
};
/** `$` 없는 A1 참조만 (dr, dc) 만큼 옮김 — 절대참조 · 다른 시트 이름은 손대지 않음(안전한 최소). */
export function shiftRelativeRefs(formula: string, dr: number, dc: number): string {
return formula.replace(
/(\$?)([A-Z]{1,3})(\$?)(\d+)/g,
(whole, absC, colLetters, absR, rowDigits) => {
if (absC === "$" && absR === "$") return whole;
let col = 0;
for (const ch of colLetters) col = col * 26 + (ch.charCodeAt(0) - 64);
const nextCol = absC === "$" ? col : col + dc;
const nextRow = absR === "$" ? Number(rowDigits) : Number(rowDigits) + dr;
if (nextCol < 1 || nextRow < 1) return "#REF!";
let letters = "";
let n = nextCol;
while (n > 0) {
const rem = (n - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
n = Math.floor((n - 1) / 26);
}
return `${absC}${letters}${absR}${nextRow}`;
},
);
}
export interface PasteSpecialSource {
시트: string;
r: number;
c: number;
}
/** `block` 을 `at` 에 골라 붙임. `sourceOrigin` 은 「값만」 때 식 칸을 계산값으로 굳히는 데 씀(없으면 식 칸은 건너뜀). */
export function pasteSpecial(
ctx: SpreadsheetContext,
block: ClipBlock,
at: CellAddress,
mode: PasteSpecialMode,
sourceOrigin?: PasteSpecialSource,
): void {
const sheetId = ctx.selection.시트;
const sheet = ctx.sheet();
const dr = at.r - (block.원점?.r ?? at.r);
const dc = at.c - (block.원점?.c ?? at.c);
const commands: Command[] = [];
const 칸: Record<string, Cell | null> = {};
for (const [key, clip] of Object.entries(block.칸)) {
const [rowStr, colStr] = key.split(",");
const rr = at.r + Number(rowStr);
const cc = at.c + Number(colStr);
if (rr < 0 || cc < 0) continue;
const target = a1(rr, cc);
const existing = sheet.칸[target];
if (mode === "서식") {
if (clip.서식) commands.push(styleCommand(sheetId, rr, cc, clip.서식));
continue;
}
if (mode === "수식") {
if (clip.식 === undefined) continue;
const moved = block.원점 ? shiftRelativeRefs(clip.식, dr, dc) : clip.식;
칸[target] = { 식: moved, 서식: existing?.서식 };
continue;
}
// mode === "값"
const value = resolveClipValue(clip, sourceOrigin, Number(rowStr), Number(colStr), ctx);
if (value === undefined) continue;
칸[target] = { 값: value, 서식: existing?.서식 };
}
if (Object.keys(칸).length > 0) commands.push({ 종류: "칸", 시트: sheetId, 칸 });
if (commands.length === 0) return;
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
}
function styleCommand(sheetId: string, r: number, c: number, style: CellStyle): Command {
const 범위: CellRange = { r0: r, c0: c, r1: r, c1: c };
return { 종류: "서식", 시트: sheetId, 범위: [범위], 바꿀: { ...style } };
}
function resolveClipValue(
clip: ClipCell,
origin: PasteSpecialSource | undefined,
dr: number,
dc: number,
ctx: SpreadsheetContext,
): Cell["값"] | undefined {
if (clip.식 !== undefined) {
if (!origin) return undefined;
const scalar = ctx.engine.value(origin.시트, origin.r + dr, origin.c + dc);
return scalarFrom(scalar);
}
return clip.값;
}
function scalarFrom(value: Scalar): Cell["값"] | undefined {
if (value === null) return undefined;
if (typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "object" && "n" in value && "d" in value)
return Number(fracToString(value as Frac));
return undefined;
}
@@ -0,0 +1,12 @@
/* =============================================================================
* spreadsheet_recalc.ts (주인 A)
* 통합문서 한 벌 풀이 — 서버 Node 진입(`common_util_spreadsheet_node.ts`) · 시험이 부름.
* 화면과 같은 엔진(`createCalcEngine`)을 한 번 돌려 `snapshot()` — 계산이 두 벌이 되지 않게.
* ========================================================================== */
import { createCalcEngine } from "./spreadsheet_graph";
import type { CalcValues, Workbook } from "./spreadsheet_types";
export function recalcWorkbook(book: Workbook): CalcValues {
return createCalcEngine(book).snapshot();
}
@@ -0,0 +1,179 @@
/* =============================================================================
* spreadsheet_refshift.ts (주인 A)
* 참조 옮김 — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 바꾸기 · 시트 지우기 때 식 글을 고쳐 적음
* (엑셀 규칙) · 복사 · 채우기 때 상대 참조만 옮김 · 구글 R1C1 식 → A1.
* 지운 칸 · 격자 밖으로 나간 참조는 `#REF!` · 범위는 남은 만큼 줄어듦(끝까지 지우면 `#REF!`).
* 낱말 단위로 참조만 바꿔 끼움 — 빈칸 · 괄호 · 수 글은 적힌 그대로 둠.
*
* 엑셀 규칙표:
* 넣기 — 넣는 자리 이후를 가리키면 밀림 · 범위가 넣는 자리를 품으면 늘어남(첫 행 앞 넣기는 통째 밀림)
* 지우기 — 지운 칸 하나 = `#REF!` · 범위는 남은 만큼 · 전부 지우면 `#REF!` · `$` 도 똑같이 옮김
* 옮기기 — 옮긴 덩어리 안을 가리키던 참조(범위는 통째 들 때만)는 따라감 · 덮어쓴 자리를 가리키면 `#REF!`
* 복사 — 상대만 (dr, dc) · 행 · 열 전체 범위는 그 축을 안 옮김
* ========================================================================== */
import { quoteSheet, rangeInside } from "./spreadsheet_address";
import { parseRef, renderRef, tokenize } from "./spreadsheet_parser";
import type { RefParts } from "./spreadsheet_parser";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type { CellAddress, CellRange, Command, Workbook } from "./spreadsheet_types";
const REF_ERR = "#REF!";
/** 참조 낱말만 `fn` 으로 바꿔 끼움(null = `#REF!`) */
function mapRefs(formula: string, fn: (p: RefParts) => RefParts | string | null): string {
let out = "";
for (const t of tokenize(formula)) {
const p = t.kind === "ref" ? parseRef(t.text) : null;
if (!p) {
out += t.text;
continue;
}
const q = fn(p);
out += q === null ? REF_ERR : typeof q === "string" ? q : renderRef(q);
}
return out;
}
/** 넣기 · 지우기 한 축 — [lo, hi] → 새 [lo, hi] · 다 지워지면 null */
export function shiftSpan(
lo: number,
hi: number,
insert: boolean,
at: number,
n: number,
max: number,
): [number, number] | null {
if (insert) {
if (lo >= at) [lo, hi] = [lo + n, hi + n];
else if (hi >= at) hi += n;
if (lo >= max) return null;
return [lo, Math.min(hi, max - 1)];
}
const end = at + n - 1;
if (lo >= at && hi <= end) return null;
return [lo < at ? lo : lo > end ? lo - n : at, hi < at ? hi : hi > end ? hi - n : at - 1];
}
const sheetIdOf = (book: Workbook, name: string) =>
book.시트.find((s) => s.이름.toLowerCase() === name.toLowerCase())?.id ?? null;
const box = (p: RefParts): CellRange => ({
r0: Math.min(p.a.r, p.b.r),
c0: Math.min(p.a.c, p.b.c),
r1: Math.max(p.a.r, p.b.r),
c1: Math.max(p.a.c, p.b.c),
});
/** 행열 · 옮기기 · 시트이름 · 시트지우기 명령에 맞춰 식 글 고침(식이 든 시트 id = `host`) — 안 바뀌면 같은 글.
* `book` 은 명령 적용 **전** 문서(시트 이름으로 id 를 찾음). `newHost` = 옮기기로 식 칸 자체가 간 시트. */
export function shiftForCommand(
formula: string,
host: string,
command: Command,
book: Workbook,
newHost: string = host,
): string {
if (command.종류 === "묶음")
return command.명령.reduce((f, c) => shiftForCommand(f, host, c, book, newHost), formula);
const target = (p: RefParts) => (p.sheet === null ? host : sheetIdOf(book, p.sheet));
const nameOf = (id: string) => book.시트.find((s) => s.id === id)?.이름 ?? "";
switch (command.종류) {
case "행넣기":
case "행지우기":
case "열넣기":
case "열지우기": {
const rows = command.종류.startsWith("행");
const insert = command.종류.endsWith("넣기");
return mapRefs(formula, (p) => {
if (target(p) !== command.시트) return p;
if (rows ? p.kind === "cols" : p.kind === "rows") return p; // 다른 축 전체 범위
const [lo, hi] = rows
? [p.a.r, p.b.r].sort((x, y) => x - y)
: [p.a.c, p.b.c].sort((x, y) => x - y);
const span = shiftSpan(lo, hi, insert, command.at, command.수, rows ? MAX_ROWS : MAX_COLS);
if (!span) return null;
const q = structuredClone(p);
// 적힌 차례(뒤집힌 범위)는 바로 세움 — 엑셀도 다시 적을 때 바로 세움
if (rows) [q.a.r, q.b.r] = span;
else [q.a.c, q.b.c] = span;
return q;
});
}
case "옮기기": {
const src = command.범위;
const dr = command.r - src.r0;
const dc = command.c - src.c0;
const dst: CellRange = { r0: command.r, c0: command.c, r1: src.r1 + dr, c1: src.c1 + dc };
return mapRefs(formula, (p) => {
const t = target(p);
if (t === null) return p;
const b = box(p);
let to = t;
const q = structuredClone(p);
if (t === command.시트 && p.kind !== "cols" && p.kind !== "rows" && rangeInside(b, src)) {
to = command.대상시트;
for (const pt of [q.a, q.b]) {
pt.r += dr;
pt.c += dc;
}
} else if (t === command.대상시트 && rangeInside(b, dst)) return null;
if (to === t && newHost === host) return q;
q.prefix = to === newHost ? "" : `${quoteSheet(nameOf(to))}!`;
return q;
});
}
case "시트이름":
return mapRefs(formula, (p) =>
p.sheet !== null && target(p) === command.시트
? { ...p, prefix: `${quoteSheet(command.이름)}!` }
: p,
);
case "시트지우기":
return mapRefs(formula, (p) => (p.sheet !== null && target(p) === command.시트 ? null : p));
default:
return formula;
}
}
/** 복사 · 채우기 — 상대 참조만 (dr, dc) 옮김 · 격자 밖은 `#REF!` */
export function moveFormula(formula: string, dr: number, dc: number): string {
return mapRefs(formula, (p) => {
const q = structuredClone(p);
for (const pt of [q.a, q.b]) {
if (!pt.absR && p.kind !== "cols") pt.r += dr;
if (!pt.absC && p.kind !== "rows") pt.c += dc;
if (pt.r < 0 || pt.r >= MAX_ROWS || pt.c < 0 || pt.c >= MAX_COLS) return null;
}
return q;
});
}
/** 구글 `data-sheets-formula`(`=2*R[0]C[-1]`) → 붙일 자리 기준 A1 식(앞 `=` 뺌) */
export function r1c1ToA1(formula: string, at: CellAddress): string {
const text = formula.startsWith("=") ? formula.slice(1) : formula;
const axis = (part: string | undefined, base: number): [number, boolean] =>
part === undefined || part === ""
? [base, false]
: part.startsWith("[")
? [base + Number(part.slice(1, -1)), false]
: [Number(part) - 1, true];
// 글 · 따옴표 시트 이름은 건너뜀 · 이름 한가운데(`ROUND`)는 안 건드림
return text.replace(
/("(?:[^"]|"")*"|'(?:[^']|'')*')|(?<![\p{L}\p{N}_.$])R(\[-?\d+\]|\d+)?C(\[-?\d+\]|\d+)?(?![\p{L}\p{N}_.(])/gu,
(whole, quoted: string | undefined, rp: string | undefined, cp: string | undefined) => {
if (quoted) return whole;
const [r, absR] = axis(rp, at.r);
const [c, absC] = axis(cp, at.c);
if (r < 0 || r >= MAX_ROWS || c < 0 || c >= MAX_COLS) return REF_ERR;
return renderRef({
prefix: "",
sheet: null,
kind: "cell",
a: { r, c, absR, absC },
b: { r, c, absR, absC },
});
},
);
}
@@ -0,0 +1,93 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_soil_ditch",
"서식": [
{},
{ "크기": 12, "굵게": true },
{
"가로": "center",
"세로": "center",
"채움": "#F2F2F2",
"테두리": {
"위": { "선": "thin" },
"아래": { "선": "thin" },
"왼": { "선": "thin" },
"오른": { "선": "thin" }
}
},
{
"가로": "distributed",
"세로": "center",
"테두리": { "위": { "선": "hair" }, "아래": { "선": "hair" } }
},
{ "세로": "center", "테두리": { "위": { "선": "hair" }, "아래": { "선": "hair" } } },
{
"세로": "center",
"형식": "0.00",
"테두리": { "위": { "선": "hair" }, "아래": { "선": "hair" } }
},
{ "형식": "0.00" }
],
"시트": [
{
"id": "s1",
"이름": "토사개거",
"칸": {
"A1": { "값": "□수로형 토사개거 산출근거", "서식": 1 },
"A3": { "값": "공종", "서식": 2 },
"B3": { "값": "규격", "서식": 2 },
"C3": { "값": "산출 내역", "서식": 2 },
"D3": { "서식": 2 },
"E3": { "서식": 2 },
"F3": { "서식": 2 },
"G3": { "값": "단위", "서식": 2 },
"H3": { "값": "수량", "서식": 2 },
"A4": { "값": "터파기", "서식": 3 },
"B4": { "값": "토사", "서식": 4 },
"C4": { "값": "(윗폭+밑폭)÷2×깊이", "서식": 4 },
"G4": { "값": "㎥", "서식": 4 },
"H4": { "식": "ROUNDDOWN((J4+J5)/2*J6,2)", "서식": 5 },
"A5": { "값": "되메우기", "서식": 3 },
"B5": { "값": "토사", "서식": 4 },
"C5": { "값": "터파기×0.3", "서식": 4 },
"G5": { "값": "㎥", "서식": 4 },
"H5": { "식": "ROUNDDOWN(H4*0.3,2)", "서식": 5 },
"A6": { "값": "계", "서식": 3 },
"H6": { "식": "SUM(H4:H5)", "서식": 5 },
"J4": { "식": "치수!B1", "서식": 6 },
"J5": { "값": 0.3, "서식": 6 },
"J6": { "값": 0.3, "서식": 6 }
},
"병합": ["A1:H1", "C3:F3", "C4:F4", "C5:F5", "A6:G6"],
"열": {
"A": { "폭": 10 },
"C": { "폭": 6 },
"D": { "폭": 6 },
"E": { "폭": 6 },
"F": { "폭": 6 },
"J": { "숨김": true }
},
"행": { "1": { "높이": 20 } },
"틀고정": { "행": 3 },
"보기": { "눈금선": false }
},
{
"id": "s2",
"이름": "치수",
"칸": {
"A1": { "값": "윗폭(m)" },
"B1": { "값": 0.9, "서식": 6 }
}
}
],
"활성": "s1",
"계산값": {
"s1": {
"H4": { "수": "0.18" },
"H5": { "수": "0.05" },
"H6": { "수": "0.23" },
"J4": { "수": "0.9" }
}
}
}
@@ -0,0 +1,289 @@
/* =============================================================================
* spreadsheet_selection.ts (주인 D)
* 고름 모델 — 한 칸 · 범위 · 행열 전체 · 전체 · 방향키(Shift 늘림 · Ctrl 끝으로) · 병합으로 늘림 ·
* 고름 안 Enter/Tab 돌기. DOM 모름(시험이 그대로 부름). 엑셀 동작이 기준.
* ========================================================================== */
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type { CellAddress, CellRange, Sheet } from "./spreadsheet_types";
import type { Selection } from "./spreadsheet_view_types";
export const LAST_R = MAX_ROWS - 1;
export const LAST_C = MAX_COLS - 1;
export function merges(sheet: Sheet): CellRange[] {
const out: CellRange[] = [];
for (const t of sheet.병합 ?? []) {
const m = parseRange(t);
if (m) out.push(m);
}
return out;
}
const overlaps = (a: CellRange, b: CellRange): boolean =>
a.r0 <= b.r1 && b.r0 <= a.r1 && a.c0 <= b.c1 && b.c0 <= a.c1;
export const inRange = (g: CellRange, r: number, c: number): boolean =>
r >= g.r0 && r <= g.r1 && c >= g.c0 && c <= g.c1;
/** 병합 일부를 걸친 범위 → 병합 전체를 품게 늘림(늘린 뒤 새로 걸친 것도) */
export function expand(range: CellRange, list: CellRange[]): CellRange {
const g = { ...range };
for (let grew = true; grew;) {
grew = false;
for (const m of list) {
if (!overlaps(g, m)) continue;
if (m.r0 < g.r0 || m.r1 > g.r1 || m.c0 < g.c0 || m.c1 > g.c1) {
g.r0 = Math.min(g.r0, m.r0);
g.r1 = Math.max(g.r1, m.r1);
g.c0 = Math.min(g.c0, m.c0);
g.c1 = Math.max(g.c1, m.c1);
grew = true;
}
}
}
return g;
}
/** 칸이 든 병합(없으면 칸 하나) */
export function mergeAt(sheet: Sheet, r: number, c: number): CellRange {
return merges(sheet).find((m) => inRange(m, r, c)) ?? { r0: r, c0: c, r1: r, c1: c };
}
/** 숨긴 행 · 필터로 걸러진 행 · 접힌 묶음(방향키 · Enter 가 건너뜀 — 엑셀) */
export function rowHidden(sheet: Sheet, r: number): boolean {
const row = sheet.행?.[String(r + 1)];
return !!(row?.숨김 || row?.걸러짐 || row?.묶음?.접힘);
}
export function colHidden(sheet: Sheet, c: number): boolean {
const cols = sheet.열;
if (!cols) return false;
const col = cols[colName(c)];
return !!(col?.숨김 || col?.묶음?.접힘);
}
export function hasValue(sheet: Sheet, r: number, c: number): boolean {
const cell = sheet.칸[toA1(r, c)];
return !!cell && (cell.식 !== undefined || (cell.값 !== undefined && cell.값 !== ""));
}
export function span(a: CellAddress, b: CellAddress): CellRange {
return {
r0: Math.min(a.r, b.r),
c0: Math.min(a.c, b.c),
r1: Math.max(a.r, b.r),
c1: Math.max(a.c, b.c),
};
}
/** 칸 하나 고름 — 병합 안이면 병합 전체 · 활성은 병합 왼위 */
export function selectCell(sheet: Sheet, r: number, c: number): Selection {
const m = mergeAt(sheet, r, c);
const at = { r: m.r0, c: m.c0 };
return { 시트: sheet.id, 범위: [m], 활성: at, 기준: { ...at } };
}
/** 닻(기준)에서 `to` 까지 — 활성은 닻 그대로(엑셀 Shift) */
export function selectSpan(sheet: Sheet, anchor: CellAddress, to: CellAddress): Selection {
const g = expand(span(anchor, to), merges(sheet));
return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } };
}
/** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */
export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection {
const r0 = Math.min(a, b);
const at = { r: active ?? r0, c: firstVisible(sheet, "c") };
return {
시트: sheet.id,
범위: [expand({ r0, r1: Math.max(a, b), c0: 0, c1: LAST_C }, merges(sheet))],
활성: at,
기준: { ...at },
};
}
export function selectCols(sheet: Sheet, a: number, b: number, active?: number): Selection {
const c0 = Math.min(a, b);
const at = { r: firstVisible(sheet, "r"), c: active ?? c0 };
return {
시트: sheet.id,
범위: [expand({ r0: 0, r1: LAST_R, c0, c1: Math.max(a, b) }, merges(sheet))],
활성: at,
기준: { ...at },
};
}
export function selectAll(sheet: Sheet, active?: CellAddress): Selection {
const at = active ?? { r: firstVisible(sheet, "r"), c: firstVisible(sheet, "c") };
return {
시트: sheet.id,
범위: [{ r0: 0, c0: 0, r1: LAST_R, c1: LAST_C }],
활성: at,
기준: { ...at },
};
}
function firstVisible(sheet: Sheet, axis: "r" | "c"): number {
let i = 0;
while (i < 200 && (axis === "r" ? rowHidden(sheet, i) : colHidden(sheet, i))) i++;
return i;
}
/** 한 칸 옮김 — 숨김 건너뜀 · 끝에서 멈춤 */
function step(sheet: Sheet, at: CellAddress, dr: number, dc: number): CellAddress {
let { r, c } = at;
do {
r += dr;
c += dc;
} while (
r >= 0 &&
r <= LAST_R &&
c >= 0 &&
c <= LAST_C &&
((dr && rowHidden(sheet, r)) || (dc && colHidden(sheet, c)))
);
if (r < 0 || r > LAST_R || c < 0 || c > LAST_C) return at;
return { r, c };
}
/** 한 줄(행 또는 열)에서 값 든 자리 — 오름차순 */
function occupied(sheet: Sheet, at: CellAddress, vertical: boolean): number[] {
const out: number[] = [];
for (const key of Object.keys(sheet.칸)) {
const a = parseA1(key);
if (!a || !hasValue(sheet, a.r, a.c)) continue;
if (vertical ? a.c === at.c : a.r === at.r) out.push(vertical ? a.r : a.c);
}
return out.sort((a, b) => a - b);
}
/** Ctrl+방향 — 값 사이를 뛰어 끝으로(엑셀: 값 칸 뭉치의 끝 · 빈 칸이면 다음 값 · 없으면 시트 끝) */
function jump(sheet: Sheet, at: CellAddress, dr: number, dc: number): CellAddress {
const vertical = dr !== 0;
const d = vertical ? dr : dc;
const pos = vertical ? at.r : at.c;
const edge = d > 0 ? (vertical ? LAST_R : LAST_C) : 0;
const set = new Set(occupied(sheet, at, vertical));
const has = (i: number): boolean => set.has(i);
const put = (i: number): CellAddress => (vertical ? { r: i, c: at.c } : { r: at.r, c: i });
if (pos === edge) return at;
if (has(pos) && has(pos + d)) {
let i = pos;
while (i !== edge && has(i + d)) i += d;
return put(i);
}
const sorted = [...set].sort((a, b) => a - b);
const next = d > 0 ? sorted.find((i) => i > pos) : sorted.reverse().find((i) => i < pos);
return put(next ?? edge);
}
export interface MoveOptions {
/** Shift — 닻은 두고 움직이는 끝만 */
extend?: boolean;
/** Ctrl — 값 끝으로 */
jump?: boolean;
}
/** 방향키 · Home 류 공통 — 새 고름 */
export function move(
sheet: Sheet,
sel: Selection,
dr: number,
dc: number,
opts: MoveOptions = {},
): Selection {
const list = merges(sheet);
if (!opts.extend) {
const from = sel.활성;
const m = mergeAt(sheet, from.r, from.c);
// 병합에서 나갈 때는 병합 끝에서 한 칸
const edge = { r: dr > 0 ? m.r1 : m.r0, c: dc > 0 ? m.c1 : m.c0 };
const to = opts.jump ? jump(sheet, edge, dr, dc) : step(sheet, edge, dr, dc);
if (to.r === edge.r && to.c === edge.c) return selectCell(sheet, from.r, from.c);
return selectCell(sheet, to.r, to.c);
}
const g = sel.범위[sel.범위.length - 1] ?? span(sel.활성, sel.활성); // 여러 범위면 마지막(활성) 범위
const anchor = sel.기준;
// 움직이는 끝 = 닻에서 먼 쪽
const far = {
r: Math.abs(g.r1 - anchor.r) >= Math.abs(anchor.r - g.r0) ? g.r1 : g.r0,
c: Math.abs(g.c1 - anchor.c) >= Math.abs(anchor.c - g.c0) ? g.c1 : g.c0,
};
let to = opts.jump ? jump(sheet, far, dr, dc) : step(sheet, far, dr, dc);
// 줄이는 쪽이면 병합 너머까지 한 번에
for (let guard = 0; guard < 50; guard++) {
const next = expand(span(anchor, to), list);
if (!sameRange(next, g) || to === far) break;
to = step(sheet, to, dr, dc);
}
return {
시트: sheet.id,
범위: [...sel.범위.slice(0, -1), expand(span(anchor, to), list)],
활성: { ...sel.활성 },
기준: { ...anchor },
};
}
export const sameRange = (a: CellRange, b: CellRange): boolean =>
a.r0 === b.r0 && a.c0 === b.c0 && a.r1 === b.r1 && a.c1 === b.c1;
/** 쓰인 칸 끝(Ctrl+End) */
export function usedEnd(sheet: Sheet): CellAddress {
let r = 0;
let c = 0;
for (const key of Object.keys(sheet.칸)) {
const a = parseA1(key);
if (!a) continue;
r = Math.max(r, a.r);
c = Math.max(c, a.c);
}
return { r, c };
}
/** 고름 안 Enter/Tab 돌기 — 범위가 한 칸(또는 병합 하나)이면 null(밖으로 옮김) */
export function cycle(sheet: Sheet, sel: Selection, dr: number, dc: number): Selection | null {
const g = sel.범위[0];
if (!g) return null;
const list = merges(sheet);
if (list.some((m) => sameRange(m, g)) || (g.r0 === g.r1 && g.c0 === g.c1)) return null;
// 행 전체 · 열 전체는 쓰인 칸 끝까지만 돎
const end = usedEnd(sheet);
const box = {
...g,
r1: g.r1 === LAST_R ? Math.max(end.r, g.r0) : g.r1,
c1: g.c1 === LAST_C ? Math.max(end.c, g.c0) : g.c1,
};
let { r, c } = sel.활성;
const cur = mergeAt(sheet, r, c);
const rows = box.r1 - box.r0 + 1;
const cols = box.c1 - box.c0 + 1;
for (let n = 0; n < rows * cols; n++) {
if (dr) {
r += dr;
if (r > box.r1) ((r = box.r0), (c = c + 1 > box.c1 ? box.c0 : c + 1));
if (r < box.r0) ((r = box.r1), (c = c - 1 < box.c0 ? box.c1 : c - 1));
} else {
c += dc;
if (c > box.c1) ((c = box.c0), (r = r + 1 > box.r1 ? box.r0 : r + 1));
if (c < box.c0) ((c = box.c1), (r = r - 1 < box.r0 ? box.r1 : r - 1));
}
if (rowHidden(sheet, r) || colHidden(sheet, c)) continue;
const m = mergeAt(sheet, r, c);
if (m.r0 !== r || m.c0 !== c || sameRange(m, cur)) continue;
return { ...sel, 범위: sel.범위.map((x) => ({ ...x })), 활성: { r, c } };
}
return null;
}
/** 고름 안 칸 전부(행열 전체는 쓰인 끝까지) — Ctrl+Enter · 지우기 대상 */
export function cellsOf(sheet: Sheet, sel: Selection): CellAddress[] {
const end = usedEnd(sheet);
const out: CellAddress[] = [];
for (const g of sel.범위) {
const r1 = g.r1 === LAST_R ? Math.max(end.r, g.r0) : g.r1;
const c1 = g.c1 === LAST_C ? Math.max(end.c, g.c0) : g.c1;
for (let r = g.r0; r <= r1; r++) for (let c = g.c0; c <= c1; c++) out.push({ r, c });
}
return out;
}
@@ -0,0 +1,113 @@
/* =============================================================================
* spreadsheet_sheet_extra.ts (3단계 · 주인 sub_laptop_4)
* 시트 숨기기 · 보이기 · 시트 탭 색. 저장 자리 `Sheet.숨김` · `Sheet.탭색`(0 계약에 방금 더함).
* 이 파일은 관리 창(전체 시트 목록 + 숨김 · 색 조작)만 그림 — 실제 탭 줄(`spreadsheet_tabs.ts`,
* E 파일)을 안 건드림.
*
* 잇는 법(E `spreadsheet_tabs.ts` · sub7 — 지금은 하나도 안 이어짐):
* 1) `mountTabs` 의 `render()` 가 `ctx.book.시트` 를 돌 때 `숨김` 인 시트는 탭을 안 그림.
* 2) 탭 엘리먼트에 `sheet.탭색` 이 있으면 밑줄 · 배경을 그 색으로(엑셀처럼).
* 3) 숨은 시트를 다시 보이려면 탭 우클릭 메뉴에 [숨긴 시트 보이기] 항목을 두고 이 파일의
* `createSheetExtraPanel(ctx)` 를 열거나, `showSheet(ctx, id)` 를 바로 부름.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type { Sheet } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 stage3.css 에 얹음(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 더함.
function applySheet(ctx: SpreadsheetContext, sheetId: string, next: Sheet): void {
ctx.dispatch({ 종류: "시트통째", 시트: { ...next, id: sheetId } });
}
/** 시트를 숨김 — 마지막 보이는 시트는 못 숨김(까닭 글 돌려줌 · 되면 null). 활성 시트를 숨기면
* 다른 보이는 시트로 활성을 옮김. */
export function hideSheet(ctx: SpreadsheetContext, id: string): string | null {
const others = ctx.book.시트.filter((s) => s.id !== id && !s.숨김);
if (!others.length) return "마지막 보이는 시트는 못 숨김";
const sheet = ctx.book.시트.find((s) => s.id === id);
if (!sheet || sheet.숨김) return null;
applySheet(ctx, id, { ...sheet, 숨김: true });
if (ctx.book.활성 === id) ctx.showSheet(others[0].id);
return null;
}
/** 숨긴 시트를 다시 보임. */
export function unhideSheet(ctx: SpreadsheetContext, id: string): void {
const sheet = ctx.book.시트.find((s) => s.id === id);
if (!sheet || !sheet.숨김) return;
applySheet(ctx, id, { ...sheet, 숨김: false });
}
/** 탭 색 — `color` 가 null 이면 색을 지움(기본 탭 색으로). */
export function setSheetTabColor(ctx: SpreadsheetContext, id: string, color: string | null): void {
const sheet = ctx.book.시트.find((s) => s.id === id);
if (!sheet) return;
const next: Sheet = { ...sheet };
if (color) next.탭색 = color;
else delete next.탭색;
applySheet(ctx, id, next);
}
/** 시트 관리 창 — 전체 시트 목록 + 숨김 토글 · 탭 색 고르기. */
export function createSheetExtraPanel(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-sheetextra" });
const list = el("ul", { className: "ss-sheetextra__list" });
let error: HTMLElement | null = null;
const showError = (msg: string | null): void => {
error?.remove();
error = null;
if (!msg) return;
error = el("p", { text: msg });
error.style.color = "var(--color-danger, red)";
root.append(error);
};
function render(): void {
list.replaceChildren(
...ctx.book.시트.map((sheet) => {
const li = el("li", { className: "ss-sheetextra__row" });
const label = el("span", { text: sheet.이름 + (sheet.숨김 ? "(숨김)" : "") });
const colorInput = el("input", {
attrs: { type: "color", value: sheet.탭색 ?? "#ffffff" },
}) as HTMLInputElement;
colorInput.addEventListener("change", () =>
setSheetTabColor(ctx, sheet.id, colorInput.value),
);
const colorClear = createButton({ label: "색 지움", variant: "ghost" });
colorClear.addEventListener("click", () => {
setSheetTabColor(ctx, sheet.id, null);
render();
});
const toggle = createButton({
label: sheet.숨김 ? "보이기" : "숨기기",
variant: "ghost",
});
toggle.addEventListener("click", () => {
if (sheet.숨김) {
unhideSheet(ctx, sheet.id);
showError(null);
} else {
showError(hideSheet(ctx, sheet.id));
}
render();
});
li.append(label, colorInput, colorClear, toggle);
return li;
}),
);
}
root.append(el("h4", { text: "시트 관리" }), list);
render();
return {
root,
refresh: render,
destroy(): void {
root.remove();
},
};
}
@@ -0,0 +1,359 @@
/* =============================================================================
* spreadsheet_shortcuts.ts (2단계 · 주인 sub_laptop_1)
* 엑셀 단축키 전수 표 — 데이터 파일(동작은 안 함). 키마다 뜻 · 이을 동작 이름 · 지금 상태(됨 ·
* 안됨 · 범위밖) · 자리(됨이면 어디 있는지 · 안됨이면 어디 만들지 추천)를 적어 둠.
* 2026-09-27 `spreadsheet_keys.ts`(D · sub7) 를 읽고 「됨」 을 매김 — keys.ts 가 바뀌면 이 표도 다시 맞출 것.
*
* 잇는 법(D · sub7) — `spreadsheet_keys.ts` 는 지금 각 키를 switch 문에 직접 심어 둠(표를 돌며 찾지
* 않음). 이 표는 ① 빠짐없이 다 있는지 볼 대조표 ② 「안됨」 항목을 새로 심을 때 이름표(동작 이름을
* 그대로 함수 이름으로 씀) 로 씀. 새로 심으면 이 파일의 그 항목 `상태` 를 "됨" 으로 바꾸고 `자리` 를 채움.
* 개발 중 대조가 필요하면 `findShortcut("Ctrl+B")` 로 찾아 콘솔에 남기는 정도로만 씀(런타임 배선 아님).
* ========================================================================== */
export type ShortcutState = "됨" | "안됨" | "범위밖";
export interface ShortcutEntry {
키: string;
뜻: string;
/** 이을 함수 이름(식별자) — 새로 심을 때 이 이름을 그대로 씀 */
동작: string;
상태: ShortcutState;
/** 「됨」이면 있는 자리 · 「안됨」이면 만들 자리 추천 · 「범위밖」이면 왜 안 하는지 */
자리: string;
}
export const SHORTCUTS: ShortcutEntry[] = [
// ── 이동 ──────────────────────────────────────────────────────────────
{ 키: "방향키", 뜻: "한 칸 이동", 동작: "move", 상태: "됨", 자리: "keys.ts navKey" },
{
키: "Shift+방향키",
뜻: "고름 늘리기",
동작: "extendSelection",
상태: "됨",
자리: "keys.ts navKey(move extend)",
},
{
키: "Ctrl+방향키",
뜻: "자료 끝으로 건너뜀",
동작: "jumpToEdge",
상태: "됨",
자리: "keys.ts navKey(move jump)",
},
{
키: "Ctrl+Shift+방향키",
뜻: "자료 끝까지 고름",
동작: "extendToEdge",
상태: "됨",
자리: "keys.ts navKey(move extend+jump)",
},
{ 키: "Home", 뜻: "그 행 맨 앞 칸", 동작: "moveHome", 상태: "됨", 자리: "keys.ts navKey" },
{
키: "Ctrl+Home",
뜻: "A1 로",
동작: "moveToStart",
상태: "됨",
자리: "keys.ts navKey(Ctrl+Home)",
},
{
키: "Ctrl+End",
뜻: "마지막으로 쓴 칸으로",
동작: "moveToUsedEnd",
상태: "됨",
자리: "keys.ts navKey(Ctrl+End)",
},
{
키: "PageUp/PageDown",
뜻: "화면 높이만큼 위 · 아래",
동작: "movePage",
상태: "됨",
자리: "keys.ts navKey",
},
{
키: "Tab/Shift+Tab",
뜻: "확정 뒤 옆 · 앞 칸",
동작: "nextCell",
상태: "됨",
자리: "keys.ts editingKey · navKey",
},
{
키: "Enter/Shift+Enter",
뜻: "확정 뒤 아래 · 위 칸",
동작: "nextRow",
상태: "됨",
자리: "keys.ts editingKey · navKey",
},
{
키: "Ctrl+PageDown/PageUp",
뜻: "다음 · 이전 시트 탭",
동작: "nextSheet",
상태: "안됨",
자리: "spreadsheet_tabs.ts 에 만들고 keys.ts navKey 에 연결",
},
// ── 고르기 ────────────────────────────────────────────────────────────
{
키: "Ctrl+A",
뜻: "시트 전체 고름",
동작: "selectAllCells",
상태: "됨",
자리: "keys.ts navKey",
},
{
키: "Ctrl+Space",
뜻: "그 열 전체 고름",
동작: "selectColumn",
상태: "됨",
자리: "keys.ts navKey",
},
{
키: "Shift+Space",
뜻: "그 행 전체 고름",
동작: "selectRow",
상태: "됨",
자리: "keys.ts navKey",
},
{
키: "Ctrl+클릭",
뜻: "범위를 하나 더 고름(여러 범위)",
동작: "addRangeToSelection",
상태: "안됨",
자리: "spreadsheet_selection.ts 확장 — spreadsheet_autofit.ts 머리에도 자리 적어 둠",
},
// ── 편집 ──────────────────────────────────────────────────────────────
{
키: "F2",
뜻: "칸 편집 시작 · 편집 방식 바꿈",
동작: "toggleEditMode",
상태: "됨",
자리: "keys.ts navKey/editingKey",
},
{
키: "Delete",
뜻: "내용만 지움(서식은 남김)",
동작: "clearContents",
상태: "됨",
자리: "keys.ts navKey",
},
{
키: "Backspace",
뜻: "지우고 새로 침",
동작: "clearAndType",
상태: "됨",
자리: "keys.ts navKey",
},
{ 키: "Esc", 뜻: "편집 취소", 동작: "cancelEdit", 상태: "됨", 자리: "keys.ts editingKey" },
{
키: "Alt+Enter",
뜻: "칸 안에서 줄바꿈",
동작: "insertLineBreak",
상태: "됨",
자리: "keys.ts editingKey",
},
{
키: "Ctrl+Enter",
뜻: "고른 칸 전부에 같은 값",
동작: "fillAllSelected",
상태: "됨",
자리: "keys.ts editingKey(commitAll)",
},
{
키: "Ctrl+D",
뜻: "맨 위 칸 값을 아래로 채움",
동작: "fillDown",
상태: "됨",
자리: "keys.ts navKey(fill)",
},
{
키: "Ctrl+R",
뜻: "맨 왼 칸 값을 오른쪽으로 채움",
동작: "fillRight",
상태: "됨",
자리: "keys.ts navKey(fill)",
},
{
키: "Ctrl+;",
뜻: "오늘 날짜를 침",
동작: "insertToday",
상태: "안됨",
자리: "날짜 형식이 1단계 밖이라 보류 — keys.ts navKey 에 새 case",
},
{
키: "Ctrl+Shift+;",
뜻: "지금 시각을 침",
동작: "insertNow",
상태: "안됨",
자리: "위와 같음(날짜 보류)",
},
{ 키: "Ctrl+Z", 뜻: "되돌리기", 동작: "undo", 상태: "됨", 자리: "keys.ts navKey" },
{ 키: "Ctrl+Y", 뜻: "다시", 동작: "redo", 상태: "됨", 자리: "keys.ts navKey" },
{
키: "Ctrl+-",
뜻: "칸 · 행 · 열 지우기(당겨 채움)",
동작: "deleteCells",
상태: "안됨",
자리: "대화상자(방향 고르기) 필요 — keys.ts navKey 에 새 case",
},
{
키: "Ctrl+Shift++",
뜻: "칸 · 행 · 열 넣기(밀어냄)",
동작: "insertCells",
상태: "안됨",
자리: "위와 같음(대화상자 필요)",
},
{
키: "Ctrl+9",
뜻: "고른 행 숨김",
동작: "hideRows",
상태: "안됨",
자리: "「숨김」 명령은 이미 있음 — keys.ts navKey 에 새 case만",
},
{ 키: "Ctrl+0", 뜻: "고른 열 숨김", 동작: "hideColumns", 상태: "안됨", 자리: "위와 같음" },
{ 키: "Ctrl+Shift+9", 뜻: "행 숨김 풀기", 동작: "unhideRows", 상태: "안됨", 자리: "위와 같음" },
{
키: "Ctrl+Shift+0",
뜻: "열 숨김 풀기",
동작: "unhideColumns",
상태: "안됨",
자리: "위와 같음",
},
{
키: "F4",
뜻: "식 편집 중 참조를 상대 → 절대 → 혼합으로 돌림",
동작: "cycleReferenceAbs",
상태: "안됨",
자리: "spreadsheet_editor.ts 편집 중 커서 위치 참조를 찾아 고침",
},
{
키: "Alt+=",
뜻: "위(왼) 수를 모아 SUM 자동 완성",
동작: "autoSum",
상태: "안됨",
자리: "keys.ts navKey — 위쪽 연속 수 범위를 찾아 `=SUM(...)` 를 침",
},
// ── 복사 · 붙여넣기 ──────────────────────────────────────────────────
{
키: "Ctrl+C",
뜻: "복사",
동작: "copySelection",
상태: "됨",
자리: "spreadsheet_clipboard.ts(E)",
},
{
키: "Ctrl+X",
뜻: "잘라내기",
동작: "cutSelection",
상태: "됨",
자리: "spreadsheet_clipboard.ts(E)",
},
{
키: "Ctrl+V",
뜻: "붙여넣기",
동작: "pasteSelection",
상태: "됨",
자리: "spreadsheet_clipboard.ts(E)",
},
{
키: "Ctrl+Shift+V",
뜻: "붙여넣기 골라서(값만 · 서식만 · 수식만)",
동작: "pasteSpecialDialog",
상태: "됨",
자리: "keys.ts navKey hook → spreadsheet_paste_special.ts",
},
// ── 찾기 ──────────────────────────────────────────────────────────────
{
키: "Ctrl+F",
뜻: "찾기",
동작: "openFind",
상태: "됨",
자리: "keys.ts navKey hook → spreadsheet_find_panel.ts",
},
{
키: "Ctrl+H",
뜻: "바꾸기",
동작: "openReplace",
상태: "됨",
자리: "keys.ts navKey hook → spreadsheet_find_panel.ts",
},
{ 키: "Shift+F5", 뜻: "찾기(엑셀 버릇)", 동작: "openFind", 상태: "됨", 자리: "keys.ts navKey" },
// ── 메모 ──────────────────────────────────────────────────────────────
{
키: "Shift+F2",
뜻: "메모 넣기 · 고치기",
동작: "editComment",
상태: "됨",
자리: "keys.ts navKey hook → spreadsheet_comments.ts",
},
// ── 서식 ──────────────────────────────────────────────────────────────
{
키: "Ctrl+1",
뜻: "셀 서식 대화상자",
동작: "openFormatDialog",
상태: "됨",
자리: "spreadsheet_format_dialog.ts(오늘 새로 · 2026-09-27) — keys.ts 에 hook 연결만 남음",
},
{
키: "Ctrl+B",
뜻: "굵게 토글",
동작: "toggleBold",
상태: "안됨",
자리: "keys.ts navKey 새 case — 서식 명령 { 굵게: !지금값 }",
},
{ 키: "Ctrl+I", 뜻: "기울임 토글", 동작: "toggleItalic", 상태: "안됨", 자리: "위와 같음" },
{ 키: "Ctrl+U", 뜻: "밑줄 토글", 동작: "toggleUnderline", 상태: "안됨", 자리: "위와 같음" },
{ 키: "Ctrl+5", 뜻: "취소선 토글", 동작: "toggleStrikethrough", 상태: "안됨", 자리: "위와 같음" },
{
키: "Ctrl+Shift+&",
뜻: "바깥 테두리 넣기",
동작: "addOutlineBorder",
상태: "안됨",
자리: "keys.ts navKey 새 case — 서식 명령 테두리 네 변",
},
{
키: "Ctrl+Shift+_",
뜻: "테두리 없앰",
동작: "clearBorders",
상태: "안됨",
자리: "keys.ts navKey 새 case",
},
// ── 시트 ──────────────────────────────────────────────────────────────
{
키: "Shift+F11",
뜻: "새 시트",
동작: "newSheet",
상태: "안됨",
자리: "spreadsheet_tabs.ts 버튼은 있을 수 있음 — 단축키만 keys.ts 에 더함",
},
// ── 범위 밖(Aislo 는 다른 방식이라 안 함) ────────────────────────────
{
키: "Ctrl+S/O/N",
뜻: "저장 · 열기 · 새 파일",
동작: "—",
상태: "범위밖",
자리: "Aislo 는 [저장] 버튼 · 서버 파일이라 브라우저 단축키를 흉내 안 냄",
},
{
키: "F9",
뜻: "손으로 다시 계산",
동작: "—",
상태: "범위밖",
자리: "자동 재계산이라 손으로 다시 계산할 일이 없음",
},
];
const norm = (key: string): string => key.replace(/\s+/g, "").toLowerCase();
export function findShortcut(키: string): ShortcutEntry | null {
const k = norm(키);
return SHORTCUTS.find((s) => norm(s.키) === k) ?? null;
}
export function unimplemented(): ShortcutEntry[] {
return SHORTCUTS.filter((s) => s.상태 === "안됨");
}
@@ -0,0 +1,180 @@
/* =============================================================================
* spreadsheet_sort_dialog.ts (주인 A · 화면)
* 정렬 대화 상자 · 빠른 정렬(오름 · 내림). 엔진은 `spreadsheet_sortfilter.ts`(명령 `정렬`).
* 범위 = 고른 범위 · 칸 하나만 골랐으면 이어진 값 덩어리(엑셀 「현재 영역」) · 머리 행은 짐작해 켬.
*
* 잇는 법(D · sub7):
* · 도구 모음 · 메뉴 「정렬…」 → `openSortDialog(ctx)` — 창을 `ctx.root` 에 띄우고 닫는 함수를 돌려줌.
* · 「오름차순」 「내림차순」 단추 → `sortSelection(ctx, 내림)` — 활성 칸 열을 기준으로 바로 정렬.
* · 막히면(병합 걸침 등) 토스트로 까닭을 보임 · 되돌리기는 `ctx.dispatch` 가 쌓음.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { colName, rangeToA1 } from "./spreadsheet_address";
import { CommandError } from "./spreadsheet_commands";
import { currentRegion, displayText, sortCommand } from "./spreadsheet_sortfilter";
import type { SortKey, SortOptions } from "./spreadsheet_sortfilter";
import type { CellRange } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_sortfilter.css";
const TEXT = {
Title: ["정렬", "Sort"],
Range: ["범위", "Range"],
Header: ["머리 행 있음", "My data has headers"],
Case: ["대소문자 구분", "Case sensitive"],
Column: ["열", "Column"],
Asc: ["오름차순", "Ascending"],
Desc: ["내림차순", "Descending"],
AddKey: ["기준 더하기", "Add level"],
RemoveKey: ["빼기", "Remove"],
Cancel: ["취소", "Cancel"],
Ok: ["정렬", "Sort"],
};
const t = (key: keyof typeof TEXT) => TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
/** 정렬 범위 — 칸 하나면 현재 영역 */
function targetRange(ctx: SpreadsheetContext): CellRange {
const g = ctx.selection.범위[0];
if (g.r0 === g.r1 && g.c0 === g.c1)
return currentRegion(ctx.book, ctx.selection.시트, g.r0, g.c0);
return g;
}
/** 머리 행 짐작 — 첫 행이 모두 글이고 둘째 행에 글 아닌 값이 있으면 머리 */
function guessHeader(ctx: SpreadsheetContext, rg: CellRange): boolean {
if (rg.r0 === rg.r1) return false;
const v = (r: number, c: number) => ctx.engine.value(ctx.selection.시트, r, c);
let allText = true;
let secondOther = false;
for (let c = rg.c0; c <= rg.c1; c++) {
const a = v(rg.r0, c);
const b = v(rg.r0 + 1, c);
if (a !== null && typeof a !== "string") allText = false;
if (b !== null && typeof b !== "string") secondOther = true;
}
return allText && secondOther;
}
function run(ctx: SpreadsheetContext, rg: CellRange, opts: SortOptions): boolean {
try {
ctx.dispatch(sortCommand(ctx.book, ctx.engine, ctx.selection.시트, rg, opts));
return true;
} catch (e) {
if (!(e instanceof CommandError)) throw e;
showToast(e.message, "error");
return false;
}
}
/** 활성 칸 열 기준 바로 정렬 */
export function sortSelection(ctx: SpreadsheetContext, desc: boolean): void {
if (ctx.readOnly) return;
const rg = targetRange(ctx);
const c = Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1);
run(ctx, rg, { 머리: guessHeader(ctx, rg), 기준: [{ 열: c, 내림: desc }] });
}
/** 정렬 대화 상자 — 닫는 함수를 돌려줌 */
export function openSortDialog(ctx: SpreadsheetContext): () => void {
const rg = targetRange(ctx);
const sheet = ctx.selection.시트;
const keys: SortKey[] = [{ 열: Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1) }];
const header = el("input", { attrs: { type: "checkbox" } });
header.checked = guessHeader(ctx, rg);
const caseBox = el("input", { attrs: { type: "checkbox" } });
const list = el("div", { className: "ss-sort__keys" });
const box = el("div", {
className: "ss-sort__box",
attrs: { role: "dialog", "aria-label": t("Title") },
});
const shade = el("div", { className: "ss-sort", children: [box] });
const columnLabel = (c: number) => {
const head = header.checked ? displayText(ctx.book, ctx.engine, sheet, rg.r0, c) : "";
return head ? `${colName(c)}: ${head}` : `${t("Column")} ${colName(c)}`;
};
function drawKeys() {
list.replaceChildren(
...keys.map((k, i) => {
const col = el("select", { attrs: { "aria-label": t("Column") } });
for (let c = rg.c0; c <= rg.c1; c++)
col.append(el("option", { text: columnLabel(c), attrs: { value: String(c) } }));
col.value = String(k.열);
col.addEventListener("change", () => (k.열 = Number(col.value)));
const dir = el("select", {
children: [
el("option", { text: t("Asc"), attrs: { value: "asc" } }),
el("option", { text: t("Desc"), attrs: { value: "desc" } }),
],
});
dir.value = k.내림 ? "desc" : "asc";
dir.addEventListener("change", () => (k.내림 = dir.value === "desc"));
const remove = createButton({
label: t("RemoveKey"),
variant: "ghost",
disabled: keys.length === 1,
onClick: () => {
keys.splice(i, 1);
drawKeys();
},
});
return el("div", { className: "ss-sort__row", children: [col, dir, remove] });
}),
);
}
const close = () => {
shade.remove();
document.removeEventListener("keydown", onKey, true);
};
const ok = () => {
if (run(ctx, rg, { 머리: header.checked, 기준: keys, 대소문자: caseBox.checked })) close();
};
const onKey = (ev: KeyboardEvent) => {
if (ev.key === "Escape") close();
else if (ev.key === "Enter" && !(ev.target instanceof HTMLSelectElement)) ok();
else return;
ev.preventDefault();
ev.stopPropagation();
};
header.addEventListener("change", drawKeys);
box.append(
el("div", { className: "ss-sort__title", text: t("Title") }),
el("div", { className: "ss-sort__meta", text: `${t("Range")} ${rangeToA1(rg)}` }),
el("label", { className: "ss-sort__meta", children: [header, t("Header")] }),
el("label", { className: "ss-sort__meta", children: [caseBox, t("Case")] }),
list,
el("div", {
className: "ss-sort__row",
children: [
createButton({
label: t("AddKey"),
variant: "ghost",
onClick: () => {
keys.push({ 열: rg.c0 });
drawKeys();
},
}),
],
}),
el("div", {
className: "ss-sort__foot",
children: [
createButton({ label: t("Cancel"), variant: "ghost", onClick: close }),
createButton({ label: t("Ok"), onClick: ok }),
],
}),
);
shade.addEventListener("mousedown", (ev) => {
if (ev.target === shade) close();
});
document.addEventListener("keydown", onKey, true);
drawKeys();
ctx.root.append(shade);
return close;
}
@@ -0,0 +1,133 @@
/* spreadsheet_sortfilter.css (주인 A) — 정렬 대화 상자 · 자동 필터 단추 · 값 목록 창 */
.ss-sort {
position: absolute;
inset: 0;
z-index: var(--z-dropdown);
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 18%);
}
.ss-sort__box,
.ss-filter__panel {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
background: var(--color-surface-raised);
color: var(--color-text-body);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-lg);
font-size: 13px;
}
.ss-sort__box {
min-width: 360px;
max-width: 90%;
}
.ss-sort__title {
font-weight: 600;
}
.ss-sort__meta,
.ss-sort__row,
.ss-sort__foot,
.ss-filter__foot {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.ss-sort__meta {
color: var(--color-text-secondary);
}
.ss-sort__row select {
flex: 1;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: inherit;
}
.ss-sort__row option {
background: var(--color-canvas);
color: var(--color-text-body);
}
.ss-sort__foot,
.ss-filter__foot {
justify-content: flex-end;
}
/* 필터 단추 층 — 크기 0(격자 조작을 막지 않음) · 단추만 누름 */
.ss-filter__layer {
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
overflow: visible;
}
.ss-filter__btn {
position: absolute;
width: 16px;
height: 16px;
padding: 0;
font-size: 9px;
line-height: 14px;
text-align: center;
cursor: pointer;
background: var(--color-surface);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.ss-filter__btn.is-on {
color: var(--color-royal-amethyst);
border-color: var(--color-royal-amethyst);
}
.ss-filter__panel {
position: absolute;
z-index: var(--z-dropdown);
width: 220px;
}
.ss-filter__list {
display: flex;
flex-direction: column;
max-height: 220px;
overflow: auto;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
}
.ss-filter__list label {
display: flex;
gap: var(--spacing-4);
align-items: center;
white-space: nowrap;
}
.ss-filter__link {
padding: 2px var(--spacing-4);
text-align: left;
background: none;
border: none;
color: inherit;
cursor: pointer;
}
.ss-filter__link:hover {
background: var(--color-mist-violet);
}
@@ -0,0 +1,289 @@
/* =============================================================================
* spreadsheet_sortfilter.ts (주인 A)
* 정렬 · 자동 필터 엔진 — DOM 모름. 값 비교 · 보이는 글은 엔진(`CalcEngine`) · 숫자 형식(B)으로 셈.
* 명령은 결과(정렬 차례 · 걸러진 행)를 박아 둠 → 다시 하기가 같은 결과 · 적용은 `applyCommand` 가 여기로 넘김.
*
* 엑셀 규칙:
* 정렬 차례 — 오름: 수 < 글 < 참거짓(FALSE<TRUE) < 오류 · 내림은 거꾸로 · 빈 칸은 늘 맨 뒤 · 같은 값은 원래 차례.
* 글은 대소문자 가리지 않음(옵션으로 가림 = 소문자 먼저). 값은 계산값(식 칸은 푼 값).
* 정렬 옮김 — 범위 열만 행째 옮김 · 식은 복사처럼 상대 참조만 옮김(범위 밖에서 범위를 가리키던 식은 그대로) ·
* 서식 · 메모도 같이 · 행 높이는 제자리 · 걸러진 행은 제자리(보이는 행끼리만 정렬) · 병합이 걸치면 막음.
* 필터 — 첫 행 = 머리 · 값 목록 = 보이는 글(숫자 형식 적용) · 빈 칸 = "" · 조건 안 맞는 행은 `걸러짐`
* (사용자 숨김과 따로) · 행열을 넣고 지우면 필터 범위 · 조건 열도 따라감 · 끄면 걸러짐 모두 풂.
*
* 잇는 법(D · sub7 · C · sub6):
* · 정렬: 대화 상자 `openSortDialog(ctx)`(spreadsheet_sort_dialog.ts) · 도구 단추 `sortSelection(ctx, 내림)`.
* · 필터: `attachFilter(ctx)`(spreadsheet_filter.ts) 를 부품 목록에 붙이고 grid.render() 뒤 `refresh()` ·
* 도구 단추 · Ctrl+Shift+L = `toggleFilter(ctx)`.
* · 격자(C): 행 `걸러짐` 도 `숨김` 처럼 높이 0 — 머리의 숨김 표시는 사용자 `숨김` 만.
* ========================================================================== */
import { formatValue } from "./spreadsheet_numfmt";
import {
colIndex,
colName,
parseRange,
rangesOverlap,
rangeToA1,
toA1,
} from "./spreadsheet_address";
import { clone, CommandError, sheetOf } from "./spreadsheet_commands";
import type { Of } from "./spreadsheet_commands";
import { compareValues, isError, isFrac } from "./spreadsheet_eval";
import { moveFormula, shiftSpan } from "./spreadsheet_refshift";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
AutoFilter,
CalcEngine,
Cell,
CellRange,
Command,
CommandEffect,
Scalar,
Sheet,
Workbook,
} from "./spreadsheet_types";
export interface SortKey {
/** 기준 열(0 부터 · 범위 안) */
열: number;
내림?: boolean;
}
export interface SortOptions {
/** 첫 행 = 머리(정렬 안 함) */
머리: boolean;
기준: SortKey[];
/** 글 대소문자 가림 */
대소문자?: boolean;
}
const filtered = (s: Sheet, r: number) => !!s.행?.[r + 1]?.걸러짐;
const rank = (v: Scalar) =>
isFrac(v) ? 0 : typeof v === "string" ? 1 : typeof v === "boolean" ? 2 : 3;
const blank = (v: Scalar) => v === null || v === "";
/** 엑셀 정렬 비교 — 빈 칸은 오름 · 내림 모두 맨 뒤 */
export function sortCompare(a: Scalar, b: Scalar, desc = false, caseSensitive = false): number {
if (blank(a) || blank(b)) return blank(a) === blank(b) ? 0 : blank(a) ? 1 : -1;
let d = rank(a) - rank(b);
if (d === 0 && !isError(a)) {
if (typeof a === "string" && caseSensitive) d = COLLATE_CASE.compare(a, b as string);
else if (typeof a === "string") d = COLLATE.compare(a, b as string);
else d = compareValues(a, b);
}
return desc ? -d : d;
}
const COLLATE = new Intl.Collator("ko", { sensitivity: "accent" });
const COLLATE_CASE = new Intl.Collator("ko", { sensitivity: "variant", caseFirst: "lower" });
/** 칸 하나에서 이어진 값 덩어리(엑셀 「현재 영역」) — 빈 칸만 둘러싼 테두리까지 늘림 */
export function currentRegion(book: Workbook, sheetId: string, r: number, c: number): CellRange {
const s = sheetOf(book, sheetId);
const filled = new Set<string>();
for (const [a1, cell] of Object.entries(s.칸))
if (cell.값 !== undefined || cell.식 !== undefined) filled.add(a1);
const has = (rr: number, cc: number) =>
rr >= 0 && cc >= 0 && rr < MAX_ROWS && cc < MAX_COLS && filled.has(toA1(rr, cc));
const g = { r0: r, c0: c, r1: r, c1: c };
for (let grew = true; grew;) {
grew = false;
const rowHas = (rr: number) => {
for (let cc = g.c0 - 1; cc <= g.c1 + 1; cc++) if (has(rr, cc)) return true;
return false;
};
const colHas = (cc: number) => {
for (let rr = g.r0 - 1; rr <= g.r1 + 1; rr++) if (has(rr, cc)) return true;
return false;
};
if (g.r0 > 0 && rowHas(g.r0 - 1)) (g.r0--, (grew = true));
if (g.r1 < MAX_ROWS - 1 && rowHas(g.r1 + 1)) (g.r1++, (grew = true));
if (g.c0 > 0 && colHas(g.c0 - 1)) (g.c0--, (grew = true));
if (g.c1 < MAX_COLS - 1 && colHas(g.c1 + 1)) (g.c1++, (grew = true));
}
return g;
}
function checkMerges(s: Sheet, range: CellRange) {
const hit = (s.병합 ?? []).some((m) => {
const rg = parseRange(m);
return rg && rangesOverlap(rg, range);
});
if (hit) throw new CommandError("병합한 칸이 든 범위는 정렬하지 못함 — 병합을 먼저 풂");
}
// ── 정렬 ───────────────────────────────────────────────────────────────────
/** 정렬 명령 만들기 — 값은 엔진 계산값으로 견줌 · 걸러진 행은 제자리 */
export function sortCommand(
book: Workbook,
engine: CalcEngine,
sheetId: string,
range: CellRange,
opts: SortOptions,
): Command {
const s = sheetOf(book, sheetId);
const data: CellRange = { ...range, r0: range.r0 + (opts.머리 ? 1 : 0) };
if (data.r0 > data.r1) throw new CommandError("정렬할 행이 없음");
if (!opts.기준.length) throw new CommandError("정렬 기준이 없음");
for (const k of opts.기준)
if (k.열 < range.c0 || k.열 > range.c1) throw new CommandError("정렬 기준 열이 범위 밖");
checkMerges(s, data);
const rows: number[] = [];
for (let r = data.r0; r <= data.r1; r++) if (!filtered(s, r)) rows.push(r);
const keys = new Map(rows.map((r) => [r, opts.기준.map((k) => engine.value(sheetId, r, k.열))]));
const sorted = [...rows].sort((a, b) => {
const va = keys.get(a)!;
const vb = keys.get(b)!;
for (let i = 0; i < opts.기준.length; i++) {
const d = sortCompare(va[i], vb[i], opts.기준[i].내림, opts.대소문자);
if (d) return d;
}
return a - b;
});
const order: number[] = [];
let next = 0;
for (let r = data.r0; r <= data.r1; r++) order.push(filtered(s, r) ? r : sorted[next++]);
return { 종류: "정렬", 시트: sheetId, 범위: data, 차례: order };
}
export function applySort(book: Workbook, cmd: Of<"정렬">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const { r0, r1, c0, c1 } = cmd.범위;
const n = r1 - r0 + 1;
if (
cmd.차례.length !== n ||
new Set(cmd.차례).size !== n ||
cmd.차례.some((r) => r < r0 || r > r1)
)
throw new CommandError("정렬 차례가 범위와 안 맞음");
checkMerges(s, cmd.범위);
const before = clone(s);
const take = <T>(table: Record<string, T> | undefined) => {
const out = new Map<string, T>();
for (let r = r0; r <= r1; r++)
for (let c = c0; c <= c1; c++) {
const a1 = toA1(r, c);
if (table && a1 in table) {
out.set(a1, table[a1]);
delete table[a1];
}
}
return out;
};
const cells = take<Cell>(s.칸);
const notes = take<string>(s.comments);
cmd.차례.forEach((src, i) => {
const to = r0 + i;
for (let c = c0; c <= c1; c++) {
const cell = cells.get(toA1(src, c));
if (cell) {
const moved = { ...cell };
if (moved.식 !== undefined) moved.식 = moveFormula(moved.식, to - src, 0);
s.칸[toA1(to, c)] = moved;
}
const note = notes.get(toA1(src, c));
if (note !== undefined) (s.comments ??= {})[toA1(to, c)] = note;
}
});
if (s.comments && !Object.keys(s.comments).length) delete s.comments;
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: true };
}
// ── 자동 필터 ──────────────────────────────────────────────────────────────
/** 칸의 보이는 글(숫자 형식 적용) — 빈 칸 "" */
export function displayText(
book: Workbook,
engine: CalcEngine,
sheetId: string,
r: number,
c: number,
) {
const s = sheetOf(book, sheetId);
const cell = s.칸[toA1(r, c)];
if (!cell || (cell.값 === undefined && cell.식 === undefined)) return "";
const idx = cell.서식 ?? s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? s.기본?.서식 ?? 0;
return formatValue(engine.value(sheetId, r, c), book.서식[idx]?.형식).글;
}
/** 필터 단추 목록 — 열의 서로 다른 보이는 글(정렬 차례 · 빈 칸 맨 뒤) · 켬 = 지금 보이는 값 */
export function filterItems(
book: Workbook,
engine: CalcEngine,
sheetId: string,
c: number,
): { 글: string; 켬: boolean }[] {
const s = sheetOf(book, sheetId);
const f = s.필터 && parseRange(s.필터.범위);
if (!f) return [];
const allowed = s.필터!.조건?.[colName(c)];
const seen = new Map<string, Scalar>();
for (let r = f.r0 + 1; r <= f.r1; r++) {
const text = displayText(book, engine, sheetId, r, c);
if (!seen.has(text)) seen.set(text, text === "" ? null : engine.value(sheetId, r, c));
}
return [...seen]
.sort((a, b) => sortCompare(a[1], b[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
.map(([글]) => ({ 글, 켬: !allowed || allowed.includes(글) }));
}
/** 필터 명령 — 범위 · 조건으로 걸러질 행을 셈(null = 필터 끔) */
export function filterCommand(
book: Workbook,
engine: CalcEngine,
sheetId: string,
filter: AutoFilter | null,
): Command {
if (!filter) return { 종류: "필터", 시트: sheetId, 필터: null, 걸러짐: [] };
const f = parseRange(filter.범위);
if (!f) throw new CommandError(`필터 범위 틀림: ${filter.범위}`);
const hidden: number[] = [];
const conds = Object.entries(filter.조건 ?? {}).map(
([col, list]) => [colIndex(col), new Set(list)] as const,
);
for (let r = f.r0 + 1; r <= f.r1; r++)
if (conds.some(([c, ok]) => !ok.has(displayText(book, engine, sheetId, r, c)))) hidden.push(r);
return { 종류: "필터", 시트: sheetId, 필터: filter, 걸러짐: hidden };
}
export function applyFilter(book: Workbook, cmd: Of<"필터">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const before = clone(s);
if (cmd.필터) s.필터 = clone(cmd.필터);
else delete s.필터;
const table = (s.행 ??= {});
for (const [k, info] of Object.entries(table)) {
delete info.걸러짐;
if (!Object.keys(info).length) delete table[k];
}
for (const r of cmd.걸러짐) (table[String(r + 1)] ??= {}).걸러짐 = true;
if (!Object.keys(table).length) delete s.행;
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false };
}
/** 행열 넣기 · 지우기 때 필터 범위 · 조건 열 따라감(머리 행 · 끝 열까지 지우면 필터 없어짐) */
export function shiftFilter(s: Sheet, rows: boolean, insert: boolean, at: number, n: number) {
const f = s.필터 && parseRange(s.필터.범위);
if (!s.필터 || !f) return;
const sp = rows
? shiftSpan(f.r0, f.r1, insert, at, n, MAX_ROWS)
: shiftSpan(f.c0, f.c1, insert, at, n, MAX_COLS);
const headGone = rows && !insert && f.r0 >= at && f.r0 < at + n;
if (!sp || headGone) {
delete s.필터;
return;
}
const next = rows ? { ...f, r0: sp[0], r1: sp[1] } : { ...f, c0: sp[0], c1: sp[1] };
s.필터.범위 = rangeToA1(next);
if (rows || !s.필터.조건) return;
const conds: Record<string, string[]> = {};
for (const [col, list] of Object.entries(s.필터.조건)) {
const c = shiftSpan(colIndex(col), colIndex(col), insert, at, n, MAX_COLS);
if (c) conds[colName(c[0])] = list;
}
s.필터.조건 = conds;
}
@@ -0,0 +1,61 @@
/* =============================================================================
* spreadsheet_spill.ts
* 긴 글 넘침(엑셀) — 칸보다 긴 글을 옆 빈 칸 위로 보임. 왼쪽 · 일반 맞춤은 오른쪽으로 · 오른쪽 맞춤은
* 왼쪽으로 · 가운데는 양쪽으로. 비지 않은 칸 · 병합 칸 · 판 끝(틀 고정 경계 · 그린 범위 밖)에서 멈춤.
* 격자(`spreadsheet_grid.ts`)가 판 하나를 그릴 때 줄마다 칸 자리(`slots`)를 모아 부름 — 계산만 여기.
* ========================================================================== */
/** 같은 판 · 같은 줄 · 병합 아닌 칸 하나(병합 칸 · 안 그린 칸은 넣지 않음 → 거기서 멈춤) */
export interface SpillSlot {
w: number;
empty: boolean;
}
/** 넘침 — 왼 · 오른쪽으로 더 쓸 폭(px) · 덮은 칸 열 범위 · 가운데 맞춤이 한쪽만 막혀 잘라 낼 폭 */
export interface Spill {
left: number;
right: number;
from: number;
to: number;
/** 가운데 맞춤 — 글 상자는 양쪽 반씩 늘리고 막힌 쪽은 이만큼 잘라 냄(글은 칸 가운데 그대로) */
clipLeft: number;
clipRight: number;
}
export type SpillAlign = "left" | "right" | "center";
/** 칸 c(폭 own)의 글 폭 need 가 넘치면 정렬 쪽 빈 칸으로 늘린 결과 · 안 넘치거나 못 늘리면 null */
export function spillOf(
slots: Map<number, SpillSlot>,
c: number,
own: number,
need: number,
align: SpillAlign,
): Spill | null {
if (need <= own) return null;
let from = c;
let to = c;
const grow = (dir: -1 | 1, want: number): number => {
let got = 0;
for (let k = c + dir; got < want; k += dir) {
const s = slots.get(k);
if (!s || !s.empty) break;
got += s.w;
if (dir < 0) from = k;
else to = k;
}
return got;
};
if (align === "center") {
const half = (need - own) / 2;
const l = Math.min(grow(-1, half), half);
const r = Math.min(grow(1, half), half);
if (!l && !r) return null;
return { left: half, right: half, from, to, clipLeft: half - l, clipRight: half - r };
}
const got = grow(align === "right" ? -1 : 1, need - own);
if (!got) return null;
return align === "right"
? { left: got, right: 0, from, to, clipLeft: 0, clipRight: 0 }
: { left: 0, right: got, from, to, clipLeft: 0, clipRight: 0 };
}
@@ -0,0 +1,113 @@
/* =============================================================================
* spreadsheet_stage3.css (주인 sub_laptop_4)
* 조건부 서식 · 데이터 유효성 · 이름 정의 · 상태 표시줄 · 시트 관리 창 — 화면 스타일 한 벌.
* 색 · 간격은 테마 토큰만.
* ========================================================================== */
.ss-condfmt,
.ss-validation,
.ss-names,
.ss-sheetextra {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
min-width: 280px;
}
.ss-condfmt__list,
.ss-validation__list,
.ss-names__list,
.ss-sheetextra__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.ss-condfmt__row,
.ss-validation__row,
.ss-names__row,
.ss-sheetextra__row {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.85rem;
}
.ss-condfmt__row > span:first-of-type,
.ss-validation__row > span:first-of-type,
.ss-names__row > span:first-of-type {
flex: 1;
}
.ss-condfmt__swatch {
display: inline-block;
width: 14px;
height: 14px;
border: 1px solid var(--color-border);
border-radius: 2px;
flex-shrink: 0;
}
.ss-condfmt__form,
.ss-validation__form,
.ss-names__form {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
align-items: flex-end;
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-8);
}
.ss-validation__dropdown {
position: absolute;
z-index: 30;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 4px;
box-shadow: var(--shadow-sm);
max-height: 160px;
overflow-y: auto;
}
.ss-validation__dropdown-item {
padding: 4px 8px;
cursor: pointer;
font-size: 0.85rem;
}
.ss-validation__dropdown-item:hover {
background: var(--color-mist-violet);
}
.ss-statusbar {
display: flex;
align-items: center;
gap: var(--spacing-8);
padding: 2px var(--spacing-8);
background: var(--color-surface);
border-top: 1px solid var(--color-border);
font-size: 0.8rem;
color: var(--color-text-body);
}
.ss-statusbar__stats {
display: flex;
gap: var(--spacing-8);
flex: 1;
color: var(--color-text-body);
}
.ss-statusbar__zoom {
color: var(--color-text-muted);
}
@@ -0,0 +1,132 @@
/* =============================================================================
* spreadsheet_statusbar.ts (3단계 · 주인 sub_laptop_4)
* 아래 상태 표시줄 — 고른 범위의 합계 · 평균 · 개수(COUNTA) · 숫자 개수(COUNT) · 최소 · 최대.
* 엑셀처럼 우클릭으로 보일 줄 고름(이 표시줄만의 취향 — 문서에는 안 남음 · 계약 손 안 댐).
* 확대 비율 — `ctx.grid.zoom()` 으로 처음 값 · 격자 root 의 `aislo-grid-zoom` 사건(sub6, 2026-09-27)을
* 들어 Ctrl+휠로 바뀔 때마다 다시 씀. `setZoom` 은 아직 계약에 없음(격자만 바꿈 · 상태줄은 읽기만).
* 화면 붙이기는 `spreadsheet.ts`(D)가 `createStatusBar(ctx)` 로 이미 함(root 를 격자 아래).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { isFrac, fracToDouble } from "./spreadsheet_functions_math";
import type { CellRange, Scalar } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 stage3.css 에 얹음(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 더함.
export type StatKey = "합계" | "평균" | "개수" | "숫자개수" | "최소" | "최대";
export const STAT_KEYS: StatKey[] = ["합계", "평균", "개수", "숫자개수", "최소", "최대"];
export interface SelectionStats {
합계: number | null;
평균: number | null;
개수: number;
숫자개수: number;
최소: number | null;
최대: number | null;
}
/** 고른 범위(여럿) 안 칸 값을 모아 통계를 냄 — 엔진 값 기준(식 칸도 풀린 값). */
export function computeSelectionStats(ctx: SpreadsheetContext): SelectionStats {
const sheet = ctx.selection.시트;
const nums: number[] = [];
let 개수 = 0;
const seen = new Set<number>();
const visit = (range: CellRange): void => {
for (let r = range.r0; r <= range.r1; r++) {
for (let c = range.c0; c <= range.c1; c++) {
const key = r * 1_000_000 + c;
if (seen.has(key)) continue;
seen.add(key);
const value: Scalar = ctx.engine.value(sheet, r, c);
if (value === null) continue;
개수++;
if (isFrac(value)) nums.push(fracToDouble(value));
}
}
};
for (const range of ctx.selection.범위) visit(range);
return {
합계: nums.length ? nums.reduce((a, b) => a + b, 0) : null,
평균: nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null,
개수,
숫자개수: nums.length,
최소: nums.length ? Math.min(...nums) : null,
최대: nums.length ? Math.max(...nums) : null,
};
}
function statText(key: StatKey, stats: SelectionStats): string {
const value = stats[key];
if (key === "개수" || key === "숫자개수") return `${key}: ${value}`;
return `${key}: ${value === null ? "—" : formatNumber(value)}`;
}
function formatNumber(n: number): string {
return Number.isInteger(n)
? String(n)
: n.toLocaleString(undefined, { maximumFractionDigits: 4 });
}
function zoomText(ratio: number): string {
return `확대 ${Math.round(ratio * 100)}%`;
}
/** 아래 상태 표시줄 — 우클릭 메뉴로 보일 줄 고름. */
export function createStatusBar(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-statusbar" });
const stats = el("div", { className: "ss-statusbar__stats" });
const zoom = el("span", {
className: "ss-statusbar__zoom",
text: zoomText(ctx.grid.zoom()),
});
const menu = createMapContextMenu("ss-statusbar");
root.append(stats, zoom, menu.element);
const onZoom = (ev: Event): void => {
const ratio = (ev as CustomEvent<{ zoom: number }>).detail?.zoom;
if (typeof ratio === "number") zoom.textContent = zoomText(ratio);
};
ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom);
let visible = new Set<StatKey>(STAT_KEYS);
function render(): void {
const s = computeSelectionStats(ctx);
stats.replaceChildren(
...STAT_KEYS.filter((k) => visible.has(k)).map((k) =>
el("span", { className: "ss-statusbar__item", text: statText(k, s) }),
),
);
}
root.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
const box = root.getBoundingClientRect();
const items: MapContextMenuItem[] = STAT_KEYS.map((k) => [
`${visible.has(k) ? "✓ " : ""}${k}`,
() => {
if (visible.has(k)) visible.delete(k);
else visible.add(k);
render();
},
]);
menu.open(ev.clientX - box.left, ev.clientY - box.top, items);
});
render();
return {
root,
refresh: render,
destroy(): void {
ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom);
menu.close();
root.remove();
},
};
}
+190
View File
@@ -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();
},
};
}
+128
View File
@@ -0,0 +1,128 @@
/* =============================================================================
* spreadsheet_text.ts (주인 E)
* 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 글자 — [한국어, 영어].
* ⚠ `@ui/ui_template_locale` 를 안 씀 — `currentLanguageIndex` 하나만 필요해도 그 파일이
* 앱 전체 사전(a · b1~4 · common · m1 · m2, 압축 뒤 약 45 KB)을 통째로 끌고 와 화면 예산(28 KB)을
* 넘김(2026-09-27 브레인 지시). 자기 언어 상태를 따로 둠 — `setSpreadsheetLanguage` 로만 바꿈.
* ========================================================================== */
let currentLanguageIndex = 0;
/** 앱 전역 언어 토글과 맞추고 싶으면 D(`spreadsheet.ts`) 가 부름 — 안 부르면 한국어 기본. */
export function setSpreadsheetLanguage(index: 0 | 1): void {
currentLanguageIndex = index;
}
const TEXT = {
Undo: ["되돌리기", "Undo"],
Redo: ["다시", "Redo"],
FontName: ["글꼴", "Font"],
FontSize: ["크기", "Size"],
Bold: ["굵게", "Bold"],
Italic: ["기울임", "Italic"],
Underline: ["밑줄", "Underline"],
Strike: ["취소선", "Strikethrough"],
TextColor: ["글자색", "Text color"],
FillColor: ["채움색", "Fill color"],
ColorClear: ["지움", "Clear"],
HAlign: ["가로 정렬", "Horizontal align"],
VAlign: ["세로 정렬", "Vertical align"],
Align_general: ["일반", "General"],
Align_left: ["왼쪽", "Left"],
Align_center: ["가운데", "Center"],
Align_right: ["오른쪽", "Right"],
Align_distributed: ["균등 분할", "Distributed"],
Align_centerContinuous: ["선택 영역 가운데", "Center across"],
VAlign_top: ["위", "Top"],
VAlign_center: ["가운데", "Middle"],
VAlign_bottom: ["아래", "Bottom"],
Wrap: ["줄 바꿈", "Wrap text"],
Merge: ["병합", "Merge"],
Unmerge: ["병합 해제", "Unmerge"],
Border: ["테두리", "Border"],
Border_none: ["없음", "None"],
Border_all: ["모두", "All"],
Border_outer: ["바깥", "Outer"],
Border_top: ["위", "Top"],
Border_bottom: ["아래", "Bottom"],
Border_left: ["왼", "Left"],
Border_right: ["오른", "Right"],
BorderLine: ["선", "Line"],
BorderColor: ["색", "Color"],
NumberFormat: ["숫자 형식", "Number format"],
NumberFormat_general: ["일반", "General"],
NumberFormat_integer: ["정수 0", "Integer"],
NumberFormat_decimal2: ["소수 0.00", "Decimal 0.00"],
NumberFormat_thousands: ["천 단위 #,##0", "Thousands"],
NumberFormat_thousands2: ["천 단위 #,##0.00", "Thousands 0.00"],
NumberFormat_percent: ["백분율 0%", "Percent"],
NumberFormat_accounting: ["회계", "Accounting"],
NumberFormat_text: ["글자 @", "Text"],
NumberFormat_placeholder: ["표시 형식", "Display format"],
NumberFormat_hint: [
"이름 골라 쓰거나 엑셀 형식 코드를 바로 침",
"Pick a preset or type an Excel format code",
],
SheetAdd: ["시트 더하기", "Add sheet"],
SheetRename: ["이름 바꾸기", "Rename"],
SheetDelete: ["지우기", "Delete"],
SheetDuplicate: ["복사", "Duplicate"],
SheetRenamePrompt: ["새 시트 이름", "New sheet name"],
SheetDeleteConfirm: [
"이 시트를 지울까요? 되돌릴 수 없습니다.",
"Delete this sheet? This cannot be undone.",
],
SheetDeleteLastError: ["마지막 시트는 지울 수 없음", "Cannot delete the last sheet"],
SheetHide: ["숨기기", "Hide"],
SheetUnhideMenu: ["숨긴 시트 보이기", "Show hidden sheets"],
MenuCut: ["잘라내기", "Cut"],
MenuCopy: ["복사", "Copy"],
MenuPasteHint: ["Ctrl+V 를 쓸 것", "Use Ctrl+V"],
MenuInsertRow: ["행 넣기", "Insert row"],
MenuDeleteRow: ["행 지우기", "Delete row"],
MenuInsertCol: ["열 넣기", "Insert column"],
MenuDeleteCol: ["열 지우기", "Delete column"],
MenuHide: ["숨기기", "Hide"],
MenuUnhide: ["숨김 취소", "Unhide"],
MenuClearContent: ["내용 지우기", "Clear content"],
MenuClearFormat: ["서식 지우기", "Clear format"],
MenuMerge: ["병합", "Merge"],
MenuUnmerge: ["병합 해제", "Unmerge"],
MenuCommentInsert: ["메모 삽입", "Insert comment"],
MenuCommentEdit: ["메모 편집", "Edit comment"],
MenuCommentDelete: ["메모 삭제", "Delete comment"],
ClipboardPasteFailed: ["붙여넣기를 읽지 못함", "Could not read paste content"],
ClipboardMergeBlocked: [
"병합된 셀의 일부를 바꿀 수 없습니다",
"Can't change part of a merged cell",
],
Unit: ["단위", "Unit"],
UnitGroup_area: ["넓이·부피·길이", "Area · volume · length"],
UnitGroup_weight: ["무게·부피(액체)", "Weight · liquid volume"],
UnitGroup_count: ["개수", "Count"],
UnitGroup_rate: ["속도·각도·비율", "Speed · angle · rate"],
UnitGroup_op: ["연산·비교", "Operators"],
UnitRecent: ["최근 씀", "Recently used"],
UnitAsFormat: ["숫자 형식으로 붙이기", "Attach as number format"],
FormulaHint_label: [
"엑셀 수식까지: 엑셀에서 Ctrl+` → 복사 → 여기 Ctrl+V · 끝나면 엑셀 Ctrl+` 다시",
"Formulas too: in Excel Ctrl+` → copy → paste here (Ctrl+V) · then Ctrl+` again in Excel",
],
FormulaHint_title: ["엑셀 수식까지 옮기는 법", "How to bring formulas along"],
FormulaHint_detail: [
"엑셀 Ctrl+` = 「수식 표시」 켜기 · 칸에 계산값 대신 「=...」 글이 그대로 보임. 그 상태로 복사해 " +
"여기 붙이면 수식까지 옮겨짐(그냥 복사하면 값만 옴 · 수식 없음). 붙인 뒤 엑셀에서 Ctrl+` 다시 " +
"눌러 원래대로.",
'Excel Ctrl+` turns on "Show Formulas" — cells show "=..." text instead of the computed ' +
"value. Copy in that state and paste here to bring the formulas along (a normal copy only " +
"brings values). Press Ctrl+` again in Excel afterward to switch back.",
],
ClipboardExcelNoFormula: [
"값만 들어왔음 — 수식까지는 엑셀 Ctrl+` 뒤 복사",
"Only values came in — for formulas too, copy after Ctrl+` in Excel",
],
} as const;
export function st(key: keyof typeof TEXT): string {
return TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
}
@@ -0,0 +1,324 @@
/* =============================================================================
* spreadsheet_toolbar.css (주인 E)
* 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 안내 토스트 — 화면 스타일 한 벌.
* 색 · 간격은 테마 토큰만(`ui_template_theme.css`).
* ========================================================================== */
.ss-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.ss-toolbar__group {
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-4);
padding-right: var(--spacing-8);
border-right: 1px solid var(--color-border);
}
.ss-toolbar__group:last-child {
border-right: 0;
}
.ss-toolbar .ui-btn {
padding: 4px 8px;
font-size: 0.82rem;
}
.ss-toolbar .ui-btn.is-on {
background: var(--color-mist-violet);
box-shadow: inset 0 0 0 1px var(--color-royal-amethyst);
}
.ss-toolbar__select {
height: 28px;
padding: 0 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font-size: 0.8rem;
}
.ss-toolbar__select option {
background: var(--color-canvas);
color: var(--color-text-body);
}
.ss-toolbar__input {
height: 28px;
width: 72px;
padding: 0 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font-size: 0.8rem;
}
.ss-toolbar__input--font {
width: 96px;
}
.ss-toolbar__input--size {
width: 48px;
}
.ss-toolbar__input--numfmt {
width: 140px;
}
.ss-toolbar__color {
width: 22px;
height: 22px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
cursor: pointer;
}
.ss-toolbar__clear {
padding: 0 4px;
border: 0;
background: none;
color: var(--color-text-muted);
font-size: 0.72rem;
cursor: pointer;
}
.ss-toolbar__popover {
position: absolute;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 180px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-toolbar__popover[hidden] {
display: none;
}
.ss-toolbar__popover-row {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.ss-toolbar__popover-presets {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-4);
}
.ss-toolbar__unit-pop {
max-height: 340px;
overflow-y: auto;
}
.ss-toolbar__unit-section + .ss-toolbar__unit-section {
margin-top: var(--spacing-8);
}
.ss-toolbar__unit-label {
margin-bottom: var(--spacing-4);
color: var(--color-text-muted);
font-size: 0.72rem;
}
.ss-toolbar__unit-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-4);
}
.ss-toolbar__unit-btn {
min-width: 0;
padding: 4px 2px;
}
.ss-toolbar__hint {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
margin-left: auto;
max-width: 320px;
}
.ss-toolbar__formula-hint {
border: 0;
padding: 0;
background: none;
color: var(--color-text-muted);
font-size: 0.72rem;
text-align: right;
opacity: 0.6;
cursor: pointer;
}
.ss-toolbar__formula-hint:hover {
opacity: 1;
}
.ss-toolbar__formula-hint-detail {
color: var(--color-text-muted);
font-size: 0.68rem;
text-align: right;
}
.ss-toolbar__formula-hint-detail[hidden] {
display: none;
}
/* ── 시트 탭 ─────────────────────────────────────────────────────────────── */
.ss-tabs {
position: relative;
display: flex;
align-items: center;
gap: 2px;
overflow-x: auto;
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.ss-tabs__tab {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: 4px 10px;
border: 1px solid var(--color-border);
border-bottom: 0;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
background: var(--color-paper);
color: var(--color-text-secondary);
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.ss-tabs__tab.is-active {
background: var(--color-canvas);
color: var(--color-text-body);
font-weight: 600;
}
.ss-tabs__tab.is-dragover {
box-shadow: inset 2px 0 0 var(--color-accent);
}
.ss-tabs__tab input {
width: 96px;
border: 1px solid var(--color-focus-ring);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
}
.ss-tabs__add {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-secondary);
cursor: pointer;
}
/* ── 우클릭 메뉴(그리드 · 탭 공용, `ui_template_context_menu.ts`) ───────────── */
.ss__context-menu {
position: absolute;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
min-width: 160px;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss__context-menu[hidden] {
display: none;
}
.ss__context-menu-item {
padding: var(--spacing-8) var(--spacing-12);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-body);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.ss__context-menu-item:hover {
background: var(--color-surface-sunken, var(--color-mist-violet));
}
.ss__context-menu-item.is-danger {
color: var(--color-danger);
}
.ss__context-menu-sep {
margin: 2px 4px;
border-top: 1px solid var(--color-border);
}
/* ── 메모 편집 창(칸 우클릭 [메모 삽입 · 편집]) ──────────────────────────────── */
.ss-comment-editor {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 35%);
}
.ss-comment-editor__panel {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: 280px;
padding: var(--spacing-16);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-comment-editor__input {
min-height: 80px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
resize: vertical;
}
.ss-comment-editor__actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
@@ -0,0 +1,533 @@
/* =============================================================================
* spreadsheet_toolbar.ts (주인 E)
* 서식 도구 모음 — 되돌리기 · 다시 · 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 ·
* 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식.
* 모두 `ctx.dispatch` 명령으로 — 이 파일은 문서를 직접 안 건드림.
* 활성 칸 서식을 `refresh()` 때 단추 상태로. readOnly 면 안 붙임(root null).
*
* 이음(D `spreadsheet.ts` 잇는 자리) — `mountToolbar(ctx)` 한 번 부르고 `root`(null 이면 안 붙임)를
* 도구 모음 자리에 붙임. 고름 · 문서가 바뀔 때마다(자기 dispatch 뒤 포함) `refresh()` 를 부름 —
* 스스로 구독하지 않음. `destroy()` 는 부품을 뗄 때.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
import { cellToText, textToCell } from "./spreadsheet_editor";
import { st } from "./spreadsheet_text";
import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_toolbar.css";
type Side = "위" | "아래" | "왼" | "오른";
type BorderPreset = "none" | "all" | "outer" | "top" | "bottom" | "left" | "right";
/** 앱 기본 글꼴 · 크기 — `spreadsheet_grid.ts`(C) 의 `fontCss` 가 쓰는 기본과 같은 값(거울) ·
* 칸 · 행 · 열 · 시트 기본 어디에도 없을 때 이 값을 흐리게(placeholder) 보임. */
const APP_DEFAULT_FONT = "굴림";
const APP_DEFAULT_SIZE = 10;
const FONTS = [
"굴림",
"굴림체",
"맑은 고딕",
"나눔고딕",
"돋움",
"Arial",
"Calibri",
"Times New Roman",
];
const NUMFMT_PRESETS: [label: string, code: string][] = [
[st("NumberFormat_general"), ""],
[st("NumberFormat_integer"), "0"],
[st("NumberFormat_decimal2"), "0.00"],
[st("NumberFormat_thousands"), "#,##0"],
[st("NumberFormat_thousands2"), "#,##0.00"],
[st("NumberFormat_percent"), "0%"],
[st("NumberFormat_accounting"), '_-* #,##0.00_-;-* #,##0.00_-;_-* "-"_-;_-@_-'],
[st("NumberFormat_text"), "@"],
];
const BORDER_LINES: BorderLine[] = [
"hair",
"thin",
"medium",
"thick",
"double",
"dotted",
"dashed",
];
type TextKey = Parameters<typeof st>[0];
const UNIT_GROUPS: [label: TextKey, items: string[]][] = [
["UnitGroup_area", ["㎡", "㎥", "m", "㎝", "㎜", "㎞"]],
["UnitGroup_weight", ["kg", "t", "ℓ", "㎖"]],
["UnitGroup_count", ["개", "본", "식", "EA", "매"]],
["UnitGroup_rate", ["m/s", "°", "%", "‰"]],
["UnitGroup_op", ["×", "÷", "±", "≤", "≥", "Σ"]],
];
const UNIT_RECENT_MAX = 8;
const UNIT_RECENT_KEY = "aislo-ss-recent-units";
function loadRecentUnits(): string[] {
try {
const raw = localStorage.getItem(UNIT_RECENT_KEY);
return raw ? (JSON.parse(raw) as string[]) : [];
} catch {
return [];
}
}
function pushRecentUnit(unit: string): string[] {
const list = [unit, ...loadRecentUnits().filter((u) => u !== unit)].slice(0, UNIT_RECENT_MAX);
try {
localStorage.setItem(UNIT_RECENT_KEY, JSON.stringify(list));
} catch {
/* 저장 못 해도 이번 넣기는 그대로 진행 */
}
return list;
}
/** 숫자 형식 코드 끝 따옴표 글자(전에 붙인 단위)를 떼고 새 단위를 붙임 — 없으면 "0" 에서 시작. */
function attachUnitToFormat(code: string, unit: string): string {
const base = (code || "0").replace(/"[^"]*"\s*$/, "");
return base + `"${unit}"`;
}
/** 활성 칸에 지금 걸린 서식 — 칸 자기 것 없으면 열 · 행 · 시트 기본(`Sheet.기본.서식`) · 그다음 0. */
function styleAt(ctx: SpreadsheetContext, r: number, c: number): CellStyle {
const sheet = ctx.sheet();
const cell = sheet.칸[toA1(r, c)];
const idx =
cell?.서식 ??
sheet.열?.[colName(c)]?.서식 ??
sheet.행?.[String(r + 1)]?.서식 ??
sheet.기본?.서식 ??
0;
return ctx.book.서식[idx] ?? {};
}
export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} };
const root = el("div", { className: "ss-toolbar" });
const group = (...children: HTMLElement[]): HTMLElement =>
el("div", { className: "ss-toolbar__group", children });
const patch = (change: { [K in keyof CellStyle]?: CellStyle[K] | null }): void => {
ctx.dispatch({
종류: "서식",
시트: ctx.selection.시트,
범위: ctx.selection.범위,
바꿀: change,
});
};
// ── 되돌리기 · 다시 ────────────────────────────────────────────────────
const undoBtn = createButton({ label: "↶", variant: "ghost", onClick: () => ctx.undo() });
undoBtn.title = st("Undo");
const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() });
redoBtn.title = st("Redo");
// ── 글꼴 · 크기 ────────────────────────────────────────────────────────
const fontInput = el("input", {
className: "ss-toolbar__input ss-toolbar__input--font",
attrs: { list: "ss-toolbar-fonts", "aria-label": st("FontName") },
}) as HTMLInputElement;
const fontList = el("datalist", {
attrs: { id: "ss-toolbar-fonts" },
children: FONTS.map((name) => el("option", { attrs: { value: name } })),
});
fontInput.append(fontList);
const commitFont = (): void => patch({ 글꼴: fontInput.value.trim() || null });
fontInput.addEventListener("change", commitFont);
const sizeInput = el("input", {
className: "ss-toolbar__input ss-toolbar__input--size",
attrs: { type: "number", min: "1", max: "409", step: "0.5", "aria-label": st("FontSize") },
}) as HTMLInputElement;
sizeInput.addEventListener("change", () => {
const n = Number(sizeInput.value);
patch({ 크기: sizeInput.value && Number.isFinite(n) && n > 0 ? n : null });
});
// ── 굵게 · 기울임 · 밑줄 · 취소선 ──────────────────────────────────────
const boldBtn = createButton({ label: "B", variant: "ghost" });
boldBtn.title = st("Bold");
const italicBtn = createButton({ label: "I", variant: "ghost" });
italicBtn.title = st("Italic");
const underlineBtn = createButton({ label: "U", variant: "ghost" });
underlineBtn.title = st("Underline");
const strikeBtn = createButton({ label: "S", variant: "ghost" });
strikeBtn.title = st("Strike");
const toggles: [HTMLButtonElement, keyof CellStyle][] = [
[boldBtn, "굵게"],
[italicBtn, "기울임"],
[underlineBtn, "밑줄"],
[strikeBtn, "취소선"],
];
for (const [btn, key] of toggles) {
btn.addEventListener("click", () => patch({ [key]: !btn.classList.contains("is-on") }));
}
// ── 글자색 · 채움색 ────────────────────────────────────────────────────
const textColor = el("input", {
className: "ss-toolbar__color",
attrs: { type: "color", "aria-label": st("TextColor"), value: "#000000" },
}) as HTMLInputElement;
textColor.addEventListener("change", () => patch({ 글자색: textColor.value }));
const textColorClear = el("button", {
className: "ss-toolbar__clear",
text: "✕",
attrs: { type: "button", title: st("ColorClear") },
});
textColorClear.addEventListener("click", () => patch({ 글자색: null }));
const fillColor = el("input", {
className: "ss-toolbar__color",
attrs: { type: "color", "aria-label": st("FillColor"), value: "#ffffff" },
}) as HTMLInputElement;
fillColor.addEventListener("change", () => patch({ 채움: fillColor.value }));
const fillColorClear = el("button", {
className: "ss-toolbar__clear",
text: "✕",
attrs: { type: "button", title: st("ColorClear") },
});
fillColorClear.addEventListener("click", () => patch({ 채움: null }));
// ── 가로 · 세로 정렬 · 줄 바꿈 ─────────────────────────────────────────
const HALIGN_LABEL = {
general: st("Align_general"),
left: st("Align_left"),
center: st("Align_center"),
right: st("Align_right"),
distributed: st("Align_distributed"),
centerContinuous: st("Align_centerContinuous"),
} as const;
const VALIGN_LABEL = {
top: st("VAlign_top"),
center: st("VAlign_center"),
bottom: st("VAlign_bottom"),
} as const;
const hAlignSelect = el("select", {
className: "ss-toolbar__select",
attrs: { "aria-label": st("HAlign") },
children: (Object.keys(HALIGN_LABEL) as (keyof typeof HALIGN_LABEL)[]).map((v) =>
el("option", { attrs: { value: v }, text: HALIGN_LABEL[v] }),
),
}) as HTMLSelectElement;
hAlignSelect.addEventListener("change", () =>
patch({ 가로: hAlignSelect.value as CellStyle["가로"] }),
);
const vAlignSelect = el("select", {
className: "ss-toolbar__select",
attrs: { "aria-label": st("VAlign") },
children: (Object.keys(VALIGN_LABEL) as (keyof typeof VALIGN_LABEL)[]).map((v) =>
el("option", { attrs: { value: v }, text: VALIGN_LABEL[v] }),
),
}) as HTMLSelectElement;
vAlignSelect.addEventListener("change", () =>
patch({ 세로: vAlignSelect.value as CellStyle["세로"] }),
);
const wrapBtn = createButton({ label: st("Wrap"), variant: "ghost" });
wrapBtn.addEventListener("click", () => patch({ 줄바꿈: !wrapBtn.classList.contains("is-on") }));
// ── 병합 ───────────────────────────────────────────────────────────────
const mergeBtn = createButton({ label: st("Merge"), variant: "ghost" });
mergeBtn.addEventListener("click", () => {
const range = ctx.selection.범위[0];
if (!range) return;
const merged = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
ctx.dispatch({ 종류: merged ? "병합풀기" : "병합", 시트: ctx.selection.시트, 범위: range });
});
// ── 테두리 ───────────────────────────────────────────────────────────
const borderBtn = createButton({ label: st("Border"), variant: "ghost" });
const borderPop = el("div", { className: "ss-toolbar__popover", attrs: { hidden: "true" } });
const lineSelect = el("select", {
className: "ss-toolbar__select",
children: BORDER_LINES.map((v) => el("option", { attrs: { value: v }, text: v })),
}) as HTMLSelectElement;
const lineColor = el("input", {
className: "ss-toolbar__color",
attrs: { type: "color", value: "#000000" },
}) as HTMLInputElement;
const presetsRow = el("div", { className: "ss-toolbar__popover-presets" });
const presets: [BorderPreset, string][] = [
["none", st("Border_none")],
["all", st("Border_all")],
["outer", st("Border_outer")],
["top", st("Border_top")],
["bottom", st("Border_bottom")],
["left", st("Border_left")],
["right", st("Border_right")],
];
for (const [preset, label] of presets) {
const b = createButton({
label,
variant: "ghost",
onClick: () => {
applyBorderPreset(preset);
borderPop.hidden = true;
},
});
presetsRow.append(b);
}
borderPop.append(
el("div", {
className: "ss-toolbar__popover-row",
children: [el("span", { text: st("BorderLine") }), lineSelect],
}),
el("div", {
className: "ss-toolbar__popover-row",
children: [el("span", { text: st("BorderColor") }), lineColor],
}),
presetsRow,
);
borderBtn.addEventListener("click", (ev) => {
ev.stopPropagation();
borderPop.hidden = !borderPop.hidden;
});
borderPop.addEventListener("click", (ev) => ev.stopPropagation());
const closeBorderPop = (): void => {
borderPop.hidden = true;
};
document.addEventListener("click", closeBorderPop);
function cellsFor(
range: CellRange,
edge: BorderPreset,
): { r: number; c: number; sides: Side[] }[] {
const { r0, c0, r1, c1 } = range;
const out: { r: number; c: number; sides: Side[] }[] = [];
for (let r = r0; r <= r1; r++) {
for (let c = c0; c <= c1; c++) {
const sides: Side[] = [];
if (edge === "all") sides.push("위", "아래", "왼", "오른");
else {
if ((edge === "outer" || edge === "top") && r === r0) sides.push("위");
if ((edge === "outer" || edge === "bottom") && r === r1) sides.push("아래");
if ((edge === "outer" || edge === "left") && c === c0) sides.push("왼");
if ((edge === "outer" || edge === "right") && c === c1) sides.push("오른");
}
if (sides.length) out.push({ r, c, sides });
}
}
return out;
}
function applyBorderPreset(preset: BorderPreset): void {
const range = ctx.selection.범위[0];
if (!range) return;
if (preset === "none") {
ctx.dispatch({
종류: "서식",
시트: ctx.selection.시트,
범위: ctx.selection.범위,
바꿀: { 테두리: null },
});
return;
}
const style: BorderSide = { 선: lineSelect.value as BorderLine, 색: lineColor.value };
const cmds: Command[] = cellsFor(range, preset).map(({ r, c, sides }) => {
const cur = { ...(styleAt(ctx, r, c).테두리 ?? {}) };
for (const s of sides) cur[s] = style;
return {
종류: "서식",
시트: ctx.selection.시트,
범위: [{ r0: r, c0: c, r1: r, c1: c }],
바꿀: { 테두리: cur },
};
});
if (cmds.length === 1) ctx.dispatch(cmds[0]);
else if (cmds.length > 1) ctx.dispatch({ 종류: "묶음", 명령: cmds });
}
// ── 숫자 형식 ─────────────────────────────────────────────────────────
const numfmtInput = el("input", {
className: "ss-toolbar__input ss-toolbar__input--numfmt",
attrs: {
list: "ss-toolbar-numfmt",
"aria-label": st("NumberFormat"),
title: st("NumberFormat_hint"),
placeholder: st("NumberFormat_placeholder"),
},
}) as HTMLInputElement;
const numfmtList = el("datalist", {
attrs: { id: "ss-toolbar-numfmt" },
children: NUMFMT_PRESETS.map(([label, code]) =>
el("option", {
attrs: { value: code },
text: `${label} (${code || st("NumberFormat_general")})`,
}),
),
});
numfmtInput.append(numfmtList);
numfmtInput.addEventListener("change", () => patch({ 형식: numfmtInput.value || null }));
// ── 단위 기호 ─────────────────────────────────────────────────────────
const unitBtn = createButton({ label: `${st("Unit")} ▾`, variant: "ghost" });
const unitPop = el("div", {
className: "ss-toolbar__popover ss-toolbar__unit-pop",
attrs: { hidden: "true" },
});
const unitAsFormat = el("input", { attrs: { type: "checkbox", id: "ss-unit-as-format" } });
const unitAsFormatRow = el("label", {
className: "ss-toolbar__popover-row",
attrs: { for: "ss-unit-as-format" },
children: [unitAsFormat, el("span", { text: st("UnitAsFormat") })],
});
const recentGrid = el("div", { className: "ss-toolbar__unit-grid" });
const recentSection = el("div", {
className: "ss-toolbar__unit-section",
attrs: { hidden: "true" },
children: [
el("div", { className: "ss-toolbar__unit-label", text: st("UnitRecent") }),
recentGrid,
],
});
function insertUnit(unit: string): void {
if (ctx.editor.editing()) {
const active = document.activeElement;
if (active instanceof HTMLTextAreaElement && ctx.root.contains(active)) {
const s = active.selectionStart ?? active.value.length;
const e = active.selectionEnd ?? active.value.length;
active.value = active.value.slice(0, s) + unit + active.value.slice(e);
active.setSelectionRange(s + unit.length, s + unit.length);
active.dispatchEvent(new Event("input", { bubbles: true }));
}
} else if (unitAsFormat.checked) {
const { r, c } = ctx.selection.활성;
patch({ 형식: attachUnitToFormat(styleAt(ctx, r, c).형식 ?? "", unit) });
} else {
const { r, c } = ctx.selection.활성;
const sheet = ctx.sheet();
const key = toA1(r, c);
const text = cellToText(sheet.칸[key]) + unit;
ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: { [key]: textToCell(text, sheet.칸[key]) } });
}
renderRecent(pushRecentUnit(unit));
unitPop.hidden = true;
}
function renderRecent(list: string[]): void {
recentGrid.textContent = "";
recentSection.hidden = list.length === 0;
for (const u of list) {
const b = createButton({ label: u, variant: "ghost", onClick: () => insertUnit(u) });
b.className += " ss-toolbar__unit-btn";
recentGrid.append(b);
}
}
renderRecent(loadRecentUnits());
unitPop.append(unitAsFormatRow, recentSection);
for (const [labelKey, items] of UNIT_GROUPS) {
const grid = el("div", { className: "ss-toolbar__unit-grid" });
for (const u of items) {
const b = createButton({ label: u, variant: "ghost", onClick: () => insertUnit(u) });
b.className += " ss-toolbar__unit-btn";
grid.append(b);
}
unitPop.append(
el("div", {
className: "ss-toolbar__unit-section",
children: [el("div", { className: "ss-toolbar__unit-label", text: st(labelKey) }), grid],
}),
);
}
unitBtn.addEventListener("click", (ev) => {
ev.stopPropagation();
unitPop.hidden = !unitPop.hidden;
});
unitPop.addEventListener("click", (ev) => ev.stopPropagation());
const closeUnitPop = (): void => {
unitPop.hidden = true;
};
document.addEventListener("click", closeUnitPop);
// ── 엑셀 수식 복사 안내(오른쪽 위 흐린 글씨 · 누르면 두세 줄 설명) ─────────
const formulaHintBtn = el("button", {
className: "ss-toolbar__formula-hint",
attrs: { type: "button", title: st("FormulaHint_title") },
text: st("FormulaHint_label"),
});
const formulaHintDetail = el("div", {
className: "ss-toolbar__formula-hint-detail",
attrs: { hidden: "true" },
text: st("FormulaHint_detail"),
});
formulaHintBtn.addEventListener("click", (ev) => {
ev.stopPropagation();
formulaHintDetail.hidden = !formulaHintDetail.hidden;
});
const formulaHint = el("div", {
className: "ss-toolbar__hint",
children: [formulaHintBtn, formulaHintDetail],
});
root.append(
group(undoBtn, redoBtn),
group(fontInput, sizeInput),
group(boldBtn, italicBtn, underlineBtn, strikeBtn),
group(textColor, textColorClear, fillColor, fillColorClear),
group(hAlignSelect, vAlignSelect, wrapBtn),
group(mergeBtn),
group(borderBtn, borderPop),
group(numfmtInput),
group(unitBtn, unitPop),
formulaHint,
);
function refresh(): void {
undoBtn.disabled = !ctx.history.canUndo();
redoBtn.disabled = !ctx.history.canRedo();
const { r, c } = ctx.selection.활성;
const style = styleAt(ctx, r, c);
fontInput.value = style.글꼴 ?? "";
fontInput.placeholder = style.글꼴 ? "" : (ctx.book.기본?.글꼴 ?? APP_DEFAULT_FONT);
sizeInput.value = style.크기 != null ? String(style.크기) : "";
sizeInput.placeholder =
style.크기 != null ? "" : String(ctx.book.기본?.크기 ?? APP_DEFAULT_SIZE);
boldBtn.classList.toggle("is-on", !!style.굵게);
italicBtn.classList.toggle("is-on", !!style.기울임);
underlineBtn.classList.toggle("is-on", !!style.밑줄);
strikeBtn.classList.toggle("is-on", !!style.취소선);
textColor.value = style.글자색 ?? "#000000";
fillColor.value = style.채움 ?? "#ffffff";
hAlignSelect.value = style.가로 ?? "general";
vAlignSelect.value = style.세로 ?? "top";
wrapBtn.classList.toggle("is-on", !!style.줄바꿈);
numfmtInput.value = style.형식 ?? "";
const range = ctx.selection.범위[0];
mergeBtn.textContent = "";
mergeBtn.append(
el("span", {
className: "ui-btn__label",
text:
range && (ctx.sheet().병합 ?? []).includes(rangeToA1(range))
? st("Unmerge")
: st("Merge"),
}),
);
}
refresh();
return {
root,
refresh,
destroy() {
document.removeEventListener("click", closeBorderPop);
document.removeEventListener("click", closeUnitPop);
root.remove();
},
};
}
+285
View File
@@ -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();
},
};
}
+553
View File
@@ -0,0 +1,553 @@
/* ====================================================================== * spreadsheet_types.ts
* 산출근거 스프레드시트 계약 — 저장 문서 · 칸 · 서식 · 수식 나무 · 값 · 명령 · 계산 엔진 모양.
* 근거 `tmp/M02_분석/9_엑셀검토.md` · PLAN 11-8. 이 파일 · `spreadsheet_view_types.ts` · 견본 JSON 은
* 0 계약 주인만 고침 — 다른 창은 브레인에게 고침을 청함. 타입만 둠(서버 Node · 시험이 그대로 씀).
*
* ── 규칙 ────────────────────────────────────────────────────────────────────
* · 외부 라이브러리 0 · 오픈소스 코드 안 베낌(Formula.js · ssf 는 시험 대조 답으로만).
* · 가져다 쓰는 것 = `@ui/ui_template_elements`(el · createButton · 이미 앱 번들) ·
* `@ui/sheet/ui_template_sheet_frac`(BigInt 분수 · 집계표와 끝수 한 벌) 둘뿐.
* · M02 만 `import()` 로 늦게 불러옴 — 다른 페이지 · `A00_Common/main.ts` 가 정적 import 금지.
* · 엔진 파일(A · B)은 DOM 을 모름 — 화면 즉시 풀이와 [저장] 때 서버 Node 풀이가 같은 TS(CLAUDE.md 5장 ②).
* · 보이는 칸만 그림 · 파일마다 700 줄 안쪽(예산 넘기 전에 쪼갬) · 화면 글자는 그 파일 안에 둠.
* · xlsx 가져오기 · 내보내기 · 인쇄 · PDF 는 만들지 않음(단추도 없음). xlsx 는 시험(F)만 읽음.
*
* ── 크기 예산 (vite build · 압축(gzip) 뒤 · M02 면적산출 · 재료산출을 열 때만 받는 조각) ──
* 엔진 A+B 48 KB · 화면 C+D+E 72 KB · css 8 KB · 합 128 KB 안쪽. 재는 곳 = F(빌드 조각 크기 시험).
*
* ── 파일 · 주인 · 예산 줄 수 ─────────────────────────────────────────────────
* 0 spreadsheet_types.ts 이 계약(타입) ~450
* 0 spreadsheet_view_types.ts 화면 부품 사이 계약(DOM 타입) ~150
* 0 spreadsheet_sample.json 저장 견본 = `basis/<열 id>.json` 한 벌
* A spreadsheet_address.ts A1 ↔ 행열 · 범위 · 열 글자 · 시트 이름 따옴표 ~150
* A spreadsheet_parser.ts 낱말 나누기 · 수식 나무 · 나무 → 글 ~500
* A spreadsheet_eval.ts 나무 풀기 · 오류 전파 · 형 바꾸기 ~350
* A spreadsheet_graph.ts 계산 엔진 — 의존 그래프 · 딸린 칸만 · 순환 ~350
* A spreadsheet_refshift.ts 넣기 · 지우기 · 옮기기 · 복사 때 참조 옮김 ~300
* A spreadsheet_commands.ts 명령 적용 → 되돌림 짝 · 빈 통합문서 ~550
* A spreadsheet_history.ts 되돌리기 · 다시 ~100
* A spreadsheet_commands_sheet.ts 시트 명령(더하기 · 지우기 · 이름 · 옮기기 · 통째) ~100
* A spreadsheet_sortfilter.ts 정렬 · 자동 필터 엔진(차례 · 걸러짐 셈 · 적용) ~300
* A spreadsheet_sort_dialog.ts 정렬 대화 상자 · 빠른 정렬(화면) ~250
* A spreadsheet_filter.ts 자동 필터 단추 · 값 목록 창(화면) ~300
* A spreadsheet_sortfilter.css ~150
* A spreadsheet_trace.ts 선행 · 종속 추적 화살표(한 단계씩 · SVG 겹판) ~270
* A spreadsheet_eval_steps.ts 수식 계산 단계 보기 창 ~230
* A spreadsheet_errcheck.ts 오류 검사 까닭 · ⚠ 단추 · 풍선(세모는 C 가 그림) ~200
* A spreadsheet_analysis.css 위 셋 화면 스타일 ~200
* A spreadsheet_recalc.ts 통합문서 한 벌 풀이(서버 Node · 시험 진입) ~80
* B spreadsheet_functions.ts 함수 표(이름 → 뜻) · 인자 수 검사 ~120
* B spreadsheet_functions_math.ts 수학 · 삼각 · 끝수 ~400
* B spreadsheet_functions_misc.ts 논리 · 글 · 찾기 · 세기 ~500
* B spreadsheet_numfmt.ts 숫자 형식 코드 → 보이는 글 ~400
* B spreadsheet_fill.ts 채우기 핸들 연속값 ~200
* C spreadsheet_grid.ts 격자 — 보이는 칸만 · 틀고정 4 판 · 병합 · 고름 그림 ~550
* C spreadsheet_headers.ts 열 글자 · 행 번호 · 폭 · 높이 끌기 · 숨김 표시 ~350
* C spreadsheet_cellstyle.ts 서식 → CSS(hair · 균등 분할 · 겹친 테두리) ~250
* C spreadsheet_grid.css ~200
* D spreadsheet.ts 진입 createSpreadsheet · 부품 잇기 · 문맥 ~350
* D spreadsheet_selection.ts 고름 모델 · Shift/Ctrl 방향 · 병합으로 늘림 ~300
* D spreadsheet_editor.ts 칸 편집기 · 수식 입력줄 · 주소 상자 · 한글 조합 · 참조 가리키기 ~600
* D spreadsheet_keys.ts 엑셀 단축키 지도 ~300
* D spreadsheet_mouse.ts 끌기 고르기 · 채우기 끌기 · 두 번 누르기 ~300
* D spreadsheet.css ~150
* D spreadsheet_links.ts 위 알약 막대 — 입력값 · 자재를 칸에 연결(늦게 불러옴) ~350
* E spreadsheet_toolbar.ts 서식 도구 모음 ~550
* E spreadsheet_tabs.ts 시트 탭 ~300
* E spreadsheet_menu.ts 우클릭 메뉴 ~300
* E spreadsheet_clipboard.ts 복사 · 잘라내기 · 붙여넣기(TSV · HTML) ~550
* E spreadsheet_toolbar.css ~150
* 3 spreadsheet_condfmt.ts 조건부 서식 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~260
* 3 spreadsheet_validation.ts 데이터 유효성 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~230
* 3 spreadsheet_names.ts 이름 정의 · 이름 관리 창(주인 sub_laptop_4 · 안 이어짐) ~220
* 3 spreadsheet_statusbar.ts 아래 상태 표시줄 — 합계 · 평균 · 개수 등(주인 sub_laptop_4 · 안 이어짐) ~150
* 3 spreadsheet_autosum.ts 자동 합계(Alt+= · Σ, 주인 sub_laptop_4 · 안 이어짐) ~90
* 3 spreadsheet_sheet_extra.ts 시트 숨김 · 보이기 · 탭 색(주인 sub_laptop_4 · 계약 더함 · 안 이어짐) ~150
* 3 spreadsheet_stage3.css 위 여섯 화면 스타일 한 벌 ~140
* F common_util/common_util_spreadsheet_node.ts Node 진입(재계산 · 폴더 밖) ~40
* F common_util/common_util_spreadsheet.py 파이썬 껍데기(common_util_node_bundle) ~60
* F package.json `build:spreadsheet` · M02 `basis` 종류(Store · Layers) · 옛 산출근거 옮김 ·
* M02_MasterTemplete_Structure.ts 끼우기 · resources/tester/test_spreadsheet_*.py(실무 2,699 칸 대조 · 크기)
*
* ── 좌표 ────────────────────────────────────────────────────────────────────
* 안쪽은 0 부터(r = 행 · c = 열) · 저장 · 식 글은 엑셀 A1(1 부터). 행 최대 1,048,576 · 열 최대 16,384(XFD).
* ========================================================================== */
import type { Frac } from "@ui/sheet/ui_template_sheet_frac";
export type { Frac };
// ═════════════════════════════════════════════════════════════════════════════
// 1. 저장 문서 — 구조물집계표 열마다 한 파일 `resources/master_template/basis/<열 id>.json`
// (층 규칙은 M02 와 같음 · 프로젝트 `templates/basis/` · `_initial/` 사본). 견본 = spreadsheet_sample.json.
// 키는 한글 · 열거 값(정렬 · 선 · 오류)은 엑셀 이름 그대로.
// ═════════════════════════════════════════════════════════════════════════════
export const MAX_ROWS = 1_048_576;
export const MAX_COLS = 16_384;
/** 칸에 넣은 값 — 수는 JSON 수(엑셀처럼 유효 15 자리) · 글 · 참거짓. 식 칸에는 없음. */
export type CellInput = number | string | boolean;
export interface Cell {
/** 입력 값 — `식` 이 있으면 없음. `'` 로 시작해 친 글은 `'` 를 떼고 글로 둠. */
값?: CellInput;
/** A1 식 — 앞 `=` 뺌(`ROUNDDOWN((G31+J31/2)*P31,2)`). 행열을 넣고 지우면 글을 고쳐 적음. */
식?: string;
/** `서식` 표 번호 — 없으면 행 · 열 서식 · 시트 `기본.서식` · 그다음 0(기본). */
서식?: number;
}
export type HAlign = "general" | "left" | "center" | "right" | "distributed" | "centerContinuous";
export type VAlign = "top" | "center" | "bottom";
export type BorderLine = "hair" | "thin" | "medium" | "thick" | "double" | "dotted" | "dashed";
export interface BorderSide {
선: BorderLine;
/** `#RRGGBB` · 없으면 검정. */
색?: string;
}
/** 칸 서식 한 벌 — 없는 칸은 기본값. 같은 서식은 표에 한 번만(칸은 번호). */
export interface CellStyle {
글꼴?: string;
/** pt */
크기?: number;
굵게?: boolean;
기울임?: boolean;
밑줄?: boolean;
취소선?: boolean;
/** `#RRGGBB` */
글자색?: string;
/** `#RRGGBB` — 없으면 채움 없음. */
채움?: string;
가로?: HAlign;
세로?: VAlign;
줄바꿈?: boolean;
테두리?: { 위?: BorderSide; 아래?: BorderSide; 왼?: BorderSide; 오른?: BorderSide };
/** 엑셀 숫자 형식 코드 그대로(`0.00` · `#,##0` · `_-* #,##0.00_-;…` · `@`) · 없으면 일반. */
형식?: string;
}
/** 묶음(윤곽) — 층 1 부터(중첩 단계) · 접힘 이면 격자가 숨김처럼 그림(숨김 · 걸러짐과는 화면 표시가 다름 — 윤곽 +/- 단추). */
export interface Outline {
층: number;
접힘?: boolean;
}
export interface ColumnInfo {
/** 엑셀 글자 폭 단위(기본 글꼴 숫자 폭 기준) — 화면이 px 로 바꿈. */
폭?: number;
숨김?: boolean;
서식?: number;
묶음?: Outline;
}
export interface RowInfo {
/** pt */
높이?: number;
숨김?: boolean;
서식?: number;
/** 자동 필터가 숨긴 행 — 사용자 `숨김` 과 따로(필터 풀면 보임) · 격자는 숨김처럼 그림. */
걸러짐?: boolean;
묶음?: Outline;
}
/** 자동 필터 — `범위` 첫 행 = 머리(단추) · `조건` = 열 글자 → 보일 값(보이는 글 · 빈 칸은 ""). 조건 없는 열은 다 보임. */
export interface AutoFilter {
범위: string;
조건?: Record<string, string[]>;
}
export interface Sheet {
/** 안 바뀌는 id(`s1` …) — 이름을 바꿔도 그대로. 식 글은 이름으로 가리킴. */
id: string;
이름: string;
/** A1 열쇠 · 빈 칸은 안 적음. */
칸: Record<string, Cell>;
/** `C3:AD3` — 겹치지 않음 · 왼위 칸만 값 · 서식은 칸마다(테두리 때문). */
병합?: string[];
/** 열 글자 열쇠(`A` · `AB`). */
열?: Record<string, ColumnInfo>;
/** 행 번호 열쇠(1 부터 · `"31"`). */
행?: Record<string, RowInfo>;
/** 위에서 고정할 행 수 · 왼쪽에서 고정할 열 수. */
틀고정?: { 행?: number; 열?: number };
보기?: { 눈금선?: boolean };
/** 시트 통째(모두 고르기) — 서식 번호 · 열 폭(글자 단위) · 행 높이(pt). 칸 · 행 · 열 값이 없을 때 씀 ·
* 없으면 통합문서 `기본`. */
기본?: { 서식?: number; 열폭?: number; 행높이?: number };
/** 칸 메모 — A1 열쇠 → 메모 글. 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 메모는 지움). */
comments?: Record<string, string>;
/** 자동 필터(시트에 하나) */
필터?: AutoFilter;
/** 조건부 서식 — 배열 앞쪽이 우선순위 위(`spreadsheet_condfmt.ts`). */
조건부서식?: CondFmtRule[];
/** 데이터 유효성(`spreadsheet_validation.ts`). */
유효성?: DataValidationRule[];
/** 이 시트에서 정의한 이름 — 범위는 이 시트 안(`spreadsheet_names.ts`). 식 나무의 `{type:"name"}` 는
* A 의 eval 이 `resolveName` 으로 풀어야 함(지금 안 이어짐 — 8-3 절 참고). */
이름정의?: NamedRange[];
/** 시트 숨김(`spreadsheet_sheet_extra.ts`) — 탭은 `spreadsheet_tabs.ts`(E)가 이 값으로 걸러야 함(안 이어짐). */
숨김?: boolean;
/** 시트 탭 색 `#RRGGBB`(`spreadsheet_sheet_extra.ts`) — 탭 칠은 E 몫(안 이어짐). */
탭색?: string;
/** 알약 연결(`spreadsheet_links.ts` · sub7 계약 더함) — 위 알약(입력값 · 자재)이 가리키는 이 시트 칸.
* 행열 넣기 · 지우기에 따라감(지운 칸이면 빠짐) · 바꿈은 `시트통째` 로(되돌리기 짝). */
연결?: CellLink[];
}
/** 자재DB 한 줄 — 재료산출 알약. */
export interface LinkMaterial {
코드: string;
이름: string;
규격: string;
단위: string;
}
/** 알약 ↔ 칸 한 줄 — `키` = 입력값 키 또는 자재 코드 · 키 하나에 칸 하나 · 칸 하나에 키 하나. */
export interface CellLink {
키: string;
이름?: string;
단위?: string;
/** A1 */
칸: string;
자재?: LinkMaterial;
}
/** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */
export interface WorkbookDefaults {
글꼴?: string;
크기?: number;
열폭?: number;
행높이?: number;
}
export interface Workbook {
종류: "통합문서";
판: 1;
/** 구조물집계표 열 id(파일 이름과 같음). */
열: string;
기본?: WorkbookDefaults;
/** 서식 표 — [0] = 기본(빈 `{}`). */
서식: CellStyle[];
시트: Sheet[];
/** 열 때 보일 시트 id. */
활성?: string;
/** 서버만 씀 — [저장] 때 Node 풀이 결과(시트 id → A1 → 값). 브라우저 값은 안 적음 · 화면은 안 읽음. */
계산값?: CalcValues;
}
// ═════════════════════════════════════════════════════════════════════════════
// 2. 주소
// ═════════════════════════════════════════════════════════════════════════════
export interface CellAddress {
r: number;
c: number;
}
/** 닫힌 범위 — r0 ≤ r1 · c0 ≤ c1. 행 전체 = c0 0 · c1 MAX_COLS-1(열 전체도 같은 꼴). */
export interface CellRange {
r0: number;
c0: number;
r1: number;
c1: number;
}
/** 시트까지 붙은 칸 — 엔진 · 명령이 씀. */
export interface SheetCellAddress extends CellAddress {
시트: string;
}
// ═════════════════════════════════════════════════════════════════════════════
// 3. 수식 — 낱말 · 나무 (A 가 만들고 B · D · E 가 읽음)
// ═════════════════════════════════════════════════════════════════════════════
export type ErrorCode =
"#NULL!" | "#DIV/0!" | "#VALUE!" | "#REF!" | "#NAME?" | "#NUM!" | "#N/A" | "#CYCLE!"; // 순환 참조 — 엑셀엔 없음 · 칸에 보임(9_엑셀검토 3-3)
export type TokenKind =
| "number"
| "string"
| "bool"
| "error"
| "ref" // 칸 · 범위 · 행 · 열 참조(시트 붙은 것 포함) — 편집기가 색 테두리로 씀
| "func" // 함수 이름(뒤 `(` 앞까지)
| "op"
| "paren"
| "comma"
| "space"
| "unknown";
/** 낱말 — `start` · `end` 는 식 글(앞 `=` 뺀) 안 글자 자리. 편집기 참조 가리키기 · 색칠용. */
export interface Token {
kind: TokenKind;
text: string;
start: number;
end: number;
}
/** 나무 안 칸 참조 — 시트 없으면 식이 든 시트. `$` 는 abs. */
export interface RefCell {
r: number;
c: number;
absR: boolean;
absC: boolean;
}
export type FormulaNode =
| { type: "number"; value: Frac; text: string }
| { type: "string"; value: string }
| { type: "bool"; value: boolean }
| { type: "error"; code: ErrorCode }
/** 칸 하나 */
| { type: "cell"; sheet: string | null; ref: RefCell }
/** 범위(`A1:B5` · `A:A` · `3:3`) — 행 · 열 전체는 끝을 MAX 로. 한쪽이라도 `#REF!` 면 error 나무 */
| { type: "range"; sheet: string | null; from: RefCell; to: RefCell }
/** 글자 그대로 남은 이름(정의 없음) → 풀면 `#NAME?` */
| { type: "name"; name: string }
| { type: "unary"; op: "-" | "+" | "%"; arg: FormulaNode }
| {
type: "binary";
op: "+" | "-" | "*" | "/" | "^" | "&" | "=" | "<>" | "<" | "<=" | ">" | ">=";
left: FormulaNode;
right: FormulaNode;
}
| { type: "call"; name: string; args: FormulaNode[] };
/** 파싱 실패 — 칸에는 식 글을 그대로 두고 값은 `#NAME?`(엑셀은 입력을 막지만 우리는 까닭과 함께 받음). */
export interface ParseFailure {
ok: false;
message: string;
/** 식 글 안 자리 */
at: number;
}
export type ParseResult = { ok: true; node: FormulaNode } | ParseFailure;
// ═════════════════════════════════════════════════════════════════════════════
// 4. 값 — 풀이 중(분수) · 저장(십진 글)
// ═════════════════════════════════════════════════════════════════════════════
export interface ErrorValue {
error: ErrorCode;
/** 풍선 까닭(한글) */
why?: string;
}
/** 칸 하나 값 — 수는 BigInt 분수(무리수 함수만 double → 유효 15 자리 십진 → 분수).
* null = 빈 칸 — 수에서 0 · 글에서 "" · 범위 함수는 건너뜀(엑셀 규칙). */
export type Scalar = Frac | string | boolean | ErrorValue | null;
/** 범위 값 — 함수가 칸마다 읽음(빈 칸 null). */
export interface RangeValue {
kind: "range";
rows: number;
cols: number;
at(r: number, c: number): Scalar;
}
export type EvalResult = Scalar | RangeValue;
/** 저장 · 서버 결과 한 칸 — 수는 십진 글(끝수 뒤 · 30 자리까지). */
export type CalcValue =
{ 수: string } | { 글: string } | { 참: boolean } | { 오류: ErrorCode; 까닭?: string };
/** 시트 id → A1 → 값 (식 칸만). */
export type CalcValues = Record<string, Record<string, CalcValue>>;
// ═════════════════════════════════════════════════════════════════════════════
// 5. 풀이 문맥 · 함수 (A 의 eval ↔ B 의 함수 표)
// ═════════════════════════════════════════════════════════════════════════════
export interface EvalContext {
/** 식이 든 칸 */
sheet: string;
r: number;
c: number;
/** 칸 값 — 식 칸이면 엔진이 먼저 풀어 줌 · 순환이면 `#CYCLE!` */
cell(sheet: string, r: number, c: number): Scalar;
range(sheet: string, range: CellRange): RangeValue;
/** 시트 이름 → id (없으면 null → `#REF!`) */
sheetId(name: string): string | null;
}
/** 인자는 늦게 풂(IF · IFERROR · AND 가 고른 것만 풀게). 범위 인자는 RangeValue 로 옴. */
export type LazyArg = () => EvalResult;
export interface SheetFunction {
/** 인자 수 — 최대 없으면 끝없음 */
min: number;
max?: number;
call(args: LazyArg[], ctx: EvalContext): EvalResult;
}
// ═════════════════════════════════════════════════════════════════════════════
// 6. 명령 — 문서를 바꾸는 길은 이것뿐(되돌리기 · 서버 재계산 · 캐시가 한 길로 감)
// ═════════════════════════════════════════════════════════════════════════════
export type Command =
/** 칸 여럿 한 번에 — null = 칸 지움(값 · 식 · 서식 모두). 붙여넣기 · 채우기 · 지우기도 이것. */
| { 종류: "칸"; 시트: string; 칸: Record<string, Cell | null> }
/** 내용만 지움(Delete) — 서식은 남김 */
| { 종류: "내용지움"; 시트: string; 범위: CellRange[] }
/** 서식 바꿈 — 칸마다 지금 서식에 `바꿀` 을 덮어 새 서식 번호(표에 없으면 더함). null 항목 = 그 속성 지움 */
| {
종류: "서식";
시트: string;
범위: CellRange[];
바꿀: { [K in keyof CellStyle]?: CellStyle[K] | null };
}
| { 종류: "서식지움"; 시트: string; 범위: CellRange[] }
/** 행 넣기 · 지우기 — 참조 옮김(지운 칸 참조는 #REF!) · 병합 늘고 줆 */
| { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기"; 시트: string; at: number; 수: number }
/** 잘라 붙이기 — 옮긴 칸을 가리키던 식도 따라감 */
| { 종류: "옮기기"; 시트: string; 범위: CellRange; 대상시트: string; r: number; c: number }
| { 종류: "병합"; 시트: string; 범위: CellRange }
| { 종류: "병합풀기"; 시트: string; 범위: CellRange }
/** 폭(글자 단위) · 높이(pt) — null = 기본으로 */
| { 종류: "열폭"; 시트: string; 열: number[]; 폭: number | null }
| { 종류: "행높이"; 시트: string; 행: number[]; 높이: number | null }
| { 종류: "숨김"; 시트: string; 축: "행" | "열"; 번호: number[]; 숨김: boolean }
| { 종류: "틀고정"; 시트: string; 행: number; 열: number }
| { 종류: "눈금선"; 시트: string; 보임: boolean }
| { 종류: "시트더하기"; 시트: Sheet; 자리: number }
| { 종류: "시트지우기"; 시트: string }
/** 이름 바꾸면 다른 시트 식 글의 이름도 바꿈 */
| { 종류: "시트이름"; 시트: string; 이름: string }
| { 종류: "시트옮기기"; 시트: string; 자리: number }
/** 되돌림 짝 전용 — 시트 통째 바꿔 넣기(행열 지우기 되돌림 등) */
| { 종류: "시트통째"; 시트: Sheet; 서식?: CellStyle[] }
/** 정렬 — `차례[i]` = 범위 r0+i 자리에 올 원래 행(범위 열만 옮김 · 식은 복사처럼 상대 참조만 옮김).
* 차례는 `sortCommand`(값 비교는 엔진)가 만듦 — 다시 하기가 같은 결과가 되게 명령에 박음. */
| { 종류: "정렬"; 시트: string; 범위: CellRange; 차례: number[] }
/** 자동 필터 켜기 · 조건 바꾸기 · 끄기(null) — `걸러짐` = 숨길 행(`filterCommand` 가 값으로 셈) */
| { 종류: "필터"; 시트: string; 필터: AutoFilter | null; 걸러짐: number[] }
/** 행 · 열 묶기(윤곽) · 접기 · 펴기 · 풀기(`묶음` null) — 격자 윤곽 +/- 단추가 씀 */
| { 종류: "묶음설정"; 시트: string; 축: "행" | "열"; 번호: number[]; 묶음: Outline | null }
/** 여러 명령 = 되돌리기 한 번 */
| { 종류: "묶음"; 명령: Command[] };
/** 명령을 적용한 뒤 계산 엔진에 알릴 것 — 칸만 바뀌었으면 칸 목록 · 모양이 바뀌었으면 rebuild. */
export interface CommandEffect {
/** 되돌림 짝 */
undo: Command;
/** 값 · 식이 바뀐 칸 — 딸린 칸만 다시 풂 */
cells: SheetCellAddress[];
/** 행열 · 옮기기 · 시트 명령 — 엔진을 통째로 다시 */
rebuild: boolean;
}
// ═════════════════════════════════════════════════════════════════════════════
// 7. 계산 엔진 — 통합문서를 참조로 쥠(명령이 문서를 바꾸면 알려 줌)
// ═════════════════════════════════════════════════════════════════════════════
export interface CalcEngine {
/** 바뀐 칸 → 딸린 칸만 다시 풂 · 다시 푼 칸(바뀐 칸 포함) 목록 */
update(changed: SheetCellAddress[]): SheetCellAddress[];
/** 문서를 통째로 다시 읽고 전부 풂(행열 · 시트 명령 · setDoc 뒤) */
rebuild(book: Workbook): void;
/** 칸 값 — 식 칸이면 푼 값 · 아니면 입력 값(수는 분수) */
value(sheet: string, r: number, c: number): Scalar;
/** 식 칸이 가리키는 칸 · 범위(편집기 색 테두리 · 오류 추적) */
precedents(sheet: string, r: number, c: number): { 시트: string; 범위: CellRange }[];
/** 식 칸 전부 → 저장 모양(서버 `계산값` · 시험 대조) */
snapshot(): CalcValues;
}
// ═════════════════════════════════════════════════════════════════════════════
// 8. 되돌리기 · 채우기 · 숫자 형식 · 클립보드 (A · B · E 공개 모양)
// ═════════════════════════════════════════════════════════════════════════════
export interface History {
/** 적용이 끝난 명령과 그 되돌림 짝을 쌓음 · 다시 스택은 비움 */
push(done: Command, undo: Command): void;
undo(): Command | null;
redo(): Command | null;
canUndo(): boolean;
canRedo(): boolean;
clear(): void;
}
export type FillDirection = "down" | "up" | "right" | "left";
/** 숫자 형식 결과 — `색` 은 `[Red]` 같은 형식 색 · `채움글` 은 `*x` 반복 글(격자가 폭만큼 늘림). */
export interface FormattedText {
글: string;
색?: string;
채움글?: string;
/** 형식이 정한 정렬(일반 정렬일 때 수 = 오른쪽 · 글 = 왼쪽) */
정렬: "left" | "right" | "center";
}
/** 붙여넣기로 읽은 칸 뭉치 — 왼위 기준 상대 자리. */
export interface ClipCell {
값?: CellInput;
/** 앞 `=` 뺀 A1 식 — `원점` 기준으로 적힌 글 */
식?: string;
서식?: CellStyle;
}
/** 클립보드 형 — 브라우저가 읽는 것은 `text/plain`(TSV) · `text/html` 둘.
* 우리 → 밖: 둘 다 씀(보이는 값 + 인라인 서식 표) · 우리 → 우리: HTML 속 data 속성에 뭉치 JSON.
* 밖 → 우리 수식: ① 구글 `data-sheets-formula`(R1C1 상대) ② 엑셀 「수식 표시」(Ctrl+`) 로 복사한 글 칸이
* `=` 뒤에 글이 있고 식으로 읽히면 식(「=」 한 글자 칸은 글) — 실제 되는지는 E 가 이 PC 엑셀로 시험. */
export interface ClipBlock {
rows: number;
cols: number;
/** `r,c` 열쇠(0 부터 · 뭉치 왼위 기준) */
칸: Record<string, ClipCell>;
/** 뭉치 안 병합(0 부터) */
병합: CellRange[];
/** 식 글이 적힌 기준 왼위 — 붙일 때 (붙일 자리 − 원점) 만큼 상대 참조를 옮김.
* null = 옮기지 않음(엑셀 수식 표시 글 — 적힌 주소 그대로 · 구글 R1C1 은 붙일 자리로 풀어 null). */
원점: CellAddress | null;
출처: "aislo" | "google" | "excel" | "text";
}
// ═════════════════════════════════════════════════════════════════════════════
// 9. 3단계 — 조건부 서식 · 데이터 유효성 · 이름 정의 (Sheet 확장 · sub_laptop_4 계약 더함 · 안 이어짐)
// ═════════════════════════════════════════════════════════════════════════════
export type CondFmtCompareOp =
"보다큼" | "보다작음" | "크거나같음" | "작거나같음" | "같음" | "다름";
export type CondFmtCondition =
| { 종류: "비교"; 연산: CondFmtCompareOp; 값: number }
| { 종류: "사이"; 아래: number; 위: number }
| { 종류: "글포함"; 글: string }
/** 임의 수식 글 — A 의 파서 · eval 이 있어야 풀림(`spreadsheet_condfmt.ts` 머리 「잇는 법」). */
| { 종류: "수식"; 식: string };
/** 조건부 서식이 먹이는 스타일 — `서식` 표 번호를 안 씀(규칙마다 직접 가짐). */
export interface CondFmtStyle {
채움?: string;
글자색?: string;
굵게?: boolean;
}
export interface CondFmtRule {
id: string;
범위: CellRange[];
조건: CondFmtCondition;
스타일: CondFmtStyle;
}
export type DataValidationKind =
{ 종류: "목록"; 값들: string[] } | { 종류: "수범위"; 아래?: number; 위?: number };
export interface DataValidationRule {
id: string;
범위: CellRange[];
규칙: DataValidationKind;
/** 어긴 값을 넣으려 하면 막을지 · 경고만 하고 넣을지(둘 다 D 편집기 잇기 몫 · 지금 안 이어짐). */
어기면: "막음" | "경고";
메시지?: string;
}
/** 이름 정의 — 이 이름을 정의한 시트 안 범위만 가리킴(다른 시트를 가리키는 이름은 3단계 밖). */
export interface NamedRange {
이름: string;
범위: CellRange;
}
@@ -0,0 +1,219 @@
/* =============================================================================
* spreadsheet_validation.ts (3단계 · 주인 sub_laptop_4)
* 데이터 유효성 — 목록(드롭다운) · 수 범위. 어기면 막거나(칸에 못 들어감) 경고만(그래도 들어감).
* 저장 자리 `Sheet.유효성`(0 계약에 방금 더함). 규칙은 범위 하나에 하나만 먹임(엑셀과 같이 — 겹치면
* 배열 앞쪽이 이김).
*
* 잇는 법(D · sub7 — 지금은 하나도 안 이어짐):
* 1) 편집기(`spreadsheet_editor.ts`)가 칸 편집을 커밋하기 전에 `checkValue(rule, candidate)` 를
* 불러 `어기면 === "막음"` 이면 `dispatch` 를 안 부르고(입력 취소) 메시지를 토스트로 · `"경고"` 면
* 토스트만 띄우고 그대로 진행.
* 2) 칸 편집이 시작될 때(`editor.begin`) `findValidation(sheet, r, c)` 가 「목록」 규칙이면
* `createValidationDropdown(ctx, r, c, rule)` 을 칸 옆에 띄우고 고르면 그 값을 칸에 넣음.
* 3) 행 · 열 넣기 · 지우기 뒤 `remapValidationRanges` 를 그 시트 `유효성` 에 적용(조건부 서식과 같은
* 자리 · A 몫 · 지금 안 이어짐).
* 4) 도구 모음 · 메뉴에 [데이터 유효성] 항목을 두고 `createValidationPanel(ctx)` 를 띄움.
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { inRange, rangeToA1 } from "./spreadsheet_address";
import type {
CellInput,
CellRange,
DataValidationKind,
DataValidationRule,
Sheet,
} from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
export type { DataValidationKind, DataValidationRule };
/** 칸이 든 범위를 덮는 첫 규칙(배열 앞쪽 우선) — 없으면 undefined. */
export function findValidation(sheet: Sheet, r: number, c: number): DataValidationRule | undefined {
return (sheet.유효성 ?? []).find((rule) => rule.범위.some((range) => inRange(range, r, c)));
}
export interface ValidationCheck {
ok: boolean;
어기면?: "막음" | "경고";
메시지: string;
}
function defaultMessage(kind: DataValidationKind): string {
if (kind.종류 === "목록") return `목록 안 값만 됨(${kind.값들.join(", ")})`;
const 아래 = kind.아래 ?? -Infinity;
const 위 = kind.위 ?? Infinity;
return `${아래 === -Infinity ? "" : 아래}${위 === Infinity ? " 이상" : ` ~ ${위}`} 수만 됨`;
}
function matchesKind(kind: DataValidationKind, candidate: CellInput): boolean {
if (kind.종류 === "목록")
return typeof candidate !== "boolean" && kind.값들.includes(String(candidate));
if (typeof candidate !== "number") return false;
if (kind.아래 !== undefined && candidate < kind.아래) return false;
if (kind.위 !== undefined && candidate > kind.위) return false;
return true;
}
/** 후보 값이 규칙을 지키는지 — 어기면 `어기면`(막음 · 경고)과 안내 글을 돌려줌. */
export function checkValue(rule: DataValidationRule, candidate: CellInput): ValidationCheck {
if (matchesKind(rule.규칙, candidate)) return { ok: true, 메시지: "" };
return { ok: false, 어기면: rule.어기면, 메시지: rule.메시지 || defaultMessage(rule.규칙) };
}
/** 행 · 열 넣기 · 지우기 뒤 범위 옮김 — `shift` 가 null 이면 그 범위는 버림(규칙이 범위를 다 잃으면 규칙째 버림). */
export function remapValidationRanges(
rules: DataValidationRule[],
shift: (range: CellRange) => CellRange | null,
): DataValidationRule[] {
const out: DataValidationRule[] = [];
for (const rule of rules) {
const moved = rule.범위.map(shift).filter((r): r is CellRange => r !== null);
if (moved.length) out.push({ ...rule, 범위: moved });
}
return out;
}
function applySheet(ctx: SpreadsheetContext, next: Sheet): void {
ctx.dispatch({ 종류: "시트통째", 시트: next });
}
let seq = 0;
const newId = (): string => `dv${Date.now().toString(36)}${seq++}`;
export function addValidationRule(
ctx: SpreadsheetContext,
rule: Omit<DataValidationRule, "id">,
): void {
const sheet = ctx.sheet();
applySheet(ctx, { ...sheet, 유효성: [...(sheet.유효성 ?? []), { ...rule, id: newId() }] });
}
export function removeValidationRule(ctx: SpreadsheetContext, id: string): void {
const sheet = ctx.sheet();
applySheet(ctx, { ...sheet, 유효성: (sheet.유효성 ?? []).filter((r) => r.id !== id) });
}
/** 목록 규칙일 때 칸 옆에 띄울 작은 드롭다운(고르면 `onPick` 이 불림 — 실제 `dispatch` 는 부른 쪽 몫). */
export function createValidationDropdown(
ctx: SpreadsheetContext,
r: number,
c: number,
rule: DataValidationRule,
onPick: (value: string) => void,
): HTMLElement | null {
if (rule.규칙.종류 !== "목록") return null;
const box = ctx.grid.cellBox(r, c);
const dropdown = el("div", { className: "ss-validation__dropdown" });
dropdown.style.left = `${box.x}px`;
dropdown.style.top = `${box.y + box.h}px`;
dropdown.style.minWidth = `${box.w}px`;
dropdown.replaceChildren(
...rule.규칙.값들.map((value) => {
const item = el("div", { className: "ss-validation__dropdown-item", text: value });
item.addEventListener("click", () => onPick(value));
return item;
}),
);
return dropdown;
}
const kindText = (k: DataValidationKind): string =>
k.종류 === "목록" ? `목록: ${k.값들.join(", ")}` : `수범위: ${k.아래 ?? "−∞"} ~ ${k.위 ?? "+∞"}`;
/** 유효성 관리 창 — 지금 시트 규칙 목록 + 고른 범위에 규칙 더하기. */
export function createValidationPanel(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-validation" });
const list = el("ul", { className: "ss-validation__list" });
const kindSelect = createSelectField({
label: "규칙",
options: [
{ value: "목록", text: "목록(드롭다운)" },
{ value: "수범위", text: "수 범위" },
],
});
const listValues = createInputField({ label: "목록 값(쉼표로 나눔)" });
const min = createInputField({ label: "아래(수범위)" });
const max = createInputField({ label: "위(수범위)" });
const onBreak = createSelectField({
label: "어기면",
options: [
{ value: "막음", text: "막음" },
{ value: "경고", text: "경고" },
],
});
const message = createInputField({ label: "안내 글(생략 가능)" });
const addBtn = createButton({ label: "규칙 더하기" });
addBtn.addEventListener("click", () => {
const 범위 = ctx.selection.범위;
if (!범위.length) return;
const 규칙 = build규칙();
if (!규칙) return;
addValidationRule(ctx, {
범위: [...범위],
규칙,
어기면: onBreak.select.value as "막음" | "경고",
메시지: message.input.value || undefined,
});
render();
});
function build규칙(): DataValidationKind | null {
if (kindSelect.select.value === "목록") {
const 값들 = listValues.input.value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return 값들.length ? { 종류: "목록", 값들 } : null;
}
const 아래 = min.input.value ? Number(min.input.value) : undefined;
const 위 = max.input.value ? Number(max.input.value) : undefined;
return 아래 === undefined && 위 === undefined ? null : { 종류: "수범위", 아래, 위 };
}
function render(): void {
const rules = ctx.sheet().유효성 ?? [];
list.replaceChildren(
...rules.map((rule) => {
const li = el("li", { className: "ss-validation__row" });
const label = el("span", {
text: `${rule.범위.map(rangeToA1).join(", ")} — ${kindText(rule.규칙)}(${rule.어기면})`,
});
const del = createButton({ label: "지움", variant: "danger" });
del.addEventListener("click", () => {
removeValidationRule(ctx, rule.id);
render();
});
li.append(label, del);
return li;
}),
);
}
const form = el("div", {
className: "ss-validation__form",
children: [
kindSelect.root,
listValues.root,
min.root,
max.root,
onBreak.root,
message.root,
addBtn,
],
});
root.append(el("h4", { text: "데이터 유효성 규칙" }), list, form);
render();
return {
root,
refresh: render,
destroy(): void {
root.remove();
},
};
}
@@ -0,0 +1,197 @@
/* =============================================================================
* spreadsheet_view_menu.ts (2단계 · 주인 sub_laptop_1)
* 이동 대화상자(F5 · 주소 · 정의된 이름) · 틀 고정 메뉴(첫 행 · 첫 열 · 현재 칸 기준 · 풀기).
*
* 정의된 이름 목록은 `Sheet.이름정의`(3단계 · sub_laptop_4 계약 · 안 이어짐) 가 있으면
* 그대로 보여 줌 — 없으면 빈 목록(주소 입력만 됨). 이름으로 다른 시트를 못 가리키는
* 지금 계약대로 활성 시트 안에서만 찾음.
*
* 잇는 법(D · sub7) — F5 → `openGoToDialog(ctx)` 를 `spreadsheet_keys.ts` navKey 에 더함.
* 틀 고정 단추(E 도구 모음)는 눌렀을 때 `openFreezePanesMenu(ctx, button)` 를 부르면 그
* 단추 아래 작은 메뉴가 뜸.
* ========================================================================== */
import { createButton, createInputField, el } from "@ui/ui_template_elements";
import { parseRange, rangeToA1 } from "./spreadsheet_address";
import type { CellRange } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
goToTitle: "이동",
addressLabel: "참조",
namedRanges: "정의된 이름",
ok: "확인",
cancel: "취소",
notFound: "주소를 찾을 수 없음",
freezeFirstRow: "첫 행 고정",
freezeFirstColumn: "첫 열 고정",
freezeAtActive: "틀 고정(현재 칸 기준)",
unfreeze: "틀 고정 풀기",
};
// ═════════════════════════════════════════════════════════════════════════════
// 이동(F5)
// ═════════════════════════════════════════════════════════════════════════════
/** 범위(또는 칸) 글을 읽어 그 범위로 고름을 옮기고 보이게 스크롤함. */
export function goTo(ctx: SpreadsheetContext, target: CellRange): void {
const top = { r: target.r0, c: target.c0 };
ctx.select({ 시트: ctx.selection.시트, 범위: [target], 활성: top, 기준: top }, true);
}
export function openGoToDialog(ctx: SpreadsheetContext): void {
const overlay = el("div", { className: "ui-confirm" });
const panel = el("div", { className: "ui-confirm__panel" });
const close = (): void => overlay.remove();
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) close();
});
const errorMsg = el("p", { text: "" });
errorMsg.style.cssText = "color:var(--color-danger, red);font-size:0.8rem;min-height:1em;";
const addressField = createInputField({
label: TEXT.addressLabel,
value: rangeToA1(
ctx.selection.범위[0] ?? {
r0: ctx.selection.활성.r,
c0: ctx.selection.활성.c,
r1: ctx.selection.활성.r,
c1: ctx.selection.활성.c,
},
),
});
const goToAddress = (): void => {
const target = parseRange(addressField.input.value);
if (!target) {
errorMsg.textContent = TEXT.notFound;
return;
}
goTo(ctx, target);
close();
};
addressField.input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") goToAddress();
});
const names = ctx.sheet().이름정의 ?? [];
const namesList = el("div", {
children: names.map((n) =>
createButton({
label: `${n.이름} (${rangeToA1(n.범위)})`,
variant: "ghost",
onClick: () => {
goTo(ctx, n.범위);
close();
},
}),
),
});
namesList.style.cssText =
"display:flex;flex-direction:column;gap:2px;max-height:160px;overflow:auto;";
panel.append(
el("h3", { text: TEXT.goToTitle }),
addressField.root,
errorMsg,
...(names.length > 0 ? [el("p", { text: TEXT.namedRanges }), namesList] : []),
el("div", {
className: "ui-confirm__actions",
children: [
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
createButton({ label: TEXT.ok, variant: "filled", onClick: goToAddress }),
],
}),
);
overlay.append(panel);
document.body.append(overlay);
addressField.input.focus();
addressField.input.select();
}
// ═════════════════════════════════════════════════════════════════════════════
// 틀 고정
// ═════════════════════════════════════════════════════════════════════════════
export function freezeFirstRow(ctx: SpreadsheetContext): void {
ctx.dispatch({
종류: "틀고정",
시트: ctx.selection.시트,
행: 1,
열: ctx.sheet().틀고정?.열 ?? 0,
});
}
export function freezeFirstColumn(ctx: SpreadsheetContext): void {
ctx.dispatch({
종류: "틀고정",
시트: ctx.selection.시트,
행: ctx.sheet().틀고정?.행 ?? 0,
열: 1,
});
}
/** 활성 칸의 왼위 자리를 기준으로 그 위 · 왼쪽을 고정(활성 칸이 A1 이면 사실상 풀기와 같음). */
export function freezeAtActiveCell(ctx: SpreadsheetContext): void {
const at = ctx.selection.활성;
ctx.dispatch({ 종류: "틀고정", 시트: ctx.selection.시트, 행: at.r, 열: at.c });
}
export function unfreezePanes(ctx: SpreadsheetContext): void {
ctx.dispatch({ 종류: "틀고정", 시트: ctx.selection.시트, 행: 0, 열: 0 });
}
/** 도구 모음 단추 아래(또는 화면 가운데, `anchor` 없으면) 뜨는 작은 메뉴 — 누르면 바로 적용하고 닫힘. */
export function openFreezePanesMenu(ctx: SpreadsheetContext, anchor?: HTMLElement): void {
const menu = el("div", { className: "ui-confirm__panel" });
menu.style.cssText =
"position:fixed;display:flex;flex-direction:column;gap:2px;min-width:180px;z-index:1000;padding:6px;";
const items: [string, () => void][] = [
[TEXT.freezeFirstRow, () => freezeFirstRow(ctx)],
[TEXT.freezeFirstColumn, () => freezeFirstColumn(ctx)],
[TEXT.freezeAtActive, () => freezeAtActiveCell(ctx)],
[TEXT.unfreeze, () => unfreezePanes(ctx)],
];
const close = (): void => {
menu.remove();
document.removeEventListener("mousedown", onOutside, true);
document.removeEventListener("keydown", onEscape);
};
const onOutside = (ev: MouseEvent): void => {
if (!menu.contains(ev.target as Node)) close();
};
const onEscape = (ev: KeyboardEvent): void => {
if (ev.key === "Escape") close();
};
menu.append(
...items.map(([label, action]) =>
createButton({
label,
variant: "ghost",
onClick: () => {
action();
close();
},
}),
),
);
document.body.append(menu);
if (anchor) {
const box = anchor.getBoundingClientRect();
menu.style.top = `${box.bottom + 4}px`;
menu.style.left = `${box.left}px`;
} else {
menu.style.top = "40%";
menu.style.left = "50%";
menu.style.transform = "translate(-50%, -50%)";
}
setTimeout(() => {
document.addEventListener("mousedown", onOutside, true);
document.addEventListener("keydown", onEscape);
}, 0);
}
@@ -0,0 +1,119 @@
/* =============================================================================
* spreadsheet_view_types.ts
* 화면 부품(C 격자 · D 입력 · E 도구) 사이 계약 — 문맥 하나를 D 가 만들어 C · E 에 넘김.
* 0 계약 주인만 고침. 파일 목록 · 주인 · 규칙은 `spreadsheet_types.ts` 머리.
*
* 흐름: 사용자 조작 → (D · E) `ctx.dispatch(명령)` → A 적용 · 되돌림 쌓기 · 엔진 update/rebuild →
* C `grid.invalidate(칸)`(보이는 칸만 다시) → `onChange(문서)`. 서버 왕복 없음 · 자동저장 없음.
* ========================================================================== */
import type {
CalcEngine,
CellAddress,
CellRange,
Command,
History,
Sheet,
SheetCellAddress,
Workbook,
} from "./spreadsheet_types";
/** 고름 — 범위 여럿(Ctrl+누르기) · 활성 칸은 편집 · 수식 입력줄이 보이는 칸 · 기준은 Shift 늘림의 닻. */
export interface Selection {
시트: string;
범위: CellRange[];
활성: CellAddress;
기준: CellAddress;
}
/** 격자 안 한 점이 가리키는 것 — 머리 끝(`colEdge` · `rowEdge`)은 폭 · 높이 끌기 자리. */
export interface HitResult {
kind: "cell" | "colHead" | "rowHead" | "corner" | "colEdge" | "rowEdge" | "fillHandle";
r: number;
c: number;
}
/** 칸 자리 — 격자 내용 좌표(px · 스크롤 전) · 병합이면 병합 전체. */
export interface CellBox {
x: number;
y: number;
w: number;
h: number;
}
/** 편집 중 식 참조 색 테두리(D 가 채우고 C 가 그림). */
export interface RefHighlight {
시트: string;
범위: CellRange;
/** 0 부터 차례 — 색은 C 가 차례로 고름 */
색번호: number;
}
/** C 격자 — `createGrid(ctx)` 가 돌려줌. */
export interface GridHandle {
/** 격자 뿌리(스크롤 판 · 틀고정 판 포함) — D 가 창 안에 붙임 */
root: HTMLElement;
/** 보이는 칸 통째로 다시(시트 바꿈 · 행열 · 폭 · 틀고정 뒤) */
render(): void;
/** 값 · 서식이 바뀐 칸만 다시(보이는 것만) */
invalidate(cells: SheetCellAddress[]): void;
/** 고름 · 참조 색 테두리 · 채우기 손잡이 다시 그림 */
renderSelection(): void;
setRefHighlights(list: RefHighlight[]): void;
cellBox(r: number, c: number): CellBox;
/** 화면 좌표 → 가리킨 것(격자 밖 null) */
hitTest(clientX: number, clientY: number): HitResult | null;
/** 칸이 보이게 스크롤(틀고정 판이면 그대로) */
reveal(r: number, c: number): void;
/** 지금 보이는 행열(틀고정 판 뺀 본문 판) */
visibleRange(): CellRange;
/** 편집기를 얹을 층과 칸 자리(화면 좌표 · 병합이면 전체) — D 편집기가 씀 */
editorSlot(r: number, c: number): { layer: HTMLElement; box: CellBox };
/** 지금 확대(1 = 100%) — 바뀌면 `root` 에서 CustomEvent("aislo-grid-zoom", { detail: { zoom } }) 를 쏨(E 상태줄이 들음) */
zoom(): number;
destroy(): void;
}
/** D 편집기 — E 도구 모음 · C 가 부름. */
export interface EditorHandle {
/** 편집 중인가 — 방향키 · 붙여넣기 뜻이 달라짐 */
editing(): boolean;
/** 활성 칸 편집 시작(`initial` = 친 글자로 시작) */
begin(initial?: string): void;
/** 확정 · 취소 */
commit(): void;
cancel(): void;
/** 수식 입력줄 · 주소 상자를 활성 칸으로 다시 */
sync(): void;
}
/** D 가 만들어 C · E 에 넘기는 문맥 — 문서는 이것 하나를 여럿이 봄. */
export interface SpreadsheetContext {
/** 부품 뿌리 */
root: HTMLElement;
/** 지금 문서 — 명령만이 바꿈(직접 고치지 말 것) */
book: Workbook;
readOnly: boolean;
engine: CalcEngine;
history: History;
selection: Selection;
/** 활성 시트 */
sheet(): Sheet;
/** 명령 하나 적용 → 되돌림 쌓기 → 엔진 → 다시 그림 → onChange. readOnly 면 안 함 */
dispatch(command: Command): void;
undo(): void;
redo(): void;
/** 고름 바꿈 → 다시 그림 · 수식 입력줄 · onSelect */
select(selection: Selection, reveal?: boolean): void;
/** 활성 시트 바꿈(탭) */
showSheet(sheetId: string): void;
grid: GridHandle;
editor: EditorHandle;
}
/** E 부품 — D 가 `mountToolbar(ctx)` 등으로 붙이고 고름 · 문서가 바뀌면 `refresh()`. */
export interface PartHandle {
root: HTMLElement | null;
refresh(): void;
destroy(): void;
}
@@ -16,6 +16,7 @@ DXF 로 저장해 달라」는 안내로 떨어진다.
from __future__ import annotations
import io
import logging
import os
import shutil
@@ -194,7 +195,39 @@ def _expand(entity: Any) -> list[Any]:
def dxf_to_entities(path: Path) -> list[dict[str, Any]]:
"""DXF 파일을 도각 엔티티 목록으로. 지원 밖 도형(해치·솔리드 등)은 버린다."""
document = ezdxf.readfile(str(path))
return _document_entities(ezdxf.readfile(str(path)))
def _layer_color(document: Any, name: str) -> str | None:
"""도면층 색(ACI) → 화면 색. 꺼진 층은 음수로 적히므로 절댓값."""
try:
aci = abs(int(document.layers.get(name).dxf.color))
red, green, blue = ezdxf_colors.aci2rgb(aci)
return f"#{red:02x}{green:02x}{blue:02x}"
except Exception:
return None
def dxf_text_to_entities(text: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""클립보드 DXF 글 → (엔티티 목록, 쓰인 도면층 [{name, color}])."""
return _entities_with_layers(ezdxf.read(io.StringIO(text)))
def drawing_file_to_entities(
filename: str, data: bytes
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""끌어다 놓은 DXF·DWG 파일 → (엔티티 목록, 쓰인 도면층 [{name, color}])."""
return _entities_with_layers(_read_drawing_file(filename, data))
def _entities_with_layers(document: Any) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""엔티티 `layerId` 는 DXF 도면층 이름 그대로 — 웹 CAD 가 이름으로 자기 도면층에 잇는다."""
entities = _document_entities(document)
names = sorted({str(entity["layerId"]) for entity in entities})
return entities, [{"name": name, "color": _layer_color(document, name)} for name in names]
def _document_entities(document: Any) -> list[dict[str, Any]]:
entities: list[dict[str, Any]] = []
index = 0
for source in document.modelspace():
@@ -266,6 +299,11 @@ def _dwg_to_dxf(source: Path, work_dir: Path) -> Path:
def import_frame_file(filename: str, data: bytes) -> list[dict[str, Any]]:
"""올린 도각 파일(DXF·DWG)을 도각 엔티티 목록으로 바꾼다."""
return _document_entities(_read_drawing_file(filename, data))
def _read_drawing_file(filename: str, data: bytes) -> Any:
"""DXF·DWG 파일 → ezdxf 문서(메모리에 다 읽음 · 임시 폴더는 바로 지움)."""
suffix = Path(filename).suffix.lower()
if suffix not in (".dxf", ".dwg"):
raise ValueError("DXF 또는 DWG 파일만 올릴 수 있습니다.")
@@ -275,6 +313,6 @@ def import_frame_file(filename: str, data: bytes) -> list[dict[str, Any]]:
source.write_bytes(data)
target = _dwg_to_dxf(source, work_dir) if suffix == ".dwg" else source
try:
return dxf_to_entities(target)
return ezdxf.readfile(str(target))
except ezdxf.DXFError as exc:
raise ValueError(f"DXF 를 읽지 못했습니다: {exc}") from exc
@@ -11,12 +11,18 @@ from pathlib import Path
from urllib.parse import quote
from uuid import UUID
import ezdxf
from fastapi import APIRouter, File, Response, UploadFile
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import export_drawing
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import (
drawing_file_to_entities,
dxf_text_to_entities,
import_frame_file,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
frame_document,
frame_template_document,
@@ -145,6 +151,74 @@ async def import_frame_template(
)
class DxfClipRequest(BaseModel):
"""클립보드에 담긴 DXF 글."""
text: str
@router.post("/dxf-clip")
async def paste_dxf_clip(request: DxfClipRequest) -> JSONResponse:
"""클립보드 DXF 글 → 붙일 엔티티 · 도면층. 프로젝트 무관(M02 양식도 씀) · 저장 안 함."""
try:
if len(request.text) > _FRAME_IMPORT_MAX_BYTES:
raise ValueError("붙여넣을 도형이 너무 많습니다(20MB 넘음).")
entities, layers = await asyncio.to_thread(dxf_text_to_entities, request.text)
validate_template_entities(entities)
return JSONResponse(
{
"status": "success",
"entities": entities,
"layers": layers,
"entity_count": len(entities),
}
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except (UnicodeError, ezdxf.DXFError):
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": "클립보드 DXF 를 읽지 못했습니다 — 깨진 글입니다.",
},
)
except Exception:
logger.exception("B07 DXF 붙여넣기 실패")
return JSONResponse(
status_code=500, content={"status": "error", "message": "DXF 를 붙여넣지 못했습니다."}
)
@router.post("/dxf-clip/file")
async def paste_drawing_file(file: UploadFile = File(...)) -> JSONResponse:
"""끌어다 놓은 DXF·DWG 파일(오토캐드 WBLOCK·DXFOUT) → 붙일 엔티티 · 도면층 · 저장 안 함."""
try:
data = await file.read()
if len(data) > _FRAME_IMPORT_MAX_BYTES:
raise ValueError("도면 파일이 너무 큽니다(20MB 넘음).")
entities, layers = await asyncio.to_thread(
drawing_file_to_entities, file.filename or "", data
)
validate_template_entities(entities)
return JSONResponse(
{
"status": "success",
"entities": entities,
"layers": layers,
"entity_count": len(entities),
}
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B07 도면 파일 붙여넣기 실패: %s", file.filename)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "도면 파일을 붙여넣지 못했습니다."},
)
@router.put("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
async def put_frame_template(
project_id: UUID, request: FrameTemplateSaveRequest
@@ -176,8 +176,9 @@ export const CANVAS_INPUT_FIELD_HEIGHT = 20;
/**
* Canvas input field background color
* 자동색(검정) — 어두운 바탕 검정 칸에 흰 글 · 밝은 바탕 흰 칸에 검정 글(`theme.ts` paintColor)
*/
export const CANVAS_INPUT_FIELD_BACKGROUND_COLOR = '#161616';
export const CANVAS_INPUT_FIELD_BACKGROUND_COLOR = '#000000';
/**
* Canvas input field text color
+80
View File
@@ -55,6 +55,9 @@ button {
z-index: 2;
pointer-events: none;
}
.cad-host-save {
--cad-title-height: 0px;
}
.controls {
pointer-events: auto;
}
@@ -146,6 +149,13 @@ body > canvas[data-id="canvas"] {
flex-direction: column;
background: var(--cad-chrome-raised);
border-bottom: 1px solid var(--cad-line);
transition: height 120ms ease;
}
/* 접힘 모드에서 탭으로 잠깐 펼침 — 도면 자리는 그대로 두고 위에 겹쳐 뜸(122px = 펼친 리본 높이) */
.cad-ribbon[data-peek="true"] {
z-index: 10;
height: 122px;
box-shadow: var(--shadow-lg);
}
.cad-ribbon-tabs {
display: flex;
@@ -154,6 +164,21 @@ body > canvas[data-id="canvas"] {
padding: 0 8px;
border-bottom: 1px solid var(--cad-line);
}
.cad-ribbon-tabs__collapse {
margin-left: auto;
width: 24px;
height: 20px;
align-self: center;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--cad-text-dim);
font-size: 11px;
}
.cad-ribbon-tabs__collapse:hover {
background: var(--cad-hover);
}
.cad-ribbon-tabs button {
padding: 0 14px;
border: 1px solid transparent;
@@ -501,6 +526,48 @@ body > canvas[data-id="canvas"] {
background: var(--cad-chrome-raised);
box-shadow: var(--shadow-lg);
}
/* 오토캐드 파일 끌어다 놓기 안내 — 캔버스 오른쪽 위 흐린 글씨(빠른 특성 창이 뜨면 그 아래로 가려짐) */
.cad-drop-hint {
position: fixed;
z-index: 2;
top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 8px);
right: 14px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
max-width: 360px;
padding: 2px 4px;
border: 0;
background: transparent;
color: var(--cad-text-muted);
font-size: 11px;
text-align: right;
opacity: 0.55;
cursor: pointer;
}
.cad-drop-hint:hover {
opacity: 1;
}
.cad-drop-hint__steps {
display: flex;
flex-direction: column;
align-items: flex-end;
color: var(--cad-text-dim);
font-size: 10px;
}
/* 파일을 끄는 동안 — 화면 가장자리 흐린 테두리(`dxf-clip-paste.ts`) */
body.cad-file-dragging::after {
content: '';
position: fixed;
z-index: 50;
inset: 6px;
border: 2px dashed var(--cad-accent);
border-radius: 6px;
opacity: 0.6;
pointer-events: none;
}
.cad-view-controls button {
width: 34px;
height: 32px;
@@ -966,11 +1033,24 @@ body > canvas[data-id="canvas"] {
}
.cad-frame-tokens__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 6px;
color: var(--cad-text-dim);
font-size: 11px;
line-height: 1.4;
}
.cad-frame-tokens__fold {
flex: none;
border: 1px solid var(--cad-line);
border-radius: 4px;
background: transparent;
color: var(--cad-text);
cursor: pointer;
}
.cad-frame-tokens__group {
display: flex;
flex-wrap: wrap;
@@ -3,10 +3,10 @@ import { toast } from 'react-toastify';
import { clearRecovery, restoreRecovery } from '../helpers/autosave';
import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json';
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage';
import { requestDrawingExport } from '../integration/aislo-drawing-bridge';
import { requestDrawingExport, requestHostSave } from '../integration/aislo-drawing-bridge';
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
import { redo, undo } from '../state';
import { isHostSave, redo, undo } from '../state';
import type { CadCommand } from './command.types';
export const FILE_COMMANDS: CadCommand[] = [
@@ -17,6 +17,10 @@ export const FILE_COMMANDS: CadCommand[] = [
glyph: '💾',
hint: '현재 도면을 브라우저에 저장한다',
run: () => {
if (isHostSave()) {
requestHostSave();
return '양식 저장 요청';
}
void exportEntitiesToLocalStorage().then(() => {
clearRecovery();
toast.success('도면을 저장했습니다.');
@@ -0,0 +1,25 @@
/** 화면 오른쪽 위 흐린 안내 — 오토캐드에서 저장한 파일을 끌어다 놓는 법 · 누르면 순서 (B07 · M02 양식 둘 다) */
import { type FC, useState } from 'react';
import { isDrawingReadOnly } from '../state';
export const DropHint: FC = () => {
const [open, setOpen] = useState(false);
if (isDrawingReadOnly()) return null;
return (
<button
type="button"
className="cad-drop-hint controls"
onClick={() => setOpen((value) => !value)}
title="오토캐드 도형을 이 화면에 붙이는 법"
>
오토캐드에서 WBLOCK(또는 DXFOUT)으로 저장 → 파일을 여기로 끌어다 놓기
{open && (
<span className="cad-drop-hint__steps">
<span>① 오토캐드에서 도형을 고르고 WBLOCK(또는 DXFOUT) 입력 → 파일 이름 정해 저장</span>
<span>② 저장한 파일(도면 파일 통째도 됨)을 이 화면으로 끌어다 놓기</span>
<span>③ 원래 좌표 · 도면층 그대로 붙고 골라진 상태 — 되돌리기 한 번에 빠짐</span>
</span>
)}
</button>
);
};
@@ -113,6 +113,8 @@ function boxSizeOf(entity: TextEntity | ImageEntity): { width: number; height: n
export const FramePlaceholderPanel: FC = () => {
const [visible, setVisible] = useState(isFrameEditMode());
// 접으면 그림 오른쪽 아래를 덮지 않는다 — 머리 줄만 남는다.
const [folded, setFolded] = useState(false);
const [picked, setPicked] = useState<TextEntity | ImageEntity | null>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
@@ -139,8 +141,21 @@ export const FramePlaceholderPanel: FC = () => {
return (
<section className="cad-frame-tokens controls">
<header className="cad-frame-tokens__header">
자리표 놓기 — 누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다
<span>
{folded ? '자리표 놓기' : '자리표 놓기 — 누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다'}
</span>
<button
type="button"
className="cad-frame-tokens__fold"
aria-expanded={!folded}
title={folded ? '자리표 패널 펴기' : '자리표 패널 접기'}
onClick={() => setFolded(!folded)}
>
{folded ? '▴' : '▾'}
</button>
</header>
{!folded && (
<>
<div className="cad-frame-tokens__group">
<span className="cad-frame-tokens__title">글자</span>
{TEXT_TOKENS.map((token) => (
@@ -190,6 +205,8 @@ export const FramePlaceholderPanel: FC = () => {
</label>
</div>
)}
</>
)}
</section>
);
};
@@ -4,7 +4,7 @@ import { HtmlEvent } from '../App.types';
import { getCommandById } from '../commands/registry';
import { runCommand } from '../commands/run-command';
import { QUICK_ACCESS_COMMANDS } from '../ribbon/ribbon.config';
import { getHostTitle } from '../state';
import { getHostTitle, isHostSave } from '../state';
export const QuickAccessBar: FC = () => {
const [hostTitle, setTitle] = useState(getHostTitle());
@@ -13,6 +13,8 @@ export const QuickAccessBar: FC = () => {
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
}, []);
// 부모 페이지가 저장·이름을 맡는 M02 에선 제목 줄을 숨겨 그림 칸을 넓힌다.
if (isHostSave()) return null;
return (
<header className="cad-titlebar controls">
<div className="cad-brand">
@@ -12,16 +12,20 @@ import { QuickAccessBar } from './QuickAccessBar';
import { Ribbon } from '../ribbon/Ribbon';
import { StatusBar } from './StatusBar';
import { useCadRefresh } from './use-cad-refresh';
import { isInspectorCollapsed, isRibbonCollapsed, setInspectorCollapsed } from './ui-state';
import { getActiveToolActor } from '../state';
import type { Tool } from '../tools';
import { ViewControls } from './ViewControls';
import { DropHint } from './DropHint';
export const Toolbar: FC = () => {
useCadRefresh();
const [panelCollapsed, setPanelCollapsed] = useState(false);
const [commandLineVisible, setCommandLineVisible] = useState(false);
const activeTool = (getActiveToolActor()?.getSnapshot()?.context?.type ?? null) as Tool | null;
const ribbonCollapsed = isRibbonCollapsed();
// 좌측 패널 접힘 — 호스트(M02 미리보기)가 접힌 채로 실을 수 있게 ui-state 에 둔다
const panelCollapsed = isInspectorCollapsed();
useEffect(() => {
// 편 상태의 폭은 App.css의 `--cad-panel-width` 기본값을 쓴다. 여기 숫자를 박아
@@ -32,8 +36,11 @@ export const Toolbar: FC = () => {
'--cad-command-height',
commandLineVisible ? '86px' : '0px'
);
// 리본 접힘 — 탭 줄(26px)만 남기고 패널 줄 자리를 걷는다(`Ribbon.tsx` 가 안 그림).
if (ribbonCollapsed) document.documentElement.style.setProperty('--cad-ribbon-height', '26px');
else document.documentElement.style.removeProperty('--cad-ribbon-height');
window.dispatchEvent(new Event('resize'));
}, [panelCollapsed, commandLineVisible]);
}, [panelCollapsed, commandLineVisible, ribbonCollapsed]);
return (
<>
@@ -48,9 +55,10 @@ export const Toolbar: FC = () => {
/>
<InspectorPanel
collapsed={panelCollapsed}
onToggleCollapsed={() => setPanelCollapsed((value) => !value)}
onToggleCollapsed={() => setInspectorCollapsed(!panelCollapsed)}
/>
<ViewControls />
<DropHint />
<QuickProperties />
<BlockLibraryPanel />
{commandLineVisible && <CommandLine />}
@@ -12,6 +12,8 @@ let inspectorCollapsed = false;
let quickPropertiesVisible = false;
let activeRibbonTab = 'home';
let blockLibraryVisible = false;
/** 호스트(M02)가 처음부터 접어 실을 때 true — 탭을 누르면 펼침(`setActiveRibbonTab`). */
let ribbonCollapsed = false;
const notify = () => notifyWindow(HtmlEvent.UPDATE_STATE);
@@ -20,12 +22,18 @@ export const isInspectorCollapsed = () => inspectorCollapsed;
export const isQuickPropertiesVisible = () => quickPropertiesVisible;
export const getActiveRibbonTab = () => activeRibbonTab;
export const isBlockLibraryVisible = () => blockLibraryVisible;
export const isRibbonCollapsed = () => ribbonCollapsed;
export function setActiveRibbonTab(tabId: string): void {
activeRibbonTab = tabId;
notify();
}
export function setRibbonCollapsed(collapsed: boolean): void {
ribbonCollapsed = collapsed;
notify();
}
export function openInspector(tab: InspectorTab): void {
inspectorTab = tab;
inspectorCollapsed = false;
@@ -41,6 +41,8 @@ export class ScreenCanvasDrawController implements DrawController {
private screenScale = 1;
private screenMouseLocation: Point;
private canvasSize: Point = new Point(100, 100);
/** 맞춤 뒤 사용자가 화면을 안 옮겼음 — 캔버스 크기가 바뀌면 다시 맞춘다(첫 표시 때 iframe 이 늦게 커짐). */
private viewFitted = false;
// Style-run batching (static scene rendering): consecutive stroke calls
// with the same style are collected into one Path2D and stroked once.
@@ -61,7 +63,9 @@ export class ScreenCanvasDrawController implements DrawController {
}
public setCanvasSize(newCanvasSize: Point) {
const changed = newCanvasSize.x !== this.canvasSize.x || newCanvasSize.y !== this.canvasSize.y;
this.canvasSize = newCanvasSize;
if (changed && this.viewFitted) this.zoomToFitScreen();
}
public getScreenScale() {
@@ -70,6 +74,7 @@ export class ScreenCanvasDrawController implements DrawController {
public setScreenScale(newScreenScale: number) {
this.screenScale = newScreenScale;
this.viewFitted = false;
triggerReactUpdate(StateVariable.screenZoom);
}
@@ -79,6 +84,7 @@ export class ScreenCanvasDrawController implements DrawController {
public setScreenOffset(newScreenOffset: Point) {
this.screenOffset = newScreenOffset;
this.viewFitted = false;
triggerReactUpdate(StateVariable.screenOffset);
}
@@ -96,6 +102,7 @@ export class ScreenCanvasDrawController implements DrawController {
}
public panScreen(screenOffsetX: number, screenOffsetY: number) {
this.viewFitted = false;
this.screenOffset = new Point(
this.screenOffset.x - screenOffsetX / this.screenScale,
this.screenOffset.y - screenOffsetY / this.screenScale
@@ -145,7 +152,14 @@ export class ScreenCanvasDrawController implements DrawController {
// 확정). 도각까지 넣으면 A1 한 장 전체가 잡혀 화면의 40%가 여백으로 간다.
const pickable = getPickableEntities();
const entities = pickable.length ? pickable : getEntities();
if (!entities.length) return;
if (!entities.length) {
// 빈 도면 — 원점(0,0)을 화면 가운데로. 안 옮기면 기준점 십자가 왼쪽 아래 귀퉁이에 잘린다.
const canvasSize = this.getCanvasSize();
const zoom = this.getScreenScale();
this.setScreenOffset(new Point(-canvasSize.x / 2 / zoom, -canvasSize.y / 2 / zoom));
this.viewFitted = true;
return;
}
const boundingBox = getBoundingBoxOfMultipleEntities(entities);
const boundingWidth = boundingBox.maxX - boundingBox.minX;
const boundingHeight = boundingBox.maxY - boundingBox.minY;
@@ -171,6 +185,7 @@ export class ScreenCanvasDrawController implements DrawController {
worldCenterY - canvasSize.y / 2 / zoomLevel
)
);
this.viewFitted = true;
}
/**
@@ -242,7 +257,7 @@ export class ScreenCanvasDrawController implements DrawController {
lineWidth: number,
dash: number[] = []
) {
// 저장된 도면 색은 다크 배경 기준 — 라이트 테마에서는 그릴 때만 대비를 맞춘다.
// 자동색(검정·흰색·기본색)은 바탕 따라 흰/검정 · 라이트 테마는 밝은 색 대비도 맞춤(그릴 때만).
const color = paintColor(rawColor);
if (this.batching) {
const effectiveWidth = isHighlighted ? lineWidth + 1 : lineWidth;
@@ -12,6 +12,8 @@ import {
getSnapPoint,
isDrawingDirty,
isDrawingReadOnly,
isOriginMarker,
isViewOnly,
} from '../state';
import { Point } from '@flatten-js/core';
import { isEntityHidden } from './visibility';
@@ -47,6 +49,9 @@ export function registerCadDebugHook(): void {
readOnly: () => isDrawingReadOnly(),
dirty: () => isDrawingDirty(),
meta: () => getDesignMeta(),
// 기준점 십자 · 미리보기(보기 전용) 켜짐 — 그림이 안 도는 탭에서도 수치로 본다.
originMarker: () => isOriginMarker(),
viewOnly: () => isViewOnly(),
// 세계 좌표 → 화면 좌표. 그림·글자가 "제 자리에 그려졌나"를 픽셀로 판정할 때 쓴다.
screen: (x: number, y: number) => {
const point = getScreenCanvasDrawController().worldToTarget(new Point(x, y));
@@ -11,6 +11,9 @@ import {
SNAP_POINT_SIZE,
} from '../App.consts';
import { type SnapPoint, SnapPointType } from '../App.types';
/** 기준점 십자 반길이(px) — 가로세로 20px. */
const ORIGIN_MARKER_SIZE = 10;
import type { DrawController } from '../drawControllers/DrawController';
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
import type { Entity } from '../entities/Entity';
@@ -229,6 +232,23 @@ export function drawCursor(drawController: ScreenCanvasDrawController) {
);
}
/** 좌표 0,0 기준점 — 화면 크기 고정 십자 + 작은 원. 도면 개체가 아니라 그리기 겹판(저장 · 선택 · 스냅 대상 아님). */
export function drawOriginMarker(drawController: ScreenCanvasDrawController) {
const origin = drawController.worldToTarget(new Point(0, 0));
drawController.setOpacity(0.6);
drawController.setLineStyles(false, false, '#ffffff', 1, []); // 자동색 — 바탕 따라 흰/검정
drawController.drawLineScreen(
new Point(origin.x - ORIGIN_MARKER_SIZE, origin.y),
new Point(origin.x + ORIGIN_MARKER_SIZE, origin.y)
);
drawController.drawLineScreen(
new Point(origin.x, origin.y - ORIGIN_MARKER_SIZE),
new Point(origin.x, origin.y + ORIGIN_MARKER_SIZE)
);
drawController.drawArcScreen(origin, ORIGIN_MARKER_SIZE / 3, 0, 2 * Math.PI, true);
drawController.setOpacity(1);
}
/** 선택한 객체의 그립(편집점)을 화면 크기 고정 사각형으로 그린다 */
export function drawGrips(drawController: ScreenCanvasDrawController, entities: Entity[]) {
for (const entity of entities) {
@@ -13,6 +13,8 @@ import {
getShouldDrawCursor,
getSnapPoint,
getSnapPointOnAngleGuide,
isOriginMarker,
isViewOnly,
} from '../state';
import {
drawCursor,
@@ -20,6 +22,7 @@ import {
drawEntities,
drawGrips,
drawHelpers,
drawOriginMarker,
drawSnapPoint,
} from './draw-functions';
import { getClosestSnapPoint } from './get-closest-snap-point';
@@ -47,6 +50,7 @@ export function draw(drawController: ScreenCanvasDrawController) {
drawScene(drawController, performance.now());
drawHighlightedEntities(drawController);
if (isOriginMarker()) drawOriginMarker(drawController);
drawGrips(drawController, getSelectedEntities());
drawHelpers(drawController, getAngleGuideEntities());
drawEntities(drawController, getGhostHelperEntities());
@@ -65,7 +69,8 @@ export function draw(drawController: ScreenCanvasDrawController) {
);
drawSnapPoint(drawController, closestSnapPoint, isMarked);
if (getShouldDrawCursor()) {
// 미리보기는 십자 커서 · 명령 안내 · 좌표 풍선을 그리지 않는다(손 모양 커서).
if (getShouldDrawCursor() && !isViewOnly()) {
drawCursor(drawController);
getInputController().draw(drawController);
}
@@ -84,7 +84,7 @@ export async function getEntitiesAndLayersFromJsonObject(
if (groupId) entity.groupId = groupId;
}
let layers = data.layers;
if (data.layers.length === 0) {
if (!layers || layers.length === 0) {
layers = [getNewLayer()];
}
return {
@@ -22,6 +22,7 @@ import {
resolveCommandInput,
} from '../commands/registry.ts';
import { runCommand } from '../commands/run-command.ts';
import { requestHostSave } from '../integration/aislo-drawing-bridge.ts';
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts';
import { calculateAngleGuidesAndSnapPoints } from '../helpers/calculate-angle-guides-and-snap-points.ts';
import { findClosestEntity } from '../helpers/find-closest-entity.ts';
@@ -40,6 +41,8 @@ import {
getSnapEnabled,
getSnapPoint,
getSnapPointOnAngleGuide,
isHostSave,
isViewOnly,
redo,
setActiveToolActor,
setAngleStep,
@@ -109,6 +112,11 @@ export class InputController {
canvas?.addEventListener('contextmenu', (evt) => {
evt.preventDefault();
});
// 가운데 단추 — 브라우저 자동 스크롤(페이지가 같이 움직임) · 붙여넣기를 막는다.
// 막는 자리는 mousedown(아래 handleMouseDown) — pointerdown 을 막으면 mousedown 이 안 와 팬이 끊긴다.
canvas?.addEventListener('auxclick', (evt: MouseEvent) => {
if (evt.button === MouseButton.Middle) evt.preventDefault();
});
// 캔버스 밖(리본·패널) 위에서의 트랙패드 핀치도 브라우저를 확대시키지 않는다.
// ctrl이 없는 휠은 그대로 둬서 패널 스크롤은 살린다.
document.addEventListener(
@@ -189,6 +197,10 @@ export class InputController {
if (evt.button === MouseButton.Middle) {
setPanStartLocation(null);
}
if (evt.button === MouseButton.Left && isViewOnly()) {
setPanStartLocation(null);
return;
}
if (evt.button === MouseButton.Left) {
if (this.isSelectToolActive()) {
// 선택 도구는 누를 때 첫 점을 이미 보냈다. 끌었으면 놓는 자리로 사각형을 닫는다.
@@ -262,6 +274,9 @@ export class InputController {
setPanStartLocation(newScreenMouseLocation);
}
// 미리보기는 보기만 — 스냅 · 가까운 개체 강조를 하지 않는다.
if (isViewOnly()) return;
// Calculate angle guides and snap points
if (getSnapEnabled()) {
calculateAngleGuidesAndSnapPoints();
@@ -320,11 +335,18 @@ export class InputController {
public handleMouseDown(evt: MouseEvent) {
if (evt.button === MouseButton.Middle) {
evt.preventDefault(); // 자동 스크롤로 부모 페이지가 같이 굴러가던 것
setPanStartLocation(this.getCanvasPoint(evt));
return;
}
if (evt.button !== MouseButton.Left) return;
if ((evt.target as HTMLElement | null)?.closest('.controls')) return;
if (isViewOnly()) {
// 미리보기 — 왼쪽 끌기는 선택 상자 대신 화면 옮기기.
evt.preventDefault();
setPanStartLocation(this.getCanvasPoint(evt));
return;
}
// AutoCAD처럼 누른 자리에서 사각형이 시작되도록 선택 도구에만 첫 점을 미리 보낸다.
// 다른 도구는 예전처럼 놓을 때 한 점을 받는다 (끌다가 점이 두 개 찍히지 않게).
if (!this.isSelectToolActive()) return;
@@ -413,11 +435,13 @@ export class InputController {
// Tab => move keyboard focus
return;
}
if (evt.ctrlKey && evt.code === 'KeyV') {
// 막지 않음 — 브라우저가 `paste` 를 쏘고 `integration/dxf-clip-paste.ts` 가 받음(한글 자판도 code 로)
return;
}
evt.preventDefault();
evt.stopPropagation();
if (evt.ctrlKey && evt.key === 'v') {
// User wants to paste the clipboard
} else if (evt.ctrlKey && !evt.shiftKey && evt.key === 'z') {
if (evt.ctrlKey && !evt.shiftKey && evt.key === 'z') {
// User wants to undo the last action
this.handleUndo(evt);
} else if (evt.ctrlKey && evt.shiftKey && evt.key === 'z') {
@@ -426,6 +450,9 @@ export class InputController {
} else if (evt.ctrlKey && evt.key === 'y') {
// User wants to redo the last action
this.handleRedo(evt);
} else if (evt.ctrlKey && evt.key === 's') {
// 부모 페이지가 저장을 맡은 화면(M02 양식)만 — B07 은 예전처럼 아무 일 없음
if (isHostSave()) requestHostSave();
} else if (evt.ctrlKey && evt.key === 'a') {
// User wants to select everything
setSelectedEntityIds(getPickableEntities().map((entity) => entity.id));
@@ -15,15 +15,20 @@ import {
getScreenCanvasDrawController,
isDrawingDirty,
isDrawingReadOnly,
isViewOnly,
resetUndoBaseline,
setActiveLayerId,
setDesignMeta,
setEntities,
setFrameEditMode,
setHostReadOnly,
setHostSave,
setHostTitle,
setLayers,
setOriginMarker,
setViewOnly,
} from '../state.ts';
import { setInspectorCollapsed, setRibbonCollapsed } from '../components/ui-state.ts';
import { toast } from 'react-toastify';
import { runCommandInput } from '../commands/run-command.ts';
import { setRecoveryScope } from '../helpers/autosave.ts';
@@ -38,6 +43,7 @@ export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b08:save-request';
export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b08:save-response';
export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b08:navigate';
export const AISLO_DRAWING_EXPORT_MESSAGE = 'aislo:b08:export-file';
export const AISLO_DRAWING_HOST_SAVE_MESSAGE = 'aislo:b08:host-save';
interface DrawingLoadMessage {
type: typeof AISLO_DRAWING_LOAD_MESSAGE;
@@ -53,6 +59,15 @@ interface DrawingLoadMessage {
readOnly?: boolean;
/** 제목표시줄 부제 — 없으면 「B07 상세 설계」. */
hostTitle?: string;
/** 저장(💾 · Ctrl+S)을 부모 [저장]에 맡기는가. */
hostSave?: boolean;
/** 리본을 접힌 채로 여는가 — 없으면(B07) 펼침 그대로. */
ribbonCollapsed?: boolean;
/** 좌측 패널(특성 · 도면층)을 접힌 채로 여는가 — 없으면(B07) 지금 상태 그대로. */
panelsCollapsed?: boolean;
/** 미리보기 — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */
viewOnly?: boolean;
originMarker?: boolean;
}
interface DrawingSaveRequestMessage {
@@ -75,6 +90,11 @@ export function requestDrawingNavigation(direction: 'prev' | 'next') {
notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction });
}
/** 저장 요청을 부모 페이지 [저장]에 넘긴다 — 캐드는 양식 파일을 모른다. */
export function requestHostSave() {
notifyParent(AISLO_DRAWING_HOST_SAVE_MESSAGE);
}
/**
* 지금 도면을 DXF·DWG 파일로 내려받도록 부모에게 요청한다 (2026-09-06 사용자 지시).
* 캐드는 프로젝트를 모르므로 파일 만들기는 부모가 서버에 맡긴다.
@@ -98,6 +118,10 @@ function registerTextDoubleClickEdit() {
const canvas = getCanvas();
if (!canvas) return;
canvas.addEventListener('dblclick', (event: MouseEvent) => {
if (isViewOnly()) {
getScreenCanvasDrawController().zoomToFitScreen();
return;
}
if (isDrawingReadOnly()) {
toast.info('확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요.');
return;
@@ -197,6 +221,11 @@ export function registerAisloDrawingBridge() {
setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {});
setHostReadOnly(event.data.readOnly === true);
setHostTitle(event.data.hostTitle ?? 'B07 상세 설계');
setHostSave(event.data.hostSave === true);
setRibbonCollapsed(event.data.ribbonCollapsed === true);
if (event.data.panelsCollapsed) setInspectorCollapsed(true);
setViewOnly(event.data.viewOnly === true);
setOriginMarker(event.data.originMarker === true);
if (event.data.frameEdit) applyFramePreview();
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
@@ -0,0 +1,106 @@
/**
* 오토캐드 도형 붙이기 — 두 길 모두 서버가 DXF 를 도형으로 바꿔 현재 도면에 원좌표 그대로 붙이고
* 고른 상태로 둔다(되돌리기 한 번에 빠짐).
* - 끌어다 놓기: 오토캐드 WBLOCK(DWG) · DXFOUT(DXF) 로 저장한 파일, 또는 도면 파일 통째.
* 끄는 동안 `body.cad-file-dragging` 으로 흐린 테두리.
* - Ctrl+V: 시스템 클립보드 글이 DXF 면 같은 길 · 아니면 앱 안 붙여넣기(PASTECLIP).
* 키 처리(`input-controller.ts`)는 Ctrl+V 의 기본 동작을 막지 않아 브라우저가 `paste` 를 쏜다 —
* `paste` 이벤트의 글은 권한 없이 읽힘(iframe 안에서도).
*/
import { toast } from 'react-toastify';
import { runCommandInput } from '../commands/run-command';
import { hasClipboardContent } from '../helpers/cad-clipboard';
import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json';
import {
addEntities,
getLayers,
isDrawingReadOnly,
setLayers,
setSelectedEntityIds,
} from '../state';
import type { JsonEntity } from '../entities/Entity';
import { type ClipLayer, isDxfText, prepareClip } from './dxf-clip';
const DXF_CLIP_URL = '/api/projects/dxf-clip';
export async function pasteDxfText(text: string): Promise<number> {
return pasteServerClip(
await fetch(DXF_CLIP_URL, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
})
);
}
export async function pasteDrawingFile(file: File): Promise<number> {
const form = new FormData();
form.append('file', file);
return pasteServerClip(
await fetch(`${DXF_CLIP_URL}/file`, { method: 'POST', credentials: 'include', body: form })
);
}
async function pasteServerClip(response: Response): Promise<number> {
const payload = (await response.json().catch(() => ({}))) as {
entities?: JsonEntity[];
layers?: ClipLayer[];
message?: string;
};
if (!response.ok || !payload.entities) {
throw new Error(payload.message ?? `HTTP ${response.status}`);
}
const { entities, newLayers } = prepareClip(payload.entities, payload.layers ?? [], getLayers());
const drawing = await getEntitiesAndLayersFromJsonObject({ entities, layers: getLayers() });
if (newLayers.length) setLayers([...getLayers(), ...newLayers]);
addEntities(drawing.entities, true);
setSelectedEntityIds(drawing.entities.map((entity) => entity.id));
return drawing.entities.length;
}
function reportPaste(job: Promise<number>): void {
job
.then((count) => toast.success(`오토캐드 도형 ${count}개를 붙여넣었습니다.`))
.catch((error: unknown) =>
toast.error(error instanceof Error ? error.message : '도형을 붙여넣지 못했습니다.')
);
}
function refuseReadOnly(): boolean {
if (!isDrawingReadOnly()) return false;
toast.info('볼 수만 있는 도면입니다 — 붙여넣을 수 없습니다.');
return true;
}
export function registerDxfClipPaste(): void {
document.addEventListener('paste', (event: ClipboardEvent) => {
if ((event.target as HTMLElement | null)?.closest?.('input, textarea, select')) return;
const text = event.clipboardData?.getData('text/plain') ?? '';
event.preventDefault();
if (!isDxfText(text)) {
if (hasClipboardContent()) runCommandInput('PASTECLIP');
return;
}
if (!refuseReadOnly()) reportPaste(pasteDxfText(text));
});
// 파일을 끌 때만 — 앱 안 끌기(글 · 패널)는 건드리지 않음. 기본 동작(파일 열기로 화면 이동)은 막음.
const dragging = (event: DragEvent) => event.dataTransfer?.types.includes('Files') ?? false;
const setDragging = (on: boolean) => document.body.classList.toggle('cad-file-dragging', on);
document.addEventListener('dragover', (event) => {
if (!dragging(event)) return;
event.preventDefault();
setDragging(true);
});
document.addEventListener('dragleave', (event) => {
if (!event.relatedTarget) setDragging(false);
});
document.addEventListener('drop', (event) => {
if (!dragging(event)) return;
event.preventDefault();
setDragging(false);
const file = event.dataTransfer?.files[0];
if (file && !refuseReadOnly()) reportPaste(pasteDrawingFile(file));
});
}
@@ -0,0 +1,49 @@
/**
* 오토캐드 → 웹 CAD 붙여넣기 — 순수 부분.
* 오토캐드 기본 명령(WBLOCK · DXFOUT)으로 저장한 파일을 끌어다 놓거나 DXF 글을 Ctrl+V 하면,
* 서버(`/api/projects/dxf-clip` · `/file`)가 돌려준 JSON 을 여기서 현재 도면에 맞춘다.
* DOM · 상태를 안 만지는 것만 둔다(시험이 바로 부름) — 잇기는 `dxf-clip-paste.ts`.
*/
import type { Layer } from '../App.types.ts';
import type { JsonEntity } from '../entities/Entity';
/** DXF 글인가 — 그룹 코드 0 다음 줄이 SECTION 으로 시작(앞 공백 · CRLF 허용). */
export function isDxfText(text: string): boolean {
return /^\s*0\s*\r?\n\s*SECTION\b/.test(text);
}
export interface ClipLayer {
name: string;
color: string | null;
}
/**
* 서버 엔티티(layerId = DXF 도면층 이름)를 현재 도면에 붙일 모양으로 —
* 같은 이름 도면층이 있으면 그 층 · 없으면 새 층(DXF 층 색) · id 는 모두 새로(두 번 붙여도 안 겹침).
*/
export function prepareClip(
entities: JsonEntity[],
clipLayers: ClipLayer[],
existing: Layer[]
): { entities: JsonEntity[]; newLayers: Layer[] } {
const idByName = new Map(existing.map((layer) => [layer.name, layer.id]));
const newLayers: Layer[] = [];
const colorByName = new Map(clipLayers.map((layer) => [layer.name, layer.color]));
const layerIdOf = (name: string): string => {
let id = idByName.get(name);
if (!id) {
id = crypto.randomUUID();
idByName.set(name, id);
const color = colorByName.get(name);
newLayers.push({ id, name, isVisible: true, isLocked: false, ...(color ? { color } : {}) });
}
return id;
};
const renew = (entity: JsonEntity): JsonEntity => ({
...entity,
id: crypto.randomUUID(),
layerId: layerIdOf(String(entity.layerId ?? '0')),
...(entity.children ? { children: entity.children.map(renew) } : {}),
});
return { entities: entities.map(renew), newLayers };
}
+5 -1
View File
@@ -1,3 +1,4 @@
import './random-uuid-polyfill';
import { Point } from '@flatten-js/core';
import React from 'react';
import ReactDOM from 'react-dom/client';
@@ -17,6 +18,7 @@ import { queryEntitiesNearPoint } from './helpers/spatial-index';
import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points';
import { InputController } from './inputController/input-controller.ts';
import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.ts';
import { registerDxfClipPaste } from './integration/dxf-clip-paste.ts';
import {
getActiveToolActor,
getCanvas,
@@ -24,6 +26,7 @@ import {
getLastDrawTimestamp,
getScreenCanvasDrawController,
getSnapPoint,
isViewOnly,
setActiveLayerId,
setActiveToolActor,
setCanvas,
@@ -81,7 +84,7 @@ function startDrawLoop(
/**
* Highlight the entity closest to the mouse when the select tool is active
*/
if (getActiveToolActor()?.getSnapshot()?.context?.type === Tool.SELECT) {
if (getActiveToolActor()?.getSnapshot()?.context?.type === Tool.SELECT && !isViewOnly()) {
const screenCanvasDrawController = getScreenCanvasDrawController();
if (!screenCanvasDrawController) {
throw new Error('getScreenCanvasDrawController() returned null');
@@ -152,6 +155,7 @@ function initApplication() {
setLayers(layers);
setActiveLayerId(layers[0].id);
registerAisloDrawingBridge();
registerDxfClipPaste();
registerCadDebugHook();
registerAutoSave();
const screenCanvasDrawController = new ScreenCanvasDrawController(context);
@@ -0,0 +1,16 @@
/**
* http + IP 주소(예: Tailscale)는 보안 문맥이 아니라 `crypto.randomUUID` 가 없음 — 개체 만들기에서 죽음.
* getRandomValues(보안 문맥 아니어도 됨)로 v4 UUID 를 채움 · 이미 있으면 그대로.
* `main.tsx` 가 맨 먼저 부름. 앱 본체 짝 = `A00_Common/random_uuid_polyfill.ts`.
*/
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
crypto.randomUUID = () => {
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` as `${string}-${string}-${string}-${string}-${string}`;
};
}
export {};
@@ -3,7 +3,12 @@ import { type FC, type ReactNode, useEffect, useRef, useState } from 'react';
import type { CadCommand } from '../commands/command.types';
import { getCommandById } from '../commands/registry';
import { runCommand } from '../commands/run-command';
import { getActiveRibbonTab, setActiveRibbonTab } from '../components/ui-state';
import {
getActiveRibbonTab,
isRibbonCollapsed,
setActiveRibbonTab,
setRibbonCollapsed,
} from '../components/ui-state';
import type { Tool } from '../tools';
import { RIBBON_TABS, type RibbonPanel, type RibbonWidget } from './ribbon.config';
@@ -147,6 +152,21 @@ const Panel: FC<{
export const Ribbon: FC<RibbonProps> = ({ activeTool, widgets, onCommandRun }) => {
const activeTabId = getActiveRibbonTab();
const activeTab = RIBBON_TABS.find((tab) => tab.id === activeTabId) ?? RIBBON_TABS[0];
const collapsed = isRibbonCollapsed();
// 접힘 모드에서 탭을 누르면 잠깐 펼침(도면 위에 겹쳐 뜸) — 리본 밖을 누르면 다시 접힘
// (오피스 「리본 자동 숨기기」). 접기 단추로 펼치면 고정 펼침.
const [peek, setPeek] = useState(false);
const navRef = useRef<HTMLElement>(null);
const peeking = collapsed && peek;
useEffect(() => {
if (!peeking) return;
const close = (event: PointerEvent) => {
if (!navRef.current?.contains(event.target as Node)) setPeek(false);
};
window.addEventListener('pointerdown', close, true);
return () => window.removeEventListener('pointerdown', close, true);
}, [peeking]);
const handleRun = (command: CadCommand) => {
runCommand(command);
@@ -154,30 +174,46 @@ export const Ribbon: FC<RibbonProps> = ({ activeTool, widgets, onCommandRun }) =
};
return (
<nav className="cad-ribbon controls" aria-label="CAD 리본">
<nav ref={navRef} className="cad-ribbon controls" aria-label="CAD 리본" data-peek={peeking}>
<div className="cad-ribbon-tabs">
{RIBBON_TABS.map((tab) => (
<button
type="button"
key={tab.id}
data-active={tab.id === activeTab.id}
onClick={() => setActiveRibbonTab(tab.id)}
onClick={() => {
setActiveRibbonTab(tab.id);
if (collapsed) setPeek(true);
}}
>
{tab.label}
</button>
))}
<button
type="button"
className="cad-ribbon-tabs__collapse"
onClick={() => {
setPeek(false);
setRibbonCollapsed(!collapsed);
}}
title={collapsed ? '리본 펼치기' : '리본 접기'}
>
{collapsed ? '⌄' : '⌃'}
</button>
</div>
<div className="cad-ribbon-panels">
{activeTab.panels.map((panel) => (
<Panel
key={panel.label}
panel={panel}
activeTool={activeTool}
widgets={widgets}
onRun={handleRun}
/>
))}
</div>
{(!collapsed || peeking) && (
<div className="cad-ribbon-panels">
{activeTab.panels.map((panel) => (
<Panel
key={panel.label}
panel={panel}
activeTool={activeTool}
widgets={widgets}
onRun={handleRun}
/>
))}
</div>
)}
</nav>
);
};
+25
View File
@@ -192,7 +192,11 @@ let frameEditMode = false;
let frameFields: Record<string, string> = {};
/** 부모가 보기 전용으로 실었는가 — M02 에서 고칠 권한이 없는 양식을 볼 때. */
let hostReadOnly = false;
/** 미리보기(M02 집계표 카드) — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */
let viewOnly = false;
let originMarker = false;
let hostTitle = 'B07 상세 설계';
let hostSave = false;
/**
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
@@ -271,6 +275,9 @@ export const isDrawingDirty = () => drawingDirty;
* 확정). 보기(확대·이동·도면층 켜기끄기)는 그대로 두고, 푸는 길은 부모의 [수정]뿐이다.
*/
export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true || hostReadOnly;
export const isViewOnly = (): boolean => viewOnly;
/** 좌표 0,0 기준점 십자를 그리는가 — 그리기 겹판일 뿐 도면 개체가 아니다. */
export const isOriginMarker = (): boolean => originMarker;
// setters
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
@@ -498,6 +505,13 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => {
/** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */
/** 부모가 실은 도면을 보기 전용으로 둔다 — 확정본과 같은 막힘(도면을 실을 때마다 새로 정함). */
/** 제목표시줄 부제 — 부모가 도면을 실을 때 정함(없으면 B07 기본 글). */
/** 저장(💾 · Ctrl+S)을 부모 페이지 [저장]에 맡기는가 — M02 양식만 켠다. */
export const isHostSave = (): boolean => hostSave;
export const setHostSave = (enabled: boolean) => {
hostSave = enabled;
// 제목 줄이 없어지면 그 높이만큼 위로 당긴다(App.css `.cad-host-save`).
document.documentElement.classList.toggle('cad-host-save', enabled);
};
export const getHostTitle = (): string => hostTitle;
export const setHostTitle = (title: string) => {
hostTitle = title;
@@ -507,6 +521,17 @@ export const setHostReadOnly = (readOnly: boolean) => {
hostReadOnly = readOnly;
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setOriginMarker = (enabled: boolean) => {
originMarker = enabled;
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setViewOnly = (enabled: boolean) => {
viewOnly = enabled;
// 그리는 십자 커서 대신 손 모양 — 캔버스는 `cursor-none` 이라 안 두면 커서가 안 보임.
const canvas = getCanvas();
if (canvas) canvas.style.cursor = enabled ? 'grab' : '';
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setFrameEditMode = (enabled: boolean, fields: Record<string, string> = {}) => {
frameEditMode = enabled;
frameFields = enabled ? fields : {};
+40 -16
View File
@@ -3,8 +3,9 @@
*
* iframe은 호스트와 **같은 오리진**이라 `localStorage["frd_theme"]`를 공유한다.
* 부모가 토글하며 값을 쓰면 이 문서에는 `storage` 이벤트가 오므로, 부모 쪽에
* 별도 전달 코드(postMessage)를 넣지 않는다. 값이 없으면 미지정 상태로 두어
* `ui_template_theme.css`의 `prefers-color-scheme` 폴백이 그대로 먹는다.
* 별도 전달 코드(postMessage)를 넣지 않는다. 값이 없으면 부모 문서의 `data-theme`
* (호스트 index.html 기본값 light)를 따른다 — 시스템 설정을 따로 따르면 호스트와 어긋난다.
* 부모가 없을 때(단독 실행)만 `prefers-color-scheme` 폴백이 먹는다.
*/
import { bumpSceneVersion } from './helpers/scene-version.ts';
@@ -27,11 +28,22 @@ function applyTheme(value: string | null): void {
bumpSceneVersion();
}
/** 부모(호스트) 문서의 테마 — 부모가 없거나 못 읽으면 null. */
function parentTheme(): string | null {
try {
return window.parent === window
? null
: window.parent.document.documentElement.getAttribute('data-theme');
} catch {
return null;
}
}
/** 부팅 시 1회 호출 — 현재 테마 반영 + 이후 변경 구독. */
export function syncThemeFromHost(): void {
applyTheme(localStorage.getItem(THEME_STORAGE_KEY));
applyTheme(localStorage.getItem(THEME_STORAGE_KEY) ?? parentTheme());
window.addEventListener('storage', (event) => {
if (event.key === THEME_STORAGE_KEY) applyTheme(event.newValue);
if (event.key === THEME_STORAGE_KEY) applyTheme(event.newValue ?? parentTheme());
});
// 시스템 설정 폴백으로 도는 중이면 OS 전환도 따라간다.
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
@@ -61,26 +73,38 @@ function relativeLuminance(r: number, g: number, b: number): number {
}
/**
* 도면 색 보정 — 저장된 엔티티 색은 다크 배경 기준(밝은 회색·흰색)이라
* 라이트 모드 흰 종이 위에서는 안 보인다. **그릴 때만** 휘도를 낮춰 색상(색조)은
* 유지한 채 대비를 살린다. 저장 데이터는 건드리지 않는다.
* 자동색 — 오토캐드 색 번호 7 처럼 바탕을 따라 흰/검정으로 그리는 색.
* 검정 · 흰색(DXF 색 7) · 기본색(`#f5f7fa` = 새 개체 · DXF ByLayer 가져온 색).
*/
const AUTO_COLORS = new Set(['#000000', '#ffffff', '#f5f7fa', 'black', 'white']);
function normalizeHex(color: string): string {
const value = color.trim().toLowerCase();
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(value);
return short ? `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}` : value;
}
export function isAutoColor(color: string): boolean {
return AUTO_COLORS.has(normalizeHex(color));
}
/**
* 도면 색 보정 — **그릴 때만** 바꾼다(저장 데이터는 그대로).
* - 자동색: 어두운 바탕 흰색 · 밝은 바탕 검정.
* - 밝은 바탕: 흰 종이에 묻히는 밝은 색은 휘도를 낮춰 색조를 살린 채 대비를 맞춘다.
* - 어두운 바탕: 자동색 밖은 그대로(어두운 색을 밝히지 않음).
*/
export function paintColor(color: string): string {
if (!isLightTheme()) return color;
const light = isLightTheme();
if (isAutoColor(color)) return light ? '#000000' : '#ffffff';
if (!light) return color;
const cached = paintCache.get(color);
if (cached !== undefined) return cached;
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color);
let result = color;
if (hex) {
const raw = hex[1];
const full =
raw.length === 3
? raw
.split('')
.map((c) => c + c)
.join('')
: raw;
const full = normalizeHex(color).slice(1);
const r = Number.parseInt(full.slice(0, 2), 16);
const g = Number.parseInt(full.slice(2, 4), 16);
const b = Number.parseInt(full.slice(4, 6), 16);

Some files were not shown because too many files have changed in this diff Show More