diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/.claude-plugin/plugin.json b/plugins/webcraft/dist/claude/plugins/webcraft/.claude-plugin/plugin.json
index 2a02081..3be8700 100644
--- a/plugins/webcraft/dist/claude/plugins/webcraft/.claude-plugin/plugin.json
+++ b/plugins/webcraft/dist/claude/plugins/webcraft/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "webcraft",
- "version": "0.10.0",
+ "version": "0.11.0",
"description": "웹 플랫폼 개발 지침(프론트·디자인 시스템·백엔드·API·DB·아키텍처)과 프로젝트 연결",
"author": {
"name": "dev-goraebap"
diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/package.json b/plugins/webcraft/dist/claude/plugins/webcraft/package.json
index e1cbf31..dc79a46 100644
--- a/plugins/webcraft/dist/claude/plugins/webcraft/package.json
+++ b/plugins/webcraft/dist/claude/plugins/webcraft/package.json
@@ -1,6 +1,6 @@
{
"name": "webcraft",
- "version": "0.10.0",
+ "version": "0.11.0",
"private": true,
"type": "module"
}
diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/composed-components.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/composed-components.md
index d79cbb2..8b8082c 100644
--- a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/composed-components.md
+++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/composed-components.md
@@ -18,6 +18,7 @@ arc42: "8.8"
| 파일 올리기 칸 | 고르기·끌어 놓기·형식과 크기 확인 | [upload](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/upload) |
| 상태 화면 | 빈 목록·결과 없음·없는 대상·실패의 그림과 문구 | [state](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/state), [기준](data-states.md) |
| 본문 칸 | 종이 위의 리치 본문: 블록 메뉴·선택 서식 도구·표·미디어 | [editor](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/editor) |
+| 문서 편집기 | 종이(A4)로 나가는 문서: 상단 서식 막대·페이지 나누기·페이지 설정·머리말·바닥글·내보내기 | [document](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/document) |
| 그룹 머리 | 그룹 이름·개수·접기, 스크롤에 붙는 띠 | [list-group](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/list-group) |
구현이 없는 조합을 메뉴에 빈 항목으로 만들지 않습니다. 제품 고유 조합(아이콘 표시 고르기, 전역 검색 팔레트, 프로필 패널)은 프로젝트가 소유합니다.
@@ -102,3 +103,19 @@ HTML 임베드는 올린 HTML을 본문 안의 격리된 틀로 미리 봅니다
떠 있는 메뉴(블록 메뉴·선택 서식 도구·`/` 목록·표 메뉴)는 그림자를 줄이고 1px 테두리로 구분합니다. 본문 속 인라인 코드는 바탕 없이 고정폭 글자에 옅은 밑줄로 보입니다.
글자색·배경색은 테마 색에서 만든 값(CSS 변수)으로 저장해 라이트·다크를 따릅니다. 다른 곳에서 가져온 본문에 직접 지정한 색(hex)은 그대로 보이지만 테마를 따르지 않습니다.
+
+## 문서 편집기
+
+정의서·보고서·계약서처럼 종이(A4)로 나가는 문서를 쓰고 읽는 화면에 씁니다. 일반 제목·본문 양식은 본문 칸을 씁니다. 본문 칸과 달리 문서 편집기는 본문 위에 늘 붙은 상단 서식 막대(`EditorToolbar`)를 둡니다. 막대 순서는 확대/축소(− 배율 +), 되돌리기·다시 하기, 블록 종류, 굵게·기울임·밑줄·취소선·코드, 색, 링크, 정렬, 목록 3종, 넣기 메뉴(표·이미지·파일·코드·Mermaid·인용·구분선), 서식 지우기이며 오른쪽 끝에 문서 설정·페이지 모양·보기 전환을 둡니다. 막대는 한 줄에 두고 좁으면 줄을 바꿉니다. 가로 스크롤은 쓰지 않습니다. 메뉴를 열 때 스크롤바가 생겼다 사라지며 막대가 흔들리기 때문입니다. 위·아래 첨자와 들여쓰기는 선택 서식 도구의 더 보기와 Tab·Shift+Tab에 둡니다.
+
+보기는 "페이지"(쪽마다 나눠 보기)와 "페이지 없음"(나누지 않고 읽는 폭 한 열) 두 가지이며 이 용어로 부릅니다. 휴대폰은 늘 페이지 없음입니다. 문서 데이터(본문 JSON)는 페이지를 모릅니다. 페이지 나누기는 화면의 장식(간격)이며 저장하지 않으므로, 같은 본문을 다른 용지로 다시 나눌 수 있고 MCP·Markdown·버전 기능은 그대로 씁니다.
+
+페이지 설정(`PageSetup`)은 본문과 따로 문서에 저장합니다. 용지(A4·A5·Letter), 방향(세로·가로), 여백 네 방향(mm), 페이지 사이 간격(px), 머리말(왼쪽·오른쪽), 바닥글(왼쪽·가운데·오른쪽), 머리말·바닥글의 가장자리 거리(mm), 첫 페이지 다르게를 담습니다. 머리말·바닥글 글에는 `{page}`(이 페이지 번호)와 `{total}`(전체 페이지 수)을 씁니다. 편집 화면 오른쪽의 문서 설정 칸(탭: 문서 / 머리말 · 바닥글)에서 바꾸며, 바꾸면 쓴 내용과 실행 취소 기록을 지킨 채 다시 나눕니다. 페이지 모양(`PageLook`)은 보는 사람의 화면 설정이라 문서에 저장하지 않습니다. 연한 바탕(기본: 페이지 바깥을 테마에 맞춰 아주 옅게 다른 색으로 칠하고, 상세 안에서는 페이지 자체를 옅게 칠해 페이지 사이 틈으로 가름)과 구분선(같은 바탕에 페이지 사이에만 실선·점선·점·물결 가로선) 두 가지이며 다크 모드도 같습니다. 그림자는 다크에서 묻히므로 기대지 않습니다.
+
+페이지는 맨 위 블록 단위로 넘기고, 목록은 항목, 표는 행 단위로 넘깁니다. 4줄 이상인 문단은 넘치는 줄부터 다음 페이지로 잇고 3줄 이하는 통째로 넘기며, 첫 페이지에 한 줄만 남으면 통째로 넘깁니다. 제목은 다음 블록과 함께 넘어가 페이지 끝에 홀로 남지 않습니다. 한 페이지보다 큰 도표·코드·그림은 나누지 않습니다.
+
+편집 화면에서 머리말·바닥글 영역(종이 위·아래 여백 전체)에 포인터를 올리면 본문과의 경계에 점선, 옅은 바탕, 오른쪽에 연필 단추가 보입니다. 누르거나 두 번 누르면 설정 칸의 그 자리로 갑니다. 빈 영역에는 올렸을 때 "머리말 추가"가 보입니다. 종이 위에서 바로 글을 고치지는 않습니다. 편집 화면은 대화상자가 아니라 전체 페이지이며, 머리 줄은 본문 수정 화면(`BodyEditPage`)과 같이 페이지 수, 저장 상태, 저장(Ctrl/⌘+S), 취소(고친 것이 있으면 확인), 완료를 둡니다.
+
+읽기(상세) 화면은 다른 상세와 같은 `DetailLayout`입니다. 오른쪽 정보 칸에 페이지 항목(`PagesPill`, "N페이지")을 두고, 누르면 페이지 목록(번호와 대표 제목, 새 제목 없이 이어지는 페이지는 "이어짐")이 열려 고른 페이지로 갑니다. 왼쪽 페이지 목록 사이드바는 두지 않습니다. 내보내기(`ExportMenu`)는 내려받기 아이콘과 묶음 메뉴로, 내보내기 묶음(PDF로 저장, 인쇄)과 공유 묶음(링크 복사)을 둡니다. 서버에서 PDF를 만드는 앱은 `onSavePdf`를 넘기고, 주지 않으면 브라우저 인쇄 창을 엽니다. DOCX는 다루지 않습니다. 인쇄의 한계는 [인쇄와 PDF](print.md)를 따릅니다.
+
+부품은 `DocumentEditor`(편집), `DocumentView`(읽기), `model.ts`(모드·페이지 설정·페이지 모양), `pagination.ts`(Tiptap 확장 `Pagination`), `PageCanvas`(종이 틀·머리말·바닥글), `DocumentSettings`, `PageLookMenu`, `ZoomControl`, `ExportMenu`, `PagesPill`, `usePagedFrame`(편집과 읽기가 함께 쓰는 연결)입니다. 상단 막대는 composites/editor의 `EditorToolbar`입니다. 채택할 때는 composites/editor·popup·property를 함께 옮깁니다.
diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/pattern-catalog.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/pattern-catalog.md
index 189b186..f47fbe9 100644
--- a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/pattern-catalog.md
+++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/pattern-catalog.md
@@ -15,7 +15,7 @@ arc42: "8.8"
| Composites (조합 부품) | 다이얼로그·곁에 여는 내용·검색·필터·표시·속성·올리기·상태·본문 칸 | [조합 부품](composed-components.md), [다이얼로그](dialog.md), [검색·필터·표시](search-filter.md) | [composites](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites) |
| Layouts (레이아웃) | 앱 셸·사이드바·모바일 탐색·페이지 머리·툴바·목록·상세·설정·옆 패널 | [앱 셸](app-layout.md), [페이지 배치](layout-patterns.md) | [layouts](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/layouts) |
| Patterns (사용 패턴) | 여러 화면에 걸리는 동작 계약: 라우트 마스킹, 조건의 주소, 대기·빈 상태·실패, 행 클릭·키보드 | [라우트 마스킹](route-masking.md), [데이터 상태](data-states.md), [입력과 상호작용](interaction-patterns.md) | [patterns](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/patterns) |
-| Playground (플레이그라운드) | 앞의 요소로 만든 기능 단위 앱 16개: 인박스, 이슈, 로그인, 문서함, 설정, 일정, 결재 신청, 권한 설정, 댓글·대화, 활동 기록, 가져오기, 오류·권한 페이지, 폴더 탐색, 태그 관리, 사용자 관리, 처음 시작 | [생성 양식](creation-form.md), [미리보기 패널](peek.md), 이 문서 | [playground](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/samples/playground) |
+| Playground (플레이그라운드) | 앞의 요소로 만든 기능 단위 앱 17개: 인박스, 이슈, 로그인, 문서함, 설정, 일정, 결재 신청, 권한 설정, 댓글·대화, 활동 기록, 가져오기, 오류·권한 페이지, 폴더 탐색, 태그 관리, 사용자 관리, 처음 시작, 문서 편집기 | [생성 양식](creation-form.md), [미리보기 패널](peek.md), 이 문서 | [playground](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/samples/playground) |
Primitives와 Composites는 재사용 코드의 단위입니다. Layouts와 Patterns는 공간과 행동의 기준이며 더 큰 부품이라는 이유로 분류하지 않습니다. 기능 하나를 작은 앱 하나로 묶어 본뜰 수 있게 한 것은 Playground이고, 여러 앱이 함께 지키는 여는 방식·상태·조작이면 Patterns입니다. Playground는 앱마다 느낌이 확실히 다른 화면만 다루고 같은 모양의 화면은 한 앱에서만 다룹니다. Patterns의 견본은 따로 화면을 만들지 않고 Playground 앱을 그 상태로 띄웁니다. 기존 앱과 느낌이 다른 화면이 필요하면 Playground에 새 앱을 더합니다. 같은 구현을 여러 설명에서 참조하되 코드와 규칙의 원본은 한 곳에 둡니다. 파일 배치는 공식 FSD 지침에 맡기고 상태·캐시·URL·초안은 frontend 지침을 따릅니다. 접근성·반응형·키보드·문구는 모든 범주의 공통 검토 기준이며 [문구·본문과 검증](content-and-review.md)을 따릅니다.
diff --git a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/print.md b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/print.md
index 9a6fd00..bcd6631 100644
--- a/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/print.md
+++ b/plugins/webcraft/dist/claude/plugins/webcraft/skills/design-system/references/print.md
@@ -43,3 +43,9 @@ arc42: "8.7"
## 화면 전용 요소
화면 전용 도구(복사·편집·크게 보기·내려받기 단추)는 인쇄에서 감춥니다. 편집기가 본문 끝에 늘 두는 빈 문단과 마지막 여백도 지웁니다. 쪽 끝에 걸치면 빈 쪽 하나가 더 생기기 때문입니다.
+
+## 페이지로 나눈 문서
+
+문서 편집기의 문서 보기는 인쇄할 때 앱 틀을 빼고 문서만 찍습니다(`data-print-root`). 페이지 설정의 용지·여백과 머리말·바닥글은 `@page` 규칙으로 씁니다. 종이 안 글자 크기는 앱 화면 크기와 관계없이 데스크톱 값으로 고정합니다. 인쇄할 때는 종이 폭이 휴대폰 글자 규칙을 켜기 때문입니다.
+
+브라우저 인쇄는 화면의 페이지 경계와 다를 수 있습니다. 이 환경의 Chrome에서는 인쇄의 글자 폭이 화면보다 약 2% 좁게 그려져 줄바꿈이 한 줄씩 달라졌고, 화면에서 넘긴 블록 앞의 강제 넘김을 따르지 않은 사례도 있었습니다. 화면과 같은 경계가 꼭 필요하면 서버에서 같은 조판으로 PDF를 만듭니다.
diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/.codex-plugin/plugin.json b/plugins/webcraft/dist/codex/plugins/webcraft/.codex-plugin/plugin.json
index c24867f..006ddc5 100644
--- a/plugins/webcraft/dist/codex/plugins/webcraft/.codex-plugin/plugin.json
+++ b/plugins/webcraft/dist/codex/plugins/webcraft/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "webcraft",
- "version": "0.10.0",
+ "version": "0.11.0",
"description": "웹 플랫폼 개발 지침(프론트·디자인 시스템·백엔드·API·DB·아키텍처)과 프로젝트 연결",
"author": {
"name": "dev-goraebap"
diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/package.json b/plugins/webcraft/dist/codex/plugins/webcraft/package.json
index e1cbf31..dc79a46 100644
--- a/plugins/webcraft/dist/codex/plugins/webcraft/package.json
+++ b/plugins/webcraft/dist/codex/plugins/webcraft/package.json
@@ -1,6 +1,6 @@
{
"name": "webcraft",
- "version": "0.10.0",
+ "version": "0.11.0",
"private": true,
"type": "module"
}
diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/composed-components.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/composed-components.md
index d79cbb2..8b8082c 100644
--- a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/composed-components.md
+++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/composed-components.md
@@ -18,6 +18,7 @@ arc42: "8.8"
| 파일 올리기 칸 | 고르기·끌어 놓기·형식과 크기 확인 | [upload](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/upload) |
| 상태 화면 | 빈 목록·결과 없음·없는 대상·실패의 그림과 문구 | [state](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/state), [기준](data-states.md) |
| 본문 칸 | 종이 위의 리치 본문: 블록 메뉴·선택 서식 도구·표·미디어 | [editor](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/editor) |
+| 문서 편집기 | 종이(A4)로 나가는 문서: 상단 서식 막대·페이지 나누기·페이지 설정·머리말·바닥글·내보내기 | [document](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/document) |
| 그룹 머리 | 그룹 이름·개수·접기, 스크롤에 붙는 띠 | [list-group](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/list-group) |
구현이 없는 조합을 메뉴에 빈 항목으로 만들지 않습니다. 제품 고유 조합(아이콘 표시 고르기, 전역 검색 팔레트, 프로필 패널)은 프로젝트가 소유합니다.
@@ -102,3 +103,19 @@ HTML 임베드는 올린 HTML을 본문 안의 격리된 틀로 미리 봅니다
떠 있는 메뉴(블록 메뉴·선택 서식 도구·`/` 목록·표 메뉴)는 그림자를 줄이고 1px 테두리로 구분합니다. 본문 속 인라인 코드는 바탕 없이 고정폭 글자에 옅은 밑줄로 보입니다.
글자색·배경색은 테마 색에서 만든 값(CSS 변수)으로 저장해 라이트·다크를 따릅니다. 다른 곳에서 가져온 본문에 직접 지정한 색(hex)은 그대로 보이지만 테마를 따르지 않습니다.
+
+## 문서 편집기
+
+정의서·보고서·계약서처럼 종이(A4)로 나가는 문서를 쓰고 읽는 화면에 씁니다. 일반 제목·본문 양식은 본문 칸을 씁니다. 본문 칸과 달리 문서 편집기는 본문 위에 늘 붙은 상단 서식 막대(`EditorToolbar`)를 둡니다. 막대 순서는 확대/축소(− 배율 +), 되돌리기·다시 하기, 블록 종류, 굵게·기울임·밑줄·취소선·코드, 색, 링크, 정렬, 목록 3종, 넣기 메뉴(표·이미지·파일·코드·Mermaid·인용·구분선), 서식 지우기이며 오른쪽 끝에 문서 설정·페이지 모양·보기 전환을 둡니다. 막대는 한 줄에 두고 좁으면 줄을 바꿉니다. 가로 스크롤은 쓰지 않습니다. 메뉴를 열 때 스크롤바가 생겼다 사라지며 막대가 흔들리기 때문입니다. 위·아래 첨자와 들여쓰기는 선택 서식 도구의 더 보기와 Tab·Shift+Tab에 둡니다.
+
+보기는 "페이지"(쪽마다 나눠 보기)와 "페이지 없음"(나누지 않고 읽는 폭 한 열) 두 가지이며 이 용어로 부릅니다. 휴대폰은 늘 페이지 없음입니다. 문서 데이터(본문 JSON)는 페이지를 모릅니다. 페이지 나누기는 화면의 장식(간격)이며 저장하지 않으므로, 같은 본문을 다른 용지로 다시 나눌 수 있고 MCP·Markdown·버전 기능은 그대로 씁니다.
+
+페이지 설정(`PageSetup`)은 본문과 따로 문서에 저장합니다. 용지(A4·A5·Letter), 방향(세로·가로), 여백 네 방향(mm), 페이지 사이 간격(px), 머리말(왼쪽·오른쪽), 바닥글(왼쪽·가운데·오른쪽), 머리말·바닥글의 가장자리 거리(mm), 첫 페이지 다르게를 담습니다. 머리말·바닥글 글에는 `{page}`(이 페이지 번호)와 `{total}`(전체 페이지 수)을 씁니다. 편집 화면 오른쪽의 문서 설정 칸(탭: 문서 / 머리말 · 바닥글)에서 바꾸며, 바꾸면 쓴 내용과 실행 취소 기록을 지킨 채 다시 나눕니다. 페이지 모양(`PageLook`)은 보는 사람의 화면 설정이라 문서에 저장하지 않습니다. 연한 바탕(기본: 페이지 바깥을 테마에 맞춰 아주 옅게 다른 색으로 칠하고, 상세 안에서는 페이지 자체를 옅게 칠해 페이지 사이 틈으로 가름)과 구분선(같은 바탕에 페이지 사이에만 실선·점선·점·물결 가로선) 두 가지이며 다크 모드도 같습니다. 그림자는 다크에서 묻히므로 기대지 않습니다.
+
+페이지는 맨 위 블록 단위로 넘기고, 목록은 항목, 표는 행 단위로 넘깁니다. 4줄 이상인 문단은 넘치는 줄부터 다음 페이지로 잇고 3줄 이하는 통째로 넘기며, 첫 페이지에 한 줄만 남으면 통째로 넘깁니다. 제목은 다음 블록과 함께 넘어가 페이지 끝에 홀로 남지 않습니다. 한 페이지보다 큰 도표·코드·그림은 나누지 않습니다.
+
+편집 화면에서 머리말·바닥글 영역(종이 위·아래 여백 전체)에 포인터를 올리면 본문과의 경계에 점선, 옅은 바탕, 오른쪽에 연필 단추가 보입니다. 누르거나 두 번 누르면 설정 칸의 그 자리로 갑니다. 빈 영역에는 올렸을 때 "머리말 추가"가 보입니다. 종이 위에서 바로 글을 고치지는 않습니다. 편집 화면은 대화상자가 아니라 전체 페이지이며, 머리 줄은 본문 수정 화면(`BodyEditPage`)과 같이 페이지 수, 저장 상태, 저장(Ctrl/⌘+S), 취소(고친 것이 있으면 확인), 완료를 둡니다.
+
+읽기(상세) 화면은 다른 상세와 같은 `DetailLayout`입니다. 오른쪽 정보 칸에 페이지 항목(`PagesPill`, "N페이지")을 두고, 누르면 페이지 목록(번호와 대표 제목, 새 제목 없이 이어지는 페이지는 "이어짐")이 열려 고른 페이지로 갑니다. 왼쪽 페이지 목록 사이드바는 두지 않습니다. 내보내기(`ExportMenu`)는 내려받기 아이콘과 묶음 메뉴로, 내보내기 묶음(PDF로 저장, 인쇄)과 공유 묶음(링크 복사)을 둡니다. 서버에서 PDF를 만드는 앱은 `onSavePdf`를 넘기고, 주지 않으면 브라우저 인쇄 창을 엽니다. DOCX는 다루지 않습니다. 인쇄의 한계는 [인쇄와 PDF](print.md)를 따릅니다.
+
+부품은 `DocumentEditor`(편집), `DocumentView`(읽기), `model.ts`(모드·페이지 설정·페이지 모양), `pagination.ts`(Tiptap 확장 `Pagination`), `PageCanvas`(종이 틀·머리말·바닥글), `DocumentSettings`, `PageLookMenu`, `ZoomControl`, `ExportMenu`, `PagesPill`, `usePagedFrame`(편집과 읽기가 함께 쓰는 연결)입니다. 상단 막대는 composites/editor의 `EditorToolbar`입니다. 채택할 때는 composites/editor·popup·property를 함께 옮깁니다.
diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/pattern-catalog.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/pattern-catalog.md
index 189b186..f47fbe9 100644
--- a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/pattern-catalog.md
+++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/pattern-catalog.md
@@ -15,7 +15,7 @@ arc42: "8.8"
| Composites (조합 부품) | 다이얼로그·곁에 여는 내용·검색·필터·표시·속성·올리기·상태·본문 칸 | [조합 부품](composed-components.md), [다이얼로그](dialog.md), [검색·필터·표시](search-filter.md) | [composites](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites) |
| Layouts (레이아웃) | 앱 셸·사이드바·모바일 탐색·페이지 머리·툴바·목록·상세·설정·옆 패널 | [앱 셸](app-layout.md), [페이지 배치](layout-patterns.md) | [layouts](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/layouts) |
| Patterns (사용 패턴) | 여러 화면에 걸리는 동작 계약: 라우트 마스킹, 조건의 주소, 대기·빈 상태·실패, 행 클릭·키보드 | [라우트 마스킹](route-masking.md), [데이터 상태](data-states.md), [입력과 상호작용](interaction-patterns.md) | [patterns](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/patterns) |
-| Playground (플레이그라운드) | 앞의 요소로 만든 기능 단위 앱 16개: 인박스, 이슈, 로그인, 문서함, 설정, 일정, 결재 신청, 권한 설정, 댓글·대화, 활동 기록, 가져오기, 오류·권한 페이지, 폴더 탐색, 태그 관리, 사용자 관리, 처음 시작 | [생성 양식](creation-form.md), [미리보기 패널](peek.md), 이 문서 | [playground](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/samples/playground) |
+| Playground (플레이그라운드) | 앞의 요소로 만든 기능 단위 앱 17개: 인박스, 이슈, 로그인, 문서함, 설정, 일정, 결재 신청, 권한 설정, 댓글·대화, 활동 기록, 가져오기, 오류·권한 페이지, 폴더 탐색, 태그 관리, 사용자 관리, 처음 시작, 문서 편집기 | [생성 양식](creation-form.md), [미리보기 패널](peek.md), 이 문서 | [playground](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/samples/playground) |
Primitives와 Composites는 재사용 코드의 단위입니다. Layouts와 Patterns는 공간과 행동의 기준이며 더 큰 부품이라는 이유로 분류하지 않습니다. 기능 하나를 작은 앱 하나로 묶어 본뜰 수 있게 한 것은 Playground이고, 여러 앱이 함께 지키는 여는 방식·상태·조작이면 Patterns입니다. Playground는 앱마다 느낌이 확실히 다른 화면만 다루고 같은 모양의 화면은 한 앱에서만 다룹니다. Patterns의 견본은 따로 화면을 만들지 않고 Playground 앱을 그 상태로 띄웁니다. 기존 앱과 느낌이 다른 화면이 필요하면 Playground에 새 앱을 더합니다. 같은 구현을 여러 설명에서 참조하되 코드와 규칙의 원본은 한 곳에 둡니다. 파일 배치는 공식 FSD 지침에 맡기고 상태·캐시·URL·초안은 frontend 지침을 따릅니다. 접근성·반응형·키보드·문구는 모든 범주의 공통 검토 기준이며 [문구·본문과 검증](content-and-review.md)을 따릅니다.
diff --git a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/print.md b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/print.md
index 9a6fd00..bcd6631 100644
--- a/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/print.md
+++ b/plugins/webcraft/dist/codex/plugins/webcraft/skills/design-system/references/print.md
@@ -43,3 +43,9 @@ arc42: "8.7"
## 화면 전용 요소
화면 전용 도구(복사·편집·크게 보기·내려받기 단추)는 인쇄에서 감춥니다. 편집기가 본문 끝에 늘 두는 빈 문단과 마지막 여백도 지웁니다. 쪽 끝에 걸치면 빈 쪽 하나가 더 생기기 때문입니다.
+
+## 페이지로 나눈 문서
+
+문서 편집기의 문서 보기는 인쇄할 때 앱 틀을 빼고 문서만 찍습니다(`data-print-root`). 페이지 설정의 용지·여백과 머리말·바닥글은 `@page` 규칙으로 씁니다. 종이 안 글자 크기는 앱 화면 크기와 관계없이 데스크톱 값으로 고정합니다. 인쇄할 때는 종이 폭이 휴대폰 글자 규칙을 켜기 때문입니다.
+
+브라우저 인쇄는 화면의 페이지 경계와 다를 수 있습니다. 이 환경의 Chrome에서는 인쇄의 글자 폭이 화면보다 약 2% 좁게 그려져 줄바꿈이 한 줄씩 달라졌고, 화면에서 넘긴 블록 앞의 강제 넘김을 따르지 않은 사례도 있었습니다. 화면과 같은 경계가 꼭 필요하면 서버에서 같은 조판으로 PDF를 만듭니다.
diff --git a/plugins/webcraft/package.json b/plugins/webcraft/package.json
index d41b717..258d988 100644
--- a/plugins/webcraft/package.json
+++ b/plugins/webcraft/package.json
@@ -1,6 +1,6 @@
{
"name": "webcraft",
- "version": "0.10.0",
+ "version": "0.11.0",
"private": true,
"type": "module",
"engines": {
diff --git a/plugins/webcraft/skills/design-system/references/composed-components.md b/plugins/webcraft/skills/design-system/references/composed-components.md
index d79cbb2..8b8082c 100644
--- a/plugins/webcraft/skills/design-system/references/composed-components.md
+++ b/plugins/webcraft/skills/design-system/references/composed-components.md
@@ -18,6 +18,7 @@ arc42: "8.8"
| 파일 올리기 칸 | 고르기·끌어 놓기·형식과 크기 확인 | [upload](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/upload) |
| 상태 화면 | 빈 목록·결과 없음·없는 대상·실패의 그림과 문구 | [state](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/state), [기준](data-states.md) |
| 본문 칸 | 종이 위의 리치 본문: 블록 메뉴·선택 서식 도구·표·미디어 | [editor](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/editor) |
+| 문서 편집기 | 종이(A4)로 나가는 문서: 상단 서식 막대·페이지 나누기·페이지 설정·머리말·바닥글·내보내기 | [document](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/document) |
| 그룹 머리 | 그룹 이름·개수·접기, 스크롤에 붙는 띠 | [list-group](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites/list-group) |
구현이 없는 조합을 메뉴에 빈 항목으로 만들지 않습니다. 제품 고유 조합(아이콘 표시 고르기, 전역 검색 팔레트, 프로필 패널)은 프로젝트가 소유합니다.
@@ -102,3 +103,19 @@ HTML 임베드는 올린 HTML을 본문 안의 격리된 틀로 미리 봅니다
떠 있는 메뉴(블록 메뉴·선택 서식 도구·`/` 목록·표 메뉴)는 그림자를 줄이고 1px 테두리로 구분합니다. 본문 속 인라인 코드는 바탕 없이 고정폭 글자에 옅은 밑줄로 보입니다.
글자색·배경색은 테마 색에서 만든 값(CSS 변수)으로 저장해 라이트·다크를 따릅니다. 다른 곳에서 가져온 본문에 직접 지정한 색(hex)은 그대로 보이지만 테마를 따르지 않습니다.
+
+## 문서 편집기
+
+정의서·보고서·계약서처럼 종이(A4)로 나가는 문서를 쓰고 읽는 화면에 씁니다. 일반 제목·본문 양식은 본문 칸을 씁니다. 본문 칸과 달리 문서 편집기는 본문 위에 늘 붙은 상단 서식 막대(`EditorToolbar`)를 둡니다. 막대 순서는 확대/축소(− 배율 +), 되돌리기·다시 하기, 블록 종류, 굵게·기울임·밑줄·취소선·코드, 색, 링크, 정렬, 목록 3종, 넣기 메뉴(표·이미지·파일·코드·Mermaid·인용·구분선), 서식 지우기이며 오른쪽 끝에 문서 설정·페이지 모양·보기 전환을 둡니다. 막대는 한 줄에 두고 좁으면 줄을 바꿉니다. 가로 스크롤은 쓰지 않습니다. 메뉴를 열 때 스크롤바가 생겼다 사라지며 막대가 흔들리기 때문입니다. 위·아래 첨자와 들여쓰기는 선택 서식 도구의 더 보기와 Tab·Shift+Tab에 둡니다.
+
+보기는 "페이지"(쪽마다 나눠 보기)와 "페이지 없음"(나누지 않고 읽는 폭 한 열) 두 가지이며 이 용어로 부릅니다. 휴대폰은 늘 페이지 없음입니다. 문서 데이터(본문 JSON)는 페이지를 모릅니다. 페이지 나누기는 화면의 장식(간격)이며 저장하지 않으므로, 같은 본문을 다른 용지로 다시 나눌 수 있고 MCP·Markdown·버전 기능은 그대로 씁니다.
+
+페이지 설정(`PageSetup`)은 본문과 따로 문서에 저장합니다. 용지(A4·A5·Letter), 방향(세로·가로), 여백 네 방향(mm), 페이지 사이 간격(px), 머리말(왼쪽·오른쪽), 바닥글(왼쪽·가운데·오른쪽), 머리말·바닥글의 가장자리 거리(mm), 첫 페이지 다르게를 담습니다. 머리말·바닥글 글에는 `{page}`(이 페이지 번호)와 `{total}`(전체 페이지 수)을 씁니다. 편집 화면 오른쪽의 문서 설정 칸(탭: 문서 / 머리말 · 바닥글)에서 바꾸며, 바꾸면 쓴 내용과 실행 취소 기록을 지킨 채 다시 나눕니다. 페이지 모양(`PageLook`)은 보는 사람의 화면 설정이라 문서에 저장하지 않습니다. 연한 바탕(기본: 페이지 바깥을 테마에 맞춰 아주 옅게 다른 색으로 칠하고, 상세 안에서는 페이지 자체를 옅게 칠해 페이지 사이 틈으로 가름)과 구분선(같은 바탕에 페이지 사이에만 실선·점선·점·물결 가로선) 두 가지이며 다크 모드도 같습니다. 그림자는 다크에서 묻히므로 기대지 않습니다.
+
+페이지는 맨 위 블록 단위로 넘기고, 목록은 항목, 표는 행 단위로 넘깁니다. 4줄 이상인 문단은 넘치는 줄부터 다음 페이지로 잇고 3줄 이하는 통째로 넘기며, 첫 페이지에 한 줄만 남으면 통째로 넘깁니다. 제목은 다음 블록과 함께 넘어가 페이지 끝에 홀로 남지 않습니다. 한 페이지보다 큰 도표·코드·그림은 나누지 않습니다.
+
+편집 화면에서 머리말·바닥글 영역(종이 위·아래 여백 전체)에 포인터를 올리면 본문과의 경계에 점선, 옅은 바탕, 오른쪽에 연필 단추가 보입니다. 누르거나 두 번 누르면 설정 칸의 그 자리로 갑니다. 빈 영역에는 올렸을 때 "머리말 추가"가 보입니다. 종이 위에서 바로 글을 고치지는 않습니다. 편집 화면은 대화상자가 아니라 전체 페이지이며, 머리 줄은 본문 수정 화면(`BodyEditPage`)과 같이 페이지 수, 저장 상태, 저장(Ctrl/⌘+S), 취소(고친 것이 있으면 확인), 완료를 둡니다.
+
+읽기(상세) 화면은 다른 상세와 같은 `DetailLayout`입니다. 오른쪽 정보 칸에 페이지 항목(`PagesPill`, "N페이지")을 두고, 누르면 페이지 목록(번호와 대표 제목, 새 제목 없이 이어지는 페이지는 "이어짐")이 열려 고른 페이지로 갑니다. 왼쪽 페이지 목록 사이드바는 두지 않습니다. 내보내기(`ExportMenu`)는 내려받기 아이콘과 묶음 메뉴로, 내보내기 묶음(PDF로 저장, 인쇄)과 공유 묶음(링크 복사)을 둡니다. 서버에서 PDF를 만드는 앱은 `onSavePdf`를 넘기고, 주지 않으면 브라우저 인쇄 창을 엽니다. DOCX는 다루지 않습니다. 인쇄의 한계는 [인쇄와 PDF](print.md)를 따릅니다.
+
+부품은 `DocumentEditor`(편집), `DocumentView`(읽기), `model.ts`(모드·페이지 설정·페이지 모양), `pagination.ts`(Tiptap 확장 `Pagination`), `PageCanvas`(종이 틀·머리말·바닥글), `DocumentSettings`, `PageLookMenu`, `ZoomControl`, `ExportMenu`, `PagesPill`, `usePagedFrame`(편집과 읽기가 함께 쓰는 연결)입니다. 상단 막대는 composites/editor의 `EditorToolbar`입니다. 채택할 때는 composites/editor·popup·property를 함께 옮깁니다.
diff --git a/plugins/webcraft/skills/design-system/references/pattern-catalog.md b/plugins/webcraft/skills/design-system/references/pattern-catalog.md
index 189b186..f47fbe9 100644
--- a/plugins/webcraft/skills/design-system/references/pattern-catalog.md
+++ b/plugins/webcraft/skills/design-system/references/pattern-catalog.md
@@ -15,7 +15,7 @@ arc42: "8.8"
| Composites (조합 부품) | 다이얼로그·곁에 여는 내용·검색·필터·표시·속성·올리기·상태·본문 칸 | [조합 부품](composed-components.md), [다이얼로그](dialog.md), [검색·필터·표시](search-filter.md) | [composites](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/composites) |
| Layouts (레이아웃) | 앱 셸·사이드바·모바일 탐색·페이지 머리·툴바·목록·상세·설정·옆 패널 | [앱 셸](app-layout.md), [페이지 배치](layout-patterns.md) | [layouts](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/layouts) |
| Patterns (사용 패턴) | 여러 화면에 걸리는 동작 계약: 라우트 마스킹, 조건의 주소, 대기·빈 상태·실패, 행 클릭·키보드 | [라우트 마스킹](route-masking.md), [데이터 상태](data-states.md), [입력과 상호작용](interaction-patterns.md) | [patterns](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/src/shared/ui/patterns) |
-| Playground (플레이그라운드) | 앞의 요소로 만든 기능 단위 앱 16개: 인박스, 이슈, 로그인, 문서함, 설정, 일정, 결재 신청, 권한 설정, 댓글·대화, 활동 기록, 가져오기, 오류·권한 페이지, 폴더 탐색, 태그 관리, 사용자 관리, 처음 시작 | [생성 양식](creation-form.md), [미리보기 패널](peek.md), 이 문서 | [playground](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/samples/playground) |
+| Playground (플레이그라운드) | 앞의 요소로 만든 기능 단위 앱 17개: 인박스, 이슈, 로그인, 문서함, 설정, 일정, 결재 신청, 권한 설정, 댓글·대화, 활동 기록, 가져오기, 오류·권한 페이지, 폴더 탐색, 태그 관리, 사용자 관리, 처음 시작, 문서 편집기 | [생성 양식](creation-form.md), [미리보기 패널](peek.md), 이 문서 | [playground](https://github.com/dev-goraebap/dev.goraebap/tree/main/site/samples/playground) |
Primitives와 Composites는 재사용 코드의 단위입니다. Layouts와 Patterns는 공간과 행동의 기준이며 더 큰 부품이라는 이유로 분류하지 않습니다. 기능 하나를 작은 앱 하나로 묶어 본뜰 수 있게 한 것은 Playground이고, 여러 앱이 함께 지키는 여는 방식·상태·조작이면 Patterns입니다. Playground는 앱마다 느낌이 확실히 다른 화면만 다루고 같은 모양의 화면은 한 앱에서만 다룹니다. Patterns의 견본은 따로 화면을 만들지 않고 Playground 앱을 그 상태로 띄웁니다. 기존 앱과 느낌이 다른 화면이 필요하면 Playground에 새 앱을 더합니다. 같은 구현을 여러 설명에서 참조하되 코드와 규칙의 원본은 한 곳에 둡니다. 파일 배치는 공식 FSD 지침에 맡기고 상태·캐시·URL·초안은 frontend 지침을 따릅니다. 접근성·반응형·키보드·문구는 모든 범주의 공통 검토 기준이며 [문구·본문과 검증](content-and-review.md)을 따릅니다.
diff --git a/plugins/webcraft/skills/design-system/references/print.md b/plugins/webcraft/skills/design-system/references/print.md
index 9a6fd00..bcd6631 100644
--- a/plugins/webcraft/skills/design-system/references/print.md
+++ b/plugins/webcraft/skills/design-system/references/print.md
@@ -43,3 +43,9 @@ arc42: "8.7"
## 화면 전용 요소
화면 전용 도구(복사·편집·크게 보기·내려받기 단추)는 인쇄에서 감춥니다. 편집기가 본문 끝에 늘 두는 빈 문단과 마지막 여백도 지웁니다. 쪽 끝에 걸치면 빈 쪽 하나가 더 생기기 때문입니다.
+
+## 페이지로 나눈 문서
+
+문서 편집기의 문서 보기는 인쇄할 때 앱 틀을 빼고 문서만 찍습니다(`data-print-root`). 페이지 설정의 용지·여백과 머리말·바닥글은 `@page` 규칙으로 씁니다. 종이 안 글자 크기는 앱 화면 크기와 관계없이 데스크톱 값으로 고정합니다. 인쇄할 때는 종이 폭이 휴대폰 글자 규칙을 켜기 때문입니다.
+
+브라우저 인쇄는 화면의 페이지 경계와 다를 수 있습니다. 이 환경의 Chrome에서는 인쇄의 글자 폭이 화면보다 약 2% 좁게 그려져 줄바꿈이 한 줄씩 달라졌고, 화면에서 넘긴 블록 앞의 강제 넘김을 따르지 않은 사례도 있었습니다. 화면과 같은 경계가 꼭 필요하면 서버에서 같은 조판으로 PDF를 만듭니다.
diff --git a/site/content/catalog/playground/document-editor.mdx b/site/content/catalog/playground/document-editor.mdx
new file mode 100644
index 0000000..5091100
--- /dev/null
+++ b/site/content/catalog/playground/document-editor.mdx
@@ -0,0 +1,19 @@
+---
+title: "문서 편집기"
+sidebar:
+ order: 17
+---
+
+import source0 from '@/shared/ui/composites/document/DocumentEditor.tsx?raw';
+import source1 from '@/shared/ui/composites/document/pagination.ts?raw';
+import source2 from '@/shared/ui/composites/editor/EditorToolbar.tsx?raw';
+
+정의서·보고서·계약서처럼 종이(A4)로 나가는 문서를 쓰고 읽는 화면을 만들 때 본뜹니다. 편집 화면은 본문 위에 늘 붙은 상단 서식 막대, 오른쪽의 문서 설정 칸(용지 · 방향 · 여백 · 머리말 · 바닥글), 포인터를 올리면 경계 점선과 연필 단추가 보이는 머리말 · 바닥글을 다룹니다. 읽기 화면은 다른 상세와 같은 배치에 오른쪽 정보 칸의 페이지 항목과 내보내기 메뉴(PDF로 저장 · 인쇄 · 링크 복사)를 둡니다. 페이지 모양은 연한 바탕과 구분선 중에서 고르고, 휴대폰은 늘 페이지 없음으로 봅니다.
+
+
+
+## 구현 코드
+
+
+
+
diff --git a/site/content/catalog/playground/overview.mdx b/site/content/catalog/playground/overview.mdx
index dcf81e9..eece489 100644
--- a/site/content/catalog/playground/overview.mdx
+++ b/site/content/catalog/playground/overview.mdx
@@ -27,6 +27,7 @@ Playground는 기능 하나를 작은 앱 하나로 묶은 실행 견본 모음
| 태그 관리 | 분류·라벨·카테고리처럼 짧은 목록과 짧은 양식(이름·색, 지울 때 쓰인 곳 수를 밝혀 확인) | [/catalog/playground/tags/](/catalog/playground/tags/) |
| 사용자 관리 | 표가 주인공인 관리 화면(검색·필터·정렬, 여러 행 골라 일괄 동작, 마스킹 대화상자·크게 보기·직접 링크 페이지로 여는 사용자 초대와 초안 유지, 휴대폰은 행 목록) | [/catalog/playground/users/](/catalog/playground/users/) |
| 온보딩 | 처음 쓰는 사람의 초기 설정 마법사(화면 테마·프로필과 아바타 그림·알림·완료의 네 단계, 쿼리 단계 이동과 이전·다음, 완료 시 환경 설정 저장과 축하 효과) | [/catalog/playground/onboarding/](/catalog/playground/onboarding/) |
+| 문서 편집기 | 정의서·보고서·계약서처럼 종이(A4)로 나가는 문서(늘 붙은 상단 서식 막대, 페이지 / 페이지 없음 전환, 블록·목록 항목·표 행·문단 줄 단위 페이지 나누기, 문서 설정 칸의 용지·여백·머리말·바닥글, 상세 정보 칸의 페이지 항목, PDF 저장·인쇄 내보내기) | [/catalog/playground/document-editor/](/catalog/playground/document-editor/) |
## 셸 배치 전환
diff --git a/site/samples/playground/DocumentEditor.samples.tsx b/site/samples/playground/DocumentEditor.samples.tsx
new file mode 100644
index 0000000..ca2f9b6
--- /dev/null
+++ b/site/samples/playground/DocumentEditor.samples.tsx
@@ -0,0 +1,17 @@
+import type { Meta, SampleOf } from "../catalog/types";
+import { DocumentEditorApp } from "./document-editor/app/DocumentEditorApp";
+
+/**
+ * 문서 편집기. 정의서·보고서·계약서처럼 종이(A4)로 나가는 문서를 쓰고 읽는 화면을 만들 때 본뜬다. 위에 늘 붙은 서식 막대,
+ * 페이지(종이 위에 페이지마다)와 페이지 없음(한 열) 전환, 페이지 끝을 넘는 블록·문단 줄·표 행을 다음 페이지로 넘기는 페이지 나누기,
+ * 페이지 목록, 화면과 같은 경계의 인쇄를 다룬다. 부품은 composites/document다.
+ */
+const meta = {
+ title: "Playground (플레이그라운드)/문서 편집기", id: "playground-document-editor", component: DocumentEditorApp,
+ layout: "fullscreen", height: "46rem", device: "desktop",
+} satisfies Meta;
+export default meta;
+type Sample = SampleOf;
+
+export const Editing: Sample = { name: "페이지 위에 쓰기", args: { initialPath: "/documents/spec/edit" } };
+export const Reading: Sample = { name: "페이지로 읽기", args: { initialPath: "/documents/spec" } };
diff --git a/site/samples/playground/DocumentEditor.test.tsx b/site/samples/playground/DocumentEditor.test.tsx
new file mode 100644
index 0000000..08bc402
--- /dev/null
+++ b/site/samples/playground/DocumentEditor.test.tsx
@@ -0,0 +1,199 @@
+import { test } from "vitest";
+import { expect, userEvent, waitFor, within } from "../catalog/testing";
+import { runSample } from "../catalog/run-sample";
+import type { SampleOf } from "../catalog/types";
+import meta from "./DocumentEditor.samples";
+
+type Sample = SampleOf;
+const pageCount = (root: HTMLElement) => Number(root.ownerDocument.querySelector("[data-page-count]")?.dataset.pageCount ?? 0);
+
+/**
+ * 페이지를 재는 단위(맨 위 블록, 목록 항목, 표 행)마다 자기 종이의 본문 영역 안에 있는지 잰다. 두 페이지에 걸친 긴 문단은 줄마다 잰다.
+ * 넘긴 블록·행은 다음 페이지 첫머리에 붙어야 한다.
+ */
+function boundaryProblems(root: HTMLElement) {
+ const document = root.ownerDocument;
+ const prose = document.querySelector(".ProseMirror")!;
+ const canvas = prose.closest("[data-page-count]")!;
+ // 여백은 종이 틀의 실제 값(rem)을 읽는다: 문서 설정으로 바뀐다.
+ const rootPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
+ const remOf = (name: string) => parseFloat(getComputedStyle(canvas).getPropertyValue(name)) * rootPx;
+ const zoom = canvas.getBoundingClientRect().width / remOf("--pg-width");
+ const marginTop = remOf("--pg-margin-top") * zoom, marginBottom = remOf("--pg-margin-bottom") * zoom;
+ const sheets = [...document.querySelectorAll("[data-sheet]")].map(sheet => sheet.getBoundingClientRect());
+ const units = [...prose.children].flatMap(child => child.matches("ul, ol") && child.children.length > 1 ? [...child.children]
+ : child.querySelector("table") ? [...child.querySelectorAll("tr")] : [child]).filter(child => !child.classList.contains("pg-gap"));
+ const problems: string[] = [];
+ const check = (rect: DOMRect, label: string, isStart: boolean) => {
+ const sheet = sheets.find(each => rect.top >= each.top - 1 && rect.top < each.bottom);
+ if (!sheet) { problems.push(`${label}: 종이 밖`); return; }
+ if (rect.top < sheet.top + marginTop - 1) problems.push(`${label}: 위 여백 침범`);
+ if (rect.bottom > sheet.bottom - marginBottom + 1) problems.push(`${label}: 아래 여백 침범 ${(rect.bottom - sheet.bottom + marginBottom).toFixed(1)}px`);
+ if (isStart && Math.abs(rect.top - sheet.top - marginTop) > 1) problems.push(`${label}: 페이지 첫머리에서 ${(rect.top - sheet.top - marginTop).toFixed(1)}px`);
+ };
+ for (const unit of units) {
+ const label = unit.textContent?.slice(0, 16) ?? "";
+ if (unit.querySelector(".pg-line-gap")) {
+ // 줄마다: 글자 조각(텍스트 노드)의 상자만 잰다. 끼워 넣은 빈 줄(간격)은 재지 않는다.
+ const walker = document.createTreeWalker(unit, NodeFilter.SHOW_TEXT);
+ for (let text = walker.nextNode(); text; text = walker.nextNode()) {
+ const range = document.createRange();
+ range.selectNodeContents(text);
+ for (const line of [...range.getClientRects()].filter(each => each.height > 4)) check(line, `${label}(줄)`, false);
+ }
+ continue;
+ }
+ const rect = unit.getBoundingClientRect();
+ if (rect.height) check(rect, label, unit.classList.contains("pg-start"));
+ }
+ return problems;
+}
+
+const VerifyEditing: Sample = {
+ name: "서식 막대·페이지 나누기·쓰면 늘어나는 페이지 검증",
+ args: { initialPath: "/documents/spec/edit" },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ const toolbar = await page.findByRole("toolbar", { name: "서식" });
+ await waitFor(() => expect(pageCount(canvasElement)).toBeGreaterThan(5), { timeout: 8000 });
+ await waitFor(() => expect(boundaryProblems(canvasElement)).toEqual([]), { timeout: 8000 });
+ // 긴 문단은 줄 단위로, 긴 표는 행 단위로 두 페이지에 걸쳐 잇는다.
+ await expect(canvasElement.ownerDocument.querySelector(".pg-line-gap")).not.toBeNull();
+ await expect(canvasElement.ownerDocument.querySelector("tr.pg-row-gap")).not.toBeNull();
+ // 서식 막대: 커서 자리의 서식이 보이고, 고른 글에 굵게를 입힌다.
+ const editor = page.getByRole("textbox", { name: "본문" });
+ const first = editor.querySelector("h2")!;
+ await userEvent.click(first);
+ await waitFor(() => expect(within(toolbar).getByRole("button", { name: "블록 종류" })).toHaveTextContent("제목 2"));
+ await userEvent.tripleClick(editor.querySelector("p")!);
+ await userEvent.click(within(toolbar).getByRole("button", { name: "굵게" }));
+ await expect(editor.querySelector("p strong")).not.toBeNull();
+ await expect(within(toolbar).getByRole("button", { name: "굵게" })).toHaveAttribute("aria-pressed", "true");
+ await userEvent.click(within(toolbar).getByRole("button", { name: "되돌리기" }));
+ await waitFor(() => expect(editor.querySelector("p strong")).toBeNull());
+ // 끝에 쓰면 페이지가 늘고, 늘어난 뒤에도 경계가 맞다.
+ const before = pageCount(canvasElement);
+ await userEvent.click(editor);
+ await userEvent.keyboard("{Control>}{End}{/Control}");
+ for (let line = 0; line < 30; line += 1) await userEvent.keyboard(`{Enter}새로 쓴 문단 ${line + 1}`);
+ await waitFor(() => expect(pageCount(canvasElement)).toBeGreaterThan(before), { timeout: 8000 });
+ await waitFor(() => expect(boundaryProblems(canvasElement)).toEqual([]), { timeout: 8000 });
+ await expect(page.getByText(`${pageCount(canvasElement)}페이지`, { exact: true })).toBeVisible();
+ // 고친 것이 있으면 저장 상태가 바뀌고, 취소는 먼저 묻는다(계속 작성하면 그대로 남는다).
+ await expect(page.getAllByRole("status").some(each => each.textContent === "저장 안 됨")).toBe(true);
+ await userEvent.click(page.getByRole("button", { name: "취소" }));
+ const ask = await page.findByRole("alertdialog");
+ await userEvent.click(within(ask).getByRole("button", { name: "계속 작성" }));
+ await expect(page.getByRole("textbox", { name: "본문" })).toHaveTextContent("새로 쓴 문단 30");
+ // 페이지 없음으로 바꿔도 쓴 내용이 그대로다.
+ await userEvent.click(page.getByRole("radio", { name: "페이지 없음" }));
+ await waitFor(() => expect(canvasElement.ownerDocument.querySelector("[data-page-count]")).toBeNull());
+ await expect(page.getByRole("textbox", { name: "본문" })).toHaveTextContent("새로 쓴 문단 30");
+ },
+};
+
+const VerifyReading: Sample = {
+ name: "페이지로 읽기·페이지 항목·페이지 모양·페이지 없음 검증",
+ args: { initialPath: "/documents/spec" },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ await waitFor(() => expect(pageCount(canvasElement)).toBeGreaterThan(5), { timeout: 8000 });
+ await waitFor(() => expect(boundaryProblems(canvasElement)).toEqual([]), { timeout: 8000 });
+ const count = pageCount(canvasElement);
+ // 오른쪽 정보 칸의 페이지 항목: 페이지 수, 누르면 페이지 목록(대표 제목), 고르면 그 페이지로 간다.
+ const pill = page.getByRole("button", { name: `페이지: ${count}페이지` });
+ await userEvent.click(pill);
+ const popover = await page.findByRole("dialog", { name: "페이지" });
+ const labels = [...popover.querySelectorAll("li, [role=option], [role=menuitem]")].map(item => item.textContent ?? "").filter(Boolean);
+ await expect(labels.length).toBe(count);
+ await expect(labels[0]).toContain("1. 소개와 목표");
+ await expect(labels.some(label => label.includes("제목 없음"))).toBe(false);
+ // 제목은 페이지 끝에 홀로 남지 않는다: 제목 바로 뒤의 블록은 같은 페이지에서 시작한다.
+ // 한 번에 재어(그 사이 스크롤·다시 나누기가 끼지 않게) 배치가 안정될 때까지 기다린다.
+ const orphanHeadings = () => {
+ const sheets = [...canvasElement.ownerDocument.querySelectorAll("[data-sheet]")].map(sheet => sheet.getBoundingClientRect());
+ const pageOf = (element: Element) => sheets.findIndex(sheet => element.getBoundingClientRect().top >= sheet.top && element.getBoundingClientRect().top < sheet.bottom);
+ return [...canvasElement.ownerDocument.querySelectorAll(".ProseMirror > :is(h2, h3)")].flatMap(heading => {
+ let next = heading.nextElementSibling;
+ while (next?.classList.contains("pg-gap")) next = next.nextElementSibling;
+ if (!next) return [];
+ return pageOf(next.matches("ul, ol") ? next.firstElementChild ?? next : next) === pageOf(heading) ? [] : [heading.textContent ?? ""];
+ });
+ };
+ await waitFor(() => expect(orphanHeadings()).toEqual([]), { timeout: 5000 });
+ await userEvent.keyboard("{Escape}");
+ // 페이지 모양: 구분선 + 물결이면 종이 사이마다 선이 하나, 연한 바탕이면 바깥이 종이와 다른 색이다.
+ await userEvent.click(page.getByRole("button", { name: "페이지 모양" }));
+ await userEvent.click(await page.findByRole("radio", { name: "구분선" }));
+ await userEvent.click(await page.findByRole("radio", { name: "물결" }));
+ const canvas = () => canvasElement.ownerDocument.querySelector("[data-page-count]")!;
+ await waitFor(() => expect(canvas().dataset.line).toBe("wavy"));
+ await expect(canvas().querySelectorAll("[data-sheet] > :last-child:not([data-sheet])").length).toBeGreaterThan(0);
+ await userEvent.click(await page.findByRole("radio", { name: "연한 바탕" }));
+ await waitFor(() => expect(canvas().dataset.look).toBe("tinted"));
+ const desk = canvas().closest("[data-print-root]")!;
+ await expect(getComputedStyle(desk).backgroundColor).not.toBe(getComputedStyle(canvas().querySelector("[data-sheet]")!).backgroundColor);
+ await userEvent.keyboard("{Escape}");
+ await userEvent.click(page.getByRole("radio", { name: "페이지 없음" }));
+ await waitFor(() => expect(canvasElement.ownerDocument.querySelector("[data-page-count]")).toBeNull());
+ await expect(page.queryByRole("button", { name: /^페이지: / })).toBeNull();
+ await expect(page.getAllByRole("heading", { level: 1, name: "시스템 정의서" }).length).toBeGreaterThan(0);
+ },
+};
+
+const VerifySettings: Sample = {
+ name: "머리말·바닥글·문서 설정·확대/축소 검증",
+ args: { initialPath: "/documents/spec/edit" },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ const document = canvasElement.ownerDocument;
+ await waitFor(() => expect(pageCount(canvasElement)).toBeGreaterThan(5), { timeout: 8000 });
+ // 종이마다 머리말과 {page}/{total}을 채운 바닥글이 있다.
+ const first = document.querySelector('[data-sheet="1"]')!, count = pageCount(canvasElement);
+ await expect(first).toHaveTextContent("시스템 정의서");
+ await expect(first).toHaveTextContent(`1 / ${count}`);
+ await expect(document.querySelector('[data-sheet="2"]')).toHaveTextContent(`2 / ${count}`);
+ // 바닥글을 두 번 누르면 문서 설정의 머리말·바닥글 칸이 열리고, 고친 글이 모든 종이에 보인다.
+ await userEvent.dblClick(first.querySelector('[aria-label^="바닥글"]')!);
+ const settings = await page.findByRole("complementary", { name: "문서 설정" });
+ await expect(within(settings).getByRole("radio", { name: "머리말 · 바닥글" })).toBeChecked();
+ const center = within(settings).getByRole("textbox", { name: "가운데" });
+ await userEvent.clear(center);
+ await userEvent.type(center, "검토용");
+ await waitFor(() => expect(document.querySelector(`[data-sheet="${count}"]`)).toHaveTextContent("검토용"));
+ // 첫 페이지 다르게: 첫 종이에는 머리말·바닥글이 없다.
+ await userEvent.click(within(settings).getByRole("switch", { name: /첫 페이지 다르게/ }));
+ await waitFor(() => expect(document.querySelector('[data-sheet="1"]')).not.toHaveTextContent("시스템 정의서"));
+ // 가로 용지로 바꾸면 종이가 넓어지고 다시 나눈다(쓴 내용은 그대로).
+ await userEvent.click(within(settings).getByRole("radio", { name: "머리말 · 바닥글" }));
+ await userEvent.click(within(settings).getByRole("radio", { name: "문서" }));
+ // 종이 폭(설정 값)이 넓어진다. 화면에서는 칸 폭에 맞춰 줄어들 수 있으므로 종이 틀의 값을 본다.
+ const paperWidth = () => parseFloat(getComputedStyle(document.querySelector("[data-page-count]")!).getPropertyValue("--pg-width"));
+ const before = paperWidth();
+ await userEvent.click(within(settings).getByRole("radio", { name: "가로" }));
+ await waitFor(() => expect(paperWidth() / before).toBeCloseTo(297 / 210, 2));
+ await waitFor(() => expect(boundaryProblems(canvasElement)).toEqual([]), { timeout: 8000 });
+ // 확대/축소: 고정 배율로 바꾸면 종이 폭이 그만큼 바뀐다.
+ const toolbar = page.getByRole("group", { name: "확대/축소" });
+ const wide = document.querySelector("[data-page-count]")!.getBoundingClientRect().width;
+ await userEvent.click(within(toolbar).getByRole("button", { name: "축소" }));
+ await waitFor(() => expect(document.querySelector("[data-page-count]")!.getBoundingClientRect().width).toBeLessThan(wide - 10));
+ },
+};
+
+const VerifyPhone: Sample = {
+ name: "휴대폰은 페이지 없이 쓰기 검증",
+ args: { initialPath: "/documents/spec/edit" },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ await expect(await page.findByRole("toolbar", { name: "서식" })).toBeVisible();
+ await expect(canvasElement.ownerDocument.querySelector("[data-page-count]")).toBeNull();
+ const root = canvasElement.ownerDocument.documentElement;
+ await expect(root.scrollWidth).toBeLessThanOrEqual(root.clientWidth);
+ },
+};
+
+test(VerifyEditing.name, () => runSample(meta, VerifyEditing), 60000);
+test(VerifyReading.name, () => runSample(meta, VerifyReading), 30000);
+test(VerifySettings.name, () => runSample(meta, VerifySettings), 40000);
+test(VerifyPhone.name, () => runSample(meta, VerifyPhone, "phone"), 20000);
diff --git a/site/samples/playground/document-editor/app/DocumentEditorApp.tsx b/site/samples/playground/document-editor/app/DocumentEditorApp.tsx
new file mode 100644
index 0000000..0acd6f3
--- /dev/null
+++ b/site/samples/playground/document-editor/app/DocumentEditorApp.tsx
@@ -0,0 +1,43 @@
+import { HStack } from '@astryxdesign/core/HStack';
+import { LinkProvider } from '@astryxdesign/core/Link';
+import { Text } from '@astryxdesign/core/Text';
+import { icon } from '@/shared/ui/foundations/icons/solar-icon';
+import type { NavSpace } from '@/shared/ui/layouts/app/nav';
+import { RouterLink } from '@/shared/ui/patterns/routing/RouterLink';
+import { PlaygroundFrame as AppFrame } from '@samples/playground/PlaygroundFrame';
+import { createMemoryHistory, createRootRoute, createRoute, createRouter, Outlet, RouterProvider, useLocation, useNavigate } from '@tanstack/react-router';
+import { useState } from 'react';
+import { useShellLayout } from '../../../catalog/shell-layout';
+import { DocumentEditPage } from '../pages/DocumentEditPage';
+import { DocumentReadPage } from '../pages/DocumentReadPage';
+import { DocumentStoreProvider } from './store';
+
+const space: NavSpace = {
+ id: 'main', title: '문서', sections: [{ title: '문서', isHeaderHidden: true, entries: [{ path: '/documents/spec', label: '시스템 정의서', icon: 'document' }] }],
+};
+
+function Shell() {
+ const layout = useShellLayout();
+ const location = useLocation();
+ const navigate = useNavigate();
+ return
+ void navigate({ href: path })}
+ brand={isFolded => {icon('document', true)}{isFolded ? null : 문서 편집기}}
+ footer={예제 · 메모리 저장}>
+
+
+ ;
+}
+
+function createDocumentRouter(initialPath: string) {
+ const root = createRootRoute({ component: Shell });
+ const read = createRoute({ getParentRoute: () => root, path: '/documents/spec', component: DocumentReadPage });
+ const edit = createRoute({ getParentRoute: () => root, path: '/documents/spec/edit', component: DocumentEditPage });
+ return createRouter({ routeTree: root.addChildren([read, edit]), history: createMemoryHistory({ initialEntries: [initialPath] }), scrollRestoration: false });
+}
+
+/** 문서 편집기 앱: 정의서 하나를 페이지 / 페이지 없음으로 읽고 쓴다(메모리 저장). */
+export function DocumentEditorApp({ initialPath = '/documents/spec' }: { initialPath?: string }) {
+ const [router] = useState(() => createDocumentRouter(initialPath));
+ return ;
+}
diff --git a/site/samples/playground/document-editor/app/store.tsx b/site/samples/playground/document-editor/app/store.tsx
new file mode 100644
index 0000000..258d28c
--- /dev/null
+++ b/site/samples/playground/document-editor/app/store.tsx
@@ -0,0 +1,27 @@
+import type { JSONContent } from '@tiptap/react';
+import { createContext, useContext, useState, type ReactNode } from 'react';
+import { DEFAULT_PAGE_LOOK, type PageLook, type PageSetup } from '@/shared/ui/composites/document/model';
+import { sampleDocument } from '../mocks/seed';
+
+/** 견본 문서: 제목·본문과 페이지 설정(용지·여백·머리말·바닥글). 본문은 페이지를 모른다. */
+type DocumentValue = { title: string; body: JSONContent; setup: PageSetup };
+const DocumentContext = createContext<{ document: DocumentValue; save: (next: DocumentValue) => void; look: PageLook; setLook: (look: PageLook) => void } | null>(null);
+
+/** 견본 하나를 메모리에 두는 저장소. 수정 화면의 저장이 읽기 화면에 바로 보인다. */
+export function DocumentStoreProvider({ children }: { children: ReactNode }) {
+ const [document, save] = useState(sampleDocument);
+ // 페이지 모양은 보는 사람의 화면 설정이라 문서와 따로 둔다(읽기와 편집이 같은 값을 쓴다).
+ const [look, setLook] = useState(DEFAULT_PAGE_LOOK);
+ return {children};
+}
+
+const useStore = () => {
+ const store = useContext(DocumentContext);
+ if (!store) throw Error('DocumentStoreProvider 안에서만 씁니다.');
+ return store;
+};
+export const useDocumentValue = () => useStore().document;
+export const useSaveDocumentValue = () => useStore().save;
+
+/** 페이지 모양과 바꾸는 함수. */
+export const usePageLook = () => { const { look, setLook } = useStore(); return [look, setLook] as const; };
diff --git a/site/samples/playground/document-editor/mocks/seed.ts b/site/samples/playground/document-editor/mocks/seed.ts
new file mode 100644
index 0000000..434f198
--- /dev/null
+++ b/site/samples/playground/document-editor/mocks/seed.ts
@@ -0,0 +1,42 @@
+import { DEFAULT_PAGE_SETUP, type PageSetup } from '@/shared/ui/composites/document/model';
+import type { JSONContent } from '@tiptap/react';
+
+const text = (value: string): JSONContent => ({ type: 'text', text: value });
+const p = (value: string): JSONContent => ({ type: 'paragraph', content: [text(value)] });
+const h = (level: 2 | 3, value: string): JSONContent => ({ type: 'heading', attrs: { level }, content: [text(value)] });
+const list = (type: 'bulletList' | 'orderedList', items: string[]): JSONContent => ({ type, content: items.map(item => ({ type: 'listItem', content: [p(item)] })) });
+const cell = (type: 'tableHeader' | 'tableCell', value: string): JSONContent => ({ type, content: [p(value)] });
+const table = (head: string[], rows: string[][]): JSONContent => ({
+ type: 'table', content: [{ type: 'tableRow', content: head.map(value => cell('tableHeader', value)) },
+ ...rows.map(row => ({ type: 'tableRow', content: row.map(value => cell('tableCell', value)) }))],
+});
+
+const LONG = '이 절은 페이지 나누기를 시험하려고 넣은 본문입니다. 짧은 문단이 페이지 끝에 걸리면 통째로 다음 페이지 첫머리로 옮겨 가고, 긴 문단은 넘치는 줄부터 다음 페이지로 이어집니다. '
+ + '글꼴과 줄 간격은 읽는 화면과 인쇄가 같아서, 화면에서 보는 페이지 경계가 PDF에서도 같은 자리에 생깁니다.';
+// 한 페이지보다 긴 문단: 어디에 놓이든 줄 단위로 두 페이지 이상에 걸친다.
+const VERY_LONG = Array.from({ length: 18 }, () => LONG).join(' ');
+
+/** 여러 페이지에 걸치는 견본 정의서: 제목·문단·아주 긴 문단·긴 목록·긴 표·코드·도표·인용이 섞여 페이지 경계를 여러 모양으로 만든다. */
+export function sampleDocument(): { title: string; body: JSONContent; setup: PageSetup } {
+ const sections: JSONContent[] = [];
+ const chapters = ['소개와 목표', '제약과 관례', '맥락과 범위', '해결 전략', '구성 요소', '실행 흐름', '배치와 운영', '품질 요구'];
+ chapters.forEach((name, index) => {
+ sections.push(h(2, `${index + 1}. ${name}`));
+ sections.push(p(`${name}에서 다룰 내용을 정리합니다. ${LONG}`));
+ sections.push(h(3, `${index + 1}.1 핵심 결정`));
+ sections.push(p(LONG));
+ if (index % 2 === 0) sections.push(list('bulletList', Array.from({ length: 9 }, (_, item) => `${name} 확인 항목 ${item + 1}: 담당과 기한을 정하고 결과를 기록합니다.`)));
+ else sections.push(list('orderedList', Array.from({ length: 6 }, (_, item) => `${item + 1}단계 — 요청을 받고 검사한 뒤 결과를 돌려줍니다.`)));
+ if (index === 2 || index === 5) sections.push(table(['구분', '책임', '비고'], Array.from({ length: index === 5 ? 34 : 8 }, (_, row) => [`항목 ${row + 1}`, row % 2 ? '서버' : '화면', '변경 시 함께 검토'])));
+ if (index === 1) sections.push(p(VERY_LONG));
+ if (index === 3) sections.push({ type: 'codeBlock', attrs: { language: 'ts' }, content: [text('export function paginate(blocks: Block[], page: Page) {\n let top = 0;\n for (const block of blocks) {\n if (top + block.height > page.content) top = 0;\n top += block.height;\n }\n}')] });
+ if (index === 4) sections.push({ type: 'mermaidBlock', attrs: { source: 'flowchart LR\n A[작성] --> B[쪽 배치]\n B --> C[읽기]\n B --> D[인쇄·PDF]' } });
+ if (index === 6) sections.push({ type: 'blockquote', content: [p('운영 서버의 설정은 배포와 따로 바꾸지 않습니다. 바꿀 때는 되돌리는 방법을 먼저 적습니다.')] });
+ sections.push(h(3, `${index + 1}.2 남은 일`));
+ sections.push(p(LONG));
+ sections.push(p(LONG));
+ });
+ const setup: PageSetup = { ...DEFAULT_PAGE_SETUP, header: { left: '시스템 정의서', right: '설계 문서 · 1.0' },
+ footer: { left: 'Webcraft', center: '대외비', right: '{page} / {total}' } };
+ return { title: '시스템 정의서', body: { type: 'doc', content: sections }, setup };
+}
diff --git a/site/samples/playground/document-editor/pages/DocumentEditPage.tsx b/site/samples/playground/document-editor/pages/DocumentEditPage.tsx
new file mode 100644
index 0000000..1b8fb8f
--- /dev/null
+++ b/site/samples/playground/document-editor/pages/DocumentEditPage.tsx
@@ -0,0 +1,72 @@
+import { Button } from '@astryxdesign/core/Button';
+import { Text } from '@astryxdesign/core/Text';
+import { AlertDialog } from '@/shared/ui/composites/dialog/AlertDialog';
+import { DocumentEditor } from '@/shared/ui/composites/document/DocumentEditor';
+import type { DocumentMode } from '@/shared/ui/composites/document/model';
+import { PageLookMenu } from '@/shared/ui/composites/document/PageLookMenu';
+import type { PageLayout } from '@/shared/ui/composites/document/pagination';
+import { useIsPhone } from '@/shared/ui/foundations/media';
+import { Page } from '@/shared/ui/layouts/page/Page';
+import { PageActions } from '@/shared/ui/layouts/page/PageParts';
+import type { JSONContent } from '@tiptap/react';
+import { useNavigate } from '@tanstack/react-router';
+import { useEffect, useRef, useState } from 'react';
+import { useDocumentValue, usePageLook, useSaveDocumentValue } from '../app/store';
+import { ModeSwitch } from './DocumentReadPage';
+import styles from './document-editor.module.css';
+
+/**
+ * 문서 수정: 대화상자 없이 전체 페이지에서 쓴다. 위에 서식 막대(왼쪽 확대/축소, 오른쪽 문서 설정 · 페이지 모양 · 페이지 / 페이지 없음), 아래에 제목 칸과 본문.
+ * 페이지 모드는 종이 위에 쓰며 페이지 끝을 넘는 것은 다음 페이지로 넘어간다. 용지·여백·머리말·바닥글은 문서 설정 칸에서 바꾼다.
+ * 머리 줄은 본문 수정 화면(BodyEditPage)과 같다: 저장 상태, 저장(Ctrl/⌘+S, 제자리), 취소(고친 것이 있으면 먼저 묻는다), 완료(저장하고 돌아간다).
+ * 휴대폰은 바닥 줄에 저장 · 완료, ←가 취소다.
+ */
+export function DocumentEditPage() {
+ const document = useDocumentValue();
+ const save = useSaveDocumentValue();
+ const navigate = useNavigate();
+ const isPhone = useIsPhone();
+ const [title, setTitle] = useState(document.title);
+ const [body, setBody] = useState(document.body);
+ const [setup, setSetup] = useState(document.setup);
+ const [mode, setMode] = useState('pages');
+ const [layout, setLayout] = useState(null);
+ const [look, setLook] = usePageLook();
+ const [isLeaveAsked, setLeaveAsked] = useState(false);
+ const snapshot = (value: { title: string; body: JSONContent; setup: typeof setup }) => JSON.stringify({ title: value.title.trim(), body: value.body, setup: value.setup });
+ const [saved, setSaved] = useState(() => snapshot(document));
+ const current = { title: title.trim() || document.title, body, setup };
+ const isDirty = snapshot(current) !== saved;
+ const store = () => { save(current); setSaved(snapshot(current)); };
+ const leave = () => void navigate({ href: '/documents/spec' });
+ const cancel = () => { if (isDirty) setLeaveAsked(true); else leave(); };
+ const done = () => { store(); leave(); };
+ // Ctrl+S (⌘S)는 브라우저의 저장 대신 이 화면을 저장한다.
+ const latest = useRef(store);
+ latest.current = store;
+ useEffect(() => {
+ const onKey = (event: KeyboardEvent) => {
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 's') { event.preventDefault(); latest.current(); }
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, []);
+ const saveButton = ;
+ const doneButton = ;
+ return
+ {layout && mode === 'pages' ? {layout.count}페이지 : null}
+ {isDirty ? '저장 안 됨' : '저장됨'}
+ {saveButton}
+
+ {doneButton}
+ >}>
+ {mode === 'pages' ? : null}>}
+ above={ setTitle(event.target.value)} />} />
+ {isPhone ? {saveButton}{doneButton} : null}
+ { setLeaveAsked(false); leave(); }} />
+ ;
+}
diff --git a/site/samples/playground/document-editor/pages/DocumentReadPage.tsx b/site/samples/playground/document-editor/pages/DocumentReadPage.tsx
new file mode 100644
index 0000000..5c8ba1b
--- /dev/null
+++ b/site/samples/playground/document-editor/pages/DocumentReadPage.tsx
@@ -0,0 +1,56 @@
+import { Button } from '@astryxdesign/core/Button';
+import { Heading } from '@astryxdesign/core/Heading';
+import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
+import type { DocumentMode } from '@/shared/ui/composites/document/model';
+import { DocumentView, scrollToPage } from '@/shared/ui/composites/document/DocumentView';
+import { ExportMenu } from '@/shared/ui/composites/document/ExportMenu';
+import { PageLookMenu } from '@/shared/ui/composites/document/PageLookMenu';
+import { PagesPill } from '@/shared/ui/composites/document/PagesPill';
+import type { PageLayout } from '@/shared/ui/composites/document/pagination';
+import { PropertyPill } from '@/shared/ui/composites/property/PropertyPill';
+import { useIsPhone } from '@/shared/ui/foundations/media';
+import { DetailLayout } from '@/shared/ui/layouts/detail/DetailLayout';
+import { Page } from '@/shared/ui/layouts/page/Page';
+import { useNavigate } from '@tanstack/react-router';
+import { useRef, useState } from 'react';
+import { useDocumentValue, usePageLook } from '../app/store';
+
+/**
+ * 문서 읽기: 다른 상세와 같은 배치(DetailLayout)다. 본문 열에 문서를 페이지(페이지마다 나눠) 또는 페이지 없음으로 읽고,
+ * 오른쪽 정보 칸에 작성 정보와 페이지 항목(누르면 페이지 목록, 고르면 그 페이지로)을 둔다. 휴대폰은 늘 페이지 없음이다.
+ * 머리 줄에 페이지 모양 · 보기 전환 · 내보내기(PDF로 저장 · 인쇄 · 링크 복사) · 편집.
+ */
+export function DocumentReadPage() {
+ const document = useDocumentValue();
+ const navigate = useNavigate();
+ const isPhone = useIsPhone();
+ const [mode, setMode] = useState('pages');
+ const [layout, setLayout] = useState(null);
+ const root = useRef(null);
+ const [look, setLook] = usePageLook();
+ const isPaged = !isPhone && mode === 'pages';
+ return
+ {isPaged ? : null}
+ {isPhone ? null : }
+
+ ;
+}
+
+/** 페이지 / 페이지 없음 전환. 읽기와 편집이 같은 것을 쓴다. */
+export function ModeSwitch({ mode, onChange }: { mode: DocumentMode; onChange: (mode: DocumentMode) => void }) {
+ return onChange(value as DocumentMode)}>
+
+
+ ;
+}
diff --git a/site/samples/playground/document-editor/pages/document-editor.module.css b/site/samples/playground/document-editor/pages/document-editor.module.css
new file mode 100644
index 0000000..86e497c
--- /dev/null
+++ b/site/samples/playground/document-editor/pages/document-editor.module.css
@@ -0,0 +1,3 @@
+/* 문서 편집기 견본 앱: 수정 화면의 제목 칸. 종이·서식 막대는 composites/document가 그린다. */
+.titleInput { inline-size: 100%; box-sizing: border-box; padding: var(--spacing-2) 0; border: 0; background: none; outline: none; color: var(--color-text-primary);
+ font: inherit; font-size: 1.75rem; font-weight: 600; }
diff --git a/site/src/shared/ui/README.md b/site/src/shared/ui/README.md
index 4328b02..7291190 100644
--- a/site/src/shared/ui/README.md
+++ b/site/src/shared/ui/README.md
@@ -5,7 +5,7 @@ Astryx를 쓰는 업무 앱이 같은 화면 구성과 상호작용을 재현할
| 폴더 | 담는 것 |
| --- | --- |
| `foundations` | Stone 확장 테마, 버튼·카드 변형 타입, 휴대폰 크기(responsive.css), 앱 진입 CSS(base.css), 분기점 훅, Solar 아이콘 |
-| `composites` | 공통 다이얼로그·확인 창·짧은 양식, 팝오버↔바텀시트, 동작·행 메뉴, 검색, 필터·표시, 속성 알약, 파일 올리기, 상태 화면, 본문 칸(리치 에디터: 블록 메뉴·선택 서식 도구·표 도구·올리는 칸·미디어·도표·HTML 임베드·링크 카드), 그룹 머리 |
+| `composites` | 공통 다이얼로그·확인 창·짧은 양식, 팝오버↔바텀시트, 동작·행 메뉴, 검색, 필터·표시, 속성 알약, 파일 올리기, 상태 화면, 본문 칸(리치 에디터: 블록 메뉴·선택 서식 도구·상단 서식 막대·표 도구·올리는 칸·미디어·도표·HTML 임베드·링크 카드), 문서 편집기(`document`: 편집 DocumentEditor·읽기 DocumentView, 모드·페이지 설정·페이지 모양 모델 model.ts, Tiptap 페이지 나누기 확장 pagination.ts, 종이 틀·머리말·바닥글 PageCanvas, DocumentSettings·PageLookMenu·ZoomControl·ExportMenu·PagesPill, 편집과 읽기가 함께 쓰는 usePagedFrame), 그룹 머리 |
| `layouts` | 앱 셸(사이드바·하단 탭·더보기), 페이지(머리·배너·툴바·적용 줄·목록·표·읽은 수), 상세, 설정, 옆 패널 |
| `patterns` | 작성 양식과 초안 저장소, 본문 수정 화면(BodyEditPage), 라우트 마스킹·라우터 링크, 목록 조건의 주소, 첫 방문 덮개(BootCover와 index.html에 넣는 Vite 플러그인) |
@@ -18,7 +18,8 @@ Astryx 기본 부품은 다시 구현하지 않는다. 폴더끼리는 상대
3. 작성 양식을 쓰면 라우터보다 위에 `DraftProvider`를 둔다.
4. 첫 방문 덮개: `vite.config`에 `bootCover({ mark, modeKey })`를 넣고, 앱 루트에 `BootCover`를 두어 첫 경로가 풀리면 `isReady`로 걷는다. 그림은 제품의 표시로 바꾼다.
5. 리치 에디터(`composites/editor`)를 쓰면 Tiptap 3 확장(`@tiptap/core`·`pm`·`extension-table`·`list`·`text-style`·`highlight`·`text-align`·`subscript`·`superscript`)과 `mermaid`, `yet-another-react-lightbox`를 함께 설치한다. 올리기(`upload`)와 링크 미리보기(`readPreview`)는 호스트가 서버에 이어 넘긴다. 미리보기는 서버가 읽어야 하고(브라우저가 남의 사이트를 직접 부르지 않는다), 올린 HTML은 격리 정책을 붙여 내보낸다.
-6. 아이콘 이름을 더하면 `site/scripts/generate-icons.mjs`의 매핑을 고치고 `npm run site:icons`를 실행한다.
+6. 문서 편집기(`composites/document`)를 쓰면 리치 에디터(`composites/editor`, 상단 막대 `EditorToolbar` 포함)와 `composites/popup`·`composites/property`를 함께 옮긴다. 페이지 설정(`PageSetup`)은 본문과 따로 문서에 저장하고, 페이지 모양(`PageLook`)은 보는 사람의 화면 설정이라 문서에 저장하지 않는다. 서버에서 PDF를 만들면 `ExportMenu`의 `onSavePdf`를 넘긴다. 넘기지 않으면 브라우저 인쇄 창을 연다.
+7. 아이콘 이름을 더하면 `site/scripts/generate-icons.mjs`의 매핑을 고치고 `npm run site:icons`를 실행한다.
## 검증 범위
diff --git a/site/src/shared/ui/composites/document/DocumentEditor.tsx b/site/src/shared/ui/composites/document/DocumentEditor.tsx
new file mode 100644
index 0000000..15ba57e
--- /dev/null
+++ b/site/src/shared/ui/composites/document/DocumentEditor.tsx
@@ -0,0 +1,74 @@
+import { Button } from '@astryxdesign/core/Button';
+import { HStack } from '@astryxdesign/core/HStack';
+import { VStack } from '@astryxdesign/core/VStack';
+import type { JSONContent } from '@tiptap/react';
+import { useRef, useState, type ReactNode } from 'react';
+import { EditorToolbar } from '../editor/EditorToolbar';
+import { RichEditor, type EditorHandle } from '../editor/RichEditor';
+import type { LinkChoice, ReadLinkPreview, UploadFile } from '../editor/types';
+import { icon } from '../../foundations/icons/solar-icon';
+import { DocumentSettings, type SettingsTab } from './DocumentSettings';
+import { useFitZoom, useIsPhoneScreen } from './hooks';
+import { DEFAULT_PAGE_SETUP, formatOf, type DocumentMode, type PageLook, type PageSetup } from './model';
+import type { PageArea } from './PageCanvas';
+import type { PageLayout } from './pagination';
+import { usePagedFrame } from './usePagedFrame';
+import { ZoomControl, type ZoomSetting } from './ZoomControl';
+import styles from './document.module.css';
+
+/**
+ * 문서 편집기. 위에 늘 붙은 서식 막대(왼쪽 확대/축소, 오른쪽 문서 설정과 toolbarEnd), 아래에 본문과 문서 설정 칸이다.
+ * 페이지 모드는 종이(setup의 용지·방향·여백) 위에 쓰며 페이지 끝을 넘는 블록·줄·표 행이 다음 페이지로 넘어가고, 머리말·바닥글이 종이
+ * 여백에 보인다(올리면 연필 단추, 누르면 설정 칸의 그 자리로). 페이지 없음은 읽는 폭 한 열에 이어 쓴다. 휴대폰은 늘 페이지 없음이다.
+ * 페이지 설정을 바꾸면 에디터를 새로 만들지 않고 다시 나눈다. 모드를 바꾸면 에디터를 새로 만들되 쓴 내용은 그대로다(실행 취소 기록만 처음부터).
+ * value는 처음 값이다. onSetupChange가 없으면 문서 설정 칸을 두지 않는다. look은 보는 사람의 화면 설정(PageLookMenu)이다.
+ */
+export function DocumentEditor({ value, onChange, mode = 'pages', setup = DEFAULT_PAGE_SETUP, onSetupChange, look, onLayout, above, toolbarEnd, label = '본문', placeholder,
+ upload, readPreview, links }: {
+ value: JSONContent; onChange: (value: JSONContent) => void; mode?: DocumentMode; setup?: PageSetup; onSetupChange?: (setup: PageSetup) => void;
+ look?: PageLook; onLayout?: (layout: PageLayout) => void;
+ /** 본문 위(제목 칸 등): 페이지 없음은 본문 열 폭, 페이지는 종이의 본문 여백에 맞춘다. */
+ above?: ReactNode;
+ /** 서식 막대 오른쪽 끝(페이지 모양·보기 전환 등). */
+ toolbarEnd?: ReactNode;
+ label?: string; placeholder?: string; upload?: UploadFile; readPreview?: ReadLinkPreview; links?: LinkChoice[];
+}) {
+ const isPhone = useIsPhoneScreen();
+ const isPaged = !isPhone && mode === 'pages';
+ const canSet = !!onSetupChange && isPaged;
+ const [handle, setHandle] = useState(null);
+ const [zoomSetting, setZoomSetting] = useState('fit');
+ const [settings, setSettings] = useState<{ tab: SettingsTab; focus: PageArea | null } | null>(null);
+ // 모드를 바꿔 에디터를 새로 만들 때 지금까지 쓴 내용으로 시작한다.
+ const latest = useRef(value);
+ const format = formatOf(setup);
+ const { ref, zoom: fit } = useFitZoom(format.width);
+ const zoom = zoomSetting === 'fit' ? fit : zoomSetting / 100;
+ const { extensions, frame } = usePagedFrame({ isPaged, setup, look, zoom, handle, onLayout,
+ onEditArea: canSet ? area => setSettings({ tab: 'headerFooter', focus: area }) : undefined,
+ activeArea: settings?.tab === 'headerFooter' ? settings.focus : null });
+ const rem = (px: number) => `calc(${px / 16}rem * ${zoom})`;
+ return
+
+ : undefined}
+ end={<>
+ {canSet ?
+
+ fit + 0.001 ? '' : undefined} gap={4}>
+ {above ? {above} : null}
+
+ { latest.current = next; onChange(next); }} isEditable isPage
+ label={label} placeholder={placeholder} upload={upload} readPreview={readPreview} links={links} onReady={setHandle} extensions={extensions} frame={frame} />
+
+
+ {canSet && settings ? setSettings({ tab, focus: null })} onClose={() => setSettings(null)} /> : null}
+
+ ;
+}
diff --git a/site/src/shared/ui/composites/document/DocumentSettings.tsx b/site/src/shared/ui/composites/document/DocumentSettings.tsx
new file mode 100644
index 0000000..cf3b2a5
--- /dev/null
+++ b/site/src/shared/ui/composites/document/DocumentSettings.tsx
@@ -0,0 +1,112 @@
+import { Button } from '@astryxdesign/core/Button';
+import { HStack } from '@astryxdesign/core/HStack';
+import { NumberInput } from '@astryxdesign/core/NumberInput';
+import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
+import { Selector } from '@astryxdesign/core/Selector';
+import { Switch } from '@astryxdesign/core/Switch';
+import { Text } from '@astryxdesign/core/Text';
+import { TextInput } from '@astryxdesign/core/TextInput';
+import { VStack } from '@astryxdesign/core/VStack';
+import { useEffect, useRef, type ReactNode } from 'react';
+import { icon } from '../../foundations/icons/solar-icon';
+import { PAPERS, type Paper, type PageSetup } from './model';
+import type { PageArea } from './PageCanvas';
+import styles from './document.module.css';
+
+export type SettingsTab = 'document' | 'headerFooter';
+type Slot = { area: PageArea; side: 'left' | 'center' | 'right' };
+
+function Section({ title, children }: { title: string; children: ReactNode }) {
+ return
+ {title}
+ {children}
+ ;
+}
+
+/**
+ * 문서 설정 칸(편집기 오른쪽). 문서 탭은 용지·방향·여백·페이지 간격, 머리말·바닥글 탭은 첫 페이지 다르게, 머리말(왼쪽·오른쪽)·
+ * 바닥글(왼쪽·가운데·오른쪽) 글과 가장자리 거리, 페이지 번호·전체 페이지 넣기다. 바꾸면 그 자리에서 다시 나눈다(저장은 호스트가 한다).
+ * focus는 종이의 머리말·바닥글을 두 번 눌렀을 때 그 칸으로 커서를 옮긴다.
+ */
+export function DocumentSettings({ setup, onChange, tab, onTab, focus, onClose }: {
+ setup: PageSetup; onChange: (setup: PageSetup) => void; tab: SettingsTab; onTab: (tab: SettingsTab) => void;
+ focus?: PageArea | null; onClose: () => void;
+}) {
+ const last = useRef({ area: 'footer', side: 'right' });
+ const inputs = useRef(new Map());
+ const keyOf = (slot: Slot) => `${slot.area}-${slot.side}`;
+ useEffect(() => {
+ if (!focus || tab !== 'headerFooter') return;
+ const slot: Slot = { area: focus, side: 'left' };
+ last.current = slot;
+ inputs.current.get(keyOf(slot))?.focus();
+ }, [focus, tab]);
+ const set = (patch: Partial) => onChange({ ...setup, ...patch });
+ const margin = (side: keyof PageSetup['margins']) => set({ margins: { ...setup.margins, [side]: value } })} />;
+ const text = (slot: Slot, label: string) => {
+ const value = slot.area === 'header' ? setup.header[slot.side as 'left' | 'right'] : setup.footer[slot.side];
+ return { if (element) inputs.current.set(keyOf(slot), element); else inputs.current.delete(keyOf(slot)); }}
+ onFocus={() => { last.current = slot; }}
+ onChange={next => slot.area === 'header' ? set({ header: { ...setup.header, [slot.side]: next } }) : set({ footer: { ...setup.footer, [slot.side]: next } })} />;
+ };
+ // 페이지 번호·전체 페이지는 마지막으로 고르던 칸의 끝에 넣는다.
+ const insertToken = (token: string) => {
+ const slot = last.current, current = slot.area === 'header' ? setup.header[slot.side as 'left' | 'right'] : setup.footer[slot.side];
+ const next = `${current}${current && !current.endsWith(' ') ? ' ' : ''}${token}`;
+ if (slot.area === 'header') set({ header: { ...setup.header, [slot.side]: next } }); else set({ footer: { ...setup.footer, [slot.side]: next } });
+ requestAnimationFrame(() => inputs.current.get(keyOf(slot))?.focus());
+ };
+ return
+
+ onTab(value as SettingsTab)}>
+
+
+
+
+
+
+ {tab === 'document' ? <>
+
+
+ set({ paper: value as Paper })}
+ options={(Object.keys(PAPERS) as Paper[]).map(paper => ({ value: paper, label: `${paper} (${PAPERS[paper].width} × ${PAPERS[paper].height}mm)` }))} />
+
+ set({ orientation: value as PageSetup['orientation'] })} layout="fill">
+
+
+
+
+
+ {margin('top')}{margin('bottom')}
+ {margin('left')}{margin('right')}
+
+
+ set({ gap: value })} />
+
+ > : <>
+
+ set({ differentFirstPage: value })} />
+
+
+ {text({ area: 'header', side: 'left' }, '왼쪽')}{text({ area: 'header', side: 'right' }, '오른쪽')}
+ set({ headerDistance: value })} />
+
+
+ {text({ area: 'footer', side: 'left' }, '왼쪽')}{text({ area: 'footer', side: 'center' }, '가운데')}
+ {text({ area: 'footer', side: 'right' }, '오른쪽')}
+ set({ footerDistance: value })} />
+
+
+ 마지막으로 고르던 칸의 끝에 넣습니다.
+
+
+
+ >}
+
+ ;
+}
diff --git a/site/src/shared/ui/composites/document/DocumentView.tsx b/site/src/shared/ui/composites/document/DocumentView.tsx
new file mode 100644
index 0000000..c3c9888
--- /dev/null
+++ b/site/src/shared/ui/composites/document/DocumentView.tsx
@@ -0,0 +1,42 @@
+import { VStack } from '@astryxdesign/core/VStack';
+import type { JSONContent } from '@tiptap/react';
+import { useState, type ReactNode, type RefObject } from 'react';
+import { RichEditor, type EditorHandle } from '../editor/RichEditor';
+import type { ReadLinkPreview } from '../editor/types';
+import { useFitZoom, useIsPhoneScreen } from './hooks';
+import { DEFAULT_PAGE_SETUP, formatOf, type DocumentMode, type PageLook, type PageSetup } from './model';
+import type { PageLayout } from './pagination';
+import { useContentVersion, usePagedFrame } from './usePagedFrame';
+import styles from './document.module.css';
+
+/** 페이지 보기에서 n번째 종이로 스크롤한다(페이지 목록이 쓴다). */
+export function scrollToPage(root: RefObject, number: number) {
+ root.current?.querySelector(`[data-sheet="${number}"]`)?.scrollIntoView({ block: 'start', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
+}
+
+/**
+ * 문서 읽기. 편집기와 같은 본문을 읽기 전용으로 그린다. 페이지 모드는 페이지마다 나눠, 페이지 없음은 읽는 폭 한 열로 읽는다
+ * (휴대폰은 늘 페이지 없음). isInline이면 상세 본문 안에서 페이지와 함께 내려가고, 아니면 칸 높이를 채우고 스스로 스크롤한다.
+ * above는 페이지 없음일 때 본문 위에 둘 것(제목 등)이다. 인쇄는 앱 틀을 빼고 이 문서만 찍는다(data-print-root).
+ */
+export function DocumentView({ value, mode = 'pages', setup = DEFAULT_PAGE_SETUP, look, isInline = false, onLayout, above, label = '본문', readPreview, onNavigate, rootRef }: {
+ value: JSONContent; mode?: DocumentMode; setup?: PageSetup; look?: PageLook; isInline?: boolean; onLayout?: (layout: PageLayout) => void;
+ above?: ReactNode; label?: string; readPreview?: ReadLinkPreview; onNavigate?: (href: string) => void;
+ /** scrollToPage로 쓸 바깥 칸. */
+ rootRef?: RefObject;
+}) {
+ const isPhone = useIsPhoneScreen();
+ const isPaged = !isPhone && mode === 'pages';
+ const [handle, setHandle] = useState(null);
+ const { ref, zoom } = useFitZoom(formatOf(setup).width, isInline ? 0 : 48);
+ const { extensions, frame } = usePagedFrame({ isPaged, setup, look, zoom, handle, onLayout });
+ const version = useContentVersion(value);
+ const attach = (element: HTMLDivElement | null) => { ref.current = element; if (rootRef) rootRef.current = element; };
+ return
+ {above && !isPaged ? {above} : null}
+
+
+
+ ;
+}
diff --git a/site/src/shared/ui/composites/document/ExportMenu.tsx b/site/src/shared/ui/composites/document/ExportMenu.tsx
new file mode 100644
index 0000000..5dd554b
--- /dev/null
+++ b/site/src/shared/ui/composites/document/ExportMenu.tsx
@@ -0,0 +1,31 @@
+import { Button } from '@astryxdesign/core/Button';
+import { useToast } from '@astryxdesign/core/Toast';
+import { VStack } from '@astryxdesign/core/VStack';
+import { icon } from '../../foundations/icons/solar-icon';
+import { ActionMenu } from '../popup/ActionMenu';
+import { AdaptivePopover } from '../popup/AdaptivePopover';
+import styles from './document.module.css';
+
+/**
+ * 내보내기: 내려받기 아이콘과 묶음 메뉴. 내보내기 묶음은 PDF로 저장과 인쇄, 공유 묶음은 링크 복사다. 동작은 호스트가 바꿀 수 있다:
+ * 서버에서 PDF를 만드는 앱은 onSavePdf를 준다. 주지 않으면 둘 다 브라우저 인쇄 창을 연다(문서만 찍고 페이지 설정의 용지·여백·머리말·
+ * 바닥글을 쓴다). link를 주지 않으면 지금 주소를 복사한다. DOCX는 아직 다루지 않는다.
+ */
+export function ExportMenu({ onSavePdf = () => window.print(), onPrint = () => window.print(), link }: {
+ onSavePdf?: () => void; onPrint?: () => void; link?: string;
+}) {
+ const toast = useToast();
+ const copy = async () => {
+ try { await navigator.clipboard.writeText(link ?? location.href); toast({ body: '링크를 복사했습니다' }); }
+ catch { toast({ body: '링크를 복사하지 못했습니다' }); }
+ };
+ return {''}}
+ content={close =>
+
+ void copy() }]} />
+ } />;
+}
diff --git a/site/src/shared/ui/composites/document/PageCanvas.tsx b/site/src/shared/ui/composites/document/PageCanvas.tsx
new file mode 100644
index 0000000..63599b9
--- /dev/null
+++ b/site/src/shared/ui/composites/document/PageCanvas.tsx
@@ -0,0 +1,64 @@
+import { Button } from '@astryxdesign/core/Button';
+import { HStack } from '@astryxdesign/core/HStack';
+import { Text } from '@astryxdesign/core/Text';
+import { VStack } from '@astryxdesign/core/VStack';
+import type { CSSProperties, ReactNode } from 'react';
+import { icon } from '../../foundations/icons/solar-icon';
+import { MM, fillPageTokens, formatOf, printPageRule, type PageLook, type PageSetup } from './model';
+import type { PageLayout } from './pagination';
+import styles from './document.module.css';
+
+export type PageArea = 'header' | 'footer';
+
+/**
+ * 페이지 보기의 종이 틀. 페이지 수만큼 종이를 본문 뒤에 깔고, 위·아래 여백에 머리말·바닥글({page}·{total}을 채운다)을 둔다.
+ * 본문은 첫 페이지의 여백 안에서 시작하고, 페이지 나누기 확장(Pagination)이 페이지 끝을 넘는 블록을 다음 종이의 여백 안으로 민다.
+ * 좁은 칸에서는 zoom으로 종이를 줄인다(나눔은 배율과 관계없다). 종이 크기는 rem이라 큰 화면이 기준 글자를 키우면 글과 함께 커진다.
+ * onEditArea가 있으면 머리말·바닥글 자리를 두 번 눌러 고칠 수 있다(편집기가 설정 칸을 연다). 인쇄는 같은 설정의 @page 규칙을 쓴다.
+ */
+export function PageCanvas({ setup, layout, zoom = 1, look, onEditArea, activeArea, children }: {
+ setup: PageSetup; layout: PageLayout | null; zoom?: number; look?: PageLook; onEditArea?: (area: PageArea) => void;
+ /** 지금 설정 칸에서 고치는 영역: 경계 점선과 표시를 늘 보인다. */
+ activeArea?: PageArea | null; children: ReactNode;
+}) {
+ const format = formatOf(setup);
+ const count = layout?.count ?? 1, stride = format.height + format.gap;
+ const rem = (px: number) => `${px / 16}rem`;
+ const style = {
+ zoom, '--pg-width': rem(format.width), '--pg-height': rem(format.height), '--pg-total': rem(count * stride - format.gap),
+ '--pg-margin-top': rem(format.marginTop), '--pg-margin-bottom': rem(format.marginBottom),
+ '--pg-margin-left': rem(format.marginLeft), '--pg-margin-right': rem(format.marginRight),
+ '--pg-gap': rem(format.gap), '--pg-header-distance': rem(setup.headerDistance * MM), '--pg-footer-distance': rem(setup.footerDistance * MM),
+ } as CSSProperties;
+ // 머리말·바닥글 영역: 종이 위·아래 여백 전체. 고칠 수 있으면 올렸을 때 본문과의 경계를 점선으로, 끝에 연필 단추를 보인다(두 번 눌러도 연다).
+ const area = (kind: PageArea, number: number, parts: string[]) => {
+ const isEmpty = !parts.some(Boolean);
+ if (isEmpty && !onEditArea) return null;
+ const name = kind === 'header' ? '머리말' : '바닥글';
+ return onEditArea(kind) : undefined}>
+ {isEmpty ? {`${name} 추가`}
+ : parts.map((part, index) =>
+ {fillPageTokens(part, number, count)}
+ )}
+ {onEditArea ? onEditArea(kind)} /> : null}
+ ;
+ };
+ return
+
+
+ {Array.from({ length: count }, (_, index) => {
+ const number = index + 1, isBare = setup.differentFirstPage && number === 1;
+ return
+ {isBare ? null : area('header', number, [setup.header.left, setup.header.right])}
+ {isBare ? null : area('footer', number, [setup.footer.left, setup.footer.center, setup.footer.right])}
+ {/* 구분선 모양: 페이지 사이 틈 가운데의 가로선(마지막 페이지 뒤에는 없다). */}
+ {look?.style === 'divider' && number < count ? : null}
+ ;
+ })}
+
+ {children}
+ ;
+}
diff --git a/site/src/shared/ui/composites/document/PageLookMenu.tsx b/site/src/shared/ui/composites/document/PageLookMenu.tsx
new file mode 100644
index 0000000..48cf838
--- /dev/null
+++ b/site/src/shared/ui/composites/document/PageLookMenu.tsx
@@ -0,0 +1,35 @@
+import { Button } from '@astryxdesign/core/Button';
+import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
+import { Text } from '@astryxdesign/core/Text';
+import { VStack } from '@astryxdesign/core/VStack';
+import { useState } from 'react';
+import { icon } from '../../foundations/icons/solar-icon';
+import { AdaptivePopover } from '../popup/AdaptivePopover';
+import type { PageLook } from './model';
+import styles from './document.module.css';
+
+
+const STYLE_HINTS: Record = {
+ tinted: '페이지 바깥을 테마에 맞춰 옅게 다른 색으로 두어 페이지를 가릅니다.',
+ divider: '같은 바탕에 페이지 사이에만 가로선을 둡니다.',
+};
+
+/** 페이지 모양 고르기: 보기 전환 옆의 단추와 그 아래 칸. 편집과 읽기가 함께 쓴다. */
+export function PageLookMenu({ value, onChange }: { value: PageLook; onChange: (look: PageLook) => void }) {
+ const [isOpen, setOpen] = useState(false);
+ return }
+ content={() =>
+ onChange({ ...value, style: style as PageLook['style'] })}>
+
+
+
+ {STYLE_HINTS[value.style]}
+ {value.style === 'divider' ? onChange({ ...value, line: line as PageLook['line'] })}>
+
+
+
+
+ : null}
+ } />;
+}
diff --git a/site/src/shared/ui/composites/document/PagesPill.tsx b/site/src/shared/ui/composites/document/PagesPill.tsx
new file mode 100644
index 0000000..ef61750
--- /dev/null
+++ b/site/src/shared/ui/composites/document/PagesPill.tsx
@@ -0,0 +1,17 @@
+import { icon } from '../../foundations/icons/solar-icon';
+import { ActionMenu } from '../popup/ActionMenu';
+import { PropertyPill } from '../property/PropertyPill';
+import type { PageLayout } from './pagination';
+
+/**
+ * 상세 오른쪽 속성 칸의 페이지 항목: "N페이지", 누르면 페이지 목록(번호와 그 페이지의 대표 제목, 새 제목 없이 이어지는 페이지는 "이어짐")이
+ * 뜨고 고르면 그 페이지로 간다. 문서의 목차처럼 정보 묶음 안에 둔다. 배치 전에는 비어 있다.
+ */
+export function PagesPill({ layout, onSelect }: { layout: PageLayout | null; onSelect: (number: number) => void }) {
+ return ({
+ id: String(page.number),
+ label: `${page.number} ${page.heading?.text ?? '제목 없음'}${page.continues ? ' (이어짐)' : ''}`,
+ onSelect: () => onSelect(page.number),
+ }))} />} />;
+}
diff --git a/site/src/shared/ui/composites/document/ZoomControl.tsx b/site/src/shared/ui/composites/document/ZoomControl.tsx
new file mode 100644
index 0000000..bddb39e
--- /dev/null
+++ b/site/src/shared/ui/composites/document/ZoomControl.tsx
@@ -0,0 +1,26 @@
+import { Button } from '@astryxdesign/core/Button';
+import { HStack } from '@astryxdesign/core/HStack';
+import { Selector } from '@astryxdesign/core/Selector';
+import { Text } from '@astryxdesign/core/Text';
+import styles from './document.module.css';
+
+/** 'fit'은 칸 폭에 맞춘 배율(넓으면 100%), 숫자는 고정 배율(%). */
+export type ZoomSetting = 'fit' | number;
+const STEPS = [50, 75, 90, 100, 110, 125, 150, 200];
+
+/** 확대/축소: −, 배율 고르기(폭에 맞춤·단계), +. 폭에 맞춤일 때는 지금 실제 배율(current)만 짧게 보인다. */
+export function ZoomControl({ value, current, onChange }: { value: ZoomSetting; current: number; onChange: (value: ZoomSetting) => void }) {
+ const percent = Math.round(current * 100);
+ const step = (direction: 1 | -1) => {
+ const next = direction > 0 ? STEPS.find(each => each > percent) : [...STEPS].reverse().find(each => each < percent);
+ if (next) onChange(next);
+ };
+ return
+ −}
+ isDisabled={percent <= STEPS[0]} onClick={() => step(-1)} />
+ onChange(next === 'fit' ? 'fit' : Number(next))}
+ options={[{ value: 'fit', label: value === 'fit' ? `${percent}%` : '폭에 맞춤' }, ...STEPS.map(each => ({ value: String(each), label: `${each}%` }))]} />
+ +}
+ isDisabled={percent >= STEPS[STEPS.length - 1]} onClick={() => step(1)} />
+ ;
+}
diff --git a/site/src/shared/ui/composites/document/document.module.css b/site/src/shared/ui/composites/document/document.module.css
new file mode 100644
index 0000000..379978b
--- /dev/null
+++ b/site/src/shared/ui/composites/document/document.module.css
@@ -0,0 +1,105 @@
+/*
+ * 페이지 보기: 종이 틀 위의 본문. 본문 폭은 종이 폭에서 좌우 여백을 뺀 것이고, 페이지 나눔 간격(.pg-gap)은 화면에만 있다.
+ * 종이의 글자 크기는 앱 화면 크기와 관계없이 데스크톱 값으로 고정한다: 인쇄할 때는 종이 폭(약 640px)이 화면 폭이 되어 휴대폰 글자 규칙
+ * (width < 64rem)이 켜지는데, 그러면 글이 커져 화면과 다른 자리에서 페이지가 나뉜다. 종이 크기는 rem이라 큰 화면의 기준 글자 확대와 함께 커진다.
+ */
+.canvas { --text-large-size: 0.9375rem; --text-body-size: 0.8125rem; --font-size-base: 0.8125rem; --text-label-size: 0.8125rem; --text-supporting-size: 0.75rem;
+ --text-heading-2-size: 1.25rem; --text-heading-3-size: 0.9375rem; --text-heading-4-size: 0.8125rem;
+ position: relative; box-sizing: border-box; flex: none; inline-size: var(--pg-width); min-block-size: var(--pg-total);
+ padding: var(--pg-margin-top) var(--pg-margin-right) var(--pg-margin-bottom) var(--pg-margin-left); page: document-page; }
+.canvas > :not(.sheets) { position: relative; z-index: 1; }
+.sheets { position: absolute; inset: 0; z-index: 0; pointer-events: none; }
+.sheet { position: absolute; inset-inline: 0; block-size: var(--pg-height); box-sizing: border-box; background: var(--color-background-surface);
+ border: 1px solid transparent; border-radius: var(--radius-element, 4px); }
+/* 페이지 모양(PageLookMenu). 연한 바탕: 바깥만 테마에 맞춰 아주 옅게 다른 색, 페이지 테두리는 거의 보이지 않는다. */
+.desk:has(.canvas[data-look="tinted"]) { background: color-mix(in oklab, var(--color-background-surface), var(--color-text-primary) 3.5%); }
+.canvas[data-look="tinted"] .sheet { border-color: color-mix(in oklab, var(--color-background-surface), var(--color-text-primary) 5%); }
+/* 구분선: 같은 바탕, 페이지 사이 틈 가운데에만 선을 둔다. */
+.pageDivider { position: absolute; inset-inline: 0; inset-block-start: calc(100% + var(--pg-gap) / 2); block-size: 0; border-block-start: 1px solid var(--color-border-emphasized);
+ transform: translateY(-50%); }
+.canvas[data-line="dashed"] .pageDivider { border-block-start-style: dashed; }
+.canvas[data-line="dotted"] .pageDivider { border-block-start: 2px dotted var(--color-border-emphasized); }
+.canvas[data-line="wavy"] .pageDivider { block-size: 6px; border: 0; background: var(--color-border-emphasized);
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='6' viewBox='0 0 16 6'%3E%3Cpath d='M0 3 Q4 0 8 3 T16 3' fill='none' stroke='black' stroke-width='1.25'/%3E%3C/svg%3E") repeat-x 0 0 / 16px 6px; }
+.lookMenu { padding: var(--spacing-3); }
+.exportMenu { padding-block: var(--spacing-1); }
+/* 묶음(내보내기 · 공유) 사이에 가는 선을 둔다. */
+.exportMenu > * + * { padding-block-start: var(--spacing-1); border-block-start: 1px solid var(--color-border); }
+
+/*
+ * 머리말·바닥글: 종이 위·아래 여백 전체가 영역이고 글은 가장자리에서 정한 거리에 놓인다. 고칠 수 있으면 올렸을 때(또는 고치는 중일 때)
+ * 본문과의 경계에 점선, 영역에 옅은 바탕, 경계 위 오른쪽에 연필 단추가 보인다. 빈 영역은 올렸을 때만 "머리말 추가"가 보인다.
+ */
+.pageHeader, .pageFooter { position: absolute; inset-inline: 0; box-sizing: border-box; padding-inline: var(--pg-margin-left) var(--pg-margin-right);
+ pointer-events: auto; color: var(--color-text-secondary); font-size: 0.75rem; border-color: transparent; border-style: dashed; border-width: 0; }
+.pageHeader { inset-block-start: 0; block-size: var(--pg-margin-top); padding-block-start: var(--pg-header-distance); align-items: flex-start; border-block-end-width: 1px; }
+.pageFooter { inset-block-end: 0; block-size: var(--pg-margin-bottom); padding-block-end: var(--pg-footer-distance); align-items: flex-end; border-block-start-width: 1px; }
+.pageHeader[data-editable]:is(:hover, [data-active]), .pageFooter[data-editable]:is(:hover, [data-active]) {
+ border-color: var(--color-border-emphasized); background: color-mix(in oklab, transparent, var(--color-text-primary) 3%); cursor: text; }
+.pageAreaText { flex: 1 1 0; min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.75rem; color: var(--color-text-secondary);
+ font-variant-numeric: tabular-nums; }
+.pageAreaText[data-align="center"] { text-align: center; }
+.pageAreaText[data-align="end"] { text-align: end; }
+.pageAreaPlaceholder { font-size: 0.75rem; color: var(--color-text-secondary); opacity: 0; }
+.pageAreaEdit.pageAreaEdit { position: absolute; inset-inline-end: 0.75rem; z-index: 2; border-radius: 50%; opacity: 0; box-shadow: 0 1px 3px var(--color-shadow); }
+.pageHeader .pageAreaEdit { inset-block-end: 0.25rem; }
+.pageFooter .pageAreaEdit { inset-block-start: 0.25rem; }
+.pageHeader[data-editable]:is(:hover, :focus-within, [data-active]) :is(.pageAreaEdit, .pageAreaPlaceholder),
+.pageFooter[data-editable]:is(:hover, :focus-within, [data-active]) :is(.pageAreaEdit, .pageAreaPlaceholder) { opacity: 1; }
+/* 블록 앞 간격은 높이 없이 margin만, 문단 안 간격은 줄 하나 크기의 빈 상자, 표 안 간격은 테두리 없는 빈 행이다. */
+.canvas :global(.pg-gap) { display: block; block-size: 0; padding: 0; border: 0; }
+.canvas :global(span.pg-line-gap) { display: block; }
+.canvas :global(tr.pg-row-gap) { display: table-row; block-size: auto; }
+.canvas :global(tr.pg-row-gap > td) { padding: 0; border: 0; background: transparent; }
+.canvas :global(.pg-measuring .pg-gap) { display: none; }
+
+/* 문서 편집기: 위에 붙은 서식 막대와 그 아래 책상(회색 바탕 위 가운데 종이). */
+/* 문서 편집기는 페이지 본문 높이를 채우고, 본문·설정 칸이 각자 스크롤한다(그래야 칸 사이 경계선이 위아래 끝까지 이어진다). */
+.document { flex: 1; block-size: 100%; min-inline-size: 0; min-block-size: 0; }
+.above { box-sizing: border-box; max-inline-size: 100%; }
+/* 책상: 종이를 가운데 둔다. 종이는 폭에 맞춰 줄어들므로 가로 스크롤을 두지 않는다(스크롤바가 생겼다 사라지며 흔들리지 않게). */
+.desk { flex: 1; min-inline-size: 0; min-block-size: 0; align-items: center; padding: var(--spacing-6) var(--spacing-4) var(--spacing-10); background: var(--color-background-surface);
+ overflow-x: clip; overflow-y: auto; scrollbar-gutter: stable; }
+.pageless { flex: 1; min-inline-size: 0; min-block-size: 0; align-items: center; padding: var(--spacing-6) var(--spacing-4) var(--spacing-10); background: var(--color-background-surface);
+ overflow-y: auto; scrollbar-gutter: stable; }
+.column { inline-size: 100%; max-inline-size: 45rem; }
+.toolbarRow { flex: none; position: relative; z-index: 6; }
+.body { flex: 1; min-block-size: 0; align-items: stretch; overflow: hidden; }
+/* 상세 본문 안에 놓인 문서 보기: 스스로 스크롤하지 않고 페이지와 함께 내려간다(상세 본문 열 폭에 종이를 맞춘다). */
+/* 상세 본문 안: 판(바깥 바탕) 없이 종이가 본문 열 폭을 채운다. 연한 바탕은 종이 자체를 테마에 맞춰 옅게 칠하고 페이지 사이 틈으로 가른다. */
+.inline { min-block-size: auto; overflow: visible; padding: 0; scrollbar-gutter: auto; }
+.desk.inline:has(.canvas[data-look="tinted"]) { background: none; }
+.inline .canvas[data-look="tinted"] .sheet { background: color-mix(in oklab, var(--color-background-surface), var(--color-text-primary) 3%); border-color: transparent; }
+/* 손으로 키운 배율이 칸보다 넓으면 그때만 가로로 민다(메뉴를 여닫을 때 스크롤바가 바뀌지 않는다). */
+.desk[data-wide] { overflow-x: auto; align-items: flex-start; }
+.zoom { flex: none; }
+.zoomGlyph { font-size: 1rem; line-height: 1; }
+/* 문서 설정 칸: 오른쪽에 붙어 본문과 따로 스크롤한다. */
+.settings { flex: none; align-self: stretch; box-sizing: border-box; inline-size: 20rem; min-block-size: 0;
+ border-inline-start: 1px solid var(--color-border); background: var(--color-background-surface); }
+.settingsHead { padding: var(--spacing-3) var(--spacing-3) var(--spacing-2); }
+.settingsBody { flex: 1; min-block-size: 0; overflow-y: auto; scrollbar-width: thin; padding-block-end: var(--spacing-6); }
+.settingsSection { padding: var(--spacing-4); border-block-start: 1px solid var(--color-border); }
+.settingsPair > * { flex: 1 1 0; min-inline-size: 0; }
+
+@media print {
+ /* 이 문서만 인쇄한다: 문서를 품지도 문서 안에 있지도 않은 것은 감추고, 문서를 품은 앱 틀은 높이·스크롤·여백을 풀어 모든 페이지가 이어지게 한다. */
+ :global(body:has([data-print-root]) :not(:has([data-print-root])):not([data-print-root]):not([data-print-root] *)) { display: none !important; }
+ :global(:has([data-print-root])) { display: block !important; position: static !important; overflow: visible !important;
+ block-size: auto !important; min-block-size: 0 !important; max-block-size: none !important; inline-size: auto !important; max-inline-size: none !important;
+ margin: 0 !important; padding: 0 !important; border: 0 !important; box-shadow: none !important; background: none !important; zoom: 1 !important; }
+ .sheets, .toolbarRow, .settings { display: none; }
+ /* 강제 페이지 넘김(break-before)은 flex 상자 안에서는 듣지 않는다. 문서에서 본문까지 이어지는 상자를 모두 일반 블록으로 둔다. */
+ :global([data-print-root]:has(.ProseMirror)) { display: block !important; }
+ :global([data-print-root] :has(.ProseMirror)) { display: block !important; min-block-size: 0 !important; block-size: auto !important; overflow: visible !important; }
+ .desk, .pageless { padding: 0; background: none; overflow: visible; }
+ .canvas { inline-size: auto; min-block-size: 0; padding: 0; zoom: 1 !important; }
+ .canvas :global(.pg-gap), .canvas :global(tr.pg-row-gap) { display: none; }
+ /* 문단 안의 나눔은 인쇄가 줄 단위로 알아서 한다: 인쇄의 글자 폭이 화면과 조금 달라(약 2%) 화면의 줄 자리에 빈 줄을 두면 빈 공간이 생긴다. */
+ .canvas :global(span.pg-line-gap) { display: none; }
+ .canvas :global(p) { orphans: 2; widows: 2; }
+ .canvas :global(.pg-start) { break-before: page; }
+ /* 긴 표는 행 사이에서 넘긴다(편집기 기본 인쇄는 표를 통째로 둔다). */
+ .canvas :global(:is(table, tr)) { break-inside: auto; }
+ .canvas :global(tr) { break-inside: avoid; }
+}
diff --git a/site/src/shared/ui/composites/document/hooks.ts b/site/src/shared/ui/composites/document/hooks.ts
new file mode 100644
index 0000000..7ea81a3
--- /dev/null
+++ b/site/src/shared/ui/composites/document/hooks.ts
@@ -0,0 +1,31 @@
+import { useMediaQuery } from '@astryxdesign/core/hooks';
+import { useLayoutEffect, useRef, useState } from 'react';
+import { PHONE_QUERY } from '../../foundations/media';
+
+/**
+ * 화면이 휴대폰 폭인지(인쇄 중에는 아니다). 인쇄할 때는 종이 폭이 화면 폭이 되어 PHONE_QUERY가 참이 되는데, 그때 페이지 없음으로
+ * 다시 그리면 화면에서 나눈 페이지 경계가 사라진다. 문서 부품은 화면(screen)에서만 휴대폰을 가른다.
+ */
+export function useIsPhoneScreen() {
+ return useMediaQuery(`screen and ${PHONE_QUERY}`);
+}
+
+/** 종이가 들어갈 칸이 종이보다 좁으면 종이를 줄일 배율. 칸이 넓으면 1이다(키우지 않는다). pageWidth는 기준 글자 16px일 때의 px다. */
+export function useFitZoom(pageWidth: number, padding = 48) {
+ const ref = useRef(null);
+ const [zoom, setZoom] = useState(1);
+ useLayoutEffect(() => {
+ const element = ref.current;
+ if (!element) return undefined;
+ // 종이 폭은 rem이라 큰 화면에서 기준 글자와 함께 커진다. 실제 화면 폭으로 맞춘다.
+ const fit = () => {
+ const rem = parseFloat(getComputedStyle(element.ownerDocument.documentElement).fontSize) / 16 || 1;
+ setZoom(Math.min(1, Math.max(0.4, (element.clientWidth - padding) / (pageWidth * rem))));
+ };
+ fit();
+ const observer = new ResizeObserver(fit);
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [pageWidth, padding]);
+ return { ref, zoom };
+}
diff --git a/site/src/shared/ui/composites/document/model.ts b/site/src/shared/ui/composites/document/model.ts
new file mode 100644
index 0000000..3442a60
--- /dev/null
+++ b/site/src/shared/ui/composites/document/model.ts
@@ -0,0 +1,81 @@
+/**
+ * 문서 편집기의 모델: 보기 모드, 페이지 설정(문서와 함께 저장), 페이지 모양(보는 사람의 화면 설정), 그리고 페이지 나누기가 쓰는 px 크기.
+ * 화면 부품과 페이지 나누기 확장은 이 파일의 타입만 공유한다. 문서 본문(JSON)은 페이지를 모른다.
+ */
+
+/** 페이지(나눠 보기)와 페이지 없음(나누지 않고 한 열로 이어 보기). */
+export type DocumentMode = 'pages' | 'pageless';
+
+/** 1mm의 CSS px(96dpi). */
+export const MM = 96 / 25.4;
+
+/** 용지 크기(mm, 세로 기준). */
+export const PAPERS = { A4: { width: 210, height: 297 }, A5: { width: 148, height: 210 }, Letter: { width: 215.9, height: 279.4 } } as const;
+export type Paper = keyof typeof PAPERS;
+
+/**
+ * 문서의 페이지 설정: 용지·방향·여백(mm)·페이지 사이 간격(px), 머리말(왼쪽·오른쪽)·바닥글(왼쪽·가운데·오른쪽)과 가장자리에서의 거리(mm).
+ * 머리말·바닥글 글에는 {page}(이 페이지 번호)와 {total}(전체 페이지 수)을 쓸 수 있다. 본문과 따로 저장한다:
+ * 같은 본문을 다른 용지로 다시 나눌 수 있다.
+ */
+export type PageSetup = {
+ paper: Paper;
+ orientation: 'portrait' | 'landscape';
+ margins: { top: number; bottom: number; left: number; right: number };
+ gap: number;
+ header: { left: string; right: string };
+ footer: { left: string; center: string; right: string };
+ headerDistance: number;
+ footerDistance: number;
+ /** 첫 페이지(표지)에는 머리말·바닥글을 두지 않는다. */
+ differentFirstPage: boolean;
+};
+
+export const DEFAULT_PAGE_SETUP: PageSetup = {
+ paper: 'A4', orientation: 'portrait', margins: { top: 25, bottom: 25, left: 22, right: 22 }, gap: 24,
+ header: { left: '', right: '' }, footer: { left: '', center: '', right: '{page} / {total}' }, headerDistance: 12, footerDistance: 12,
+ differentFirstPage: false,
+};
+
+/**
+ * 페이지 보기의 모양(보는 사람의 화면 설정, 문서에는 저장하지 않는다).
+ * tinted: 바깥(상세 안에서는 페이지 자체)을 테마에 맞춰 아주 옅게 다른 색으로 두어 바탕색 차이로 페이지를 가른다.
+ * divider: 같은 바탕에 페이지 사이에만 가로선을 둔다(line: 실선·점선·점·물결).
+ */
+export type PageLook = { style: 'tinted' | 'divider'; line: 'solid' | 'dashed' | 'dotted' | 'wavy' };
+export const DEFAULT_PAGE_LOOK: PageLook = { style: 'tinted', line: 'solid' };
+
+/** 페이지 크기와 여백(CSS px, 기준 글자 16px). 페이지 나누기와 종이 틀이 쓴다. name이 같으면 같은 배치다. */
+export type PageFormat = { name: string; width: number; height: number; marginTop: number; marginBottom: number; marginLeft: number; marginRight: number; gap: number };
+
+/** 방향을 반영한 용지 크기(mm). */
+function paperSize(setup: PageSetup): [width: number, height: number] {
+ const paper = PAPERS[setup.paper];
+ return setup.orientation === 'portrait' ? [paper.width, paper.height] : [paper.height, paper.width];
+}
+
+/** 페이지 설정을 페이지 나누기·종이 틀이 쓰는 px 크기로 바꾼다. */
+export function formatOf(setup: PageSetup): PageFormat {
+ const [width, height] = paperSize(setup), { margins: m } = setup;
+ return {
+ name: `${setup.paper}-${setup.orientation}-${m.top}-${m.bottom}-${m.left}-${m.right}`,
+ width: width * MM, height: height * MM, gap: setup.gap,
+ marginTop: m.top * MM, marginBottom: m.bottom * MM, marginLeft: m.left * MM, marginRight: m.right * MM,
+ };
+}
+
+/** 머리말·바닥글 글의 {page}·{total}을 채운다. */
+export const fillPageTokens = (text: string, page: number, total: number) => text.replaceAll('{page}', String(page)).replaceAll('{total}', String(total));
+
+/** 인쇄용 @page 규칙: 용지·여백과 머리말·바닥글(쪽 번호는 인쇄의 counter로). 종이 틀이