From 3aa4176ad65814e5804876e57064f397291adcf8 Mon Sep 17 00:00:00 2001 From: dev-goraebap Date: Tue, 10 Nov 2026 21:34:12 +0900 Subject: [PATCH] =?UTF-8?q?Webcraft=200.11.0:=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EB=82=98=EB=88=A0=20=EC=93=B0=EA=B3=A0=20?= =?UTF-8?q?=EC=9D=BD=EB=8A=94=20=EB=AC=B8=EC=84=9C=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 키트 composites/document: 편집 DocumentEditor·읽기 DocumentView, 페이지 나누기 확장(블록·목록 항목·표 행·긴 문단 줄, 제목은 다음 블록과 함께, 외톨이 줄 방지), 종이 틀과 머리말·바닥글({page}/{total}), 문서 설정 칸(용지·방향·여백·간격), 페이지 모양(연한 바탕·구분선 4종), 확대/축소, 내보내기(onSavePdf 주입), 상세 정보 칸의 페이지 항목. 모델은 model.ts, 편집과 읽기의 연결은 usePagedFrame. - 키트 composites/editor: 본문 밖에 두는 상단 서식 막대(EditorToolbar), RichEditor에 확장·틀·핸들을 끼우는 자리. 아이콘 자리 16px 고정과 display: contents 간격 때문에 깨지던 명령 목록·블록 메뉴·색 단추를 고친다. - Playground 문서 편집기: 전체 페이지 편집(저장 상태·저장·취소·완료), 상세 배치의 읽기, 견본 테스트. - 지침: composed-components의 문서 편집기 절(본문 칸과 달리 상단 막대를 두는 예외), pattern-catalog의 Playground 17개, print의 페이지로 나눈 문서(브라우저 인쇄 경계는 화면과 다를 수 있음). Co-Authored-By: Claude Opus 5.5 --- .../webcraft/.claude-plugin/plugin.json | 2 +- .../dist/claude/plugins/webcraft/package.json | 2 +- .../references/composed-components.md | 17 + .../references/pattern-catalog.md | 2 +- .../skills/design-system/references/print.md | 6 + .../webcraft/.codex-plugin/plugin.json | 2 +- .../dist/codex/plugins/webcraft/package.json | 2 +- .../references/composed-components.md | 17 + .../references/pattern-catalog.md | 2 +- .../skills/design-system/references/print.md | 6 + plugins/webcraft/package.json | 2 +- .../references/composed-components.md | 17 + .../references/pattern-catalog.md | 2 +- .../skills/design-system/references/print.md | 6 + .../catalog/playground/document-editor.mdx | 19 ++ site/content/catalog/playground/overview.mdx | 1 + .../playground/DocumentEditor.samples.tsx | 17 + .../playground/DocumentEditor.test.tsx | 199 ++++++++++++ .../document-editor/app/DocumentEditorApp.tsx | 43 +++ .../playground/document-editor/app/store.tsx | 27 ++ .../playground/document-editor/mocks/seed.ts | 42 +++ .../pages/DocumentEditPage.tsx | 72 +++++ .../pages/DocumentReadPage.tsx | 56 ++++ .../pages/document-editor.module.css | 3 + site/src/shared/ui/README.md | 5 +- .../ui/composites/document/DocumentEditor.tsx | 74 +++++ .../composites/document/DocumentSettings.tsx | 112 +++++++ .../ui/composites/document/DocumentView.tsx | 42 +++ .../ui/composites/document/ExportMenu.tsx | 31 ++ .../ui/composites/document/PageCanvas.tsx | 64 ++++ .../ui/composites/document/PageLookMenu.tsx | 35 +++ .../ui/composites/document/PagesPill.tsx | 17 + .../ui/composites/document/ZoomControl.tsx | 26 ++ .../composites/document/document.module.css | 105 +++++++ .../shared/ui/composites/document/hooks.ts | 31 ++ .../shared/ui/composites/document/model.ts | 81 +++++ .../ui/composites/document/pagination.ts | 291 ++++++++++++++++++ .../ui/composites/document/usePagedFrame.tsx | 38 +++ .../ui/composites/editor/EditorToolbar.tsx | 138 +++++++++ .../ui/composites/editor/RichEditor.tsx | 33 +- .../ui/composites/editor/SelectionMenu.tsx | 8 +- .../ui/composites/editor/editor.module.css | 23 +- .../src/shared/ui/composites/editor/schema.ts | 6 + site/src/shared/ui/manifest.json | 4 +- 44 files changed, 1703 insertions(+), 25 deletions(-) create mode 100644 site/content/catalog/playground/document-editor.mdx create mode 100644 site/samples/playground/DocumentEditor.samples.tsx create mode 100644 site/samples/playground/DocumentEditor.test.tsx create mode 100644 site/samples/playground/document-editor/app/DocumentEditorApp.tsx create mode 100644 site/samples/playground/document-editor/app/store.tsx create mode 100644 site/samples/playground/document-editor/mocks/seed.ts create mode 100644 site/samples/playground/document-editor/pages/DocumentEditPage.tsx create mode 100644 site/samples/playground/document-editor/pages/DocumentReadPage.tsx create mode 100644 site/samples/playground/document-editor/pages/document-editor.module.css create mode 100644 site/src/shared/ui/composites/document/DocumentEditor.tsx create mode 100644 site/src/shared/ui/composites/document/DocumentSettings.tsx create mode 100644 site/src/shared/ui/composites/document/DocumentView.tsx create mode 100644 site/src/shared/ui/composites/document/ExportMenu.tsx create mode 100644 site/src/shared/ui/composites/document/PageCanvas.tsx create mode 100644 site/src/shared/ui/composites/document/PageLookMenu.tsx create mode 100644 site/src/shared/ui/composites/document/PagesPill.tsx create mode 100644 site/src/shared/ui/composites/document/ZoomControl.tsx create mode 100644 site/src/shared/ui/composites/document/document.module.css create mode 100644 site/src/shared/ui/composites/document/hooks.ts create mode 100644 site/src/shared/ui/composites/document/model.ts create mode 100644 site/src/shared/ui/composites/document/pagination.ts create mode 100644 site/src/shared/ui/composites/document/usePagedFrame.tsx create mode 100644 site/src/shared/ui/composites/editor/EditorToolbar.tsx 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 = } + 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 ?