From 819ab9b4bb0e118135daf1a809173bae3be556de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Tue, 8 Sep 2026 07:57:57 +0900 Subject: [PATCH 01/24] =?UTF-8?q?docs:=20=ED=8E=B8=EC=A7=91=20=EC=82=AC?= =?UTF-8?q?=EB=A1=80=EC=97=90=EC=84=9C=20=EC=B5=9C=EC=86=8C=20=EB=AC=B8?= =?UTF-8?q?=EB=B2=95=EA=B3=BC=20=EC=A1=B0=ED=95=A9=20=ED=95=98=EC=9D=B4?= =?UTF-8?q?=EB=9D=BC=ED=82=A4=EB=A5=BC=20=EB=8F=84=EC=B6=9C=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- standards/editing-grammar.md | 257 +++++++++++++++++++++++++++++++---- 1 file changed, 228 insertions(+), 29 deletions(-) diff --git a/standards/editing-grammar.md b/standards/editing-grammar.md index 1cdc03b3..84e71cf3 100644 --- a/standards/editing-grammar.md +++ b/standards/editing-grammar.md @@ -18,10 +18,11 @@ member와 편집 문법의 크기는 서로 다른 문제다. - Don't: 이번 설계로 Core API를 늘리거나 기존 동작을 변경하지 않는다. 새로운 범용 editor, command bus, profile registry나 package hierarchy를 만들지 않는다. -구현 근거의 범위는 JSON Document, Selection, Editing, Affordance, Web과 -Document·Sheet·Rich Text의 대표 편집 경로다. Object의 전체 선택과 Database의 -cut 미지원은 profile 차이를 판정하는 사례로 포함한다. 모든 Hands의 완성도, -브라우저별 입력 일치, 협업 wire protocol의 수렴은 이 설계의 검증 결과가 아니다. +아래 도출은 11개 내부 편집 사례의 model·의도·조합과 외부 관습을 비교한다. +실행된 공통 적합성 증거의 범위는 Document·Sheet·Rich Text의 대표 편집 경로와 +Selection·Editing·Affordance·Web 연결이다. 사례를 읽은 것과 같은 profile의 +독립 구현을 검증한 것은 구별한다. 모든 Hands의 완성도, 브라우저별 입력 일치, +협업 wire protocol의 수렴은 이 설계의 검증 결과가 아니다. ## 현재 문제와 원인 @@ -30,39 +31,207 @@ cut 미지원은 profile 차이를 판정하는 사례로 포함한다. 모든 H `dispatch` 형태이며, 동사의 사전 조건과 결과는 editor별 코드와 문서에 있다. `Official Hands`도 아직 호환성 약속을 확정하지 않은 후보다. -이 때문에 여러 editor가 같은 API 형태를 제공해도 같은 편집 문법을 따른다는 -증거가 되지 않는다. `selectAllAffordance`의 전체 선택 토글처럼 특정 관습이 -범용 기본값으로 보이고, `selection.move`처럼 문서 변경과 선택 변경이 이름에서 -구별되지 않는 사례도 있다. 동작을 선언하는 규칙과 그 규칙을 검증하는 사례의 -연결이 부족한 것이 원인이다. +현재 공용 적합성 runner는 대표 editor의 공통 의미를 검증한다. 그 증거를 다른 +장르로 확대하려면 규칙의 적용 조건과 profile 차이가 먼저 드러나야 한다. +`selectAllAffordance`의 전체 선택 토글과 선택 의도의 멱등성, +`selection.move`라는 이름 아래의 블록 이동과 선택 위치 이동도 구별해야 한다. +아직 남은 공백은 앱 사례에서 공통 의미를 도출하는 근거, 중첩된 편집 맥락의 +계약, 같은 profile의 독립 구현 증거다. -## 세 종류의 계약과 기존 소유자 +## 앱 사례에서 최소 문법 도출하기 -다음은 새 runtime 계층이 아니라 기존 책임들이 약속할 내용의 구분이다. +### 명령 이름보다 대상과 결과를 먼저 비교한다 -| 계약 | 고정할 의미 | 정본 소유자 | +Apple의 [1992년 Macintosh Human Interface Guidelines](https://www.vintageapple.org/inside_r/pdf/Human_Interface_Guidelines_1992.pdf) +인쇄 쪽수 286–300은 선택 후 작업하는 관습과 텍스트·배열·그래픽 선택을 구분하고, +필드 전체 선택과 내부 텍스트 편집의 차이도 설명한다. Windows의 +[표준 Edit 메뉴](https://learn.microsoft.com/en-us/windows/win32/uxguide/cmd-menus#standard-menus)에도 +같은 명령 어휘가 반복된다. 전자는 역사적 제품 지침, 후자는 Windows 7 시대의 +플랫폼 지침이다. 오래된 공통 어휘의 근거이며 모든 동작이 30년간 동일했다는 +증명은 아니다. 사람의 편집 관습에서 도출하는 것은 관찰 가능한 의미다. Core의 +여섯 member, JSON Patch 표현, package 구조가 그 관습의 유일한 구현이라는 +결론은 나오지 않는다. + +다음 표는 현재 구현의 표본이다. 링크는 각 책임의 구현으로 연결된다. 마지막 +열의 중첩 가능성이 모두 구현·검증됐다는 뜻은 아니며 현재 지원과 분리해 읽는다. + +| 사례 | 대상·위치·선택 | 현재 대표 작업 | 도출에 필요한 차이 또는 중첩 | +| --- | --- | --- | --- | +| [Document](../packages/json-document-editing/src/document.ts) | block ID + text offset, 여러 범위 | 블록 이동·삭제·복사, 블록 text 교체 | Copy는 전체 블록. native 내부 문자열 선택과 대상이 다름 | +| [Rich Text](../packages/json-document-rich-text/src/editor.ts) | node ID + text/child point + affinity, 여러 범위 | text 삽입, structured slice 대체, mark 변경 | collapsed point에서도 삽입 가능. schema가 허용하는 구간·구조를 함께 판단 | +| [Order](../packages/json-document-editing/src/order.ts) | item ID, 선형 순서의 범위 | 이름 변경, 항목 제거·복사·삽입 | 항목 선택과 이름 입력 중 문자열 선택을 구별 | +| [Tree](../packages/json-document-editing/src/tree.ts) | node ID + parent 관계, visible topology의 범위 | 노드 선택·제거·복사·삽입 | Copy·제거는 선택한 노드의 후손까지 포함. 보이는 선택과 실제 영향 집합이 다름 | +| [Sheet](../packages/json-document-editing/src/sheet.ts) | row/column ID, 셀 직사각형과 primary | 셀 commit, fill, Cut·Paste | Cut은 primary 값을 비움. 셀 구조를 제거하거나 내부 문자열을 잘라내는 작업과 다름 | +| [Database](../packages/json-document-editing/src/database.ts) | record/property ID, view가 제공하는 표 topology | cell commit, record 추가·삭제, view configure, Paste | 셀 값과 record 구조, 저장되는 view 설정을 구별. 현재 Cut 부재는 보편 규칙이 아님 | +| [Object](../packages/json-document-editing/src/object.ts) | object ID 집합, x/y/width/height | translate, resize, fill, Copy·Paste | 선택 위치의 이동과 객체 좌표 변경은 다름. 현재 객체 model이 완전한 중첩 text editor는 아님 | +| [Kanban](../packages/json-document-editing/src/kanban.ts) | card ID 집합, column ID + beforeCardId | 카드 이동·제거 | 이동은 소속과 순서를 바꿈. 보드 모양만으로 새 공통 선택 family를 요구하지 않음 | +| [Calendar](../packages/json-document-editing/src/calendar.ts) | event ID + occurrenceStart, 구체화된 occurrence 범위 | 생성·이동·resize, Copy·Paste | 선택한 occurrence와 반복 일정 변경 범위는 다름. 시간·반복 규칙은 domain 의미 | +| [Annotation](../packages/json-document-editing/src/annotation.ts) | annotation ID 집합, source ID + selector | 생성·본문 변경·이동·resize·삭제 | 편집되는 annotation과 가리키는 source 영역을 구별. 원본 이미지 픽셀 편집 사례가 아님 | +| [Composer](../packages/json-document-composer/src/commands.ts) | Rich Text point, reference atom, attachment ID | text·reference 삽입, attachment·model 변경 | 여러 편집 대상을 한 draft에서 조합. 제출·외부 실행의 효과는 별도 Application 계약 | + +11개 사례는 같은 레포의 구현이며 다수가 같은 EditingSession을 쓴다. 그 반복은 +내부 재사용 가능성의 증거다. 서로 다른 생태계가 독립적으로 같은 의미를 채택했다는 +증거로 11번 세지 않는다. Raster·파일 이동은 아래 외부 반례로만 사용한다. + +### 하나의 편집을 설명하는 최소 질문 + +의미를 설명하는 단위는 **작업을 받을 편집 맥락 + 대상·위치 + 의도 → 결과**다. +API 인자에 모두 넣으라는 뜻은 아니다. 상태나 명시 인자로 이미 정해지는 것은 +그 계약에서 읽으면 된다. 한 범용 state 객체나 command envelope를 추가하지 않는다. + +```text +편집의 의미 +├─ 대상: 무엇의 내용·속성·구조를 다루는가 +│ ├─ identity: 같은 대상을 어떻게 알아보는가 +│ └─ position / topology: 어디이며 어떤 순서·관계로 해석하는가 +├─ 작업 맥락: 어느 편집기가 무엇을 대상으로 받는가 +│ ├─ selection: 대상 집합·범위·영역 또는 삽입점 +│ ├─ 입력 focus: 현재 입력을 받는 곳 +│ └─ 중첩: 객체 / 셀 / 그 안의 텍스트 사이의 진입·복귀 +├─ 의도: 탐색·선택 / 내용·구조 변경 / 교환 / 복원 +└─ 결과와 작업 경계 + ├─ 진행 중인 draft·preview / 확정 / 취소·거절 + ├─ 변경된 내용과 후속 선택·위치, 교환 payload + └─ 관찰 시점과 Undo/Redo의 대상·단위 +``` + +이 트리는 질문의 분류다. 상속 관계나 새 runtime 모듈 목록이 아니다. 다음 구별을 +없애면 실제 사례의 결과를 설명할 수 없다. + +| 구별 | 없애면 설명할 수 없는 사례 | 남길 계약 / 기존 연결 | | --- | --- | --- | -| 공통 편집 규칙 | 선택 전이, 편집의 원자성, 복사와 History의 관계 | Selection·Editing | -| Hands profile | 편집 대상, 범위 해석, 삭제·붙여넣기 결과, 지원 작업 | Document Type과 해당 editor의 기존 owner를 조합한 Hands | -| 입력 매핑 | 키·포인터·IME를 어떤 편집 의도로 해석하는가 | Adapter·Affordance와 framework Connector | +| Identity와 position | 같은 블록의 offset 변경, 이동한 같은 카드, 같은 event의 여러 occurrence | Point 단위·동등성·mapping은 해당 editor와 Selection. EG-TARGET | +| Selection과 실제 작업 대상 | `annotation.create`의 새 대상, `record.delete(recordId)`의 명시 대상, Tree의 선택된 부모와 숨은 후손 | 대상은 selection·명시 인자·삽입 위치와 의도로 정한다. 모든 작업에 non-empty selection을 강제하거나 효과를 보이는 선택에만 제한하지 않음 | +| 입력 focus와 range의 `focus` | 표 셀 입력 중 문자열의 역방향 선택 | 전자는 입력을 받는 곳, 후자는 범위의 한 endpoint. 이름이 같아도 같은 상태가 아님. EG-SELECT·EG-TARGET | +| 저장 내용과 보이는 topology | 접힌 Tree, 정렬된 Database, 저장되는 `view.configure` | 가시 순서를 읽는 것과 view 설정을 수정하는 것은 다른 작업. EG-TARGET·EG-EDIT | +| 요청·진행 중 작업과 확정된 편집 | 여러 pointer preview, IME composition, 여러 입력을 묶는 History | 사건 하나·dispatch 하나·commit 하나·Undo 한 step은 일대일이 아님. EG-GESTURE·EG-HISTORY·DOM lifecycle | +| 작업 결과와 이후 현재 상태 | 구독 중 재진입, 외부 변경 이후 Undo | 결과의 관찰 시점과 선택한 History owner의 복원 의미를 보존. EG-RESULT·EG-HISTORY | + +문자마다 stable ID가 있어야 한다거나 모든 위치를 숫자 하나로 표현해야 한다는 +결론은 나오지 않는다. [Rich Text point](../packages/json-document-rich-text/src/model.ts)의 +text/child 구별·affinity와 [Selection family](../packages/json-document-selection/src/core/family.ts)의 +transition·map·reconcile·targets가 이미 이 차이의 정본 경계를 제공한다. + +### 중첩은 입력을 받을 곳의 계약이다 + +[W3C APG Grid](https://www.w3.org/WAI/ARIA/apg/patterns/grid/#editing-and-navigating-inside-a-cell)는 +셀 탐색에 쓰는 화살표와 셀 안의 caret·widget 조작을 구분한다. +[Keynote의 텍스트 상자](https://support.apple.com/en-ca/guide/keynote/tan4fd6ee725/mac)는 +객체 자체와 그 안의 텍스트를 따로 선택한다. 두 자료는 지침·제품 관습의 근거이며 +범용 editing scope API를 규정하지 않는다. ```text -keyboard / pointer / IME ─ Adapter·Affordance ─┐ -직접 호출하는 프로그램 ──────────────────────┤ - ↓ - 해당 editor의 편집 의도 - + Selection / Topology - ↓ - EditingPlan + selectionAfter - ↓ - EditingSession → JSONDocument.commit - ↓ - 해당 편집의 snapshot / History +Application +└─ 표를 다루는 Hand + ├─ 셀 선택: Copy → 셀 payload, Delete 입력 → 선택한 셀 정책 + └─ 셀 내부 편집 + └─ 텍스트 선택: Copy → 문자열, Delete 입력 → 문자 편집 + +Canvas Hand +└─ 텍스트 객체 선택 + └─ 내부 텍스트 편집 ``` -이는 대표적인 값 변경 흐름이다. Copy는 조회이며, 선택만 바꾸는 작업은 document -commit을 만들지 않고, Undo/Redo는 선택한 History owner를 실행한다. 순수 JSON -소비자에게 Editing이나 Hands 설치를 요구하지 않는다. +중첩을 조합하는 profile은 진입·복귀, 입력 수신자, 지원하지 않거나 거절한 의도의 +전달 여부, 후속 선택을 결정해야 한다. 내부 편집기가 삭제를 거절했다는 이유만으로 +바깥 객체 삭제가 실행되는 해석을 허용할지 명시해야 한다. 이 설계의 후보는 +**해당 맥락이 맡은 편집의 거절을 바깥의 다른 편집으로 암묵 변환하지 않는 것**이다. +처리하지 않은 입력의 명시적 위임과 실패를 구별하며, 정확한 키·진입 방법은 입력 +profile이 정한다. 아직 전체 Hands에 실행 검증된 새 규칙은 아니다. + +여기서 편집 맥락은 HTML `editing host`, DOM element, JSON subtree, History +instance와 일대일 대응하지 않는다. Toolbar로 focus가 옮겨져도 편집 명령은 저장된 +선택을 대상으로 할 수 있다. Headless 호출은 DOM focus 없이 대상을 지정할 수 있다. +Calendar intent의 `scope: "this" | "this-and-following" | "all"`은 반복 일정의 +**변경 범위**이며 이 입력 맥락과 다른 개념이다. + +현재 [DocumentTextControl](../packages/json-document-react/src/use-document-text-control.ts)은 +native range의 방향을 투영하고, [Web virtual selection scope](../packages/json-document-web/src/virtual-selection-scope.ts)는 +자신의 영역에서 전체 텍스트 선택·Copy와 editable target 우선 처리를 맡는다. +이는 기존 소유자의 증거이며 둘 중 하나를 모든 편집 맥락의 정본이라고 선언할 +근거는 아니다. 범용 scope registry나 Host별 dispatcher를 새로 만들지 않는다. + +### 반례가 정하는 공통 규칙의 경계 + +| 과도한 일반화 | 근거 있는 반례 | 이 설계의 처리 | +| --- | --- | --- | +| 선택은 언제나 Undo 대상이 아님 | [GIMP Undoing](https://docs.gimp.org/3.0/en/gimp-concepts-undo.html)은 Scissors Select 작업과 Quick Mask 전환의 Undo를 설명 | 현재 일시적 Selection의 EG-SELECT·EG-HISTORY를 유지. Raster 선택 제작의 History 정책으로 일반화하지 않음 | +| 이동은 항상 Copy 후 즉시 제거한 뒤 Paste | [Finder 이동](https://support.apple.com/en-gb/102650)은 Clipboard의 파일을 목적지에 옮기는 동작을 제공 | EG-CUT은 현재 즉시 제거하는 cut 계약. 지연 이동은 같은 계약의 이름만 다른 구현이 아님 | +| 복사한 저장 값을 그대로 넣으면 의미도 보존 | [Excel 수식 이동·복사](https://support.microsoft.com/en-us/excel/move-or-copy-a-formula-in-excel)는 복사 시 상대 참조 변경, 수식 이동 시 참조 유지를 구별 | payload의 참조·identity·배치 변환은 profile 계약. JSON 모양이나 text 일치만으로 교환 호환성을 판정하지 않음 | +| Selection은 항상 문자열의 시작과 끝 | 레포의 Sheet rectangle, Calendar materialized occurrences와 Selection mask 확장 | 대상별 family를 유지. mask algebra의 존재는 raster Hand 완성의 증거가 아님 | +| 입력이 끝나면 Undo 한 step | 여러 preview와 typing/composition grouping, GIMP의 선택 도구 작업 | 작업의 확정·취소와 History grouping을 따로 명시 | + +공통 의미로 남는 것은 대상과 위치의 구별, 같은 의도의 명시된 결과, 효과의 +관찰·실패 경계다. 구체적인 선택 모양, 복사할 내용, 붙여넣기 위치, 영향을 받는 +참조, 기록할 상태와 작업 묶음은 profile이 채운다. Copy의 source 내용 보존과 +clipboard 자체의 변경도 별개의 효과다. 원자적 문서 편집은 이 레포가 제공하는 +강한 계약이며 모든 제품이 이미 같은 실패 보장을 구현했다는 역사적 주장은 아니다. + +## 하이라키: 구체화와 조합 + +고정할 공통 의미와 profile이 결정할 내용을 먼저 나눈다. 그 다음 기존 모듈이 +어떤 부분을 실현하는지 연결한다. 앱 이름과 package 폴더 순서에서 의미의 상속 +관계를 역으로 추정하지 않는다. + +```text +편집 계약 +├─ 공통 규칙: 대상·선택·효과·관찰·실패의 의미 +├─ 대상별 구체화 +│ └─ model / 유효한 position / selection·topology / 연산·교환·복원 정책 +└─ 입력 관습별 구체화 + └─ key·pointer·IME / focus·중첩 / gesture·native lifecycle + +대상별 계약 + 선택한 입력 계약 + │ 조합하고 함께 검증 + v + Hands profile + │ 여러 Hand와 외부 서비스를 조합 + v + Application / Host +``` + +대상별 계약과 입력 계약은 서로 다른 축이다. Sheet가 Rich Text를 상속하는 것이 +아니며 Windows·macOS 관습 때문에 문서 model을 복제할 이유도 없다. Hands profile은 +함께 약속하는 조합이다. 위의 구분을 새 package나 public descriptor type으로 +등록하지 않는다. 실제 문서·API·Usage는 아래의 기존 정본 owner에 둔다. + +의미를 실현하는 책임 지도는 다음과 같다. 화살표는 이 설계의 책임 연결이며 +현재 package import graph의 모든 edge를 표현하지 않는다. + +```text +Headless API Keyboard / Pointer / IME + | | + | Adapter / Affordance + +-------------------+---------------+ + v + Domain editor + / \ + Selection·Topology EditingPlan + selectionAfter + \ / + v v + EditingSession + 관찰·작업·History 연결 + | + v + JSONDocument + 값·원자적 commit + local / collaboration 구현 + +Connector: 이 책임들을 framework의 구독·render·focus lifecycle에 연결 +Host: 정본 API의 조합 순서·제품 정책·데이터·layout·외부 instance 주입 +``` + +Selection의 수학적 전이는 JSONDocument mutation 없이 사용할 수 있고, 순수 JSON +소비자는 Editing 없이 Core를 사용할 수 있다. Collaboration은 같은 JSONDocument +계약의 다른 구현이며 `EditingHistory` 연결은 별도 선택이다. 입력 맥락 하나마다 +History를 하나씩 생성하거나 Application 전체에 단일 History를 강제하지 않는다. +같은 문서를 여러 view에서 편집할 때의 History 귀속은 선택한 owner의 계약이다. + +### 기존 소유자와 연결 + +Copy는 조회이며, 선택만 바꾸는 작업은 document commit을 만들지 않고, +Undo/Redo는 선택한 History owner를 실행한다. 위의 흐름을 모든 요청이 +Core commit으로 향하는 단일 경로로 해석하지 않는다. | 기존 owner | 계속 소유할 책임 | 다른 곳으로 넘기지 않을 결정 | | --- | --- | --- | @@ -85,6 +254,13 @@ layout·concrete external instance와 조합을 소유한다. 증거를 통해 이루어진다. DOM API 모양, JSON 저장 shape, 특정 키 조합은 이 규칙의 전제가 아니다. +이 표의 적용 범위는 현재 구조·텍스트 편집 profile의 계약이다. EG-SELECT의 +일시적 선택과 문서 History 분리, EG-CUT의 즉시 제거를 다른 장르의 모든 관습으로 +일반화하지 않는다. 범위 확장의 anchor 규칙은 anchor를 가진 range family에 +적용한다. 다른 family나 raster·지연 이동 profile을 동결하려면 해당 상태와 +History·transfer 의미를 먼저 명시해야 하며, 기존 profile의 규칙을 느슨하게 +바꾸어 같은 profile이라고 부르지 않는다. + | ID | 관찰 가능한 규칙 | Owner | | --- | --- | --- | | EG-SELECT | 선택만 바꾸는 작업은 document value와 문서 Undo/Redo 기록을 바꾸지 않는다. 범위 확장은 유효한 기존 anchor를 보존하고 focus를 이동한다. | Selection·Editing | @@ -266,6 +442,29 @@ offset도 비교하며 블록 toggle과 전체 블록 Copy의 의미는 유지 유지하는 증거다. offset 하나로 과거 native range 전체를 복원한다는 계약이나 profile 동결로 확대하지 않는다. +## 새 도출의 검증 질문 + +다음은 실행할 수 있도록 시작 상태와 판정 결과를 적은 **후보 사례**다. 앞의 +기존 적합성 표의 통과 항목과 합산하지 않는다. 이 문서 변경은 새 behavior vector나 +구현을 추가하지 않는다. + +| 사례 | 시작 상태 → 작업 → 판정할 결과 | 연결·현재 증거의 한계 | +| --- | --- | --- | +| 셀과 내부 문자열 | `Alpha` 셀 선택 / 내부 `ph` 선택 각각에서 Copy → 셀 payload / `ph` | EG-TARGET·EG-COPY. 기존 Sheet·native selection 증거는 각각 있으며 이 중첩 전체의 공통 binding은 없음 | +| 거절 시 바깥으로 전이 금지 | 선택한 객체 안의 text editor가 Delete 거절 → 객체 제거·외부 History entry가 생기지 않음 | EG-EDIT·입력 계약 후보. [Web clipboard ownership](../packages/json-document-web/tests/clipboard-rejection.test.ts)은 한 binding의 거절 처리 증거이며 일반적인 중첩 거절 vector는 없음 | +| Toolbar 대상 보존 | editor A에서 범위 선택 → Toolbar에 focus → Copy 실행 → A의 선택 payload | 입력 focus와 Selection 구별. 전체 Hands에 대한 공통 실행 증거 없음 | +| 셀 값과 행 구조 | 값이 있는 셀을 비움 / 해당 record 삭제 → 전자는 구조 유지, 후자는 record 제거 | EG-EDIT·profile 대상. 현재 Sheet Cut과 Database `record.delete`는 서로 다른 계약 | +| 반복 일정 영향 범위 | 같은 occurrence에 `this` / `all` 이동 → profile이 정한 반복 일정 집합만 영향 | EG-TARGET·EG-EDIT. Calendar domain 사례이며 입력 맥락 전환으로 해석하지 않음 | +| Draft 취소와 Undo | 미확정 rename draft 수정 → cancel → 확정 label 불변; 확정 rename → Undo → 이전 label | EG-GESTURE·EG-HISTORY. 구조 gesture runner가 모든 rename·IME 취소를 증명하지 않음 | +| 외부 참조·다른 profile로 Paste | 수식 또는 reference를 다른 위치·profile에 Paste → 변환·보존·거절 중 선언한 결과 | EG-PASTE. 현재 세 editor의 내부 round trip만으로 cross-profile 교환을 인증할 수 없음 | +| 공유 문서의 두 편집 맥락 | A에서 편집 → B로 입력 focus 전환 → Undo → 선택한 History owner가 명시한 기여·선택 복원 | EG-HISTORY·EG-RESULT. 외부 History 연결 증거와 모든 Hand의 입력 routing 증거는 다름 | + +이번 도출로 설계 방향을 정할 수 있는 부분은 대상·위치·선택·입력 수신자의 구별, +대상별 계약과 입력 계약의 조합, 기존 owner의 유지다. 아직 동결할 수 없는 부분은 +중첩 편집의 진입·거절·복귀 규칙, profile 간 payload 변환, raster/file 편집의 +적용 범위, 여러 편집 맥락의 History routing이다. 같은 profile을 독립적으로 +구현했을 때 이 사례들의 결과가 일치해야 장기 호환성 약속으로 승격할 수 있다. + ## 장기 호환성 약속하는 것은 **고정된 profile의 지원 입력과 관찰 가능한 결과**다. 같은 profile From c8570dc76bbc6ea03ddb23c53ffc21fa4103e74f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Tue, 8 Sep 2026 08:01:14 +0900 Subject: [PATCH 02/24] =?UTF-8?q?docs:=20=EC=97=AD=EC=82=AC=EC=A0=81=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=ED=99=95=EC=9E=A5=EC=9D=98=20=EC=B0=A8?= =?UTF-8?q?=EC=9D=B4=EC=99=80=20Host=20=EC=A1=B0=ED=95=A9=20=EC=97=AD?= =?UTF-8?q?=ED=95=A0=EC=9D=84=20=EB=AA=85=ED=99=95=ED=9E=88=20=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- standards/editing-grammar.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/standards/editing-grammar.md b/standards/editing-grammar.md index 84e71cf3..6d255542 100644 --- a/standards/editing-grammar.md +++ b/standards/editing-grammar.md @@ -158,6 +158,7 @@ native range의 방향을 투영하고, [Web virtual selection scope](../package | 선택은 언제나 Undo 대상이 아님 | [GIMP Undoing](https://docs.gimp.org/3.0/en/gimp-concepts-undo.html)은 Scissors Select 작업과 Quick Mask 전환의 Undo를 설명 | 현재 일시적 Selection의 EG-SELECT·EG-HISTORY를 유지. Raster 선택 제작의 History 정책으로 일반화하지 않음 | | 이동은 항상 Copy 후 즉시 제거한 뒤 Paste | [Finder 이동](https://support.apple.com/en-gb/102650)은 Clipboard의 파일을 목적지에 옮기는 동작을 제공 | EG-CUT은 현재 즉시 제거하는 cut 계약. 지연 이동은 같은 계약의 이름만 다른 구현이 아님 | | 복사한 저장 값을 그대로 넣으면 의미도 보존 | [Excel 수식 이동·복사](https://support.microsoft.com/en-us/excel/move-or-copy-a-formula-in-excel)는 복사 시 상대 참조 변경, 수식 이동 시 참조 유지를 구별 | payload의 참조·identity·배치 변환은 profile 계약. JSON 모양이나 text 일치만으로 교환 호환성을 판정하지 않음 | +| Shift-click 확장은 항상 같은 anchor를 고정 | Apple HIG 1992 인쇄 쪽수 290–291은 addition과 fixed-point 두 방식을 설명 | 현재 EG-SELECT의 고정 anchor 전이를 유지. 다른 입력 관습을 같은 전이로 일반화하지 않음 | | Selection은 항상 문자열의 시작과 끝 | 레포의 Sheet rectangle, Calendar materialized occurrences와 Selection mask 확장 | 대상별 family를 유지. mask algebra의 존재는 raster Hand 완성의 증거가 아님 | | 입력이 끝나면 Undo 한 step | 여러 preview와 typing/composition grouping, GIMP의 선택 도구 작업 | 작업의 확정·취소와 History grouping을 따로 명시 | @@ -185,9 +186,9 @@ clipboard 자체의 변경도 별개의 효과다. 원자적 문서 편집은 │ 조합하고 함께 검증 v Hands profile - │ 여러 Hand와 외부 서비스를 조합 + │ Host가 여러 Hand와 외부 서비스를 조합 v - Application / Host + Application ``` 대상별 계약과 입력 계약은 서로 다른 축이다. Sheet가 Rich Text를 상속하는 것이 @@ -256,8 +257,8 @@ layout·concrete external instance와 조합을 소유한다. 이 표의 적용 범위는 현재 구조·텍스트 편집 profile의 계약이다. EG-SELECT의 일시적 선택과 문서 History 분리, EG-CUT의 즉시 제거를 다른 장르의 모든 관습으로 -일반화하지 않는다. 범위 확장의 anchor 규칙은 anchor를 가진 range family에 -적용한다. 다른 family나 raster·지연 이동 profile을 동결하려면 해당 상태와 +일반화하지 않는다. 범위 확장의 anchor 규칙은 고정 anchor로 확장하는 현재 +range 전이에 적용한다. 다른 family나 raster·지연 이동 profile을 동결하려면 해당 상태와 History·transfer 의미를 먼저 명시해야 하며, 기존 profile의 규칙을 느슨하게 바꾸어 같은 profile이라고 부르지 않는다. From 412f5121547927027c001d7911fee07e9939cc48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Tue, 8 Sep 2026 09:00:03 +0900 Subject: [PATCH 03/24] =?UTF-8?q?docs:=20=EA=B8=B0=EB=B3=B8=20Profile?= =?UTF-8?q?=EC=9D=98=20=ED=8E=B8=EC=A7=91=20=EC=95=BD=EC=86=8D=EA=B3=BC=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=95=ED=82=A4=20=EA=B4=80=EC=8A=B5=EC=9D=84=20?= =?UTF-8?q?=EA=B5=AC=EC=B2=B4=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- standards/editing-grammar.md | 173 +++++++++++++++++++++++++++++++++-- 1 file changed, 164 insertions(+), 9 deletions(-) diff --git a/standards/editing-grammar.md b/standards/editing-grammar.md index 6d255542..9caf48c7 100644 --- a/standards/editing-grammar.md +++ b/standards/editing-grammar.md @@ -11,6 +11,10 @@ Stable admission이나 외부 상호운용성을 주장하지 않는다. 작은 Core는 장기간 약속할 수 있는 의미만 소유한다. JSON Document의 여섯 member와 편집 문법의 크기는 서로 다른 문제다. +기반 계약은 여러 편집 정책을 수용하고, **기본 Profile은 사람들이 익숙한 편집 +동작을 구체적인 약속으로 제공한다**. 예외의 존재는 기본값의 적용 범위와 변형을 +설명하는 근거다. 기본값 자체도 장기간 호환성을 지킬 계약에 포함한다. + - Outcome: 같은 Hands profile을 소비하는 제품은 선택·편집·복사·복원에서 같은 관찰 가능한 의미를 얻는다. - Done: 이 설계에서 공통 규칙과 profile 선택을 구분하고, 각 규칙의 기존 owner, @@ -36,10 +40,40 @@ Selection·Editing·Affordance·Web 연결이다. 사례를 읽은 것과 같은 `selectAllAffordance`의 전체 선택 토글과 선택 의도의 멱등성, `selection.move`라는 이름 아래의 블록 이동과 선택 위치 이동도 구별해야 한다. 아직 남은 공백은 앱 사례에서 공통 의미를 도출하는 근거, 중첩된 편집 맥락의 -계약, 같은 profile의 독립 구현 증거다. +계약, 같은 profile의 독립 구현 증거다. 각 owner에 있는 기본 동작을 함께 쓰는 +Profile의 약속과, 그 Profile이 허용할 차이도 연결해야 한다. ## 앱 사례에서 최소 문법 도출하기 +### 단축키가 드러내는 관습부터 찾는다 + +핵심 기능을 찾는 출발점은 **단축키로 반복 실행하도록 드러낸 작업**이다. +사용자가 근육기억으로 익힌 조작을 장기 호환성의 대상으로 보고, 여러 제품과 +오랜 자료에 반복되는 기능부터 조사한다. 단축키의 존재는 조사 우선순위의 단서이며, +그 기능의 영구 불변성이나 Core 소유권을 단독으로 증명하지는 않는다. + +[Mac 단축키](https://support.apple.com/en-us/102650)와 +[Excel 단축키](https://support.microsoft.com/en-us/accessibility/excel/keyboard-shortcuts-in-excel)는 +Undo·Copy·선택 확장을 반복해서 제공한다. 반면 문자와 셀의 선택 단위는 다르고, +Excel의 `F2`는 셀 내부 편집으로 들어가는 맥락을 드러낸다. 현재 제품의 공개 +동작과 아래 역사적 지침을 대조하며, 현재 목록만으로 30년의 연속성을 주장하지 않는다. + +| 먼저 살펴볼 입력·기능 | 추출할 의미와 차이 | 기존 책임에 연결할 후보 | +| --- | --- | --- | +| Undo·Redo 단축키 | 되돌릴 작업의 단위, 전후 내용·선택, 실행할 History | Editing·History와 입력 계약 | +| Copy·Cut·Paste 단축키 | 대상 확보, 교환 표현, 제거·삽입 결과 | Editing·domain codec·Web | +| 방향키·Shift 확장·Select All | 탐색과 선택 확장, 전체의 범위와 반복 입력 결과 | Selection family·topology·입력 계약 | +| Delete·Backspace | 선택 대상 제거와 삽입점 주변 삭제, 값 비우기와 구조 제거 | 대상별 Editing 계약 | +| Enter·F2·Escape | 내부 편집 진입, 확정·취소·복귀의 맥락 | Affordance lifecycle·domain·Web | +| 서식·채우기 등 대상별 단축키 | 같은 입력 계열에서도 다른 대상의 속성·내용 변경 | 해당 domain 계약 | +| Save·Print | 저장·출력처럼 문서 편집 밖의 효과 | Application 계약 | + +이 표는 기능을 모두 추가하라는 목록이 아니다. 발견한 작업을 **시작 맥락·대상 → +의도 → 결과·선택·History**로 풀고, 여러 대상에서 유지되는 의미를 공통 문법으로, +대상별 차이를 domain 계약으로, 키와 수신 조건을 입력 계약으로 구체화한다. +기본 Hands profile은 이를 익숙한 동작으로 조합한다. 단축키에 직접 드러나지 않는 +선택 mapping·원자성·gesture 취소도 그 작업을 성립시키는 데 필요하면 남긴다. + ### 명령 이름보다 대상과 결과를 먼저 비교한다 Apple의 [1992년 Macintosh Human Interface Guidelines](https://www.vintageapple.org/inside_r/pdf/Human_Interface_Guidelines_1992.pdf) @@ -114,7 +148,7 @@ transition·map·reconcile·targets가 이미 이 차이의 정본 경계를 제 ### 중첩은 입력을 받을 곳의 계약이다 -[W3C APG Grid](https://www.w3.org/WAI/ARIA/apg/patterns/grid/#editing-and-navigating-inside-a-cell)는 +[W3C APG Grid](https://www.w3.org/WAI/ARIA/apg/patterns/grid/#keyboardinteraction-settingfocusandnavigatinginsidecells)는 셀 탐색에 쓰는 화살표와 셀 안의 caret·widget 조작을 구분한다. [Keynote의 텍스트 상자](https://support.apple.com/en-ca/guide/keynote/tan4fd6ee725/mac)는 객체 자체와 그 안의 텍스트를 따로 선택한다. 두 자료는 지침·제품 관습의 근거이며 @@ -139,6 +173,13 @@ Canvas Hand 처리하지 않은 입력의 명시적 위임과 실패를 구별하며, 정확한 키·진입 방법은 입력 profile이 정한다. 아직 전체 Hands에 실행 검증된 새 규칙은 아니다. +명령 후보를 찾는 단계의 미적용과 실행한 편집의 거절도 구별한다. +[ProseMirror Commands](https://prosemirror.net/docs/guide/#commands)는 적용할 수 없는 +명령의 `false`와 다음 후보 탐색을 정의한다. 이를 이 레포의 `EditingResult.ok: false`와 +같은 뜻으로 연결하지 않는다. [DOM Event](https://dom.spec.whatwg.org/#interface-event)의 +기본 동작 취소·전파 중단도 편집 성공과 별개의 관찰이며, 어떤 입력을 맡았는지는 +해당 binding의 계약으로 판단한다. + 여기서 편집 맥락은 HTML `editing host`, DOM element, JSON subtree, History instance와 일대일 대응하지 않는다. Toolbar로 focus가 옮겨져도 편집 명령은 저장된 선택을 대상으로 할 수 있다. Headless 호출은 DOM focus 없이 대상을 지정할 수 있다. @@ -155,7 +196,7 @@ native range의 방향을 투영하고, [Web virtual selection scope](../package | 과도한 일반화 | 근거 있는 반례 | 이 설계의 처리 | | --- | --- | --- | -| 선택은 언제나 Undo 대상이 아님 | [GIMP Undoing](https://docs.gimp.org/3.0/en/gimp-concepts-undo.html)은 Scissors Select 작업과 Quick Mask 전환의 Undo를 설명 | 현재 일시적 Selection의 EG-SELECT·EG-HISTORY를 유지. Raster 선택 제작의 History 정책으로 일반화하지 않음 | +| 선택만 바꾼 작업은 어떤 경우에도 Undo 기록이 아님 | [GIMP Undoing](https://docs.gimp.org/3.0/en/gimp-concepts-undo.html)의 선택 도구 작업과 [AppKit Undo](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/UndoArchitecture/Articles/AppKitUndo.html)의 저장되지 않는 view selection 기록 | 편집 전 선택 복원과 selection-only 기록을 구별. 현재 EG-SELECT·EG-HISTORY의 기본 정책을 다른 profile 전체에 강제하지 않음 | | 이동은 항상 Copy 후 즉시 제거한 뒤 Paste | [Finder 이동](https://support.apple.com/en-gb/102650)은 Clipboard의 파일을 목적지에 옮기는 동작을 제공 | EG-CUT은 현재 즉시 제거하는 cut 계약. 지연 이동은 같은 계약의 이름만 다른 구현이 아님 | | 복사한 저장 값을 그대로 넣으면 의미도 보존 | [Excel 수식 이동·복사](https://support.microsoft.com/en-us/excel/move-or-copy-a-formula-in-excel)는 복사 시 상대 참조 변경, 수식 이동 시 참조 유지를 구별 | payload의 참조·identity·배치 변환은 profile 계약. JSON 모양이나 text 일치만으로 교환 호환성을 판정하지 않음 | | Shift-click 확장은 항상 같은 anchor를 고정 | Apple HIG 1992 인쇄 쪽수 290–291은 addition과 fixed-point 두 방식을 설명 | 현재 EG-SELECT의 고정 anchor 전이를 유지. 다른 입력 관습을 같은 전이로 일반화하지 않음 | @@ -249,6 +290,71 @@ Document·Sheet 등은 Editing에, Rich Text는 자신의 package에 구현돼 Hands는 기존 공개 API들의 함께 검증된 조합이다. Host는 제품 데이터·권한·copy· layout·concrete external instance와 조합을 소유한다. +## 기본 Profile이 제공할 편집 동작 + +이 설계에서 기본 Hands profile은 **선택한 대상·입력·History 계약과 그 조합의 +적합성 기준**을 묶는다. +기본 Profile은 해당 편집 대상을 사용할 때 추가 정책 선택 없이 기대할 동작을 정한다. +Text·Sheet·Object가 같은 저장 model이나 선택 모양을 사용할 필요는 없다. +공통 기본 규칙을 각 대상별 계약과 조합하며, 같은 책임은 기존 정본 모듈에서 구현한다. + +| 층위 | 약속하는 것 | 변형의 경계 | +| --- | --- | --- | +| Core와 기반 모듈 | JSONDocument의 원자적 commit·관찰, Selection·Editing 등 각 owner의 공개 계약 | 어떤 Profile도 소비하는 기반 계약의 보장을 약화하지 않음 | +| 기본 Hands profile | 대상별 의미와 정착한 기본 입력·편집 결과·선택·복원 | 설정을 생략했을 때의 동작까지 명시하고 검증 | +| 허용된 설정 또는 다른 Profile | 다른 입력 관습·기록 범위·교환 정책이 필요한 경우의 선언된 차이 | 같은 Profile 안에서 허용한 선택인지, 별도 Profile/revision인지 식별 | + +기존 [JSON Document v3 profile](json-document-v3/profile.md)은 기반 계약을, +[Rich Text v1 profile](json-document-rich-text-v1/profile.md)은 대상별 계약을 정의한다. +[Official Hands Profile](../docs/public/official-hands.md)은 입력까지 조합하는 위치다. +기본 편집 동작을 묶는다는 이유로 이 계약들을 하나의 runtime 객체로 합치지 않는다. + +### 채택할 기본 의미와 구체화할 조건 + +다음은 이 초안이 기본으로 제공하려는 의미다. 기존 동작·EG 규칙과 연결하며, +뒤에 적은 조합 검증이 끝나기 전까지 모든 Hands의 구현 완료로 표시하지 않는다. + +| 동작 | 기본 Profile의 약속 | 구체화할 조건 / 연결 | +| --- | --- | --- | +| Undo 입력 | macOS `Cmd+Z`, Windows `Ctrl+Z`를 Undo 의도로 해석 | 편집 맥락의 수신 History, 실행 가능 여부와 조합 중 입력 처리. Web·Affordance | +| 편집 Undo/Redo | 외부 변경이 개입하지 않은 Undo는 편집 전 내용·선택을, Redo는 편집 후 내용·선택을 함께 복원 | 선택 범위와 방향, 여러 view의 귀속, 외부 변경 후 유효한 위치 복원. EG-HISTORY·EG-RESULT | +| 선택만 변경 | document와 문서 Undo/Redo 기록을 유지 | 선택 변경을 별도 Undo 대상으로 제공하는 정책과 구별. EG-SELECT | +| Copy | 현재 편집 맥락의 대상을 읽고 document·selection·History를 유지 | 셀 전체와 내부 문자열, 구조 표현과 교환 표현. EG-TARGET·EG-COPY | +| Cut/Paste | 확보한 대상에 대한 제거 또는 목적지에 맞는 삽입을 편집 결과와 후속 선택으로 설명 | 표현 선택·변환·거절·native 위임의 경계. EG-CUT·EG-PASTE | +| 작업 단위 | typing·composition·drag를 사용자에게 의미 있는 Undo 단위로 제공 | grouping의 경계와 취소 결과는 대상·입력·History 계약의 조합에서 명시. EG-GESTURE·EG-HISTORY | + +[CKEditor Undo](https://ckeditor.com/docs/ckeditor5/latest/api/module_undo_undo-Undo.html)는 +batch와 편집 전 selection을 함께 기록하며, +[UndoCommand](https://ckeditor.com/docs/ckeditor5/latest/api/module_undo_undocommand-UndoCommand.html)는 +그 selection을 복원한다. [CodeMirror History](https://codemirror.net/docs/ref/#commands.history)는 +일반 Undo와 selection 변경도 되돌리는 `undoSelection`을 구별한다. 해당 editor의 +공개 계약은 기본 복원의 근거가 되며, 특정 내부 알고리즘을 Core에 요구하지 않는다. + +특히 **편집과 함께 선택을 복원하는 것**과 **선택만 바꾼 작업을 새 Undo step으로 +기록하는 것**은 별개의 결정이다. 기본 Profile은 전자를 약속하고 현재 EG-SELECT의 +selection-only 정책을 유지한다. 후자의 변형이 필요하면 History owner의 지원과 +그 Profile의 적합성을 별도로 확인한다. + +### 기본값과 설정의 호환성 + +설정을 생략하는 것도 Profile의 유효한 사용이다. 따라서 기본값만 바꾸는 변경도 +기존 소비자의 결과를 바꾸면 호환성 변경이다. 사용자가 근육기억으로 익힌 조작은 +키 조합뿐 아니라 어느 맥락의 무엇을 어떻게 바꾸는지까지 포함한다. 기본 키가 +같아도 Undo 수신자나 선택 복원을 바꾸면 그 약속을 바꾼 것이다. +문서와 검증 사례에는 Profile revision, 대상·입력 계약, 선택한 설정과 생략 시 +기본값을 식별할 수 있게 남긴다. +모든 payload에 새 식별 필드를 추가하라는 요구는 아니다. + +| 변경 | 같은 Profile을 유지할 조건 | +| --- | --- | +| 내부 구현·캐시 교체 | 같은 입력·설정에서 대상·결과·선택·History·실패 의미가 보존됨 | +| 사용자의 단축키 재설정 | Profile이 허용한 입력 설정이며 Undo 의도와 복원 결과는 유지됨 | +| 기본 단축키·selection-only 기록·Paste 우선순위 변경 | 기존 revision의 생략된 설정과 명시된 설정 모두 기존 의미를 유지해야 함. 보존하지 못하면 별도 Profile/revision으로 제공 | + +허용할 설정의 목록은 각 Profile에 둔다. keymap을 재정의할 수 있는 API가 있다고 +모든 재정의를 같은 기본 Profile의 적합한 동작으로 인정하지 않는다. 선택지는 +열어 두되, 기본 경로를 사용하는 제품이 반복해서 정책을 설계할 필요가 없게 한다. + ## 공통으로 고정할 편집 규칙 아래 ID는 설계 요구사항이다. 실제 동결은 owner의 versioned 계약과 적합성 @@ -256,7 +362,7 @@ layout·concrete external instance와 조합을 소유한다. 규칙의 전제가 아니다. 이 표의 적용 범위는 현재 구조·텍스트 편집 profile의 계약이다. EG-SELECT의 -일시적 선택과 문서 History 분리, EG-CUT의 즉시 제거를 다른 장르의 모든 관습으로 +selection-only 기록 정책과 EG-CUT의 즉시 제거를 다른 장르의 모든 관습으로 일반화하지 않는다. 범위 확장의 anchor 규칙은 고정 anchor로 확장하는 현재 range 전이에 적용한다. 다른 family나 raster·지연 이동 profile을 동결하려면 해당 상태와 History·transfer 의미를 먼저 명시해야 하며, 기존 profile의 규칙을 느슨하게 @@ -284,6 +390,13 @@ Headless `cut()`은 보존 가능한 payload를 반환하고, Web event binding 서로 다른 결과로 검증한다. 브라우저가 custom edit를 다시 실행하지 않도록 하는 event ownership은 Web이 소유한다. +Web의 메모리 Clipboard 테스트가 확인하는 것은 표현 쓰기와 editor callback의 +결과다. 쓰기 실패 시 제거 callback이 호출되지 않았더라도 실제 브라우저의 후속 +기본 동작까지 차단됐다고 단정할 수 없다. [Clipboard API 초안의 cut 처리](https://www.w3.org/TR/2026/WD-clipboard-apis-20260624/#cut-action)와 +연결해 event 취소 상태·후속 native 편집·OS Clipboard 반영을 별도로 관찰해야 한다. +이것은 기본 Cut 보장의 검증 범위이며 확정된 브라우저 결함이나 OS transaction +보장으로 해석하지 않는다. + EG-HISTORY에서 외부 변경 이후의 의미는 선택한 History 계약을 따른다. 현재 local inverse History는 이를 비우며, collaboration History는 내 기여를 선택적으로 되돌린다. 둘을 같은 복원 알고리즘으로 고정하지 않는다. 이미 존재하는 @@ -294,6 +407,12 @@ composition grouping은 [DOM 편집 lifecycle](dom-editing-lifecycle.md)의 별 계약을 따른다. `createGestureSession`의 존재만으로 Host의 preview가 문서를 변경하지 않는다고 증명할 수 없으므로 실제 연결까지 검증한다. +입력 중 native 표시와 확정된 document, History step도 구별한다. +[Input Events Level 2 초안](https://www.w3.org/TR/2026/WD-input-events-2-20260501/#input-event-order-during-composition)은 +취소할 수 없는 IME 조합 갱신을 설명한다. 기본 Profile의 입력 결과·취소·Undo +의미를 고정하고, 실제 입력 trace가 그 결과로 귀결되는지는 Web binding에서 +검증한다. Working Draft의 특정 이벤트 순서나 지연 시간을 Core에 고정하지 않는다. + ## Hands profile이 반드시 결정할 내용 각 profile은 다음 질문에 하나의 답 또는 명시적인 설정별 답을 제공한다. 이는 @@ -346,6 +465,23 @@ Copy를 부분 문자열 Copy로 바꾸지는 않는다. Sheet의 primary rectan 아니다. 이미 다른 의미를 가진 호출들을 한 이름으로 합치면 대상과 결과의 차이가 숨는다. 공통 protocol은 공유하는 규칙에 두고, 문서의 의미는 기존 owner에 둔다. +### 기본 Profile의 코드 연결을 설계할 위치 + +다음 표는 후속 구현에서 확인할 정본과 공백을 연결한다. 이 문서 변경은 해당 API를 +확장하지 않으며, 기존 API로 조합할 수 있는지 확인한 뒤 부족한 owner만 확장한다. + +| 책임 | 기존 코드와 API | 다음 설계에서 닫을 공백 | +| --- | --- | --- | +| 기본 입력 해석·실행 가능 표시 | Web [keyboard.ts](../packages/json-document-web/src/keyboard.ts)의 `defaultWebKeymap`, `createWebKeyboardAdapter`; Affordance의 `historyAffordance` | 설정 생략·허용된 재설정에서 Undo 의도가 보존되는지 확인. 기본 keymap을 Host마다 복제하지 않음 | +| 편집·선택·History 연결 | Editing의 `createEditingSession`, [EditingHistory](../packages/json-document-editing/src/history.ts) | 모델의 전후 선택과 native 선택 투영을 구별하고, 여러 view에서 실행할 History와 복원 대상 명시 | +| 중첩 진입·복귀와 입력 수신 | Affordance의 rename/gesture lifecycle, Web의 `isWebEditingHostTarget`, 각 domain의 Web binding | 후보 미적용·맡은 편집 거절·명시적 위임을 구별하는 연결. 유효한 조합을 지원하지 못하면 정본 API에서 해결 | +| 표현 교환과 Paste | Web의 `createWebClipboardBinding`, domain의 codec·표현·paste API | 표현 선택 실패와 선택한 payload의 편집 거절을 구별. 실제 native 후속 처리까지 관찰 | + +먼저 아래의 기본 Undo 사례를 공개 API 조합에 연결하고, 같은 기대 결과를 입력 +binding까지 확장한다. 중첩·여러 view·외부 표현의 차이는 해당 계약에서 별도로 +구체화한다. 구현을 바꿀 때 필요한 owner reference·Usage·source 등록은 각 owning +package의 기존 위치에서 함께 갱신한다. + ## 적합성 설계 테스트의 단위는 함수 유무가 아니라 **시작 상태 → 의미 있는 작업 → 관찰 결과**다. @@ -452,6 +588,8 @@ profile 동결로 확대하지 않는다. | 사례 | 시작 상태 → 작업 → 판정할 결과 | 연결·현재 증거의 한계 | | --- | --- | --- | | 셀과 내부 문자열 | `Alpha` 셀 선택 / 내부 `ph` 선택 각각에서 Copy → 셀 payload / `ph` | EG-TARGET·EG-COPY. 기존 Sheet·native selection 증거는 각각 있으며 이 중첩 전체의 공통 binding은 없음 | +| 기본 Undo와 선택 복원 | 아래의 `Alpha` 범위 교체 → Undo → Redo; 정방향·역방향 각각 실행 | EG-HISTORY·EG-RESULT. 전후 model selection 증거와 실제 native range 복원 증거를 구별 | +| 선택만 변경한 뒤 Redo | 편집 → Undo → 선택만 이동 → Redo → 기록된 편집 후 내용·선택 | EG-SELECT·EG-HISTORY. 기본 Profile에서 선택 이동이 새 문서 History entry를 만들거나 Redo를 지우지 않음 | | 거절 시 바깥으로 전이 금지 | 선택한 객체 안의 text editor가 Delete 거절 → 객체 제거·외부 History entry가 생기지 않음 | EG-EDIT·입력 계약 후보. [Web clipboard ownership](../packages/json-document-web/tests/clipboard-rejection.test.ts)은 한 binding의 거절 처리 증거이며 일반적인 중첩 거절 vector는 없음 | | Toolbar 대상 보존 | editor A에서 범위 선택 → Toolbar에 focus → Copy 실행 → A의 선택 payload | 입력 focus와 Selection 구별. 전체 Hands에 대한 공통 실행 증거 없음 | | 셀 값과 행 구조 | 값이 있는 셀을 비움 / 해당 record 삭제 → 전자는 구조 유지, 후자는 record 제거 | EG-EDIT·profile 대상. 현재 Sheet Cut과 Database `record.delete`는 서로 다른 계약 | @@ -459,12 +597,29 @@ profile 동결로 확대하지 않는다. | Draft 취소와 Undo | 미확정 rename draft 수정 → cancel → 확정 label 불변; 확정 rename → Undo → 이전 label | EG-GESTURE·EG-HISTORY. 구조 gesture runner가 모든 rename·IME 취소를 증명하지 않음 | | 외부 참조·다른 profile로 Paste | 수식 또는 reference를 다른 위치·profile에 Paste → 변환·보존·거절 중 선언한 결과 | EG-PASTE. 현재 세 editor의 내부 round trip만으로 cross-profile 교환을 인증할 수 없음 | | 공유 문서의 두 편집 맥락 | A에서 편집 → B로 입력 focus 전환 → Undo → 선택한 History owner가 명시한 기여·선택 복원 | EG-HISTORY·EG-RESULT. 외부 History 연결 증거와 모든 Hand의 입력 routing 증거는 다름 | +| Cut 쓰기 실패 후 native 처리 | editable selection에서 지원하는 Cut의 표현 쓰기 실패 → 해당 작업의 원본 제거 없음 | EG-CUT·Web. 제거 callback 미호출 외에 event 취소·후속 beforeinput/input·document·History를 관찰. 브라우저 재현 전 검증 공백 | +| IME와 Undo 수신 | 조합 완료 / 취소 / 조합 중 Undo·focus 이동 → 선언된 내용·선택·History·입력 상태 | EG-HISTORY·Web lifecycle. 합성 composition fixture와 OS IME 검증을 구별 | + +기본 Undo 사례는 Rich Text에서 다른 변경이 끼어들지 않고 `ph`를 `X`로 교체하는 +상황으로 구체화한다. offset은 같은 text node 안의 위치다. -이번 도출로 설계 방향을 정할 수 있는 부분은 대상·위치·선택·입력 수신자의 구별, -대상별 계약과 입력 계약의 조합, 기존 owner의 유지다. 아직 동결할 수 없는 부분은 -중첩 편집의 진입·거절·복귀 규칙, profile 간 payload 변환, raster/file 편집의 -적용 범위, 여러 편집 맥락의 History routing이다. 같은 profile을 독립적으로 -구현했을 때 이 사례들의 결과가 일치해야 장기 호환성 약속으로 승격할 수 있다. +| 단계 | 내용 | 선택과 History의 기대 결과 | +| --- | --- | --- | +| 시작 | `Alpha` | 정방향 `(anchor: 2, focus: 4)` 또는 역방향 `(anchor: 4, focus: 2)`으로 `ph` 선택. History 비어 있음 | +| 교체 | `AlXa` | offset 3의 collapsed caret. 한 번의 Undo 가능 | +| Undo | `Alpha` | 시작 때의 범위와 방향 복원. Undo 불가·Redo 가능 | +| Redo | `AlXa` | offset 3의 collapsed caret 복원. Undo 가능·Redo 불가 | + +선택만 바꾼 뒤 Redo하는 후보는 Undo 행과 Redo 행 사이에서 caret을 옮겨 실행한다. +이 선택 이동은 별도 step이 아니며, Redo는 기록된 교체 후 선택을 복원해야 한다. +이 표는 공개 동작에 대한 기대 결과다. 새 테스트의 통과를 주장하지 않으며, +Document의 offset 하나만으로 과거 native range 전체를 복원할 수 있다고 가정하지 않는다. + +기본 Profile의 역할과 Undo·선택 복원의 기본 의미는 설계에 채택한다. 입력 수신, +중첩의 진입·거절·복귀, profile 간 payload 변환과 여러 view의 History 귀속은 +위 사례로 더 구체화한다. 기본값을 정하는 것과 동결을 인증하는 것은 다른 단계다. +같은 profile을 독립적으로 구현했을 때 결과가 일치하고 실제 입력 경로에서도 +약속이 유지되어야 장기 호환성 증거로 승격할 수 있다. ## 장기 호환성 From 97405f78f464b7b311a822d68fddc999422b05e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Tue, 8 Sep 2026 09:38:49 +0900 Subject: [PATCH 04/24] =?UTF-8?q?docs:=20=EA=B8=B0=EB=B3=B8=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EA=B3=84=EC=95=BD=ED=91=9C=EC=99=80=20=EC=B5=9C?= =?UTF-8?q?=EC=86=8C=20=EC=BD=94=EB=93=9C=20=EB=B3=80=EA=B2=BD=EC=95=88?= =?UTF-8?q?=EC=9D=84=20=EA=B5=AC=EC=B2=B4=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- standards/editing-grammar.md | 151 +++++++++++++++++++++++++---------- 1 file changed, 107 insertions(+), 44 deletions(-) diff --git a/standards/editing-grammar.md b/standards/editing-grammar.md index 9caf48c7..9c43ce7c 100644 --- a/standards/editing-grammar.md +++ b/standards/editing-grammar.md @@ -309,19 +309,37 @@ Text·Sheet·Object가 같은 저장 model이나 선택 모양을 사용할 필 [Official Hands Profile](../docs/public/official-hands.md)은 입력까지 조합하는 위치다. 기본 편집 동작을 묶는다는 이유로 이 계약들을 하나의 runtime 객체로 합치지 않는다. -### 채택할 기본 의미와 구체화할 조건 +### 기본 동작별 계약 -다음은 이 초안이 기본으로 제공하려는 의미다. 기존 동작·EG 규칙과 연결하며, -뒤에 적은 조합 검증이 끝나기 전까지 모든 Hands의 구현 완료로 표시하지 않는다. +기본 Profile에서 먼저 닫을 범위는 아래 12개 동작이다. 각 대상에 적용되는 동작을 +기본 경로로 제공하며, 미지원과 일시적 실행 불가를 구별한다. 예를 들어 내부 편집을 +제공하는 Hand에는 진입·확정·취소·복귀가 필요하지만, 모든 객체에 text editor를 +내장할 필요는 없다. 표는 이 초안이 채택하는 계약이며 전체 Hands의 구현 완료나 +기존 API 기본값의 즉시 변경을 뜻하지 않는다. -| 동작 | 기본 Profile의 약속 | 구체화할 조건 / 연결 | -| --- | --- | --- | -| Undo 입력 | macOS `Cmd+Z`, Windows `Ctrl+Z`를 Undo 의도로 해석 | 편집 맥락의 수신 History, 실행 가능 여부와 조합 중 입력 처리. Web·Affordance | -| 편집 Undo/Redo | 외부 변경이 개입하지 않은 Undo는 편집 전 내용·선택을, Redo는 편집 후 내용·선택을 함께 복원 | 선택 범위와 방향, 여러 view의 귀속, 외부 변경 후 유효한 위치 복원. EG-HISTORY·EG-RESULT | -| 선택만 변경 | document와 문서 Undo/Redo 기록을 유지 | 선택 변경을 별도 Undo 대상으로 제공하는 정책과 구별. EG-SELECT | -| Copy | 현재 편집 맥락의 대상을 읽고 document·selection·History를 유지 | 셀 전체와 내부 문자열, 구조 표현과 교환 표현. EG-TARGET·EG-COPY | -| Cut/Paste | 확보한 대상에 대한 제거 또는 목적지에 맞는 삽입을 편집 결과와 후속 선택으로 설명 | 표현 선택·변환·거절·native 위임의 경계. EG-CUT·EG-PASTE | -| 작업 단위 | typing·composition·drag를 사용자에게 의미 있는 Undo 단위로 제공 | grouping의 경계와 취소 결과는 대상·입력·History 계약의 조합에서 명시. EG-GESTURE·EG-HISTORY | +| 동작 | 대상 → 기본 결과 | 후속 선택·History | 구체화할 차이 / 기존 owner | +| --- | --- | --- | --- | +| 탐색 | 현재 맥락의 위치 → 유효한 이웃·경계로 이동, 문서 불변 | 대상별 caret 또는 논리 focus·선택 전이. 문서 History 유지 | 문자·셀·가시 노드의 단위와 선택 동반 여부. Selection·Topology·입력 계약 | +| 선택 | 유효한 대상·삽입점 → 요청한 선택으로 전이 | 내용·문서 Undo/Redo 기록 유지 | range·key set, primary와 복수 범위. Selection·editor | +| 선택 확장 | 기존 선택 → 확장한 범위 | 고정 anchor 범위는 anchor를 보존하며 focus 이동. 문서 History 유지 | 문자·셀·가시 순서; key 집합 추가는 별도 전이. Selection·Topology | +| 전체 선택 | 현재 맥락이 정한 전체 대상 → 한 선택 전이로 전부 선택 | 같은 대상 집합에서 반복해도 유지. 문서 History 유지 | 기본 입력도 선택 해제로 토글하지 않음. 단계적 범위 확대·토글은 다른 입력 정책. Selection·Affordance | +| 삽입·교체 | 삽입점·대체 범위 또는 명시 대상 → 유효한 새 내용·구조 | 삽입 후 작업 위치 또는 삽입 대상 선택. 확정 작업 단위로 Undo | 문자열 대체·셀 값 commit·객체 삽입의 의미. domain·Editing | +| 삭제 | 선택 대상 또는 삽입점 주변 → 선언한 내용·구조 제거 | 유효한 남은 위치·대상 또는 빈 선택. 확정 작업 단위로 Undo | 문자 삭제·셀 값 비우기·subtree 제거 구별. domain·Editing | +| Copy | 현재 맥락의 대상 → 구조 payload와 교환 표현 | document·selection·History 유지 | 셀 전체와 내부 문자열, 표현과 대상 폐포. domain·Web | +| Cut | 확보한 대상 → payload 확보 후 그 대상 제거 | domain이 정한 삭제 후 선택. 성공한 편집의 Undo | 확보 실패 시 제거 없음, 제거 거절 시 편집 상태 유지. domain·Editing·Web | +| Paste | 목적지·대체 범위와 payload → 목적지 계약에 맞는 삽입 | 삽입 결과에 맞는 선택. 확정 작업 단위로 Undo | 표현 우선순위·identity·참조 변환·경계·거절. domain·Web·Editing | +| Undo·Redo | 선택한 History의 작업 → 편집 전·후 상태 복원 | 외부 변경이 없으면 내용과 선택 범위·방향을 함께 복원 | grouping·외부 변경·여러 view의 귀속. Editing·선택한 History owner | +| 내부 편집 진입 | 선택한 셀·항목·객체 → 해당 내부 편집 맥락 | 진입만으로 문서 History를 만들지 않음. 외부 선택과 복귀 대상 보존 | F2·Enter·pointer의 의미와 내부 선택 초기화. Affordance·domain·Web | +| 확정·취소·복귀 | 진행 중인 명시적 draft → 성공 시 확정·복귀, 취소 시 확정 내용 유지 | 성공한 확정만 Undo 대상. 확정 거절 시 draft와 편집 맥락 유지 | 대상이 유효한 rename·cell draft의 기본 정책. 연속 입력·IME는 별도 lifecycle. Affordance·domain·Web | + +기본 Undo 입력은 macOS `Cmd+Z`, Windows `Ctrl+Z`다. Copy·Cut·Paste와 전체 +선택도 선택한 플랫폼의 기본 입력으로 연결한다. 키 이름만으로 수신자를 정하지 +않으며, 중첩 맥락·native control·조합 중 입력의 소유권을 함께 결정한다. +typing·composition·drag의 grouping은 각 계약의 작업 경계로 명시한다. + +Object의 방향키가 좌표 변경을 뜻하면 그것은 탐색과 다른 편집이며 Undo 대상이다. +Rich Text의 Enter가 단락 삽입이면 그것을 draft 확정과 같은 작업으로 해석하지 +않는다. 이런 차이는 새 전역 command union 대신 대상별 계약에 남긴다. [CKEditor Undo](https://ckeditor.com/docs/ckeditor5/latest/api/module_undo_undo-Undo.html)는 batch와 편집 전 selection을 함께 기록하며, @@ -433,23 +451,28 @@ composition grouping은 [DOM 편집 lifecycle](dom-editing-lifecycle.md)의 별 기대되는 기본 편집 흐름을 생략할 수 없으며, 그 선택의 외부 관습이나 제품 목적을 profile에서 설명한다. -### 현재 구현을 설명하는 대표 매핑 +### 대상별로 비교한 현재 결과 이 표는 현재 동작의 관찰이다. 관찰된 기본값이 곧 영구히 고정할 규칙은 아니다. -| 항목 | Document | Sheet | Rich Text | +| 대상 / binding | 선택·Copy 대상 | 교체·제거 → 후속 선택 | Paste → 후속 선택 | | --- | --- | --- | --- | -| 편집 단위 | stable ID를 가진 블록과 text | stable row/column ID의 셀 | stable node ID와 text/child point | -| Copy 대상 | 선택된 블록 전체 | primary rectangle | 선택된 structured slice | -| Paste | 기본적으로 마지막 선택 블록 뒤에 삽입 | focus 셀부터 고정 경계 안에 기록 | 선택 구간과 schema에 맞게 slice 삽입 | -| 제거 | 선택 블록 제거 | Cut은 primary rectangle 값을 `null`로 비움 | 선택 구간 제거·schema 제약 적용 | -| 후속 선택 | 삽입 블록마다 collapsed range, 첫 블록 primary; 삭제 후 남은 이웃 | 붙여넣은 직사각형, Cut은 기존 선택 | transform 후 mapping된 text/child point | - -현재 binding은 각각 `json-document-editing/src/document.ts`, `sheet.ts`, -`json-document-rich-text/src/editor.ts`에 있다. Document의 text offset이 블록 -Copy를 부분 문자열 Copy로 바꾸지는 않는다. Sheet의 primary rectangle 정책도 -여러 범위 전체의 Copy와 구별한다. 이런 차이는 profile 이름과 사용법에서 드러나야 -하며, 공통 함수 이름 때문에 사용자가 동일한 대상을 예상하게 해서는 안 된다. +| [Rich Text](../packages/json-document-rich-text/src/editor.ts) | node ID + text/child range, structured slice | `text.insert`는 선택 구간 대체; 제거는 schema 제약 적용 → mapping된 point | 선택 구간에 slice 삽입 → 삽입 결과의 point | +| [Document](../packages/json-document-editing/src/document.ts) | block ID + offset, Copy는 블록 전체 | `text.replace`는 블록 문자열 교체 → 지정 offset 또는 끝; 제거 → 남은 이웃 | 기본적으로 마지막 선택 블록 뒤에 삽입 → 블록마다 collapsed range, 첫 블록 primary | +| [Tree](../packages/json-document-editing/src/tree.ts) | 가시 노드 범위; Copy·제거는 숨은 후손도 포함 | 선택 subtree 제거 → 남은 가시 이웃 또는 빈 선택 | 대상 노드와 같은 parent 아래 새 ID의 subtree 추가 → 삽입 root들 선택 | +| [Sheet](../packages/json-document-editing/src/sheet.ts) | 셀 range들의 집합; Copy는 primary rectangle | `cell.commit` → 해당 셀; `selection.fill(null)`은 선택된 셀 값, Cut은 primary 값만 비움 → 선택 유지 | focus 셀부터 경계 내 기록 → 붙여넣은 직사각형 | +| [Object](../packages/json-document-editing/src/object.ts) | object ID 집합; Copy는 해당 객체 | 속성 변경·이동과 객체 제거를 구별; 제거 → 저장 순서의 남은 이웃 또는 빈 선택 | 새 ID와 선언한 좌표 offset으로 추가 → 삽입 객체들 선택 | + +이들의 기본 local History는 외부 변경이 없는 편집의 전후 내용·선택을 기록한다. +같은 Undo 규칙을 공유해도 문자열 range·가시 노드 범위·셀 rectangle·object ID +집합을 하나의 선택 모양으로 합칠 필요는 없다. + +탐색의 단위도 대상별이다. Text의 caret, Tree의 가시 순서, Sheet의 행·열 topology를 +구별하고 Object의 좌표 변경과 분리한다. 항목 label의 내부 편집은 현재 +[Order](../packages/json-document-editing/src/order.ts)의 `item.rename`과 +`createRenameSession` 조합으로 확인한다. Tree·Object에 같은 내부 편집이 이미 +제공된다고 일반화하지 않는다. Database의 record, Calendar의 occurrence, Annotation의 +source 참조와 Composer의 중첩은 앞의 11개 사례에서 정한 domain 차이를 유지한다. ## 구현에서 계약으로 옮길 때의 결정 @@ -465,22 +488,54 @@ Copy를 부분 문자열 Copy로 바꾸지는 않는다. Sheet의 primary rectan 아니다. 이미 다른 의미를 가진 호출들을 한 이름으로 합치면 대상과 결과의 차이가 숨는다. 공통 protocol은 공유하는 규칙에 두고, 문서의 의미는 기존 owner에 둔다. -### 기본 Profile의 코드 연결을 설계할 위치 +### 하이라키 검토와 최소 코드 변경안 -다음 표는 후속 구현에서 확인할 정본과 공백을 연결한다. 이 문서 변경은 해당 API를 -확장하지 않으며, 기존 API로 조합할 수 있는지 확인한 뒤 부족한 owner만 확장한다. +기본 Undo 사례는 기존 `createRichTextEditor`와 Web keymap의 공개 API 조합으로 +실행됐다. 따라서 이 경로를 위해 Core에 Selection·History를 넣거나 새 session +계층을 추가하지 않는다. 현재 공백은 기반 보장을 기본 동작으로 조합하는 계약에 있다. -| 책임 | 기존 코드와 API | 다음 설계에서 닫을 공백 | +| 확인한 계약과 증거 | 판단 / 최소 변경안 | 완료를 판정할 사례 | | --- | --- | --- | -| 기본 입력 해석·실행 가능 표시 | Web [keyboard.ts](../packages/json-document-web/src/keyboard.ts)의 `defaultWebKeymap`, `createWebKeyboardAdapter`; Affordance의 `historyAffordance` | 설정 생략·허용된 재설정에서 Undo 의도가 보존되는지 확인. 기본 keymap을 Host마다 복제하지 않음 | -| 편집·선택·History 연결 | Editing의 `createEditingSession`, [EditingHistory](../packages/json-document-editing/src/history.ts) | 모델의 전후 선택과 native 선택 투영을 구별하고, 여러 view에서 실행할 History와 복원 대상 명시 | -| 중첩 진입·복귀와 입력 수신 | Affordance의 rename/gesture lifecycle, Web의 `isWebEditingHostTarget`, 각 domain의 Web binding | 후보 미적용·맡은 편집 거절·명시적 위임을 구별하는 연결. 유효한 조합을 지원하지 못하면 정본 API에서 해결 | -| 표현 교환과 Paste | Web의 `createWebClipboardBinding`, domain의 codec·표현·paste API | 표현 선택 실패와 선택한 payload의 편집 거절을 구별. 실제 native 후속 처리까지 관찰 | +| Rich Text의 범위 교체·Undo·Redo와 [EditingHistory](../packages/json-document-editing/src/history.ts): 아래 12개 공개 API 사례 통과 | 이 범위의 runtime·API 변경 불필요. 기존 owner의 conformance binding에 사례를 정착시키고 실제 native 선택 경로 확인 | 양방향 범위 복원, Undo 후 선택 이동과 Redo 보존. model과 DOM 관찰 구별 | +| [selectAllAffordance](../packages/json-document-affordance/src/select.ts)는 `allSelected: true`에서 `clear` 반환 | Affordance에 반복 입력 정책을 명시적으로 선택할 수 있는 경로 추가. 기본 Hands는 선택 유지 정책 사용; 기존 토글 호출의 생략된 설정은 보존 | 같은 universe에서 두 번 Mod+A → 전체 선택 유지; 명시한 토글 경로 → 해제 | +| [createRenameSession](../packages/json-document-affordance/src/session.ts)의 `onCommit`은 `void`; editor 거절 후에도 draft를 지우고 `onFinish` 호출 | Affordance에 성공 여부를 받는 확정 경로 추가. 거절 시 draft 유지, 성공 시 한 번 종료. 기존 `void` callback 경로는 보존 | label 검증 거절 → 내용·선택·History와 draft 유지; 수정 후 재시도 성공 → 한 번 확정·복귀 | +| [Rich Text Web](../packages/json-document-rich-text-web/src/contenteditable.ts)은 Undo 키 조건을 직접 판단하고, 구조 입력은 [Web keyboard](../packages/json-document-web/src/keyboard.ts)를 소비 | 공통 Undo/Redo 해석은 Web resolver를 소비하도록 연결. Text 삭제·IME·root 소유권은 Rich Text Web에 유지. 기존 modifier 수용 범위의 차이는 명시적 입력 정책으로 보존 | 기본 Cmd/Ctrl+Z와 Redo가 같은 의도로 해석됨; 추가 modifier·조합 중 입력은 선언한 정책으로 판정 | +| Document·Order·Tree·Sheet의 `selection.set`은 단일 point 전이; 전체 선택을 단일 전이로 받는 공개 경로는 없음 | 기존 domain Intent에 전체 선택 작업을 추가하고 Selection family로 한 번에 적용. 전체 집합은 Document·Order의 순서, Tree의 visible topology, Sheet의 행·열로 결정. Host의 첫·끝 연속 dispatch는 중간 관찰을 노출하므로 채택하지 않음 | 0·1·여러 대상에서 전체 선택 한 작업, 중간 부분 선택 publication 없음, 반복 결과·History 유지 | + +`onCommit` 반환값을 단순히 `void | boolean`으로 바꾸는 안도 기존 callback의 +호환성을 확인해야 한다. 현재 테스트의 `commits.push(...)`처럼 값을 반환하면서 +`void` callback으로 쓰던 호출이 있다. 성공 확인 경로는 기존 호출과 분리해 추가하고, +정확한 public signature는 그 owner의 호환성 검증과 함께 확정한다. + +Affordance의 후속 API 초안은 다음 두 호출을 지원하는 것이다. 아직 존재하지 않는 +옵션을 보여주는 설계 예이며 현재 Usage가 아니다. + +```ts +selectAllAffordance(stroke, state, { repeat: "preserve" }); +createRenameSession({ + tryCommit: (itemId, label) => editor.dispatch({ type: "item.rename", itemId, label }).ok, + onFinish: restoreItemFocus, +}); +``` -먼저 아래의 기본 Undo 사례를 공개 API 조합에 연결하고, 같은 기대 결과를 입력 -binding까지 확장한다. 중첩·여러 view·외부 표현의 차이는 해당 계약에서 별도로 -구체화한다. 구현을 바꿀 때 필요한 owner reference·Usage·source 등록은 각 owning -package의 기존 위치에서 함께 갱신한다. +`repeat` 생략은 기존 토글을 유지하고 기본 Hands의 조합이 `preserve`를 선택한다. +rename은 기존 `onCommit`과 새 동기 `tryCommit` 중 하나를 받으며 둘을 함께 +허용하지 않는 안이다. `false`는 draft 유지, `true`는 종료를 뜻하고 Affordance가 +EditingResult나 domain 검증 규칙을 직접 소유하지 않는다. 비동기 확정은 이 안의 +범위가 아니다. domain의 전체 선택 작업도 기존 point 선택 호출을 보존하는 추가 +계약으로 다루며 새 공통 selection state 객체나 registry를 요구하지 않는다. + +Host 연결도 구별한다. [Order Demo](../site/src/routes/order-demo/OrderDemoRoute.tsx)는 +정본 rename session을 `item.rename`에 연결하므로 거절 후 draft 처리를 Host에 +다시 구현하지 않는다. [Canvas Demo](../site/src/routes/canvas-demo/CanvasDemoRoute.tsx)의 +전체 선택은 `selectAllAffordance` 결과를 Object의 한 `selection.set`에 연결한다. +기본 정책의 소유자는 Affordance·Profile이며 Canvas의 조건문으로 바꾸지 않는다. + +후속 구현 순서는 기존 Undo 증거의 정착, 기본 전체 선택의 정본 입력·domain 연결, +명시적 draft 확정 결과의 연결이다. Native Cut·IME·여러 view의 History는 아래 +검증 질문으로 입력·외부 효과를 확인한 뒤 해당 owner에서 설계한다. 구현을 바꿀 때 +owner reference·Usage·source 등록도 각 package의 기존 위치에서 함께 갱신한다. +이 문서 변경은 표의 runtime 수정이나 public signature를 적용하지 않는다. ## 적합성 설계 @@ -581,20 +636,21 @@ profile 동결로 확대하지 않는다. ## 새 도출의 검증 질문 -다음은 실행할 수 있도록 시작 상태와 판정 결과를 적은 **후보 사례**다. 앞의 -기존 적합성 표의 통과 항목과 합산하지 않는다. 이 문서 변경은 새 behavior vector나 -구현을 추가하지 않는다. +다음은 시작 상태와 판정 결과를 적은 **조합 검증 사례**다. 일부 model 경로는 +임시 공개 API 실행으로 확인했으며, 영구 conformance vector·native 입력 검증과 +구별한다. 앞의 기존 적합성 표의 통과 항목과 합산하지 않는다. 이 문서 변경은 새 +behavior vector나 runtime 구현을 추가하지 않는다. | 사례 | 시작 상태 → 작업 → 판정할 결과 | 연결·현재 증거의 한계 | | --- | --- | --- | | 셀과 내부 문자열 | `Alpha` 셀 선택 / 내부 `ph` 선택 각각에서 Copy → 셀 payload / `ph` | EG-TARGET·EG-COPY. 기존 Sheet·native selection 증거는 각각 있으며 이 중첩 전체의 공통 binding은 없음 | -| 기본 Undo와 선택 복원 | 아래의 `Alpha` 범위 교체 → Undo → Redo; 정방향·역방향 각각 실행 | EG-HISTORY·EG-RESULT. 전후 model selection 증거와 실제 native range 복원 증거를 구별 | -| 선택만 변경한 뒤 Redo | 편집 → Undo → 선택만 이동 → Redo → 기록된 편집 후 내용·선택 | EG-SELECT·EG-HISTORY. 기본 Profile에서 선택 이동이 새 문서 History entry를 만들거나 Redo를 지우지 않음 | +| 기본 Undo와 선택 복원 | 아래의 `Alpha` 범위 교체 → Undo → Redo; 정방향·역방향 각각 실행 | EG-HISTORY·EG-RESULT. 임시 공개 API 실행에서 model 복원 확인; native range와 영구 vector는 별도 | +| 선택만 변경한 뒤 Redo | 편집 → Undo → 선택만 이동 → Redo → 기록된 편집 후 내용·선택 | EG-SELECT·EG-HISTORY. 임시 실행에서 새 문서 History entry 없이 Redo·편집 후 선택 보존 확인 | | 거절 시 바깥으로 전이 금지 | 선택한 객체 안의 text editor가 Delete 거절 → 객체 제거·외부 History entry가 생기지 않음 | EG-EDIT·입력 계약 후보. [Web clipboard ownership](../packages/json-document-web/tests/clipboard-rejection.test.ts)은 한 binding의 거절 처리 증거이며 일반적인 중첩 거절 vector는 없음 | | Toolbar 대상 보존 | editor A에서 범위 선택 → Toolbar에 focus → Copy 실행 → A의 선택 payload | 입력 focus와 Selection 구별. 전체 Hands에 대한 공통 실행 증거 없음 | | 셀 값과 행 구조 | 값이 있는 셀을 비움 / 해당 record 삭제 → 전자는 구조 유지, 후자는 record 제거 | EG-EDIT·profile 대상. 현재 Sheet Cut과 Database `record.delete`는 서로 다른 계약 | | 반복 일정 영향 범위 | 같은 occurrence에 `this` / `all` 이동 → profile이 정한 반복 일정 집합만 영향 | EG-TARGET·EG-EDIT. Calendar domain 사례이며 입력 맥락 전환으로 해석하지 않음 | -| Draft 취소와 Undo | 미확정 rename draft 수정 → cancel → 확정 label 불변; 확정 rename → Undo → 이전 label | EG-GESTURE·EG-HISTORY. 구조 gesture runner가 모든 rename·IME 취소를 증명하지 않음 | +| Draft 취소·거절과 Undo | draft 수정 → cancel 또는 검증 거절 → 확정 label 불변; 성공한 rename → Undo → 이전 label | EG-GESTURE·EG-HISTORY. 현재 rename 거절에서 문서 보존은 확인했으나 draft 종료도 관찰. 제안한 거절 후 유지 계약의 공백 | | 외부 참조·다른 profile로 Paste | 수식 또는 reference를 다른 위치·profile에 Paste → 변환·보존·거절 중 선언한 결과 | EG-PASTE. 현재 세 editor의 내부 round trip만으로 cross-profile 교환을 인증할 수 없음 | | 공유 문서의 두 편집 맥락 | A에서 편집 → B로 입력 focus 전환 → Undo → 선택한 History owner가 명시한 기여·선택 복원 | EG-HISTORY·EG-RESULT. 외부 History 연결 증거와 모든 Hand의 입력 routing 증거는 다름 | | Cut 쓰기 실패 후 native 처리 | editable selection에서 지원하는 Cut의 표현 쓰기 실패 → 해당 작업의 원본 제거 없음 | EG-CUT·Web. 제거 callback 미호출 외에 event 취소·후속 beforeinput/input·document·History를 관찰. 브라우저 재현 전 검증 공백 | @@ -612,8 +668,15 @@ profile 동결로 확대하지 않는다. 선택만 바꾼 뒤 Redo하는 후보는 Undo 행과 Redo 행 사이에서 caret을 옮겨 실행한다. 이 선택 이동은 별도 step이 아니며, Redo는 기록된 교체 후 선택을 복원해야 한다. -이 표는 공개 동작에 대한 기대 결과다. 새 테스트의 통과를 주장하지 않으며, -Document의 offset 하나만으로 과거 native range 전체를 복원할 수 있다고 가정하지 않는다. + +이 사례는 기존 공개 entrypoint의 빌드 결과로 실행했다. 정방향·역방향 2가지, +Undo 후 caret 이동 여부 2가지, 직접 호출·기본 keymap의 metaKey·ctrlKey 해석 +3가지를 조합한 12개 실행에서 내용·선택·Undo/Redo 상태가 기대와 일치했다. +키 경로는 `createWebKeyboardAdapter().resolve`의 결과를 editor의 `undo`·`redo`에 +전달했다. 실제 DOM event 수신이나 native range 복원을 검증한 것은 아니다. +재현 코드와 실행 조건은 [이슈의 학습 기록](https://github.com/developer-1px/json-document/issues/725#issuecomment-5577252880)에 보관하고, 영구 suite의 새 테스트로 +집계하지 않는다. Document의 offset 하나로 과거 native range 전체를 복원할 수 +있다고 가정하지 않는다. 기본 Profile의 역할과 Undo·선택 복원의 기본 의미는 설계에 채택한다. 입력 수신, 중첩의 진입·거절·복귀, profile 간 payload 변환과 여러 view의 History 귀속은 From aae30a66159e4ec0649fa94806d55805341cb965 Mon Sep 17 00:00:00 2001 From: "teo.v" <66650330+developer-1px@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:03:10 +0900 Subject: [PATCH 05/24] =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=20Profile=EC=9D=98=20=EC=A0=84=EC=B2=B4=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=C2=B7=ED=99=95=EC=A0=95=C2=B7Undo=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EC=9D=84=20=EB=B3=B4=EA=B0=95=ED=95=9C=EB=8B=A4=20(#728)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(editing): 기본 전체 선택과 확정 결과 계약을 보강한다 (#727) * feat(site): 기본 편집 동작을 Usage와 브라우저 검증에 연결한다 (#727) --------- Co-authored-by: 유용태 --- docs/api-reference/affordance.md | 4 +- docs/api-reference/editing.md | 4 + docs/public/affordance-rename.md | 47 ++++--- docs/public/affordance-select.md | 25 ++++ docs/public/intent.md | 5 + packages/json-document-affordance/README.md | 25 +++- .../json-document-affordance/src/select.ts | 4 +- .../json-document-affordance/src/session.ts | 14 +- .../tests/conformance/editing-grammar.test.ts | 94 +++++++++++++- packages/json-document-editing/README.md | 20 +++ .../json-document-editing/src/document.ts | 10 ++ packages/json-document-editing/src/order.ts | 10 ++ .../src/range-selection.ts | 25 +++- packages/json-document-editing/src/sheet.ts | 15 ++- packages/json-document-editing/src/tree.ts | 11 +- .../tests/conformance/select-all.test.ts | 120 ++++++++++++++++++ .../json-document-rich-text-web/README.md | 8 ++ .../src/contenteditable.ts | 13 +- .../tests/history-keyboard.test.ts | 86 +++++++++++++ .../tests/conformance/editing-grammar.test.ts | 24 +++- .../routes/canvas-demo/CanvasDemoRoute.tsx | 6 +- .../document-demo/DocumentDemoRoute.tsx | 18 ++- site/src/routes/order-demo/OrderDemoRoute.tsx | 16 ++- site/src/routes/sheet-demo/SheetDemo.tsx | 17 ++- site/src/routes/tree-demo/TreeDemoRoute.tsx | 18 ++- .../src/shared/demo-workbench/demo-sources.ts | 26 ++++ site/tests/browser/demo-workbench.spec.ts | 18 +++ site/tests/browser/document-demo.spec.ts | 18 +++ site/tests/browser/editor-slice-demos.spec.ts | 33 +++++ site/tests/browser/rich-text-demo.spec.ts | 22 ++++ site/tests/browser/sheet-demo.spec.ts | 17 +++ site/tests/unit/demo-workbench.test.tsx | 20 ++- 32 files changed, 739 insertions(+), 54 deletions(-) create mode 100644 packages/json-document-editing/tests/conformance/select-all.test.ts create mode 100644 packages/json-document-rich-text-web/tests/history-keyboard.test.ts diff --git a/docs/api-reference/affordance.md b/docs/api-reference/affordance.md index 15c900c2..370e11f0 100644 --- a/docs/api-reference/affordance.md +++ b/docs/api-reference/affordance.md @@ -366,7 +366,7 @@ createLineFocusSession(options: { readonly initialKey?: Key ## `createRenameSession` ```ts -createRenameSession(options: { readonly onCommit: (key: Key, draft: string) => void; readonly onCancel?: (key: Key, draft: string) => void; readonly onFinish?: (key: Key) => void; readonly onSnapshot?: (snapshot: RenameSessionSnapshot | null) => void; }): RenameSession +createRenameSession(options: ({ readonly onCommit: (key: Key, draft: string) => void; readonly tryCommit?: never; } | { readonly tryCommit: (key: Key, draft: string) => boolean; readonly onCommit?: never; }) & { readonly onCancel?: (key: Key, draft: string) => void; readonly onFinish?: (key: Key) => void; readonly onSnapshot?: (snapshot: RenameSessionSnapshot | null) => void; }): RenameSession ``` ## `createTypeaheadSession` @@ -781,7 +781,7 @@ resolveAffordanceKey(stroke: WebKeyboardStroke): AffordancePreview ## `selectAllAffordance` ```ts -selectAllAffordance(stroke: Pick, state: { readonly allSelected: boolean; }): AffordancePreview +selectAllAffordance(stroke: Pick, state: { readonly allSelected: boolean; }, options?: { readonly repeat?: "preserve" | "toggle"; }): AffordancePreview ``` ## `SelectOperation` diff --git a/docs/api-reference/editing.md b/docs/api-reference/editing.md index 711a3cfb..512f2cd1 100644 --- a/docs/api-reference/editing.md +++ b/docs/api-reference/editing.md @@ -841,6 +841,7 @@ interface DocumentEditor { ```ts type DocumentIntent = + | { readonly type: "selection.select-all" } | { readonly type: "selection.set"; readonly blockId: string; readonly mode?: "replace" | "extend" | "toggle"; readonly offset?: number } | { readonly type: "text.replace"; readonly blockId: string; readonly text: string; readonly offset?: number } | { readonly type: "block.insert"; readonly afterId?: string; readonly text?: string } @@ -1322,6 +1323,7 @@ interface OrderEditor { ```ts type OrderIntent = + | { readonly type: "selection.select-all" } | { readonly type: "selection.set"; readonly itemId: string; @@ -1454,6 +1456,7 @@ interface SheetEditor { ```ts type SheetIntent = + | { readonly type: "selection.select-all"; readonly topology?: SheetTopology } | { readonly type: "selection.set"; readonly rowId: string; @@ -1558,6 +1561,7 @@ interface TreeEditor { ```ts type TreeIntent = + | { readonly type: "selection.select-all"; readonly topology: TreeTopology } | { readonly type: "selection.set"; readonly nodeId: string; diff --git a/docs/public/affordance-rename.md b/docs/public/affordance-rename.md index 9536ae5b..10e7f4fa 100644 --- a/docs/public/affordance-rename.md +++ b/docs/public/affordance-rename.md @@ -4,33 +4,34 @@ Rename은 고른 대상의 레이블을 고치는 손입니다. F2와 느린 dou 같은 손을 엽니다. Escape는 [Escape](affordance-cancel.md)입니다. ```ts -import { applyAffordance, renameAffordance } from "@interactive-os/json-document-affordance"; +import { applyAffordance, createRenameSession, renameAffordance } from "@interactive-os/json-document-affordance"; + +const rename = createRenameSession({ + tryCommit: (itemId, label) => editor.dispatch({ type: "item.rename", itemId, label }).ok, + onSnapshot: renderDraft, + onFinish: restoreFocus, +}); function onKeyDown(event: KeyboardEvent) { applyAffordance(renameAffordance(event), { hand: (hand) => { - if (hand.type !== "rename") return; - if (hand.action === "begin") setRenaming(focusKey); - if (hand.action === "cancel") setRenaming(null); - if (hand.action === "commit" && renaming) { - editor.dispatch({ type: "item.rename", itemId: renaming, label: draft }); - setRenaming(null); - } + if (hand.type === "rename" && hand.action === "begin") rename.begin(focusKey, label); }, }); } -function onClick(event: MouseEvent, itemId: string) { - applyAffordance(renameAffordance({ type: "pointer", detail: event.detail, intervalMs }), { - hand: (hand) => { - if (hand.type === "rename" && hand.action === "begin") setRenaming(itemId); - }, - }); +function onClick(event: MouseEvent, itemId: string, label: string) { + rename.handlePointer(itemId, label, event.detail, event.timeStamp); +} + +function onDraftKeyDown(event: KeyboardEvent) { + if (rename.handleKey(event.key)) event.preventDefault(); } ``` -호스트는 레이블 필드를 그립니다. begin/cancel은 호스트 화면 상태이고, -commit만 json-document로 갑니다. 글 편집 자체는 Hands와 [Caret](affordance-caret.md)입니다. +호스트는 session snapshot으로 레이블 필드를 그리고 입력 변경을 `rename.update`에 +연결합니다. session이 draft의 시작·확정·취소를 소유하고, 확정 시 domain editor가 +문서를 변경합니다. 글 편집 자체는 Hands와 [Caret](affordance-caret.md)입니다. 닫는 손: - F2 @@ -41,12 +42,20 @@ commit만 json-document로 갑니다. 글 편집 자체는 Hands와 [Caret](affo ## Session API -`createRenameSession({ onCommit, onCancel, onFinish, onSnapshot })`은 active key, draft, -slow double-click 간격과 commit/cancel을 소유합니다. Host는 snapshot으로 input을 -그리고 `onCommit(key, draft)`에서 domain rename Intent를 보냅니다. +`createRenameSession`은 active key, draft, slow double-click 간격과 commit/cancel을 +소유합니다. 기존 `onCommit(key, draft): void` 또는 동기 +`tryCommit(key, draft): boolean` 중 하나를 받습니다. 둘을 동시에 지정하거나 +비동기 `tryCommit`을 전달할 수 없습니다. + +`tryCommit`이 `false`이면 active key와 draft를 유지하고 `onFinish`를 호출하지 +않습니다. 사용자가 수정한 뒤 다시 확정할 수 있습니다. `true` 또는 기존 +`onCommit` 완료 시 snapshot을 비우고 `onFinish`를 한 번 호출합니다. +Escape는 commit 없이 종료합니다. 검증과 문서 변경은 domain이 소유합니다. `onCancel(key, draft)`는 저장되지 않은 값을 복구하거나, Calendar처럼 생성과 동시에 시작된 rename을 취소할 때 새 항목을 제거하는 제품 정책을 연결합니다. Calendar는 `useCalendarRenameInput(hand)`으로 focus/select, input change, Enter·Escape, blur를 같은 session에 연결합니다. Host는 반환된 `ref`, `value`, event handler만 제목 input에 전달합니다. + +Usage와 Source: [Order Demo](/demo/order)의 F2 → 입력 → Enter 또는 Escape. diff --git a/docs/public/affordance-select.md b/docs/public/affordance-select.md index 23996de5..664aa7a1 100644 --- a/docs/public/affordance-select.md +++ b/docs/public/affordance-select.md @@ -10,6 +10,7 @@ import { editingCommandFromWebKeyboardStroke, pointerSelect, planeHitAffordance, + selectAllAffordance, } from "@interactive-os/json-document-affordance"; function onPointerDown(event: PointerEvent, itemId: string) { @@ -39,6 +40,18 @@ const editing = useEditing({ neighbor: (key, command) => neighborFromProductTopology(key, command), }, }); + +function onSelectAll(event: KeyboardEvent) { + applyAffordance(selectAllAffordance(event, { + allSelected: editor.selectedItemIds.length === items.length, + }, { repeat: "preserve" }), { + hand: (hand) => { + if (hand.type !== "select-all") return; + editor.dispatch({ type: "selection.select-all" }); + event.preventDefault(); + }, + }); +} ``` 호스트는 보이는 키와 장르 Intent만 넘깁니다. keymap을 덮어쓰지 않습니다. @@ -54,6 +67,18 @@ const editing = useEditing({ 공통 해석만 소유하며, 현재 focus와 다음 이웃을 결정하는 topology는 Host가 `focusKey`와 `neighbor`로 주입합니다. +### `selectAllAffordance(stroke, state, { repeat })` + +기본 편집 Usage는 `repeat: "preserve"`를 선택합니다. Mod+A를 반복해도 +`select-all`을 보내며 전체 선택을 해제하지 않습니다. 옵션 생략 또는 +`repeat: "toggle"`은 기존 `allSelected ? clear : select-all` 동작입니다. +Document·Order·Tree·Sheet의 `selection.select-all`은 대상 전체를 한 번의 +선택 전이로 만듭니다. Tree는 visible topology, Sheet는 선언한 행·열을 사용합니다. +빈 대상은 빈 선택이 되고, 선택 변경은 문서 Undo/Redo 기록을 추가하거나 지우지 않습니다. +실제 Demo는 Web의 `isWebEditingHostTarget`으로 내부 text field의 Mod+A를 보존합니다. + +Usage와 Source: [Order](/demo/order), [Tree](/demo/tree), [Sheet](/demo/sheet), [Document](/demo). + 닫는 손: - 클릭 replace, 이미 고른 집합 유지 - Shift+click 추가/제거 diff --git a/docs/public/intent.md b/docs/public/intent.md index 8e6c2040..7fda4da1 100644 --- a/docs/public/intent.md +++ b/docs/public/intent.md @@ -67,6 +67,7 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. | `type` | 필드 | 결과 | | --- | --- | --- | | `selection.set` | `blockId`, `mode?`, `offset?` | 블록 선택 변경 | +| `selection.select-all` | | 첫 블록 offset 0부터 마지막 블록 text 끝까지 한 번에 선택 | | `text.replace` | `blockId`, `text`, `offset?` | 블록 text 변경 | | `block.insert` | `afterId?`, `text?` | 블록 추가 | | `selection.remove` | | 선택한 블록 제거 | @@ -83,6 +84,7 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. | `type` | 필드 | 결과 | | --- | --- | --- | | `selection.set` | `rowId`, `columnId`, `mode?` | 셀 선택 변경 | +| `selection.select-all` | `topology?` | 지정한 행·열 전체를 하나의 범위로 선택 | | `selection.fill` | `value`, `topology?` | 선택한 셀 채우기 | | `cell.commit` | `rowId`, `columnId`, `value` | 한 셀의 값 확정 | | `clipboard.paste` | `clipboard`, `topology?` | Clipboard 셀 붙여넣기 | @@ -95,6 +97,7 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. | `type` | 필드 | 결과 | | --- | --- | --- | | `selection.set` | `nodeId`, `topology`, `mode?` | 보이는 노드 선택 변경 | +| `selection.select-all` | `topology` | 보이는 노드 전체를 하나의 범위로 선택 | | `selection.remove` | `topology` | 선택한 노드 제거 | | `clipboard.paste` | `clipboard`, `topology`, `afterId?` | 붙여넣기 | @@ -121,6 +124,8 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. | `type` | 필드 | 결과 | | --- | --- | --- | | `selection.set` | `itemId`, `mode?` | 항목 선택 변경 | +| `selection.select-all` | | 전체 항목을 하나의 범위로 선택 | +| `item.rename` | `itemId`, `label` | 레이블 확정; 실패 결과는 draft session에 전달 | | `selection.remove` | | 선택한 항목 제거 | | `clipboard.paste` | `clipboard`, `afterId?` | 붙여넣기 | diff --git a/packages/json-document-affordance/README.md b/packages/json-document-affordance/README.md index 8a4d6313..607d3159 100644 --- a/packages/json-document-affordance/README.md +++ b/packages/json-document-affordance/README.md @@ -56,6 +56,20 @@ drop targets, and insertion positions without owning DOM or product color. own the reusable state that spans several events. Product selection and rename Intents remain callbacks supplied by the host. +`createRenameSession` accepts either the legacy `onCommit(key, draft): void` +or synchronous `tryCommit(key, draft): boolean`. A false result keeps the active +key and draft without publishing a finish. Updating and retrying can then +complete the edit; success or cancellation clears the draft and calls `onFinish` +once. The domain editor still owns validation and document changes: + +```ts +createRenameSession({ + tryCommit: (itemId, label) => editor.dispatch({ type: "item.rename", itemId, label }).ok, + onSnapshot: renderDraft, + onFinish: restoreFocus, +}); +``` + `createBoardDragSession` owns the input-agnostic active item, drop-target preview, commit, and cancel lifecycle for Board Hands. Web pointer and HTML Drag and Drop sessions feed it; Hosts still resolve targets and dispatch the @@ -79,13 +93,14 @@ open/focus semantics remain outside this geometry contract. Usage: [Affordance](https://developer-1px.github.io/json-document/docs/affordance) -`selectAllAffordance` implements an explicit Mod+A toggle input convention: -when everything is selected it emits `clear`; otherwise it emits `select-all`. -The semantic `select-all` command itself is idempotent. Hosts choosing this -input convention consume the existing Affordance API. +`selectAllAffordance(stroke, state, { repeat: "preserve" })` emits `select-all` +for Mod+A even when everything is selected. The default editing Usage chooses +this policy. Omission or `{ repeat: "toggle" }` retains the existing behavior: +emit `clear` when `state.allSelected`, otherwise `select-all`. This is an input +policy; domain editors own the selected universe and its semantic transition. [Editing grammar integration tests](tests/conformance/editing-grammar.test.ts) -connect that mapping to KeySelection and connect `createGestureSession` to +connect both mappings to selection, cover rejected draft commits, and connect `createGestureSession` to Document's `selection.move`. Structural preview and cancellation leave committed value/history unchanged; commit dispatches the latest preview once. This proves the tested composition, not every Host callback. IME composition has a separate diff --git a/packages/json-document-affordance/src/select.ts b/packages/json-document-affordance/src/select.ts index 2744c59c..879a7e39 100644 --- a/packages/json-document-affordance/src/select.ts +++ b/packages/json-document-affordance/src/select.ts @@ -111,13 +111,15 @@ export function resolveAffordanceKey(stroke: WebKeyboardStroke): AffordancePrevi return { hand: keyboard.resolve(stroke) }; } +/** Mod+A selects all. Choose preserve for repeated selection; omission retains the legacy toggle. */ export function selectAllAffordance( stroke: Pick, state: { readonly allSelected: boolean }, + options: { readonly repeat?: "preserve" | "toggle" } = {}, ): AffordancePreview { const mod = stroke.metaKey || stroke.ctrlKey; if (!mod || stroke.key.toLowerCase() !== "a") return { hand: null }; - return { hand: { type: state.allSelected ? "clear" : "select-all" } }; + return { hand: { type: state.allSelected && options.repeat !== "preserve" ? "clear" : "select-all" } }; } export function typeaheadAffordance(input: { diff --git a/packages/json-document-affordance/src/session.ts b/packages/json-document-affordance/src/session.ts index f2a63a2d..da75e6bc 100644 --- a/packages/json-document-affordance/src/session.ts +++ b/packages/json-document-affordance/src/session.ts @@ -79,8 +79,14 @@ export interface RenameSession { cancel(): void; } -export function createRenameSession(options: { +/** Owns the draft lifecycle. A false tryCommit retains the draft without finishing; onCommit always finishes. */ +export function createRenameSession(options: ({ readonly onCommit: (key: Key, draft: string) => void; + readonly tryCommit?: never; +} | { + readonly tryCommit: (key: Key, draft: string) => boolean; + readonly onCommit?: never; +}) & { readonly onCancel?: (key: Key, draft: string) => void; readonly onFinish?: (key: Key) => void; readonly onSnapshot?: (snapshot: RenameSessionSnapshot | null) => void; @@ -94,7 +100,11 @@ export function createRenameSession(options: { function finish(commit: boolean) { if (snapshot === null) return; const finished = snapshot; - if (commit) options.onCommit(finished.key, finished.draft); + if (commit) { + if (options.tryCommit !== undefined) { + if (!options.tryCommit(finished.key, finished.draft)) return; + } else options.onCommit(finished.key, finished.draft); + } else options.onCancel?.(finished.key, finished.draft); publish(null); options.onFinish?.(finished.key); diff --git a/packages/json-document-affordance/tests/conformance/editing-grammar.test.ts b/packages/json-document-affordance/tests/conformance/editing-grammar.test.ts index 6af1531c..43a7c7ca 100644 --- a/packages/json-document-affordance/tests/conformance/editing-grammar.test.ts +++ b/packages/json-document-affordance/tests/conformance/editing-grammar.test.ts @@ -1,9 +1,99 @@ -import { createDocumentEditor } from "@interactive-os/json-document-editing"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { createDocumentEditor, createOrderEditor, type OrderDocument } from "@interactive-os/json-document-editing"; import { createKeySelectionFamily, emptyKeySelection, type KeySelectionContext } from "@interactive-os/json-document-selection"; import { describe, expect, test } from "vitest"; -import { createGestureSession, selectAllAffordance, type GestureCancelReason } from "../../src/index.js"; +import { createGestureSession, createRenameSession, selectAllAffordance, type GestureCancelReason } from "../../src/index.js"; describe("editing grammar / input mapping", () => { + test.each(["metaKey", "ctrlKey"] as const)("EG-SELECT / %s+A preserve profile retains all on repeat", (modifier) => { + const editor = createDocumentEditor({ blocks: [{ id: "a", text: "A" }, { id: "b", text: "B" }] }); + const stroke = { key: "A", metaKey: false, ctrlKey: false, [modifier]: true }; + for (const allSelected of [false, true, true]) { + const hand = selectAllAffordance(stroke, { allSelected }, { repeat: "preserve" }).hand; + expect(hand).toEqual({ type: "select-all" }); + if (hand?.type === "select-all") editor.dispatch({ type: "selection.select-all" }); + expect(editor.selectedBlockIds).toEqual(["a", "b"]); + expect(editor.snapshot).toMatchObject({ canUndo: false, canRedo: false }); + } + expect(selectAllAffordance(stroke, { allSelected: true }, { repeat: "toggle" }).hand).toEqual({ type: "clear" }); + expect(selectAllAffordance({ ...stroke, key: "x" }, { allSelected: true }, { repeat: "preserve" }).hand).toBeNull(); + expect(selectAllAffordance({ key: "a", metaKey: false, ctrlKey: false }, { allSelected: true }, { repeat: "preserve" }).hand).toBeNull(); + }); + + test("EG-COMMIT / rejection retains draft; retry finishes once; cancel never commits", () => { + const attempts: string[] = []; + const finished: string[] = []; + const cancelled: string[] = []; + const published: unknown[] = []; + const session = createRenameSession({ + tryCommit(key, draft) { attempts.push(`${key}:${draft}`); return draft.length > 0; }, + onFinish: (key) => finished.push(key), + onCancel: (key, draft) => cancelled.push(`${key}:${draft}`), + onSnapshot: (snapshot) => published.push(snapshot), + }); + session.begin("a", "Alpha"); + session.update(""); + expect(session.handleKey("Enter")).toBe(true); + session.commit(); + expect(session.getSnapshot()).toEqual({ key: "a", draft: "" }); + expect(published).toEqual([{ key: "a", draft: "Alpha" }, { key: "a", draft: "" }]); + expect(finished).toEqual([]); + session.update("Beta"); + expect(session.handleKey("Enter")).toBe(true); + session.commit(); + expect(session.getSnapshot()).toBeNull(); + expect(attempts).toEqual(["a:", "a:", "a:Beta"]); + expect(finished).toEqual(["a"]); + session.begin("b", "Draft"); + expect(session.handleKey("Escape")).toBe(true); + expect(session.getSnapshot()).toBeNull(); + expect(cancelled).toEqual(["b:Draft"]); + expect(finished).toEqual(["a", "b"]); + expect(attempts).toHaveLength(3); + }); + + test("rename callback contracts remain mutually exclusive and synchronous", () => { + const commits: string[] = []; + const legacy = createRenameSession({ onCommit: (_key, draft) => commits.push(draft) }); + legacy.begin("a", "Alpha"); + legacy.commit(); + expect(commits).toEqual(["Alpha"]); + expect(legacy.getSnapshot()).toBeNull(); + // These calls are checked by the owning package's test typecheck. + // @ts-expect-error Select exactly one commit contract. + createRenameSession({ onCommit: () => {}, tryCommit: () => true }); + // @ts-expect-error A commit contract is required. + createRenameSession({}); + // @ts-expect-error Result-aware commit is synchronous. + createRenameSession({ tryCommit: async () => true }); + }); + + test("EG-COMMIT / Order rejection preserves value, selection and history until retry", () => { + const initial: OrderDocument = { items: [{ id: "a", label: "Alpha" }] }; + const editor = createOrderEditor(createJSONDocument(initial, { + validate: (candidate) => (candidate as OrderDocument).items.every((item) => item.label.length > 0) + ? { ok: true } : { ok: false, code: "schema_violation" }, + })); + const before = editor.snapshot; + let finished = 0; + const rename = createRenameSession({ + tryCommit: (itemId, label) => editor.dispatch({ type: "item.rename", itemId, label }).ok, + onFinish: () => { finished++; }, + }); + rename.begin("a", "Alpha"); + rename.update(""); + rename.handleKey("Enter"); + expect(editor.snapshot).toEqual(before); + expect(rename.getSnapshot()).toEqual({ key: "a", draft: "" }); + expect(finished).toBe(0); + rename.update("Beta"); + rename.handleKey("Enter"); + expect(rename.getSnapshot()).toBeNull(); + expect(finished).toBe(1); + expect(editor.snapshot).toMatchObject({ value: { items: [{ id: "a", label: "Beta" }] }, canUndo: true }); + expect(editor.undo()).toMatchObject({ ok: true, snapshot: { value: initial, selection: before.selection, canUndo: false } }); + }); + test.each(["metaKey", "ctrlKey"] as const)("EG-SELECT / %s+A toggle profile sends clear as a distinct intent", (modifier) => { const context: KeySelectionContext = { keys: ["a", "b"], universe: "visible:v1", universeMismatch: "clear" }; const family = createKeySelectionFamily(); diff --git a/packages/json-document-editing/README.md b/packages/json-document-editing/README.md index f92b0d22..7d368264 100644 --- a/packages/json-document-editing/README.md +++ b/packages/json-document-editing/README.md @@ -114,6 +114,26 @@ no-op, canceled, preview, and remote-presence changes do not create local docume history. Native text selection remains input/editor-owned and connects through an explicit edit lease rather than becoming a structural selection variant. +`selection.select-all` replaces the complete range selection in one publication. +Document selects from the first block's offset 0 to the last block's text end; +Order selects the full item order. Tree requires `topology` and selects its +visible IDs. Sheet uses its document axes or the supplied `topology` row/column +order. An empty universe clears selection. Repeating the operation preserves +content and document Undo/Redo; it still publishes one selection revision under +the existing session contract. Tree Copy/Cut still includes selected nodes' +descendants, and Sheet Copy/Cut still uses the primary rectangle. + +```ts +documentEditor.dispatch({ type: "selection.select-all" }); +orderEditor.dispatch({ type: "selection.select-all" }); +treeEditor.dispatch({ type: "selection.select-all", topology: { visibleIds } }); +sheetEditor.dispatch({ type: "selection.select-all", topology: { rowIds, columnIds } }); +``` + +[Whole-selection conformance cases](tests/conformance/select-all.test.ts) cover +empty, single, repeated, reordered and filtered targets, one publication, and +document history retention. Usage: the Document, Order, Tree and Sheet demos. + `Database` keeps typed property schema and records in canonical JSON while its saved Table views own property order, visibility, width, sort, and filter. The editor projects each saved view into a visible record/property topology for diff --git a/packages/json-document-editing/src/document.ts b/packages/json-document-editing/src/document.ts index acda405f..ad3d2937 100644 --- a/packages/json-document-editing/src/document.ts +++ b/packages/json-document-editing/src/document.ts @@ -8,6 +8,7 @@ import { collapsedRangeSelection, emptyRangeSelection, reconcileRangeSelection, + replaceRangeSelection, selectRangePoint, } from "./range-selection.js"; import { lineInterval, lineTopology } from "./topology.js"; @@ -62,6 +63,7 @@ export const documentClipboardFormat = { }; export type DocumentIntent = + | { readonly type: "selection.select-all" } | { readonly type: "selection.set"; readonly blockId: string; readonly mode?: "replace" | "extend" | "toggle"; readonly offset?: number } | { readonly type: "text.replace"; readonly blockId: string; readonly text: string; readonly offset?: number } | { readonly type: "block.insert"; readonly afterId?: string; readonly text?: string } @@ -113,6 +115,14 @@ export function createDocumentEditor(source: EditingDocumentSource { const blocks = value().blocks; + if (intent.type === "selection.select-all") { + const first = blocks[0]; + const last = blocks.at(-1); + const selection = replaceRangeSelection(session.snapshot.selection, + first && last ? { anchor: pointAt(first, 0), focus: pointAt(last, last.text.length) } : null, + (left, right) => left.blockId === right.blockId && left.offset === right.offset); + return success(session.select(asDocumentSelection(selection))); + } if (intent.type === "selection.set") { const index = blocks.findIndex((block) => block.id === intent.blockId); if (index < 0) return failure("selection.block-not-found"); diff --git a/packages/json-document-editing/src/order.ts b/packages/json-document-editing/src/order.ts index d0269535..86dc5fd1 100644 --- a/packages/json-document-editing/src/order.ts +++ b/packages/json-document-editing/src/order.ts @@ -10,6 +10,7 @@ import { collapsedRangeSelection, emptyRangeSelection, reconcileRangeSelection, + replaceRangeSelection, selectRangePoint, type RangeSelectionState, } from "./range-selection.js"; @@ -64,6 +65,7 @@ export const orderClipboardFormat = { }; export type OrderIntent = + | { readonly type: "selection.select-all" } | { readonly type: "selection.set"; readonly itemId: string; @@ -117,6 +119,14 @@ export function createOrderEditor( function dispatch(intent: OrderIntent): EditingResult { const items = value().items; + if (intent.type === "selection.select-all") { + const first = items[0]; + const last = items.at(-1); + const selection = replaceRangeSelection(session.snapshot.selection, + first && last ? { anchor: { itemId: first.id }, focus: { itemId: last.id } } : null, + (left, right) => left.itemId === right.itemId); + return success(session.select(asOrderSelection(selection))); + } if (intent.type === "selection.set") { if (!items.some((item) => item.id === intent.itemId)) return failure("selection.item-not-found"); const point: OrderPoint = { itemId: intent.itemId }; diff --git a/packages/json-document-editing/src/range-selection.ts b/packages/json-document-editing/src/range-selection.ts index 33b9b73f..34a0dbbc 100644 --- a/packages/json-document-editing/src/range-selection.ts +++ b/packages/json-document-editing/src/range-selection.ts @@ -19,11 +19,7 @@ export function selectRangePoint( mode: RangeSelectionMode, sameTarget: (left: Point, right: Point) => boolean, ): RangeSelectionState { - const topology: OrderedTopology = { - equals: sameTarget, - interval: (anchor, focus) => sameTarget(anchor, focus) ? [anchor] : [anchor, focus], - reconcilePoint: (candidate) => candidate, - }; + const topology = pointTopology(sameTarget); const family = createRangeSelectionFamily(); return family.transition(current, mode === "replace" ? { type: "collapse", point } @@ -32,6 +28,25 @@ export function selectRangePoint( : { type: "toggle-point", point }, { topology }).state; } +/** Replace all ranges in one transition; a missing domain range clears selection. */ +export function replaceRangeSelection( + current: RangeSelectionState, + range: SelectionRange | null, + sameTarget: (left: Point, right: Point) => boolean, +): RangeSelectionState { + return createRangeSelectionFamily().transition(current, + range === null ? { type: "clear" } : { type: "replace-range", range }, + { topology: pointTopology(sameTarget) }).state; +} + +function pointTopology(equals: (left: Point, right: Point) => boolean): OrderedTopology { + return { + equals, + interval: (anchor, focus) => equals(anchor, focus) ? [anchor] : [anchor, focus], + reconcilePoint: (candidate) => candidate, + }; +} + export function collapsedRangeSelection(point: Point): RangeSelectionState { return collapsed(point); } diff --git a/packages/json-document-editing/src/sheet.ts b/packages/json-document-editing/src/sheet.ts index e531ea0f..1407bf56 100644 --- a/packages/json-document-editing/src/sheet.ts +++ b/packages/json-document-editing/src/sheet.ts @@ -11,7 +11,7 @@ import { } from "./session.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; import type { EditingHistoryOptions } from "./history.js"; -import { reconcileRangeSelection } from "./range-selection.js"; +import { reconcileRangeSelection, replaceRangeSelection } from "./range-selection.js"; import { cutEditingClipboard, isClipboardJSONValue, isClipboardRecord } from "./clipboard.js"; import { gridCellsInRange, gridPointIndex, gridPointKey, gridRangeBounds, type GridTopology } from "./topology.js"; import { assertSheetDocument, assertUniqueSheetIds } from "./sheet-validation.js"; @@ -83,6 +83,7 @@ export const sheetClipboardFormat = { }; export type SheetIntent = + | { readonly type: "selection.select-all"; readonly topology?: SheetTopology } | { readonly type: "selection.set"; readonly rowId: string; @@ -196,6 +197,18 @@ export function createSheetEditor(source: EditingDocumentSource, } function dispatch(intent: SheetIntent): EditingResult { + if (intent.type === "selection.select-all") { + const { rowIds, columnIds } = resolveTopology(value(), intent.topology, index()); + const firstRow = rowIds[0]; + const firstColumn = columnIds[0]; + const lastRow = rowIds.at(-1); + const lastColumn = columnIds.at(-1); + const selection = replaceRangeSelection(session.snapshot.selection, + firstRow !== undefined && firstColumn !== undefined && lastRow !== undefined && lastColumn !== undefined + ? { anchor: { rowId: firstRow, columnId: firstColumn }, focus: { rowId: lastRow, columnId: lastColumn } } + : null, sameSheetPoint); + return success(session.select(withPrimaryAliases(selection))); + } if (intent.type === "selection.set") { const point = resolvePoint(value(), intent.rowId, intent.columnId, index()); if (point === null) return failure("selection.cell-not-found"); diff --git a/packages/json-document-editing/src/tree.ts b/packages/json-document-editing/src/tree.ts index e9e9565e..e6845085 100644 --- a/packages/json-document-editing/src/tree.ts +++ b/packages/json-document-editing/src/tree.ts @@ -5,7 +5,7 @@ import { import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; import { createEditingId } from "./identity.js"; import type { EditingHistoryOptions } from "./history.js"; -import { reconcileRangeSelection } from "./range-selection.js"; +import { reconcileRangeSelection, replaceRangeSelection } from "./range-selection.js"; import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; import { createRangeSelectionFamily, @@ -75,6 +75,7 @@ export const treeClipboardFormat = { }; export type TreeIntent = + | { readonly type: "selection.select-all"; readonly topology: TreeTopology } | { readonly type: "selection.set"; readonly nodeId: string; @@ -171,6 +172,14 @@ export function createTreeEditor( function dispatch(intent: TreeIntent): EditingResult { const topology = resolveTopology(intent.topology); + if (intent.type === "selection.select-all") { + const first = topology.visibleIds[0]; + const last = topology.visibleIds.at(-1); + const selection = replaceRangeSelection(session.snapshot.selection, + first !== undefined && last !== undefined ? { anchor: { nodeId: first }, focus: { nodeId: last } } : null, + (left, right) => left.nodeId === right.nodeId); + return success(session.select(asTreeSelection(selection))); + } if (intent.type === "selection.set") { if (!(topologyCache.get(topology) as TreeTopologyIndex).visible.has(intent.nodeId)) { return failure("selection.node-not-visible"); diff --git a/packages/json-document-editing/tests/conformance/select-all.test.ts b/packages/json-document-editing/tests/conformance/select-all.test.ts new file mode 100644 index 00000000..2a32cde4 --- /dev/null +++ b/packages/json-document-editing/tests/conformance/select-all.test.ts @@ -0,0 +1,120 @@ +import { expect, test } from "vitest"; +import { + createDocumentEditor, createOrderEditor, createTreeEditor, createSheetEditor, + type EditingSnapshot, type EditingResult, +} from "../../src/index.js"; +import type { JSONValue } from "@interactive-os/json-document"; + +function assertSelectionTransition( + editor: { + readonly snapshot: EditingSnapshot; + subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; + }, + selectAll: () => EditingResult, + expected: unknown, +) { + const before = editor.snapshot; + const seen: Selection[] = []; + const release = editor.subscribe((snapshot) => seen.push(snapshot.selection)); + try { + for (let repeat = 0; repeat < 3; repeat++) { + expect(selectAll().ok).toBe(true); + expect(editor.snapshot.selection).toEqual(expected); + expect(editor.snapshot).toMatchObject({ value: before.value, canUndo: before.canUndo, canRedo: before.canRedo }); + // One final selection per dispatch, with no first/last intermediate state. + expect(seen).toHaveLength(repeat + 1); + expect(seen[repeat]).toEqual(expected); + } + } finally { release(); } +} + +const empty = { kind: "range", ranges: [], primaryIndex: null }; +const range = (anchor: JSONValue, focus: JSONValue) => ({ kind: "range", ranges: [{ anchor, focus }], primaryIndex: 0 }); + +test.each([0, 1, 3])("Document select-all covers %i blocks, including text endpoints", (count) => { + const blocks = [{ id: "a", text: "Alpha" }, { id: "b", text: "Beta" }, { id: "c", text: "Gamma" }].slice(0, count); + const editor = createDocumentEditor({ blocks }); + const first = blocks[0], last = blocks.at(-1); + const expected = first && last ? range({ blockId: first.id, offset: 0 }, { blockId: last.id, offset: last.text.length }) : empty; + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all" }), expected); + expect(editor.selectedBlockIds).toEqual(blocks.map((block) => block.id)); + if (first) { + editor.dispatch({ type: "text.replace", blockId: first.id, text: "Changed" }); + expect(editor.undo().ok).toBe(true); + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all" }), expected); + expect(editor.redo().ok).toBe(true); + expect(editor.snapshot.canUndo).toBe(true); + } +}); + +test.each([0, 1, 3])("Order select-all covers %i items and preserves history", (count) => { + const items = [{ id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }].slice(0, count); + const editor = createOrderEditor({ items }); + const first = items[0], last = items.at(-1); + const expected = first && last ? range({ itemId: first.id }, { itemId: last.id }) : empty; + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all" }), expected); + expect(editor.selectedItemIds).toEqual(items.map((item) => item.id)); + if (first) { + editor.dispatch({ type: "item.rename", itemId: first.id, label: "Changed" }); + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all" }), expected); + expect(editor.undo().ok).toBe(true); + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all" }), expected); + expect(editor.redo().ok).toBe(true); + } +}); + +test.each([[], ["b"], ["b", "a"]].map((visibleIds) => ({ visibleIds })))("Tree select-all uses exactly the visible topology $visibleIds", ({ visibleIds }) => { + const editor = createTreeEditor({ nodes: [ + { id: "a", parentId: null, label: "A" }, + { id: "hidden", parentId: "a", label: "Hidden" }, + { id: "b", parentId: null, label: "B" }, + ] }); + const topology = { visibleIds }; + const first = visibleIds[0], last = visibleIds.at(-1); + const expected = first && last ? range({ nodeId: first }, { nodeId: last }) : empty; + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all", topology }), expected); + expect(editor.selectedNodeIdsIn(topology)).toEqual(visibleIds); + if (visibleIds.includes("a")) { + // Selection scope remains visible; existing Copy/Cut descendant closure is unchanged. + expect(editor.copy(topology)?.nodes.map((node) => node.id)).toEqual(["a", "hidden", "b"]); + expect(editor.cut(topology)?.result.ok).toBe(true); + expect(editor.undo().ok).toBe(true); + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all", topology }), expected); + expect(editor.redo().ok).toBe(true); + } +}); + +test.each([ + { rowIds: [], columnIds: ["x"] }, { rowIds: ["a"], columnIds: [] }, + { rowIds: ["b"], columnIds: ["y"] }, { rowIds: ["b", "a"], columnIds: ["y", "x"] }, +])("Sheet select-all respects declared axes $rowIds / $columnIds", (topology) => { + const editor = createSheetEditor({ + columns: [{ id: "x", label: "X" }, { id: "y", label: "Y" }], + rows: [{ id: "a", cells: { x: 1, y: 2 } }, { id: "b", cells: { x: 3, y: 4 } }, { id: "hidden", cells: { x: 5, y: 6 } }], + }); + const anchor = topology.rowIds[0] && topology.columnIds[0] + ? { rowId: topology.rowIds[0], columnId: topology.columnIds[0] } : null; + const focus = anchor ? { rowId: topology.rowIds.at(-1)!, columnId: topology.columnIds.at(-1)! } : null; + const expected = { ...(anchor ? range(anchor, focus) : empty), anchor, focus }; + const select = () => editor.dispatch({ type: "selection.select-all", topology }); + assertSelectionTransition(editor, select, expected); + expect(editor.selectedCellsIn(topology).map(({ rowId, columnId }) => ({ rowId, columnId }))).toEqual( + topology.rowIds.flatMap((rowId) => topology.columnIds.map((columnId) => ({ rowId, columnId }))), + ); + if (anchor) { + expect(editor.dispatch({ type: "selection.fill", value: 0, topology }).ok).toBe(true); + expect(editor.undo().ok).toBe(true); + assertSelectionTransition(editor, select, expected); + expect(editor.redo().ok).toBe(true); + } +}); + +test("Sheet omitted topology covers all cells; empty documents clear selection", () => { + const sheet = createSheetEditor({ columns: [{ id: "x", label: "X" }], rows: [{ id: "a", cells: { x: 1 } }, { id: "b", cells: { x: 2 } }] }); + expect(sheet.dispatch({ type: "selection.select-all" }).ok).toBe(true); + expect(sheet.selectedCells).toHaveLength(2); + const emptySheet = createSheetEditor({ rows: [], columns: [] }); + assertSelectionTransition(emptySheet, () => emptySheet.dispatch({ type: "selection.select-all" }), { ...empty, anchor: null, focus: null }); + const tree = createTreeEditor({ nodes: [] }); + assertSelectionTransition(tree, () => tree.dispatch({ type: "selection.select-all", topology: { visibleIds: [] } }), empty); +}); diff --git a/packages/json-document-rich-text-web/README.md b/packages/json-document-rich-text-web/README.md index bd6b77cb..590202db 100644 --- a/packages/json-document-rich-text-web/README.md +++ b/packages/json-document-rich-text-web/README.md @@ -8,6 +8,14 @@ Selection round-trips both text offsets and container child boundaries. Copy, cut, and paste publish/consume structured Rich Text, safe semantic HTML, and plain text in that priority order. +Keyboard Undo/Redo consumes the Web package's `createWebKeyboardAdapter` defaults +(`Mod-z`, `Mod-Shift-z`). This binding retains its historical Alt variants through +explicit keymap entries. Root ownership and composition handling stay in this +binding. [Keyboard history tests](tests/history-keyboard.test.ts) exercise native +range replacement and meta/control Undo/Redo, including backward ranges and +selection movement after Undo without losing Redo. These synthetic DOM cases +complement the Rich Text demo's real-browser input tests. + IME composition uses a DOM reconciliation lease rather than inserting `compositionend.data` directly. The binding captures the canonical selection and pre-composition DOM text, lets the platform mutate the active DOM while diff --git a/packages/json-document-rich-text-web/src/contenteditable.ts b/packages/json-document-rich-text-web/src/contenteditable.ts index 348db05f..b77a22a8 100644 --- a/packages/json-document-rich-text-web/src/contenteditable.ts +++ b/packages/json-document-rich-text-web/src/contenteditable.ts @@ -4,9 +4,15 @@ import type { RichTextSelection, } from "@interactive-os/json-document-rich-text"; import { createRichTextNodeId } from "@interactive-os/json-document-rich-text"; -import { createWebClipboardBinding, isWebEditingHostTarget, type WebClipboardData, type WebClipboardEvent } from "@interactive-os/json-document-web"; +import { createWebClipboardBinding, createWebKeyboardAdapter, isWebEditingHostTarget, type WebClipboardData, type WebClipboardEvent } from "@interactive-os/json-document-web"; import { createRichTextClipboardCodec, createRichTextClipboardRepresentations } from "./clipboard.js"; +// Preserve this binding's historical Alt acceptance alongside the shared Mod-z defaults. +const keyboard = createWebKeyboardAdapter({ keymap: { + "Mod-Alt-z": { type: "undo" }, + "Mod-Alt-Shift-z": { type: "redo" }, +} }); + export interface RichTextContentEditableBinding { isComposing(): boolean; syncSelection(): RichTextSelection | null; @@ -174,9 +180,10 @@ export function createRichTextContentEditableBinding(options: { report("text.delete", editor.dispatch({ type: "text.delete", direction, unit: "character" })); return; } - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") { + const command = keyboard.resolve(event); + if (command?.type === "undo" || command?.type === "redo") { event.preventDefault(); - report(event.shiftKey ? "redo" : "undo", event.shiftKey ? editor.redo() : editor.undo()); + report(command.type, command.type === "redo" ? editor.redo() : editor.undo()); } }; diff --git a/packages/json-document-rich-text-web/tests/history-keyboard.test.ts b/packages/json-document-rich-text-web/tests/history-keyboard.test.ts new file mode 100644 index 00000000..d45cfae8 --- /dev/null +++ b/packages/json-document-rich-text-web/tests/history-keyboard.test.ts @@ -0,0 +1,86 @@ +import { createJSONDocument } from "@interactive-os/json-document"; +import { createRichTextEditor, type RichTextDocument, type RichTextSelection } from "@interactive-os/json-document-rich-text"; +import { afterEach, expect, test } from "vitest"; +import { createRichTextContentEditableBinding } from "../src/index.js"; + +const initial: RichTextDocument = { + profile: "urn:interactive-os:json-document:rich-text:1", id: "doc", type: "doc", + content: [{ id: "p", type: "paragraph", content: [{ id: "t", type: "text", text: "Alpha", marks: [] }] }], +}; +const range = (anchor: number, focus = anchor): RichTextSelection => ({ + kind: "range", primaryIndex: 0, ranges: [{ + anchor: { kind: "text", nodeId: "t", offset: anchor, affinity: "forward" }, + focus: { kind: "text", nodeId: "t", offset: focus, affinity: "forward" }, + }], +}); +const disposers: (() => void)[] = []; +afterEach(() => { + disposers.splice(0).forEach((dispose) => dispose()); + document.body.replaceChildren(); +}); +function fixture(backward = false) { + const editor = createRichTextEditor({ document: createJSONDocument(initial) }); + const root = document.createElement("article"); + root.contentEditable = "true"; + root.dataset.richTextContainerId = "doc"; + root.innerHTML = '

Alpha

'; + document.body.append(root); + const text = root.querySelector("span")!.firstChild!; + document.getSelection()!.setBaseAndExtent(text, backward ? 4 : 2, text, backward ? 2 : 4); + const actions: string[] = []; + const binding = createRichTextContentEditableBinding({ root, editor, onAction: (action) => actions.push(action) }); + disposers.push(() => binding.destroy()); + root.dispatchEvent(new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: "X" })); + expect(editor.snapshot).toMatchObject({ + value: { content: [{ content: [{ id: "t", text: "AlXa" }] }] }, + selection: range(3), canUndo: true, canRedo: false, + }); + return { root, editor, actions }; +} +function key(target: HTMLElement, options: KeyboardEventInit) { + const event = new KeyboardEvent("keydown", { key: "z", bubbles: true, cancelable: true, ...options }); + target.dispatchEvent(event); + return event; +} + +test.each(["metaKey", "ctrlKey"] as const)("%s Undo/Redo restores directed selection through the real binding", (modifier) => { + for (const backward of [false, true]) for (const moveAfterUndo of [false, true]) { + const { root, editor, actions } = fixture(backward); + const before = backward ? range(4, 2) : range(2, 4); + expect(key(root, { [modifier]: true }).defaultPrevented).toBe(true); + expect(editor.snapshot).toMatchObject({ value: initial, selection: before, canUndo: false, canRedo: true }); + if (moveAfterUndo) { + editor.dispatch({ type: "selection.set", selection: range(0) }); + expect(editor.snapshot).toMatchObject({ selection: range(0), canUndo: false, canRedo: true }); + } + expect(key(root, { [modifier]: true, shiftKey: true, key: "Z" }).defaultPrevented).toBe(true); + expect(editor.snapshot).toMatchObject({ + value: { content: [{ content: [{ id: "t", text: "AlXa" }] }] }, + selection: range(3), canUndo: true, canRedo: false, + }); + expect(actions.slice(-2)).toEqual(["undo", "redo"]); + } +}); + +test.each(["metaKey", "ctrlKey"] as const)("%s preserves legacy Alt and composing modifier acceptance", (modifier) => { + const { root, editor } = fixture(); + expect(key(root, { [modifier]: true, altKey: true, isComposing: true }).defaultPrevented).toBe(true); + expect(editor.snapshot).toMatchObject({ value: initial, selection: range(2, 4), canRedo: true }); + expect(key(root, { [modifier]: true, altKey: true, shiftKey: true }).defaultPrevented).toBe(true); + expect(editor.snapshot.selection).toEqual(range(3)); +}); + +test("unmatched keys and nested controls never consume the outer history", () => { + const { root, editor } = fixture(); + const before = editor.snapshot; + for (const options of [{}, { altKey: true }, { metaKey: true, key: "x" }]) { + expect(key(root, options).defaultPrevented).toBe(false); + } + for (const tag of ["input", "textarea", "select", "div"]) { + const nested = document.createElement(tag); + if (tag === "div") nested.setAttribute("contenteditable", "true"); + root.append(nested); + expect(key(nested, { metaKey: true }).defaultPrevented).toBe(false); + } + expect(editor.snapshot).toEqual(before); +}); diff --git a/packages/json-document-rich-text/tests/conformance/editing-grammar.test.ts b/packages/json-document-rich-text/tests/conformance/editing-grammar.test.ts index d3a85ed1..5ea210d4 100644 --- a/packages/json-document-rich-text/tests/conformance/editing-grammar.test.ts +++ b/packages/json-document-rich-text/tests/conformance/editing-grammar.test.ts @@ -1,6 +1,6 @@ import { createJSONDocument } from "@interactive-os/json-document"; import { createRangeSelectionFamily } from "@interactive-os/json-document-selection"; -import { expect } from "vitest"; +import { expect, test } from "vitest"; import { editingGrammar } from "../../../json-document-editing/tests/conformance/editing-grammar.js"; import { createRichTextEditor, type RichTextClipboard, type RichTextDocument, @@ -19,6 +19,28 @@ const range = (anchor: number, focus = anchor): RichTextSelection => ({ kind: "range", ranges: [{ anchor: point(anchor), focus: point(focus) }], primaryIndex: 0, }); +test.each([ + { backward: false, moveAfterUndo: false }, { backward: true, moveAfterUndo: false }, + { backward: false, moveAfterUndo: true }, { backward: true, moveAfterUndo: true }, +])("EG-HISTORY / replacement restores directed range ($backward), redo survives selection ($moveAfterUndo)", ({ backward, moveAfterUndo }) => { + const editor = createRichTextEditor({ document: createJSONDocument(initial) }); + const before = backward ? range(4, 2) : range(2, 4); + expect(editor.dispatch({ type: "selection.set", selection: before }).ok).toBe(true); + expect(editor.dispatch({ type: "text.insert", text: "X" }).ok).toBe(true); + const after = { ...initial, content: [ + { id: "p", type: "paragraph", content: [{ id: "t", type: "text", text: "AlXa", marks: [] }] }, + initial.content[1], + ] }; + expect(editor.snapshot).toMatchObject({ value: after, selection: range(3), canUndo: true, canRedo: false }); + expect(editor.undo()).toMatchObject({ ok: true, snapshot: { value: initial, selection: before, canUndo: false, canRedo: true } }); + if (moveAfterUndo) { + expect(editor.dispatch({ type: "selection.set", selection: range(0) })).toMatchObject({ + ok: true, snapshot: { value: initial, selection: range(0), canUndo: false, canRedo: true }, + }); + } + expect(editor.redo()).toMatchObject({ ok: true, snapshot: { value: after, selection: range(3), canUndo: true, canRedo: false } }); +}); + editingGrammar("Rich Text v1 / inline slice / local history", () => { const document = createJSONDocument(initial); let id = 0; diff --git a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx index 633d1da3..71eab282 100644 --- a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx +++ b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx @@ -555,17 +555,13 @@ export function CanvasDemoRoute() { applyAffordance( selectAllAffordance(event, { allSelected: editor.selectedObjects.length === unlockedIds.length && unlockedIds.length > 0, - }), + }, { repeat: "preserve" }), { hand: (hand) => { if (hand.type === "select-all") { editor.dispatch({ type: "selection.set", objectIds: unlockedIds, mode: "replace" }); event.preventDefault(); } - if (hand.type === "clear") { - editor.dispatch({ type: "selection.set", objectIds: [], mode: "replace" }); - event.preventDefault(); - } }, }, ); diff --git a/site/src/routes/document-demo/DocumentDemoRoute.tsx b/site/src/routes/document-demo/DocumentDemoRoute.tsx index 9b417f6b..3e39db0a 100644 --- a/site/src/routes/document-demo/DocumentDemoRoute.tsx +++ b/site/src/routes/document-demo/DocumentDemoRoute.tsx @@ -19,12 +19,15 @@ import { createWebClipboardSurface, createWebClipboardTextWriter, documentClipboardCodec, + isWebEditingHostTarget, lineBoundary, moveLinePoint, } from "@interactive-os/json-document-web"; import { historyAffordance, editingCommandFromWebKeyboardStroke, + applyAffordance, + selectAllAffordance, } from "@interactive-os/json-document-affordance"; import { Inspector } from "../../shared/ui/inspector"; import { Command, Toggle, SelectableItem } from "@interactive-os/json-document-ui-primitives-react"; @@ -205,7 +208,20 @@ export function DocumentDemoRoute() { ref={surfaceRef} tabIndex={0} {...clipboardSurface} - onKeyDown={editing.getKeyDownHandler()} + onKeyDown={(event) => { + if (isWebEditingHostTarget(event.currentTarget, event.target)) { + applyAffordance(selectAllAffordance(event, { + allSelected: editor.selectedBlockIds.length === document.blocks.length, + }, { repeat: "preserve" }), { + hand: (hand) => { + if (hand.type !== "select-all") return; + run(() => dispatchIntent({ type: "selection.select-all" }), "All blocks selected"); + event.preventDefault(); + }, + }); + } + if (!event.defaultPrevented) editing.getKeyDownHandler()(event); + }} className={ui.state.focus} > {document.blocks.length === 0 ? ( diff --git a/site/src/routes/order-demo/OrderDemoRoute.tsx b/site/src/routes/order-demo/OrderDemoRoute.tsx index d031748d..2b9de43d 100644 --- a/site/src/routes/order-demo/OrderDemoRoute.tsx +++ b/site/src/routes/order-demo/OrderDemoRoute.tsx @@ -10,6 +10,7 @@ import { import { useEditing, useEditingObservation } from "@interactive-os/json-document-react"; import { focusWebItem, + isWebEditingHostTarget, createWebClipboardSurface, lineBoundary, moveLinePoint, @@ -25,6 +26,7 @@ import { applyAffordance, escapeAffordance, renameAffordance, + selectAllAffordance, } from "@interactive-os/json-document-affordance"; import { Inspector } from "../../shared/ui/inspector"; import { Command, Field, SelectableItem } from "@interactive-os/json-document-ui-primitives-react"; @@ -71,7 +73,7 @@ export function OrderDemoRoute() { }, })); const [renameSession] = useState(() => createRenameSession({ - onCommit: (itemId, label) => run({ type: "item.rename", itemId, label }, "Item renamed"), + tryCommit: (itemId, label) => run({ type: "item.rename", itemId, label }, "Item renamed").ok, onFinish: (itemId) => requestAnimationFrame(() => focusWebItem(orderRef.current, itemId)), onSnapshot: (snapshot) => setRenaming(snapshot === null ? null : { id: snapshot.key, draft: snapshot.draft }), })); @@ -133,6 +135,18 @@ export function OrderDemoRoute() { } function onKeyDown(event: KeyboardEvent) { + if (isWebEditingHostTarget(event.currentTarget, event.target)) { + applyAffordance(selectAllAffordance(event, { + allSelected: editor.selectedItemIds.length === document.items.length, + }, { repeat: "preserve" }), { + hand: (hand) => { + if (hand.type !== "select-all") return; + run({ type: "selection.select-all" }, "All items selected"); + event.preventDefault(); + }, + }); + if (event.defaultPrevented) return; + } if (focusSession.handle(event, ids())) { event.preventDefault(); const next = focusSession.getFocusKey(); diff --git a/site/src/routes/sheet-demo/SheetDemo.tsx b/site/src/routes/sheet-demo/SheetDemo.tsx index 4890bbc7..5b2f8f46 100644 --- a/site/src/routes/sheet-demo/SheetDemo.tsx +++ b/site/src/routes/sheet-demo/SheetDemo.tsx @@ -20,12 +20,14 @@ import { moveGridPoint, rovingFocusItemProps, sheetClipboardCodec, + isWebEditingHostTarget, webGridCellAddressProps, } from "@interactive-os/json-document-web"; import { historyAffordance, editingCommandFromWebKeyboardStroke, applyAffordance, + selectAllAffordance, } from "@interactive-os/json-document-affordance"; import { Field, GridCell } from "@interactive-os/json-document-ui-primitives-react"; import { Inspector } from "../../shared/ui/inspector"; @@ -203,7 +205,20 @@ export function SheetDemo() { aria-label="Editable sheet" tabIndex={0} {...clipboardSurface} - onKeyDown={editing.getKeyDownHandler()} + onKeyDown={(event) => { + if (isWebEditingHostTarget(event.currentTarget, event.target)) { + applyAffordance(selectAllAffordance(event, { + allSelected: editor.selectedCells.length === sheet.rows.length * sheet.columns.length, + }, { repeat: "preserve" }), { + hand: (hand) => { + if (hand.type !== "select-all") return; + run(() => dispatchIntent({ type: "selection.select-all" }), "All cells selected"); + event.preventDefault(); + }, + }); + } + if (!event.defaultPrevented) editing.getKeyDownHandler()(event); + }} className={classes("min-w-0 overflow-auto", ui.state.focus)} > diff --git a/site/src/routes/tree-demo/TreeDemoRoute.tsx b/site/src/routes/tree-demo/TreeDemoRoute.tsx index 3b87a49c..35f9f6b0 100644 --- a/site/src/routes/tree-demo/TreeDemoRoute.tsx +++ b/site/src/routes/tree-demo/TreeDemoRoute.tsx @@ -11,10 +11,13 @@ import { useEditingObservation, useTreeEditing } from "@interactive-os/json-docu import { createWebClipboardSurface, treeClipboardCodec, + isWebEditingHostTarget, } from "@interactive-os/json-document-web"; import { historyAffordance, editingCommandFromWebKeyboardStroke, + applyAffordance, + selectAllAffordance, } from "@interactive-os/json-document-affordance"; import { Inspector } from "../../shared/ui/inspector"; import { Command, SelectableItem } from "@interactive-os/json-document-ui-primitives-react"; @@ -152,7 +155,20 @@ export function TreeDemoRoute() { className="m-0 grid list-none gap-1 p-0" tabIndex={0} {...clipboardSurface} - onKeyDown={editing.getKeyDownHandler()} + onKeyDown={(event) => { + if (isWebEditingHostTarget(event.currentTarget, event.target)) { + applyAffordance(selectAllAffordance(event, { + allSelected: editor.selectedNodeIdsIn(topology).length === topology.visibleIds.length, + }, { repeat: "preserve" }), { + hand: (hand) => { + if (hand.type !== "select-all") return; + run({ type: "selection.select-all", topology }, "All visible nodes selected"); + event.preventDefault(); + }, + }); + } + if (!event.defaultPrevented) editing.getKeyDownHandler()(event); + }} > {rows.map((row) => { return ( diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index 4988fbeb..754b4cc0 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -21,6 +21,7 @@ import markdownRendererSource from "../../../../packages/json-document-markdown- import dateValuesSource from "../../../../packages/json-document-calendar/src/date-values.ts?raw"; import editingItemSource from "../../../../packages/json-document-react/src/use-editing.ts?raw"; import affordanceSessionSource from "../../../../packages/json-document-affordance/src/session.ts?raw"; +import affordanceSelectSource from "../../../../packages/json-document-affordance/src/select.ts?raw"; import viewportPositionSource from "../../../../packages/json-document-affordance/src/viewport-position.ts?raw"; import anchoredFloatingPositionSource from "../../../../packages/json-document-affordance/src/anchored-floating-position.ts?raw"; import webFocusItemSource from "../../../../packages/json-document-web/src/focus-item.ts?raw"; @@ -33,6 +34,8 @@ import virtualSelectionReactSource from "../../../../packages/json-document-reac import anchoredFloatingPositionReactSource from "../../../../packages/json-document-react/src/use-anchored-floating-position.ts?raw"; import documentTextControlSource from "../../../../packages/json-document-react/src/use-document-text-control.ts?raw"; import documentEditingSource from "../../../../packages/json-document-editing/src/document.ts?raw"; +import orderEditingSource from "../../../../packages/json-document-editing/src/order.ts?raw"; +import treeEditorSource from "../../../../packages/json-document-editing/src/tree.ts?raw"; import sheetEditingSource from "../../../../packages/json-document-editing/src/sheet.ts?raw"; import editingClipboardSource from "../../../../packages/json-document-editing/src/clipboard.ts?raw"; import editingSessionSource from "../../../../packages/json-document-editing/src/session.ts?raw"; @@ -196,6 +199,7 @@ const registeredUsageSources = new Map([ ["packages/json-document-react/src/editing-observation.ts", editingObservationSource], ["packages/json-document-react/src/use-editing.ts", editingItemSource], ["packages/json-document-affordance/src/session.ts", affordanceSessionSource], + ["packages/json-document-affordance/src/select.ts", affordanceSelectSource], ["packages/json-document-affordance/src/viewport-position.ts", viewportPositionSource], ["packages/json-document-affordance/src/anchored-floating-position.ts", anchoredFloatingPositionSource], ["packages/json-document-web/src/focus-item.ts", webFocusItemSource], @@ -208,6 +212,8 @@ const registeredUsageSources = new Map([ ["packages/json-document-react/src/use-anchored-floating-position.ts", anchoredFloatingPositionReactSource], ["packages/json-document-react/src/use-document-text-control.ts", documentTextControlSource], ["packages/json-document-editing/src/document.ts", documentEditingSource], + ["packages/json-document-editing/src/order.ts", orderEditingSource], + ["packages/json-document-editing/src/tree.ts", treeEditorSource], ["packages/json-document-editing/src/sheet.ts", sheetEditingSource], ["packages/json-document-editing/src/clipboard.ts", editingClipboardSource], ["packages/json-document-editing/src/session.ts", editingSessionSource], @@ -956,6 +962,26 @@ const registeredPublicUsages = [ symbol: "createRenameSession", sourcePath: "packages/json-document-affordance/src/session.ts", }, + { + packageName: "@interactive-os/json-document-affordance", + symbol: "selectAllAffordance", + sourcePath: "packages/json-document-affordance/src/select.ts", + }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "createDocumentEditor", + sourcePath: "packages/json-document-editing/src/document.ts", + }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "createOrderEditor", + sourcePath: "packages/json-document-editing/src/order.ts", + }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "createTreeEditor", + sourcePath: "packages/json-document-editing/src/tree.ts", + }, { packageName: "@interactive-os/json-document-affordance", symbol: "createLineFocusSession", diff --git a/site/tests/browser/demo-workbench.spec.ts b/site/tests/browser/demo-workbench.spec.ts index e128fac2..61fd4043 100644 --- a/site/tests/browser/demo-workbench.spec.ts +++ b/site/tests/browser/demo-workbench.spec.ts @@ -1,5 +1,23 @@ import { expect, test } from "@playwright/test"; +for (const { route, entry, owner } of [ + { route: "/demo/order", entry: "OrderDemoRoute.tsx", owner: "order.ts" }, + { route: "/demo/tree", entry: "TreeDemoRoute.tsx", owner: "tree.ts" }, + { route: "/demo/sheet", entry: "SheetDemoRoute.tsx", owner: "sheet.ts" }, + { route: "/demo", entry: "DocumentDemoRoute.tsx", owner: "document.ts" }, +]) { + test(`${route} Usage links select-all to its canonical owner`, async ({ page }) => { + await page.goto(route); + const workbench = page.getByRole("region", { name: "Demo workbench" }); + await workbench.getByRole("tab", { name: entry, exact: true }).click(); + await workbench.getByRole("tab", { name: owner, exact: true }).click(); + await expect(workbench.getByRole("tabpanel").locator("pre")).toContainText('"selection.select-all"'); + await expect(workbench.getByRole("link", { name: "API Reference" })).toHaveAttribute("href", "/docs/api/editing"); + await workbench.getByRole("tab", { name: "select.ts", exact: true }).click(); + await expect(workbench.getByRole("tabpanel").locator("pre")).toContainText('"preserve"'); + }); +} + test("switches between the live demo and its actual full source without resetting demo state", async ({ page }) => { await page.goto("/demo"); diff --git a/site/tests/browser/document-demo.spec.ts b/site/tests/browser/document-demo.spec.ts index d16ff59e..38c6e7f3 100644 --- a/site/tests/browser/document-demo.spec.ts +++ b/site/tests/browser/document-demo.spec.ts @@ -1,5 +1,23 @@ import { expect, test, type Page } from "@playwright/test"; +test("Document repeated select-all preserves blocks and leaves native text selection owned by the field", async ({ page }) => { + await page.goto("/demo"); + const surface = page.getByRole("region", { name: "Editable document" }).locator('[tabindex="0"]'); + await surface.focus(); + for (const modifier of ["Meta", "Control"]) { + await surface.press(`${modifier}+a`); + await surface.press(`${modifier}+a`); + await expect(page.locator('article[data-block-id][data-selected="true"]')).toHaveCount(4); + } + await expect(page.getByRole("button", { name: "Undo", exact: true })).toBeDisabled(); + const field = page.getByRole("textbox", { name: "Block 1 text" }); + await field.click(); + await field.press("ControlOrMeta+a"); + await expect.poll(() => field.evaluate((node: HTMLTextAreaElement) => [node.selectionStart, node.selectionEnd])) + .toEqual([0, (await field.inputValue()).length]); + await expect(page.locator('article[data-block-id][data-selected="true"]')).toHaveCount(1); +}); + test("Document keeps native caret and directional range offsets in the editor", async ({ page }) => { await page.goto("/demo"); await page.getByText("Inspect editing state", { exact: true }).click(); diff --git a/site/tests/browser/editor-slice-demos.spec.ts b/site/tests/browser/editor-slice-demos.spec.ts index c26626ac..f2ff27ac 100644 --- a/site/tests/browser/editor-slice-demos.spec.ts +++ b/site/tests/browser/editor-slice-demos.spec.ts @@ -54,6 +54,24 @@ test("Object composes native and toolbar paste with the same placement Intent", .toEqual([[24, 24], [48, 48], [48, 48]]); }); +test("Order repeated select-all and cancelled rename preserve selected items and document history", async ({ page }) => { + await page.goto("/demo/order"); + const order = page.getByLabel("Editable order").locator("ol"); + await order.focus(); + for (const modifier of ["Meta", "Control"]) { + await order.press(`${modifier}+a`); + await order.press(`${modifier}+a`); + await expect(order.locator('[data-selected="true"]')).toHaveCount(4); + } + await order.press("F2"); + const rename = page.getByRole("textbox", { name: "Rename Inbox" }); + await rename.fill("Unsaved"); + await rename.press("Escape"); + await expect(page.getByRole("button", { name: /Inbox/ })).toBeFocused(); + await expect(order.locator('[data-selected="true"]')).toHaveCount(4); + await expect(page.getByRole("button", { name: "Undo", exact: true })).toBeDisabled(); +}); + test("Order typeahead jumps to the matching label and Escape clears the buffer", async ({ page }) => { await page.goto("/demo/order"); await page.getByLabel("Editable order").locator("ol").focus(); @@ -245,6 +263,7 @@ test("Canvas select-all, delete, and locked objects", async ({ page }) => { const lock = page.getByRole("button", { name: "Lock", exact: true }); await canvas.focus(); await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.press("ControlOrMeta+A"); await expect(note).toHaveAttribute("data-selected", "true"); await expect(page.getByRole("button", { name: "Card" })).toHaveAttribute("data-selected", "true"); await expect(lock).toHaveAttribute("data-selected", "false"); @@ -332,6 +351,20 @@ test("Canvas copy-drag, constrain, resize, and zoom", async ({ page }) => { expect(scale).toMatch(/matrix\((1\.[1-9]|[2-9])/); }); +test("Tree repeated select-all uses the collapsed visible topology", async ({ page }) => { + await page.goto("/demo/tree"); + await page.getByRole("button", { name: "Collapse Fruit" }).click(); + const tree = page.getByLabel("Editable tree").locator("ul"); + await tree.focus(); + for (const modifier of ["Meta", "Control"]) { + await tree.press(`${modifier}+a`); + await tree.press(`${modifier}+a`); + await expect(tree.locator('[data-selected="true"]')).toHaveCount(4); + await expect(page.getByRole("button", { name: "Apple", exact: true })).toHaveCount(0); + } + await expect(page.getByRole("button", { name: "Undo", exact: true })).toBeDisabled(); +}); + test("Tree uses host visible order and restores a cut with undo", async ({ page }) => { await page.goto("/demo/tree"); await page.getByText("Inspect editing state", { exact: true }).click(); diff --git a/site/tests/browser/rich-text-demo.spec.ts b/site/tests/browser/rich-text-demo.spec.ts index 4bdcf95d..51b7b94f 100644 --- a/site/tests/browser/rich-text-demo.spec.ts +++ b/site/tests/browser/rich-text-demo.spec.ts @@ -1,5 +1,27 @@ import { expect, test, type Page } from "@playwright/test"; +for (const modifier of ["Meta", "Control"]) for (const backward of [false, true]) { + test(`Rich Text ${modifier} Undo restores ${backward ? "backward" : "forward"} range; selection movement retains Redo`, async ({ page }) => { + await page.goto("/editing/rich-text"); + const before = await json(page, "rich-text-document-json"); + const text = textNode(before, "text-editable").text; + await setSelection(page, "text-editable", backward ? 4 : 2, backward ? 2 : 4); + const selectionBefore = (await json(page, "rich-text-selection-json")).selection; + await page.keyboard.type("X"); + await expect.poll(async () => textNode(await json(page, "rich-text-document-json"), "text-editable").text).toBe(`${text.slice(0, 2)}X${text.slice(4)}`); + const after = await json(page, "rich-text-document-json"); + await page.keyboard.press(`${modifier}+z`); + await expect.poll(() => json(page, "rich-text-document-json")).toEqual(before); + await expect.poll(async () => (await json(page, "rich-text-selection-json")).selection).toEqual(selectionBefore); + await expect.poll(() => domSelection(page)).toEqual({ nodeId: "text-editable", anchorOffset: backward ? 4 : 2, focusOffset: backward ? 2 : 4 }); + await setSelection(page, "text-editable", 0, 0); + await expect(page.getByRole("button", { name: "Redo", exact: true })).toBeEnabled(); + await page.keyboard.press(`${modifier}+Shift+z`); + await expect.poll(() => json(page, "rich-text-document-json")).toEqual(after); + await expect.poll(() => domSelection(page)).toEqual({ nodeId: "text-editable", anchorOffset: 3, focusOffset: 3 }); + }); +} + test("Rich Text collaborative history routes DOM undo through the selective owner", async ({ page }) => { await page.goto("/editing/rich-text?history=collaboration"); await setSelection(page, "text-heading", 2, 2); diff --git a/site/tests/browser/sheet-demo.spec.ts b/site/tests/browser/sheet-demo.spec.ts index e0af47e9..1b87492d 100644 --- a/site/tests/browser/sheet-demo.spec.ts +++ b/site/tests/browser/sheet-demo.spec.ts @@ -1,5 +1,22 @@ import { expect, test, type Page } from "@playwright/test"; +test("Sheet repeated select-all preserves the rectangle and native field select-all", async ({ page }) => { + await page.goto("/demo/sheet"); + const surface = page.getByLabel("Editable sheet"); + await surface.focus(); + for (const modifier of ["Meta", "Control"]) { + await surface.press(`${modifier}+a`); + await surface.press(`${modifier}+a`); + await expect(page.locator('td[data-selected="true"]')).toHaveCount(12); + } + await expect(page.getByRole("button", { name: "Undo", exact: true })).toBeDisabled(); + const field = page.getByRole("textbox", { name: "Name row 1" }); + await field.click(); + await field.press("ControlOrMeta+a"); + await expect.poll(() => field.evaluate((node: HTMLInputElement) => [node.selectionStart, node.selectionEnd])).toEqual([0, 5]); + await expect(page.locator('td[data-selected="true"]')).toHaveCount(1); +}); + test("Sheet demo completes rectangular selection, clipboard, edit, undo, and redo", async ({ page }) => { const consoleProblems: string[] = []; page.on("console", (message) => { diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index 6464367b..17d30ff5 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -83,37 +83,43 @@ describe("Demo definition and source discovery", () => { const document = await discoverDemoSources("routes/document-demo/DocumentDemoRoute.tsx"); expect(document.map((file) => file.path)).toEqual([ "routes/document-demo/DocumentDemoRoute.tsx", + "packages/json-document-web/src/input.ts", "packages/json-document-ui-primitives-react/src/controls.tsx", "packages/json-document-ui-primitives-react/src/product-shell.tsx", "packages/json-document-react/src/use-editing.ts", "packages/json-document-react/src/editing-observation.ts", + "packages/json-document-affordance/src/select.ts", + "packages/json-document-web/src/keyboard.ts", "packages/json-document-web/src/clipboard.ts", "packages/json-document-react/src/use-document-text-control.ts", - "packages/json-document-web/src/input.ts", "packages/json-document-editing/src/document.ts", ]); const source = await document[0]!.load(); expect(source).toContain("export function DocumentDemoRoute()"); expect(source).toContain('from "@interactive-os/json-document-react"'); expect(document.filter((file) => file.path.startsWith("packages/")).map((file) => file.path)).toEqual([ + "packages/json-document-web/src/input.ts", "packages/json-document-ui-primitives-react/src/controls.tsx", "packages/json-document-ui-primitives-react/src/product-shell.tsx", "packages/json-document-react/src/use-editing.ts", "packages/json-document-react/src/editing-observation.ts", + "packages/json-document-affordance/src/select.ts", + "packages/json-document-web/src/keyboard.ts", "packages/json-document-web/src/clipboard.ts", "packages/json-document-react/src/use-document-text-control.ts", - "packages/json-document-web/src/input.ts", "packages/json-document-editing/src/document.ts", ]); expect(document.some((file) => file.path.includes("shared/ui"))).toBe(false); expect(document.filter((file) => file.path.startsWith("packages/")).map((file) => file.referencePath)).toEqual([ + "/docs/api/web", "/docs/api/ui-primitives-react", "/docs/api/ui-primitives-react", "/docs/api/react", "/docs/api/react", + "/docs/api/affordance", "/docs/api/web", - "/docs/api/react", "/docs/api/web", + "/docs/api/react", "/docs/api/editing", ]); }); @@ -144,6 +150,7 @@ describe("Demo definition and source discovery", () => { "packages/json-document-affordance/src/session.ts", "packages/json-document-ui-primitives-react/src/controls.tsx", "packages/json-document-react/src/use-editing.ts", + "packages/json-document-editing/src/order.ts", ]); }); @@ -163,10 +170,13 @@ describe("Demo definition and source discovery", () => { const sources = await discoverDemoSources("routes/sheet-demo/SheetDemo.tsx"); expect(sources.map((file) => file.path)).toEqual([ "routes/sheet-demo/SheetDemo.tsx", + "packages/json-document-web/src/input.ts", "packages/json-document-ui-primitives-react/src/controls.tsx", "packages/json-document-ui-primitives-react/src/product-shell.tsx", "packages/json-document-react/src/use-editing.ts", "packages/json-document-react/src/editing-observation.ts", + "packages/json-document-affordance/src/select.ts", + "packages/json-document-web/src/keyboard.ts", "packages/json-document-web/src/clipboard.ts", "packages/json-document-editing/src/sheet.ts", "packages/json-document-react/src/use-grid-editing.ts", @@ -211,10 +221,14 @@ describe("Demo definition and source discovery", () => { test("registers Tree visibility and React binding sources next to Tree usage", async () => { expect((await discoverDemoSources("routes/tree-demo/TreeDemoRoute.tsx")).map((file) => file.path)).toEqual([ "routes/tree-demo/TreeDemoRoute.tsx", + "packages/json-document-web/src/input.ts", "packages/json-document-ui-primitives-react/src/controls.tsx", "packages/json-document-ui-primitives-react/src/product-shell.tsx", "packages/json-document-react/src/use-editing.ts", "packages/json-document-react/src/editing-observation.ts", + "packages/json-document-affordance/src/select.ts", + "packages/json-document-web/src/keyboard.ts", + "packages/json-document-editing/src/tree.ts", "packages/json-document-web/src/clipboard.ts", "packages/json-document-react/src/use-tree-editing.ts", "packages/json-document-editing/src/tree-visibility.ts", From 62decf587e4d0e4a648fb5d3d512f0090821ad09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Tue, 8 Sep 2026 16:40:11 +0900 Subject: [PATCH 06/24] fix(keyboard): consume canonical default chord interpretation --- docs/api-reference/affordance.md | 4 +- docs/api-reference/composer.md | 1 + package-lock.json | 4 +- packages/json-document-affordance/README.md | 11 +++- .../json-document-affordance/src/select.ts | 28 ++++++--- .../tests/affordance.test.ts | 38 ++++++++++++- .../json-document-composer-react/README.md | 5 ++ .../src/use-composer.tsx | 4 +- .../tests/composer-react.test.tsx | 20 ++++++- packages/json-document-composer/README.md | 12 ++++ packages/json-document-composer/package.json | 2 + .../json-document-composer/src/interaction.ts | 15 ++++- .../tests/composer.test.ts | 13 +++++ packages/json-document-composer/tsconfig.json | 1 + packages/json-document-web/README.md | 10 ++++ .../tests/web-adapters.test.ts | 18 ++++++ .../src/shared/demo-workbench/demo-sources.ts | 10 ++++ site/tests/unit/composer-demo.test.tsx | 3 + site/tests/unit/default-keyboard.test.tsx | 57 +++++++++++++++++++ site/tests/unit/demo-workbench.test.tsx | 1 + 20 files changed, 240 insertions(+), 17 deletions(-) create mode 100644 site/tests/unit/default-keyboard.test.tsx diff --git a/docs/api-reference/affordance.md b/docs/api-reference/affordance.md index 370e11f0..a032e743 100644 --- a/docs/api-reference/affordance.md +++ b/docs/api-reference/affordance.md @@ -381,7 +381,7 @@ createViewportPositionSession(options: ViewportPositionOptions): Viewp ## `deleteAffordance` ```ts -deleteAffordance(input: { readonly key?: string; }): AffordancePreview +deleteAffordance(input: Partial): AffordancePreview ``` ## `disclosureAffordance` @@ -781,7 +781,7 @@ resolveAffordanceKey(stroke: WebKeyboardStroke): AffordancePreview ## `selectAllAffordance` ```ts -selectAllAffordance(stroke: Pick, state: { readonly allSelected: boolean; }, options?: { readonly repeat?: "preserve" | "toggle"; }): AffordancePreview +selectAllAffordance(stroke: Pick & Partial>, state: { readonly allSelected: boolean; }, options?: { readonly repeat?: "preserve" | "toggle"; }): AffordancePreview ``` ## `SelectOperation` diff --git a/docs/api-reference/composer.md b/docs/api-reference/composer.md index f182ef1a..da3ad8f4 100644 --- a/docs/api-reference/composer.md +++ b/docs/api-reference/composer.md @@ -144,6 +144,7 @@ interface ComposerKeyStroke { readonly key: string; readonly shiftKey?: boolean; readonly commandKey?: boolean; + readonly altKey?: boolean; } ``` ## `ComposerReference` diff --git a/package-lock.json b/package-lock.json index 0f02d817..3e02b7fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6562,6 +6562,7 @@ "@interactive-os/json-document-rich-text": "*", "@interactive-os/json-document-rich-text-mention": "*", "@interactive-os/json-document-rich-text-suggestion": "*", + "@interactive-os/json-document-web": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", "vitest": "^4.1.7" @@ -6571,7 +6572,8 @@ "@interactive-os/json-document-file-intake": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-mention": "^0.1.0-rc.0", - "@interactive-os/json-document-rich-text-suggestion": "^0.1.0-rc.0" + "@interactive-os/json-document-rich-text-suggestion": "^0.1.0-rc.0", + "@interactive-os/json-document-web": "^0.1.0-rc.0" } }, "packages/json-document-composer-react": { diff --git a/packages/json-document-affordance/README.md b/packages/json-document-affordance/README.md index 607d3159..2a569e82 100644 --- a/packages/json-document-affordance/README.md +++ b/packages/json-document-affordance/README.md @@ -94,8 +94,8 @@ open/focus semantics remain outside this geometry contract. Usage: [Affordance](https://developer-1px.github.io/json-document/docs/affordance) `selectAllAffordance(stroke, state, { repeat: "preserve" })` emits `select-all` -for Mod+A even when everything is selected. The default editing Usage chooses -this policy. Omission or `{ repeat: "toggle" }` retains the existing behavior: +for Mod+A without Alt or Shift, even when everything is selected. The default +editing Usage chooses this policy. Omission or `{ repeat: "toggle" }` retains the existing behavior: emit `clear` when `state.allSelected`, otherwise `select-all`. This is an input policy; domain editors own the selected universe and its semantic transition. @@ -105,3 +105,10 @@ Document's `selection.move`. Structural preview and cancellation leave committed value/history unchanged; commit dispatches the latest preview once. This proves the tested composition, not every Host callback. IME composition has a separate [DOM editing lifecycle](../../standards/dom-editing-lifecycle.md) contract. + +`deleteAffordance(stroke)` consumes the Web default structural keymap: bare +Delete/Backspace delete; modified variants return no hand. Omitted modifiers +remain false for existing partial-input calls. Pass the full event to preserve +modifier facts. `selectAllAffordance` uses Web `chordFromStroke` for the same +normalization, then applies its own select-all repetition policy. Text word or +line deletion belongs to the text input adapter. diff --git a/packages/json-document-affordance/src/select.ts b/packages/json-document-affordance/src/select.ts index 879a7e39..89f81725 100644 --- a/packages/json-document-affordance/src/select.ts +++ b/packages/json-document-affordance/src/select.ts @@ -1,4 +1,5 @@ import { + chordFromStroke, createWebKeyboardAdapter, selectionOperationFromModifiers, type WebKeyboardCommand, @@ -88,9 +89,16 @@ export function planeHitAffordance(input: { }; } -export function deleteAffordance(input: { readonly key?: string }): AffordancePreview { - if (input.key === "Delete" || input.key === "Backspace") return { hand: { type: "delete" } }; - return { hand: null }; +/** Uses the default structural delete chord; omitted modifiers are false. */ +export function deleteAffordance(input: Partial): AffordancePreview { + const command = keyboard.resolve({ + key: input.key ?? "", + shiftKey: input.shiftKey ?? false, + metaKey: input.metaKey ?? false, + ctrlKey: input.ctrlKey ?? false, + altKey: input.altKey ?? false, + }); + return { hand: command?.type === "delete" ? command : null }; } export function contextMenuAffordance(input: { @@ -111,14 +119,20 @@ export function resolveAffordanceKey(stroke: WebKeyboardStroke): AffordancePrevi return { hand: keyboard.resolve(stroke) }; } -/** Mod+A selects all. Choose preserve for repeated selection; omission retains the legacy toggle. */ +/** Mod+A without Alt/Shift selects all. Choose preserve for repetition; omission retains the legacy toggle. */ export function selectAllAffordance( - stroke: Pick, + stroke: Pick & Partial>, state: { readonly allSelected: boolean }, options: { readonly repeat?: "preserve" | "toggle" } = {}, ): AffordancePreview { - const mod = stroke.metaKey || stroke.ctrlKey; - if (!mod || stroke.key.toLowerCase() !== "a") return { hand: null }; + const chord = chordFromStroke({ + key: stroke.key, + shiftKey: stroke.shiftKey ?? false, + metaKey: stroke.metaKey, + ctrlKey: stroke.ctrlKey, + altKey: stroke.altKey ?? false, + }); + if (chord !== "Mod-a") return { hand: null }; return { hand: { type: state.allSelected && options.repeat !== "preserve" ? "clear" : "select-all" } }; } diff --git a/packages/json-document-affordance/tests/affordance.test.ts b/packages/json-document-affordance/tests/affordance.test.ts index c87a454f..c626f126 100644 --- a/packages/json-document-affordance/tests/affordance.test.ts +++ b/packages/json-document-affordance/tests/affordance.test.ts @@ -29,6 +29,7 @@ import { panAffordance, resizeAffordance, resolveAffordanceKey, + selectAllAffordance, snapAffordance, treeAffordance, createBoardDragSession, @@ -41,7 +42,7 @@ import { wheelAffordance, zoomAffordance, } from "../src/index.js"; -import { pressInteractionFromWeb } from "@interactive-os/json-document-web"; +import { createWebKeyboardAdapter, pressInteractionFromWeb } from "@interactive-os/json-document-web"; describe("Affordance sessions", () => { test("owns drag, resize, and control handle descriptor, cursor, delta, and lifecycle semantics", () => { @@ -780,3 +781,38 @@ describe("snapAffordance disable key", () => { .toEqual({ type: "translate", dx: 47, dy: 51 }); }); }); + + +describe("default keyboard modifier contract", () => { + const keyboard = createWebKeyboardAdapter(); + for (let mask = 0; mask < 16; mask++) { + const modifiers = { metaKey: !!(mask & 1), ctrlKey: !!(mask & 2), shiftKey: !!(mask & 4), altKey: !!(mask & 8) }; + test(`preserves all modifier facts (${mask})`, () => { + for (const key of ["Delete", "Backspace"]) { + const stroke = { key, ...modifiers }; + expect(deleteAffordance(stroke).hand).toEqual(mask === 0 ? { type: "delete" } : null); + expect(deleteAffordance(stroke).hand).toEqual(keyboard.resolve(stroke)); + } + for (const key of ["z", "Z"]) { + const stroke = { key, ...modifiers }; + expect(resolveAffordanceKey(stroke).hand).toEqual(keyboard.resolve(stroke)); + expect(editingCommandFromWebKeyboardStroke(stroke)).toEqual(keyboard.resolve(stroke)); + } + for (const key of ["a", "A"]) { + const stroke = { key, ...modifiers }; + const selected = (modifiers.metaKey || modifiers.ctrlKey) && !modifiers.shiftKey && !modifiers.altKey; + expect(selectAllAffordance(stroke, { allSelected: true }, { repeat: "preserve" }).hand) + .toEqual(selected ? { type: "select-all" } : null); + expect(selectAllAffordance(stroke, { allSelected: true }).hand) + .toEqual(selected ? { type: "clear" } : null); + } + }); + } + + test("keeps legacy partial inputs without inventing modifiers", () => { + expect(deleteAffordance({ key: "Backspace" }).hand).toEqual({ type: "delete" }); + expect(deleteAffordance({}).hand).toBeNull(); + expect(selectAllAffordance({ key: "a", metaKey: true, ctrlKey: false }, { allSelected: false }).hand) + .toEqual({ type: "select-all" }); + }); +}); diff --git a/packages/json-document-composer-react/README.md b/packages/json-document-composer-react/README.md index cbb26934..bd4fda15 100644 --- a/packages/json-document-composer-react/README.md +++ b/packages/json-document-composer-react/README.md @@ -20,3 +20,8 @@ composed from the canonical suggestion packages. Product copy, styling, layout, suggestions, and concrete ports remain Host-owned. Draft/editor subscription, suggestion integration, keyboard/history execution, Web file intake, focus recovery, and submit lifecycle remain canonical across Host replacements. + +`useComposer` preserves Alt when translating key events to Composer interaction +meaning. Its history capture handler consumes only the default Undo/Redo chord; +Alt-modified variants leave the event and canonical history untouched. Submit +and command-menu handling retain the configured Composer policy. diff --git a/packages/json-document-composer-react/src/use-composer.tsx b/packages/json-document-composer-react/src/use-composer.tsx index 733a3fa1..e80662f3 100644 --- a/packages/json-document-composer-react/src/use-composer.tsx +++ b/packages/json-document-composer-react/src/use-composer.tsx @@ -108,7 +108,7 @@ export function useComposer) { - const interaction = composerInteractionFromKeyStroke({ key: event.key, shiftKey: event.shiftKey, commandKey: event.metaKey || event.ctrlKey }, config.interaction); + const interaction = composerInteractionFromKeyStroke({ key: event.key, shiftKey: event.shiftKey, commandKey: event.metaKey || event.ctrlKey, altKey: event.altKey }, config.interaction); if (commandMenu.open) { commandMenu.handleKeyDown(event); if (event.defaultPrevented) { @@ -123,7 +123,7 @@ export function useComposer) { - const interaction = composerInteractionFromKeyStroke({ key: event.key, shiftKey: event.shiftKey, commandKey: event.metaKey || event.ctrlKey }, config.interaction); + const interaction = composerInteractionFromKeyStroke({ key: event.key, shiftKey: event.shiftKey, commandKey: event.metaKey || event.ctrlKey, altKey: event.altKey }, config.interaction); if (interaction !== "history.undo" && interaction !== "history.redo") return; event.preventDefault(); event.stopPropagation(); diff --git a/packages/json-document-composer-react/tests/composer-react.test.tsx b/packages/json-document-composer-react/tests/composer-react.test.tsx index cb0c06da..dadfaad1 100644 --- a/packages/json-document-composer-react/tests/composer-react.test.tsx +++ b/packages/json-document-composer-react/tests/composer-react.test.tsx @@ -37,6 +37,24 @@ describe("Composer React integration", () => { fireEvent.keyDown(screen.getByTestId("mod-keyboard"), { key: "Enter", ctrlKey: true }); expect(modSubmit).toHaveBeenCalledTimes(1); }); + + test.each(["metaKey", "ctrlKey"] as const)("preserves Alt through the real %s history event path", (modifier) => { + render(); + fireEvent.click(screen.getByRole("button", { name: "history-text" })); + const target = screen.getByTestId("history-keyboard"); + const draft = screen.getByTestId("history-draft"); + const inserted = draft.textContent; + expect(inserted).toContain("hello"); + expect(fireEvent.keyDown(target, { key: "z", [modifier]: true, altKey: true })).toBe(true); + expect(draft.textContent).toBe(inserted); + expect(fireEvent.keyDown(target, { key: "z", [modifier]: true })).toBe(false); + expect(draft.textContent).not.toContain("hello"); + const undone = draft.textContent; + expect(fireEvent.keyDown(target, { key: "Z", [modifier]: true, shiftKey: true, altKey: true })).toBe(true); + expect(draft.textContent).toBe(undone); + expect(fireEvent.keyDown(target, { key: "Z", [modifier]: true, shiftKey: true })).toBe(false); + expect(draft.textContent).toBe(inserted); + }); }); function hostConfig(model: Model, submit: "enter" | "mod-enter"): ComposerHostConfig { @@ -57,7 +75,7 @@ function ComposerHostHarness(props: { readonly host: strin ports: { createId: () => `${props.host}-${++id}`, submit: props.submit }, labels: { mentionSuggestions: `${props.host} mentions`, skillSuggestions: `${props.host} skills` }, }); - return
+ return
{JSON.stringify(composer.document.value)}
; diff --git a/packages/json-document-composer/README.md b/packages/json-document-composer/README.md index 32b8262f..71ae0e79 100644 --- a/packages/json-document-composer/README.md +++ b/packages/json-document-composer/README.md @@ -9,3 +9,15 @@ those validated candidates into Composer context attachments. `resolveComposerSuggestions(trigger, suggestions)` owns trigger-aware matching of a product-configured suggestion catalog. React menu lifecycle and atom projection live in `@interactive-os/json-document-composer-react`. + +`composerInteractionFromKeyStroke(stroke, policy)` preserves the existing +`commandKey` input (Meta or Control) and accepts optional `altKey` alongside +`shiftKey`. Omitted modifiers are false. Its keyboard compatibility boundary +uses `@interactive-os/json-document-web`'s pure default resolver for Undo/Redo: +Mod+Z undoes, Mod+Shift+Z redoes, and Alt-modified variants return `null`. +Composer still owns Escape and the configured Enter submit/newline meaning. +The keyboard dependency is confined to `interaction.ts`; draft model, schema, +and commands do not interpret Web events. No DOM environment is required. + +Usage: [Composer](https://developer-1px.github.io/json-document/demo/composer). +The React integration passes all modifier facts to this boundary. diff --git a/packages/json-document-composer/package.json b/packages/json-document-composer/package.json index b0d1612e..49ec3d43 100644 --- a/packages/json-document-composer/package.json +++ b/packages/json-document-composer/package.json @@ -21,6 +21,7 @@ "verify": "npm run typecheck && npm test && npm run build" }, "peerDependencies": { + "@interactive-os/json-document-web": "^0.1.0-rc.0", "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-file-intake": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-mention": "^0.1.0-rc.0", @@ -28,6 +29,7 @@ "@interactive-os/json-document-rich-text": "^0.1.0-rc.0" }, "devDependencies": { + "@interactive-os/json-document-web": "*", "@interactive-os/json-document": "*", "@interactive-os/json-document-file-intake": "*", "@interactive-os/json-document-rich-text-mention": "*", diff --git a/packages/json-document-composer/src/interaction.ts b/packages/json-document-composer/src/interaction.ts index d37f422e..d1eed3e4 100644 --- a/packages/json-document-composer/src/interaction.ts +++ b/packages/json-document-composer/src/interaction.ts @@ -1,19 +1,32 @@ +import { createWebKeyboardAdapter } from "@interactive-os/json-document-web"; import type { ComposerInteractionPolicy } from "./host-config.js"; +const keyboard = createWebKeyboardAdapter(); + export interface ComposerKeyStroke { readonly key: string; readonly shiftKey?: boolean; readonly commandKey?: boolean; + readonly altKey?: boolean; } export type ComposerInteraction = "dismiss" | "history.redo" | "history.undo" | "newline" | "submit"; +/** Uses the Web default history keymap, then applies Composer submit/newline policy. */ export function composerInteractionFromKeyStroke( stroke: ComposerKeyStroke, policy: ComposerInteractionPolicy, ): ComposerInteraction | null { if (stroke.key === "Escape") return "dismiss"; - if (stroke.commandKey && stroke.key.toLowerCase() === "z") return stroke.shiftKey ? "history.redo" : "history.undo"; + const command = keyboard.resolve({ + key: stroke.key, + shiftKey: stroke.shiftKey ?? false, + metaKey: stroke.commandKey ?? false, + ctrlKey: false, + altKey: stroke.altKey ?? false, + }); + if (command?.type === "undo") return "history.undo"; + if (command?.type === "redo") return "history.redo"; if (stroke.key !== "Enter") return null; const submits = policy.submit === "mod-enter" ? stroke.commandKey === true : stroke.commandKey !== true && stroke.shiftKey !== true; if (submits) return "submit"; diff --git a/packages/json-document-composer/tests/composer.test.ts b/packages/json-document-composer/tests/composer.test.ts index 7594cb17..423beb2e 100644 --- a/packages/json-document-composer/tests/composer.test.ts +++ b/packages/json-document-composer/tests/composer.test.ts @@ -120,3 +120,16 @@ describe("Composer domain", () => { expect(composerInteractionFromKeyStroke({ key: "Escape" }, policy)).toBe("dismiss"); }); }); + + +describe("Composer default history keyboard", () => { + for (const commandKey of [false, true]) for (const shiftKey of [false, true]) for (const altKey of [false, true]) { + test(`history command=${commandKey} shift=${shiftKey} alt=${altKey}`, () => { + for (const key of ["z", "Z"]) { + const stroke = { key, commandKey, shiftKey, altKey }; + expect(composerInteractionFromKeyStroke(stroke, { submit: "enter", newline: "shift-enter" })) + .toBe(commandKey && !altKey ? shiftKey ? "history.redo" : "history.undo" : null); + } + }); + } +}); diff --git a/packages/json-document-composer/tsconfig.json b/packages/json-document-composer/tsconfig.json index 04c4097d..074937d3 100644 --- a/packages/json-document-composer/tsconfig.json +++ b/packages/json-document-composer/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" }, "references": [ { "path": "../json-document" }, + { "path": "../json-document-web" }, { "path": "../json-document-file-intake" }, { "path": "../json-document-rich-text-mention" }, { "path": "../json-document-rich-text-suggestion" }, diff --git a/packages/json-document-web/README.md b/packages/json-document-web/README.md index ac5dfe8d..1c724bc6 100644 --- a/packages/json-document-web/README.md +++ b/packages/json-document-web/README.md @@ -239,3 +239,13 @@ const range = textSelectionFromControl({ currentTarget: textarea }); [Document Usage](https://developer-1px.github.io/json-document/demo); its source view links the React binding to this package's `input.ts` implementation and [API reference](https://developer-1px.github.io/json-document/docs/api/web). + +Default keyboard interpretation has one owner here. `chordFromStroke` folds +Meta/Control into `Mod`, preserves Alt/Shift, normalizes single-character case, +and maps the space key to `Space`. Unlisted chords resolve to `null`; for +example, Mod+Alt+Z and Mod+Backspace have no default structural command. +`createWebKeyboardAdapter({ keymap, defaults: false })` can explicitly assign +such chords for a product profile. Affordance consumes the default delete +mapping; Composer consumes its Undo/Redo mapping. Select-all remains an +Affordance policy over the canonical chord normalizer, outside +`WebKeyboardCommand`. diff --git a/packages/json-document-web/tests/web-adapters.test.ts b/packages/json-document-web/tests/web-adapters.test.ts index 3b93bd46..4ccf8aa8 100644 --- a/packages/json-document-web/tests/web-adapters.test.ts +++ b/packages/json-document-web/tests/web-adapters.test.ts @@ -646,6 +646,24 @@ describe("Web keyboard Adapter", () => { expect(adapter.resolve({ key: "c", shiftKey: false, metaKey: true, ctrlKey: false })).toBeNull(); }); + test("preserves modifiers while allowing explicit product chord assignments", () => { + const product = createWebKeyboardAdapter({ + defaults: false, + keymap: { "Mod-Alt-z": { type: "undo" }, "Mod-Backspace": { type: "delete" } }, + }); + for (const modifiers of [{ metaKey: true, ctrlKey: false }, { metaKey: false, ctrlKey: true }]) { + const undo = { key: "Z", shiftKey: false, altKey: true, ...modifiers }; + const remove = { key: "Backspace", shiftKey: false, ...modifiers }; + expect(adapter.resolve(undo)).toBeNull(); + expect(adapter.resolve(remove)).toBeNull(); + expect(product.resolve(undo)).toEqual({ type: "undo" }); + expect(product.resolve(remove)).toEqual({ type: "delete" }); + expect(product.resolve({ ...undo, altKey: false })).toBeNull(); + expect(adapter.resolve({ ...undo, altKey: false })).toEqual({ type: "undo" }); + expect(adapter.resolve({ ...undo, altKey: false, shiftKey: true })).toEqual({ type: "redo" }); + } + }); + test("lets the host replace chords without inventing editing commands", () => { const custom = createWebKeyboardAdapter({ keymap: { ...defaultWebKeymap, Enter: { type: "toggle" } }, diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index 754b4cc0..5d45974e 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -501,6 +501,11 @@ const registeredPublicUsages = [ symbol: "createWebKeyboardAdapter", sourcePath: "packages/json-document-web/src/keyboard.ts", }, + { + packageName: "@interactive-os/json-document-web", + symbol: "chordFromStroke", + sourcePath: "packages/json-document-web/src/keyboard.ts", + }, { packageName: "@interactive-os/json-document-web", symbol: "calendarMinutesFromWebGrid", @@ -967,6 +972,11 @@ const registeredPublicUsages = [ symbol: "selectAllAffordance", sourcePath: "packages/json-document-affordance/src/select.ts", }, + { + packageName: "@interactive-os/json-document-affordance", + symbol: "deleteAffordance", + sourcePath: "packages/json-document-affordance/src/select.ts", + }, { packageName: "@interactive-os/json-document-editing", symbol: "createDocumentEditor", diff --git a/site/tests/unit/composer-demo.test.tsx b/site/tests/unit/composer-demo.test.tsx index e3838843..b08708bb 100644 --- a/site/tests/unit/composer-demo.test.tsx +++ b/site/tests/unit/composer-demo.test.tsx @@ -71,6 +71,9 @@ describe("Agent Chat Composer Hands", () => { expect(screen.getByTestId("composer-draft-json").textContent).toContain("요구사항.md"); expect(screen.getByRole("button", { name: "전송 (Enter)" }).hasAttribute("disabled")).toBe(false); + const draftBefore = screen.getByTestId("composer-draft-json").textContent; + expect(fireEvent.keyDown(screen.getByTestId("agent-chat-composer"), { key: "z", metaKey: true, altKey: true })).toBe(true); + expect(screen.getByTestId("composer-draft-json").textContent).toBe(draftBefore); fireEvent.keyDown(screen.getByTestId("agent-chat-composer"), { key: "z", metaKey: true }); expect(screen.queryByText("요구사항.md")).toBeNull(); fireEvent.keyDown(screen.getByTestId("agent-chat-composer"), { key: "z", metaKey: true, shiftKey: true }); diff --git a/site/tests/unit/default-keyboard.test.tsx b/site/tests/unit/default-keyboard.test.tsx new file mode 100644 index 00000000..c3b0bdb9 --- /dev/null +++ b/site/tests/unit/default-keyboard.test.tsx @@ -0,0 +1,57 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, test } from "vitest"; +import { deleteAffordance, selectAllAffordance } from "@interactive-os/json-document-affordance"; +import { CanvasDemoRoute } from "../../src/routes/canvas-demo/CanvasDemoRoute"; +import { DocumentDemoRoute } from "../../src/routes/document-demo/DocumentDemoRoute"; + +afterEach(cleanup); + +test.each(["metaKey", "ctrlKey"] as const)("Canvas preserves %s modifier facts for selection and deletion", (modifier) => { + render(); + const canvas = screen.getByLabelText("Canvas"); + const objects = () => canvas.querySelectorAll("[data-object-id][data-locked]"); + const selected = () => canvas.querySelectorAll('[data-object-id][data-locked][data-selected="true"]'); + const count = objects().length; + expect(count).toBeGreaterThan(1); + const initialSelection = selected().length; + for (const extra of [{ altKey: true }, { shiftKey: true }, { altKey: true, shiftKey: true }]) { + expect(fireEvent.keyDown(canvas, { key: "a", [modifier]: true, ...extra })).toBe(true); + expect(selected().length).toBe(initialSelection); + } + expect(fireEvent.keyDown(canvas, { key: "a", [modifier]: true })).toBe(false); + const selectionCount = selected().length; + expect(selectionCount).toBeGreaterThan(0); + fireEvent.keyDown(canvas, { key: "a", [modifier]: true }); + expect(selected().length).toBe(selectionCount); + expect(fireEvent.keyDown(canvas, { key: "Backspace", [modifier]: true })).toBe(true); + expect(objects().length).toBe(count); + expect(selected().length).toBe(selectionCount); + expect(fireEvent.keyDown(canvas, { key: "Backspace" })).toBe(false); + expect(objects().length).toBe(count - selectionCount); +}); + +test("Document select-all preserves its scope, repetition, and native input ownership", () => { + render(); + const document = screen.getByLabelText("Editable document"); + const surface = document.querySelector('[tabindex="0"]')!; + const selected = () => document.querySelectorAll('article[data-block-id][data-selected="true"]'); + const initial = selected().length; + expect(fireEvent.keyDown(surface, { key: "a", ctrlKey: true, altKey: true })).toBe(true); + expect(selected().length).toBe(initial); + const input = document.querySelector("input, textarea")!; + expect(input).not.toBeNull(); + expect(fireEvent.keyDown(input, { key: "a", ctrlKey: true })).toBe(true); + expect(selected().length).toBe(initial); + expect(fireEvent.keyDown(surface, { key: "a", ctrlKey: true })).toBe(false); + const count = document.querySelectorAll("article[data-block-id]").length; + expect(selected().length).toBe(count); + fireEvent.keyDown(surface, { key: "a", ctrlKey: true }); + expect(selected().length).toBe(count); +}); + +test("native KeyboardEvent properties reach the public Affordance APIs", () => { + expect(deleteAffordance(new KeyboardEvent("keydown", { key: "Backspace", ctrlKey: true })).hand).toBeNull(); + expect(deleteAffordance(new KeyboardEvent("keydown", { key: "Backspace" })).hand).toEqual({ type: "delete" }); + expect(selectAllAffordance(new KeyboardEvent("keydown", { key: "a", metaKey: true, altKey: true }), { allSelected: false }).hand).toBeNull(); + expect(selectAllAffordance(new KeyboardEvent("keydown", { key: "a", metaKey: true }), { allSelected: false }).hand).toEqual({ type: "select-all" }); +}); diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index 17d30ff5..d4c6b14c 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -200,6 +200,7 @@ describe("Demo definition and source discovery", () => { "packages/json-document-composer/src/commands.ts", "packages/json-document-composer/src/host-config.ts", "packages/json-document-composer/src/interaction.ts", + "packages/json-document-web/src/keyboard.ts", "packages/json-document-rich-text-suggestion/src/index.ts", "packages/json-document-rich-text-suggestion-react/src/index.ts", "packages/json-document-composer-react/src/use-composer.tsx", From 02741ec3c49a502c628704ed676fd406bbe0cf19 Mon Sep 17 00:00:00 2001 From: "teo.v" <66650330+developer-1px@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:22:19 +0900 Subject: [PATCH 07/24] =?UTF-8?q?refactor(annotation):=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=C2=B7gesture=C2=B7=EC=B6=9C=EB=A0=A5=EC=9D=98=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B8=20=EA=B2=BD=EA=B3=84=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?(#589)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotation의 선택·gesture·geometry·출력 책임을 정본 모듈로 연결합니다. 기존 선택 순서와 Undo/Redo 복원, 도구·댓글·raster 동작을 유지하고 Host에는 정책과 화면 조합을 남깁니다. 최신 HEAD ba163a3f의 Plan, Package, Site, External kit 검증이 모두 통과했습니다. Closes #588 --- docs/api-reference/annotation.md | 99 ++ docs/api-reference/editing.md | 27 + docs/api-reference/packages.mjs | 1 + docs/evaluate.mjs | 1 + docs/public/hands.md | 21 +- package-lock.json | 37 + package.json | 1 + packages/json-document-annotation/LICENSE | 3 + packages/json-document-annotation/README.md | 41 + .../json-document-annotation/package.json | 66 ++ .../src/annotation-hand.tsx | 326 +++++++ .../src/annotation-output.ts | 58 ++ .../json-document-annotation/src/index.ts | 4 + .../tests/annotation-hand.test.tsx | 49 + .../tests/annotation-output.test.tsx | 62 ++ .../json-document-annotation/tsconfig.json | 29 + .../tsconfig.test.json | 1 + .../json-document-annotation/vitest.config.ts | 2 + packages/json-document-editing/README.md | 8 + .../src/annotation-selection.ts | 30 + .../json-document-editing/src/annotation.ts | 49 +- packages/json-document-editing/src/index.ts | 4 +- .../tests/annotation-editor.test.ts | 60 ++ scripts/ci-plan.mjs | 3 + scripts/ci-plan.test.mjs | 3 +- scripts/external-kit-plan.test.mjs | 1 + scripts/release-package.mjs | 1 + scripts/release-package.test.mjs | 11 + scripts/verify-external-kit.mjs | 1 + site/config/json-document-source-aliases.ts | 4 + site/package.json | 1 + .../check-canonical-module-closure.mjs | 7 + site/scripts/check-interaction-handles.mjs | 2 +- site/site-routes.json | 8 + site/src/app/routeTree.gen.ts | 21 + .../app/routes/_page/docs/api/annotation.tsx | 8 + .../annotation-demo/AnnotationDemoRoute.tsx | 904 +----------------- .../annotation-demo/annotation-demo-styles.ts | 14 +- site/src/routes/docs/DocsRoute.tsx | 1 + site/src/routes/docs/doc-pages.ts | 2 + .../src/shared/demo-workbench/demo-sources.ts | 30 + site/tests/browser/annotation-demo.spec.ts | 53 + site/tests/unit/demo-workbench.test.tsx | 16 + site/tsconfig.json | 1 + standards/repository-implementation-shape.md | 3 +- tsconfig.build.json | 1 + 46 files changed, 1178 insertions(+), 897 deletions(-) create mode 100644 docs/api-reference/annotation.md create mode 100644 packages/json-document-annotation/LICENSE create mode 100644 packages/json-document-annotation/README.md create mode 100644 packages/json-document-annotation/package.json create mode 100644 packages/json-document-annotation/src/annotation-hand.tsx create mode 100644 packages/json-document-annotation/src/annotation-output.ts create mode 100644 packages/json-document-annotation/src/index.ts create mode 100644 packages/json-document-annotation/tests/annotation-hand.test.tsx create mode 100644 packages/json-document-annotation/tests/annotation-output.test.tsx create mode 100644 packages/json-document-annotation/tsconfig.json create mode 100644 packages/json-document-annotation/tsconfig.test.json create mode 100644 packages/json-document-annotation/vitest.config.ts create mode 100644 packages/json-document-editing/src/annotation-selection.ts create mode 100644 site/src/app/routes/_page/docs/api/annotation.tsx diff --git a/docs/api-reference/annotation.md b/docs/api-reference/annotation.md new file mode 100644 index 00000000..c7e6c8a6 --- /dev/null +++ b/docs/api-reference/annotation.md @@ -0,0 +1,99 @@ +# @interactive-os/json-document-annotation API + +**Owner:** Hands + +Annotation Hand interaction과 SVG projection의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. + +> 이 문서는 `packages/json-document-annotation/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. + +## `AnnotationHand` + +```ts +AnnotationHand(props: AnnotationHandProps): import("/node_modules/@types/react/jsx-runtime").JSX.Element +``` +## `AnnotationHandClassNames` + +```ts +interface AnnotationHandClassNames { + readonly frame?: string; + readonly stage?: string; + readonly canvas?: string; + readonly commentCard?: string; + readonly commentInput?: string; + readonly commentPreview?: string; + readonly sendButton?: string; + readonly toolDock?: string; + readonly dockButton?: string; + readonly dockDivider?: string; +} +``` +## `AnnotationHandLabels` + +```ts +interface AnnotationHandLabels { + readonly canvas?: string; + readonly tools?: string; + readonly instruction?: string; + readonly instructionPlaceholder?: string; + readonly sendComment?: string; + readonly deleteAnnotation?: string; + readonly downloadImage?: string; +} +``` +## `AnnotationHandProps` + +```ts +interface AnnotationHandProps { + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly tool: AnnotationTool; + readonly onToolChange: (tool: AnnotationTool) => void; + readonly reactionShadow?: string; + readonly createId: () => string; + readonly classNames?: AnnotationHandClassNames; + readonly enabledTools?: ReadonlyArray; + readonly labels?: AnnotationHandLabels; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly onAnnouncement?: (message: string) => void; +} +``` +## `AnnotationOutput` + +```ts +interface AnnotationOutput { + readonly structured: string; + readonly structuredDownloadUrl: string; + readonly renderedImage: string | null; + readonly imageError: boolean; + readonly canRestore: boolean; + save(): void; + restore(): boolean; +} +``` +## `AnnotationOutputOptions` + +```ts +interface AnnotationOutputOptions { + /** The same document instance passed to createAnnotationEditor. */ + readonly document: JSONDocument; + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly renderImage: boolean; +} +``` +## `AnnotationTool` + +```ts +type AnnotationTool = "select" | "comment" | "draw" | "arrow" | "like" | "dislike"; +``` +## `annotationTools` + +```ts +const annotationTools: readonly [{ readonly id: "select"; readonly label: "Select"; readonly shortcut: "V"; readonly icon: ForwardRefExoticComponent & RefAttributes>; }, ... 4 more ..., { ...; }] +``` +## `useAnnotationOutput` + +```ts +useAnnotationOutput(options: AnnotationOutputOptions): AnnotationOutput +``` diff --git a/docs/api-reference/editing.md b/docs/api-reference/editing.md index 512f2cd1..9cd1f1c6 100644 --- a/docs/api-reference/editing.md +++ b/docs/api-reference/editing.md @@ -26,6 +26,11 @@ interface Annotation extends Record { readonly id: string; re ```ts const ANNOTATION_PROFILE_V1: "urn:interactive-os:json-document:annotation:1" ``` +## `AnnotationBounds` + +```ts +interface AnnotationBounds extends AnnotationPoint { readonly width: number; readonly height: number } +``` ## `AnnotationDocument` ```ts @@ -62,6 +67,11 @@ type AnnotationPresentation = | { readonly type: "stroke" } | { readonly type: "arrow" }; ``` +## `annotationResizeHandle` + +```ts +annotationResizeHandle(selector: AnnotationSelector): "end" | "south-east" | null +``` ## `AnnotationSelection` ```ts @@ -76,6 +86,18 @@ type AnnotationSelector = | { readonly type: "path"; readonly points: ReadonlyArray } | { readonly type: "arrow"; readonly from: AnnotationPoint; readonly to: AnnotationPoint }; ``` +## `annotationSelectorBounds` + +```ts +annotationSelectorBounds(selector: AnnotationSelector): AnnotationBounds +``` +## `AnnotationSelectorTransform` + +```ts +type AnnotationSelectorTransform = + | { readonly type: "move"; readonly dx: number; readonly dy: number } + | { readonly type: "resize"; readonly handle: "end" | "south-east"; readonly dx: number; readonly dy: number }; +``` ## `AnnotationSource` ```ts @@ -1521,6 +1543,11 @@ interface SheetSelection extends Record { ```ts type SheetTopology = GridTopology; ``` +## `transformAnnotationSelector` + +```ts +transformAnnotationSelector(selector: AnnotationSelector, transform: AnnotationSelectorTransform): AnnotationSelector | null +``` ## `TreeClipboard` ```ts diff --git a/docs/api-reference/packages.mjs b/docs/api-reference/packages.mjs index 0e02331d..0ba9cdfe 100644 --- a/docs/api-reference/packages.mjs +++ b/docs/api-reference/packages.mjs @@ -13,6 +13,7 @@ export const apiReferencePackages = [ ["animation-react", "@interactive-os/json-document-animation-react", "packages/json-document-animation-react/src/index.ts", "UI Primitives", "생성 대기 시각 언어"], ["markdown-react", "@interactive-os/json-document-markdown-react", "packages/json-document-markdown-react/src/index.ts", "Artifact", "스트리밍 Markdown 투영과 렌더링"], ["database", "@interactive-os/json-document-database", "packages/json-document-database/src/index.ts", "Hands", "Database Hand domain 계약"], + ["annotation", "@interactive-os/json-document-annotation", "packages/json-document-annotation/src/index.ts", "Hands", "Annotation Hand interaction과 SVG projection"], ["calendar", "@interactive-os/json-document-calendar", "packages/json-document-calendar/src/index.ts", "Hands", "Calendar React lifecycle와 occurrence interaction 계약"], ["web", "@interactive-os/json-document-web", "packages/json-document-web/src/index.ts", "Adapter", "Web platform adapter"], ["contenteditable", "@interactive-os/json-document-contenteditable", "packages/json-document-contenteditable/src/index.ts", "Adapter", "contenteditable platform adapter"], diff --git a/docs/evaluate.mjs b/docs/evaluate.mjs index 1bac21b2..14d3534a 100644 --- a/docs/evaluate.mjs +++ b/docs/evaluate.mjs @@ -123,6 +123,7 @@ const surfaces = { ajvReadme: read("packages/json-document-ajv/README.md"), zodReadme: read("packages/json-document-zod/README.md"), databaseReadme: read("packages/json-document-database/README.md"), + annotationReadme: read("packages/json-document-annotation/README.md"), tanstackTableReadme: read("packages/json-document-tanstack-table/README.md"), webReadme: read("packages/json-document-web/README.md"), contenteditableReadme: read("packages/json-document-contenteditable/README.md"), diff --git a/docs/public/hands.md b/docs/public/hands.md index c62d177a..3c1b40b3 100644 --- a/docs/public/hands.md +++ b/docs/public/hands.md @@ -14,14 +14,19 @@ editor.undo(); `AnnotationDocument`는 source와 selector geometry, presentation을 직렬화하고, selection과 undo/redo는 editor snapshot에 둡니다. Point, rectangle, path와 arrow selector는 geometry의 유일한 정본이며 presentation은 geometry를 반복하지 -않습니다. SVG 좌표 변환, pointer gesture, Canvas rasterization과 comment UI는 -Editing owner 밖에서 조합합니다. - -```ts -const gesture = createGestureSession(); -const point = projectWebClientPointToSVG(clientPoint, viewport); -const raster = await readWebRasterFile(file); -const output = await renderWebAnnotationRaster({ document, sourceId, sourceURL, style }); +않습니다. `@interactive-os/json-document-annotation`의 `AnnotationHand`가 +도구, gesture-to-Intent, SVG projection, transient preview와 comment UI를 +하나의 공개 surface로 제공합니다. + +```tsx + crypto.randomUUID()} + rasterStyle={style} +/> ``` Gesture는 Affordance가 input-independent lifecycle로 소유하고 Pointer capture는 diff --git a/package-lock.json b/package-lock.json index 0f02d817..43f43b8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "packages/json-document-markdown-react", "packages/json-document-zod", "packages/json-document-database", + "packages/json-document-annotation", "packages/json-document-calendar", "packages/json-document-tanstack-table", "packages/json-document-web", @@ -1124,6 +1125,10 @@ "resolved": "packages/json-document-animation-react", "link": true }, + "node_modules/@interactive-os/json-document-annotation": { + "resolved": "packages/json-document-annotation", + "link": true + }, "node_modules/@interactive-os/json-document-calendar": { "resolved": "packages/json-document-calendar", "link": true @@ -6504,6 +6509,37 @@ "react": "^18.0.0 || ^19.0.0" } }, + "packages/json-document-annotation": { + "name": "@interactive-os/json-document-annotation", + "version": "0.1.0-rc.0", + "license": "MIT", + "dependencies": { + "@interactive-os/json-document": ">=3.0.0-rc.0 <4", + "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", + "lucide-react": "^1.33.0" + }, + "devDependencies": { + "@interactive-os/json-document": "*", + "@interactive-os/json-document-affordance": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-ui-primitives-react": "*", + "@interactive-os/json-document-web": "*", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "jsdom": "^29.1.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "packages/json-document-calendar": { "name": "@interactive-os/json-document-calendar", "version": "0.1.0-rc.0", @@ -7022,6 +7058,7 @@ "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-ajv": "*", "@interactive-os/json-document-animation-react": "*", + "@interactive-os/json-document-annotation": "*", "@interactive-os/json-document-calendar": "*", "@interactive-os/json-document-collaboration": "*", "@interactive-os/json-document-composer": "*", diff --git a/package.json b/package.json index 81f3367a..1cab3947 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "packages/json-document-markdown-react", "packages/json-document-zod", "packages/json-document-database", + "packages/json-document-annotation", "packages/json-document-calendar", "packages/json-document-tanstack-table", "packages/json-document-web", diff --git a/packages/json-document-annotation/LICENSE b/packages/json-document-annotation/LICENSE new file mode 100644 index 00000000..b66a4819 --- /dev/null +++ b/packages/json-document-annotation/LICENSE @@ -0,0 +1,3 @@ +MIT License + +Copyright (c) Interactive OS contributors diff --git a/packages/json-document-annotation/README.md b/packages/json-document-annotation/README.md new file mode 100644 index 00000000..2f593fd2 --- /dev/null +++ b/packages/json-document-annotation/README.md @@ -0,0 +1,41 @@ +# @interactive-os/json-document-annotation + +`AnnotationHand` is the canonical React interaction surface for raster +annotations. Editing owns the persistent document and selector transforms; +the Hand owns tools, gesture-to-Intent orchestration, SVG projection, +transient previews, resize handles, and comment UI. + +```tsx +import { AnnotationHand } from "@interactive-os/json-document-annotation"; + + crypto.randomUUID()} + rasterStyle={rasterStyle} +/> +``` + +The Host owns the active `tool` and injects `onToolChange`, IDs, enabled tools, +copy, class names, `reactionShadow`, raster style, and the +concrete source URL. The serialized output remains an `AnnotationDocument`; +selection and history stay in the editor snapshot. + + +`useAnnotationOutput({ document, editor, sourceUrl, rasterStyle, renderImage })` +provides `structured`, `structuredDownloadUrl`, `renderedImage`, `imageError`, +`canRestore`, `save()` and `restore()`. Pass the same Core `document` instance +used to create `editor`. `save()` retains an immutable document snapshot; +`restore()` uses a Core commit and clears selection, returning whether it +succeeded. This is external document replacement, so the editor's external +history policy applies. A saved snapshot cannot be restored into a different +Core document instance. Image rendering is lazy and ignores stale completions. +The Host composes its own output tabs, copyable code display and download links. + +The Hand uses Key Selection through Editing, `useInteractionHandle` for move +and resize, Web pointer capture for creation, and the Web keyboard resolver for +Undo/Redo/Delete. Tool shortcuts are plain V/C/D/A/L/K; modified shortcuts and +IME composition do not choose a tool. Preview and commit both consume Editing's +`transformAnnotationSelector`. diff --git a/packages/json-document-annotation/package.json b/packages/json-document-annotation/package.json new file mode 100644 index 00000000..7bba7fc7 --- /dev/null +++ b/packages/json-document-annotation/package.json @@ -0,0 +1,66 @@ +{ + "name": "@interactive-os/json-document-annotation", + "version": "0.1.0-rc.0", + "description": "Official React Annotation Hand for json-document.", + "type": "module", + "license": "MIT", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/developer-1px/json-document.git", + "directory": "packages/json-document-annotation" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "tag": "next" + }, + "files": [ + "dist", + "!dist/.tsbuildinfo", + "README.md", + "LICENSE" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "clean": "rm -rf dist", + "build": "npm run clean && tsc -b tsconfig.json", + "test": "vitest run --config vitest.config.ts", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", + "typecheck": "tsc -p tsconfig.test.json --noEmit", + "verify": "npm run typecheck && npm test && npm run build" + }, + "dependencies": { + "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", + "lucide-react": "^1.33.0", + "@interactive-os/json-document": ">=3.0.0-rc.0 <4" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@interactive-os/json-document-affordance": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-ui-primitives-react": "*", + "@interactive-os/json-document-web": "*", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "jsdom": "^29.1.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "typescript": "^5.0.0", + "vitest": "^4.1.7", + "@interactive-os/json-document": "*" + } +} diff --git a/packages/json-document-annotation/src/annotation-hand.tsx b/packages/json-document-annotation/src/annotation-hand.tsx new file mode 100644 index 00000000..9759107a --- /dev/null +++ b/packages/json-document-annotation/src/annotation-hand.tsx @@ -0,0 +1,326 @@ +import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react"; +import { createGestureSession, type InteractionHandleEvent, type InteractionHandleDescriptor } from "@interactive-os/json-document-affordance"; +import { + annotationResizeHandle, + annotationSelectorBounds, + transformAnnotationSelector, + type Annotation, + type AnnotationDocument, + type AnnotationEditor, + type AnnotationPoint, + type AnnotationSource, +} from "@interactive-os/json-document-editing"; +import { createWebKeyboardAdapter, createWebPointerSession, projectWebClientPointToSVG, renderWebAnnotationRaster, webSVGViewportFromElement, type WebAnnotationRasterStyle } from "@interactive-os/json-document-web"; +import { Command, Field, Toggle, useInteractionHandle } from "@interactive-os/json-document-ui-primitives-react"; +import { ArrowUpRight, Download, MessageSquare, MousePointer2, Pencil, SendHorizontal, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"; + +export type AnnotationTool = "select" | "comment" | "draw" | "arrow" | "like" | "dislike"; +type Gesture = + | { readonly type: "create"; readonly tool: Exclude; readonly start: AnnotationPoint; readonly current: AnnotationPoint } + | { readonly type: "draw"; readonly points: ReadonlyArray } + | { readonly type: "move" | "resize"; readonly id: string; readonly start: AnnotationPoint; readonly current: AnnotationPoint }; + +export const annotationTools = [ + { id: "select", label: "Select", shortcut: "V", icon: MousePointer2 }, + { id: "comment", label: "Comment", shortcut: "C", icon: MessageSquare }, + { id: "draw", label: "Draw", shortcut: "D", icon: Pencil }, + { id: "arrow", label: "Arrow", shortcut: "A", icon: ArrowUpRight }, + { id: "like", label: "Like", shortcut: "L", icon: ThumbsUp }, + { id: "dislike", label: "Dislike", shortcut: "K", icon: ThumbsDown }, +] as const; + +export interface AnnotationHandLabels { + readonly canvas?: string; + readonly tools?: string; + readonly instruction?: string; + readonly instructionPlaceholder?: string; + readonly sendComment?: string; + readonly deleteAnnotation?: string; + readonly downloadImage?: string; +} + +export interface AnnotationHandClassNames { + readonly frame?: string; + readonly stage?: string; + readonly canvas?: string; + readonly commentCard?: string; + readonly commentInput?: string; + readonly commentPreview?: string; + readonly sendButton?: string; + readonly toolDock?: string; + readonly dockButton?: string; + readonly dockDivider?: string; +} + +export interface AnnotationHandProps { + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly tool: AnnotationTool; + readonly onToolChange: (tool: AnnotationTool) => void; + readonly reactionShadow?: string; + readonly createId: () => string; + readonly classNames?: AnnotationHandClassNames; + readonly enabledTools?: ReadonlyArray; + readonly labels?: AnnotationHandLabels; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly onAnnouncement?: (message: string) => void; +} + +const defaultLabels = { + canvas: "Raster annotation canvas", tools: "Annotation tools", instruction: "Annotation instruction", + instructionPlaceholder: "수정 요청을 입력하세요…", sendComment: "Send comment", + deleteAnnotation: "Delete annotation", downloadImage: "Download annotated image", +}; +const accent = "var(--annotation-accent)"; +const keyboard = createWebKeyboardAdapter(); +const toolKeyboard = createWebKeyboardAdapter({ defaults: false, keymap: Object.fromEntries(annotationTools.map(({ id, shortcut }) => [shortcut.toLowerCase(), id])) }); + +export function AnnotationHand(props: AnnotationHandProps) { + useSyncExternalStore(props.editor.subscribe, () => props.editor.snapshot.revision, () => props.editor.snapshot.revision); + const labels = { ...defaultLabels, ...props.labels }; const classes = props.classNames ?? {}; + const enabled = props.enabledTools ?? annotationTools.map(({ id }) => id); + const { tool, onToolChange: setTool } = props; + const [editingId, setEditingId] = useState(null); const [previewId, setPreviewId] = useState(null); + const [, redraw] = useState(0); + const [gestures] = useState(() => createGestureSession({ onBegin: rerender, onPreview: rerender, onCommit: rerender, onCancel: rerender })); + const [pointer] = useState(() => createWebPointerSession<{ readonly active: true }>()); + const document = props.editor.snapshot.value as AnnotationDocument; const selectedId = props.editor.snapshot.selection.primaryId; + const selected = document.annotations.find(({ id }) => id === selectedId) ?? null; const source = document.sources[0]!; const gesture = gestures.getActive(); + function rerender() { redraw((value) => value + 1); } + function announce(message: string) { props.onAnnouncement?.(message); } + function select(id: string | null) { props.editor.dispatch({ type: "selection.set", annotationId: id, mode: "replace" }); } + function choose(next: AnnotationTool) { setTool(next); setEditingId(null); if (selectedId !== null) select(null); } + function remove() { if (selectedId === null) return; props.editor.dispatch({ type: "annotation.delete", annotationId: selectedId }); setEditingId(null); announce("선택한 annotation을 삭제했습니다."); } + + function canvasDown(event: PointerEvent) { + if (event.target !== event.currentTarget && (event.target as Element).closest("[data-annotation-id]")) return; + const point = eventPoint(event); if (point === null) return; if (tool === "select") return select(null); + pointer.begin(event.currentTarget, event.pointerId, { active: true }); + gestures.begin(tool === "draw" ? { type: "draw", points: [point] } : { type: "create", tool, start: point, current: point }); + } + function handleInteraction(interaction: InteractionHandleEvent, event: PointerEvent, annotation: Annotation, type: "move" | "resize") { + if (interaction.phase === "start") { + if (type === "move") { setEditingId(null); setPreviewId(null); select(annotation.id); if (tool !== "select") return; } + const start = eventPoint(event); + if (start !== null) gestures.begin({ type, id: annotation.id, start, current: start }); + return; + } + if (interaction.phase === "cancel") { gestures.cancel("pointer-cancel"); announce("진행 중인 조작을 취소했습니다."); return; } + const active = gestures.getActive(); + if (active?.type !== type || active.id !== annotation.id) return; + const current = eventPoint(event); if (current === null) return; + gestures.preview({ ...active, current }); + if (interaction.phase === "commit") commitActiveGesture(); + } + function pointerMove(event: PointerEvent) { + const gesture = gestures.getActive(); + if (gesture === null || pointer.getSnapshot()?.pointerId !== event.pointerId) return; const point = eventPoint(event); if (point === null) return; + if (gesture.type === "draw") { const last = gesture.points[gesture.points.length - 1]; if (last && distance(last, point) >= 4) gestures.preview({ ...gesture, points: [...gesture.points, point] }); } + else gestures.preview({ ...gesture, current: point }); + } + function pointerUp(event: PointerEvent) { + pointerMove(event); + if (pointer.commit(event.pointerId) !== null) commitActiveGesture(); + } + function commitActiveGesture() { + const committed = gestures.commit(); if (committed === null) return; + if (committed.type === "draw" || committed.type === "create") { + const annotation = committed.type === "draw" ? drawAnnotation(source.id, committed.points, props.createId) : createAnnotation(source.id, committed, props.createId); + if (annotation === null || !props.editor.dispatch({ type: "annotation.create", annotation }).ok) return; setTool("select"); + setEditingId(annotation.presentation.type === "reaction" ? null : annotation.id); announce(createdMessage(annotation)); return; + } + const dx = committed.current.x - committed.start.x; const dy = committed.current.y - committed.start.y; + if (committed.type === "move" && Math.hypot(dx, dy) < 4) { + const annotation = document.annotations.find(({ id }) => id === committed.id); if (annotation?.presentation.type !== "reaction") setEditingId(committed.id); return; + } + const annotation = document.annotations.find(({ id }) => id === committed.id); if (!annotation) return; + const handle = annotationResizeHandle(annotation.target.selector); + const result = committed.type === "move" ? props.editor.dispatch({ type: "annotation.move", annotationId: committed.id, dx, dy }) + : handle === null ? null : props.editor.dispatch({ type: "annotation.resize", annotationId: committed.id, handle, dx, dy }); + if (!result?.ok) return; + announce(committed.type === "move" ? "Annotation을 이동했습니다." : "Target을 resize했습니다."); + } + function cancel(event: PointerEvent, reason: "pointer-cancel" | "lost-capture") { + if (pointer.cancel(event.pointerId, reason === "lost-capture" ? "lost-capture" : "cancel") === null) return; + gestures.cancel(reason); announce("진행 중인 조작을 취소했습니다."); + } + function keyDown(event: KeyboardEvent) { + if (event.nativeEvent.isComposing) return; + const command = keyboard.resolve(event); + if (command?.type === "undo" || command?.type === "redo") { event.preventDefault(); props.editor[command.type](); return; } + if (command?.type === "delete") { event.preventDefault(); remove(); return; } + const next = toolKeyboard.resolve(event); + if (next && enabled.includes(next)) { event.preventDefault(); choose(next); return; } + if (event.key === "Escape") { event.preventDefault(); const active = pointer.getSnapshot(); if (active) pointer.cancel(active.pointerId); gestures.cancel("cancel"); choose("select"); } + } + async function download() { + const result = await renderWebAnnotationRaster({ document, sourceId: source.id, sourceURL: props.sourceUrl, style: props.rasterStyle }); + if (!result.ok) return announce("Annotation 이미지를 만들지 못했습니다."); + const link = window.document.createElement("a"); link.href = result.dataURL; link.download = "annotation-request.png"; link.click(); announce("Annotation이 적용된 이미지를 다운로드했습니다."); + } + return
+
+ cancel(event, "lost-capture")} onPointerCancel={(event) => cancel(event, "pointer-cancel")} onPointerMove={pointerMove} onPointerUp={pointerUp} role="application" tabIndex={0} viewBox={`0 0 ${source.width} ${source.height}`}> + + {document.annotations.map((annotation, index) => setPreviewId(visible ? annotation.id : null)} />)} + {gesture?.type === "create" ? : null}{gesture?.type === "draw" ? : null} + + {document.annotations.map((annotation, index) => gesture === null && previewId === annotation.id && annotation.body.instruction.trim() && editingId !== annotation.id ? : null)} + {selected && editingId === selected.id ? cancelComment(selected)} onSave={(instruction) => saveComment(selected, instruction)} onSubmit={(instruction) => submitComment(selected, instruction)} /> : null} +
+ +
; + + function saveComment(annotation: Annotation, instruction: string) { const value = instruction.trim(); if (annotation.body.instruction !== value) props.editor.dispatch({ type: "annotation.body.set", annotationId: annotation.id, instruction: value }); setTool("select"); announce("수정 요청을 추가했습니다."); } + function submitComment(annotation: Annotation, instruction: string) { saveComment(annotation, instruction); setEditingId(null); } + function cancelComment(annotation: Annotation) { if (!annotation.body.instruction) props.editor.dispatch({ type: "annotation.delete", annotationId: annotation.id }); else select(null); setEditingId(null); } +} + +function CommentComposer(props: { annotation: Annotation; index: number; source: AnnotationSource; classNames: AnnotationHandClassNames; labels: typeof defaultLabels; onCancel: () => void; onSave: (value: string) => void; onSubmit: (value: string) => void }) { + const [draft, setDraft] = useState(props.annotation.body.instruction); const input = useRef(null); const dock = composerDock(props.annotation, props.source); + useEffect(() => setDraft(props.annotation.body.instruction), [props.annotation.id, props.annotation.body.instruction]); + useEffect(() => { const frame = requestAnimationFrame(() => input.current?.focus()); return () => cancelAnimationFrame(frame); }, [props.annotation.id]); + return
+ { if (draft.trim()) props.onSave(draft); }} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); if (draft.trim()) props.onSubmit(draft); } else if (event.key === "Escape") props.onCancel(); }} /> + props.onSubmit(draft)} onMouseDown={(event) => event.preventDefault()}> +
; +} +function CommentPreview({ annotation, index, source, className }: { annotation: Annotation; index: number; source: AnnotationSource; className?: string | undefined }) { const dock = composerDock(annotation, source); return
{annotation.body.instruction}
; } +function AnnotationShape(props: { + readonly annotation: Annotation; + readonly index: number; + readonly selected: boolean; + readonly onHandle: (interaction: InteractionHandleEvent, event: PointerEvent, annotation: Annotation, type: "move" | "resize") => void; + readonly onPreview: (visible: boolean) => void; +}) { + const { annotation } = props; + const drag = useInteractionHandle({ + descriptor: { kind: "drag", cursor: { idle: "move", active: "grabbing" } }, + onHandle: (interaction, event) => props.onHandle(interaction, event, annotation, "move"), + }); + const selector = annotation.target.selector; + const bounds = annotationSelectorBounds(annotation.target.selector); + const common = { + fill: "none", + stroke: accent, + strokeWidth: props.selected ? 6 : 4, + vectorEffect: "non-scaling-stroke" as const, + }; + return ( + props.onPreview(false)} + onFocus={() => props.onPreview(true)} + onPointerEnter={() => props.onPreview(true)} + onPointerLeave={() => props.onPreview(false)} + {...drag.handleProps} + role="button" + tabIndex={0} + style={{ cursor: drag.cursor }} + > + {annotation.presentation.type === "marker" && selector.type === "point" ? ( + + ) : null} + {annotation.presentation.type === "reaction" && selector.type === "point" ? ( + + ) : null} + {annotation.presentation.type === "outline" && selector.type === "rectangle" ? ( + <> + + {props.selected ? ( + props.onHandle(interaction, event, annotation, "resize")} + /> + ) : null} + + ) : null} + {annotation.presentation.type === "stroke" && selector.type === "path" ? ( + <> + + {props.selected ? ( + props.onHandle(interaction, event, annotation, "resize")} /> + ) : null} + + ) : null} + {annotation.presentation.type === "arrow" && selector.type === "arrow" ? ( + <> + + {props.selected ? ( + props.onHandle(interaction, event, annotation, "resize")} + /> + ) : null} + + ) : null} + {annotation.presentation.type !== "marker" && annotation.presentation.type !== "reaction" ? ( + + ) : null} + + ); +} + +function AnnotationPointHandle(props: { + readonly "aria-label": string; + readonly cx: number; + readonly cy: number; + readonly descriptor: InteractionHandleDescriptor; + readonly onHandle: (interaction: InteractionHandleEvent, event: PointerEvent) => void; +}) { + const binding = useInteractionHandle({ descriptor: props.descriptor, onHandle: props.onHandle }); + return ; +} + +function Badge(props: { readonly index: number; readonly point: AnnotationPoint; readonly selected: boolean }) { + return ( + + + {props.index} + + ); +} + +function commentBubblePath(point: AnnotationPoint): string { + const { x, y } = point; + return `M ${x} ${y - 24} C ${x + 13.25} ${y - 24} ${x + 24} ${y - 13.25} ${x + 24} ${y} C ${x + 24} ${y + 13.25} ${x + 13.25} ${y + 24} ${x} ${y + 24} L ${x - 24} ${y + 24} L ${x - 24} ${y} C ${x - 24} ${y - 13.25} ${x - 13.25} ${y - 24} ${x} ${y - 24} Z`; +} + + +function Stroke({ points, selected, draft }: { points: ReadonlyArray; selected?: boolean; draft?: boolean }) { return ; } +function Arrow({ from, to, selected }: { from: AnnotationPoint; to: AnnotationPoint; selected: boolean }) { const a = Math.atan2(to.y - from.y, to.x - from.x); const point = (delta: number) => ({ x: to.x - 34 * Math.cos(a + delta), y: to.y - 34 * Math.sin(a + delta) }); const l = point(-Math.PI / 6), r = point(Math.PI / 6); return ; } +function Reaction(props: { readonly point: AnnotationPoint; readonly reaction: "like" | "dislike"; readonly selected: boolean; readonly draft?: boolean }) { + const Icon = props.reaction === "like" ? ThumbsUp : ThumbsDown; + return ( + + + + + + ); +} +function DraftShape({ gesture }: { gesture: Extract }) { if (gesture.tool === "like" || gesture.tool === "dislike") return ; if (gesture.tool === "arrow") return ; if (distance(gesture.start, gesture.current) < 16) return ; return ; } +function project(annotation: Annotation, gesture: Gesture | null): Annotation { if (!gesture || (gesture.type !== "move" && gesture.type !== "resize") || gesture.id !== annotation.id) return annotation; const selector = transformAnnotationSelector(annotation.target.selector, gesture.type === "move" ? { type: "move", dx: gesture.current.x - gesture.start.x, dy: gesture.current.y - gesture.start.y } : { type: "resize", handle: annotationResizeHandle(annotation.target.selector) ?? "south-east", dx: gesture.current.x - gesture.start.x, dy: gesture.current.y - gesture.start.y }); return selector ? { ...annotation, target: { ...annotation.target, selector } } : annotation; } +function createAnnotation(sourceId: string, gesture: Extract, id: () => string): Annotation | null { const { tool, start, current } = gesture; if (tool === "like" || tool === "dislike") return { id: id(), target: { sourceId, selector: { type: "point", ...start } }, body: { instruction: "" }, presentation: { type: "reaction", reaction: tool } }; if (tool === "comment") return { id: id(), target: { sourceId, selector: distance(start, current) < 16 ? { type: "point", ...start } : { type: "rectangle", ...rectangle(start, current) } }, body: { instruction: "" }, presentation: { type: distance(start, current) < 16 ? "marker" : "outline" } }; return distance(start, current) < 8 ? null : { id: id(), target: { sourceId, selector: { type: "arrow", from: start, to: current } }, body: { instruction: "" }, presentation: { type: "arrow" } }; } +function drawAnnotation(sourceId: string, points: ReadonlyArray, id: () => string): Annotation | null { return points.length < 2 || pathLength(points) < 16 ? null : { id: id(), target: { sourceId, selector: { type: "path", points } }, body: { instruction: "" }, presentation: { type: "stroke" } }; } +function eventPoint(event: PointerEvent): AnnotationPoint | null { const svg = event.currentTarget.ownerSVGElement ?? event.currentTarget as SVGSVGElement; const point = projectWebClientPointToSVG({ x: event.clientX, y: event.clientY }, webSVGViewportFromElement(svg)); return point && { x: point.x, y: point.y }; } +function rectangle(a: AnnotationPoint, b: AnnotationPoint) { return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(b.x - a.x), height: Math.abs(b.y - a.y) }; } +function distance(a: AnnotationPoint, b: AnnotationPoint) { return Math.hypot(b.x - a.x, b.y - a.y); } +function pathLength(points: ReadonlyArray) { return points.slice(1).reduce((total, point, index) => total + distance(points[index] ?? point, point), 0); } +function pathData(points: ReadonlyArray) { const first = points[0]; if (!first) return ""; if (points.length === 2) return `M ${first.x} ${first.y} L ${points[1]!.x} ${points[1]!.y}`; const curves = points.slice(1, -1).map((point, index) => { const next = points[index + 2] ?? point; return `Q ${point.x} ${point.y} ${(point.x + next.x) / 2} ${(point.y + next.y) / 2}`; }); const last = points[points.length - 1] ?? first; return [`M ${first.x} ${first.y}`, ...curves, `L ${last.x} ${last.y}`].join(" "); } +function composerDock(annotation: Annotation, source: AnnotationSource) { const bounds = annotationSelectorBounds(annotation.target.selector); return { horizontal: bounds.x + bounds.width / 2 > source.width * .75 ? "left" : "right", vertical: bounds.y < 48 ? "below" : bounds.y > source.height - 48 ? "above" : "center", bounds }; } +function dockStyle(dock: ReturnType, source: AnnotationSource) { const left = dock.horizontal === "left" ? dock.bounds.x - 36 : dock.bounds.x + 36; const x = dock.horizontal === "left" ? "-100%" : "0"; const y = dock.vertical === "above" ? "-100%" : dock.vertical === "below" ? "0" : "-50%"; return { left: `${left / source.width * 100}%`, top: `${dock.bounds.y / source.height * 100}%`, transform: `translate(${x}, ${y})` }; } +function createdMessage(annotation: Annotation) { if (annotation.presentation.type === "reaction") return annotation.presentation.reaction === "like" ? "좋아요 스티커를 붙였습니다." : "싫어요 스티커를 붙였습니다."; return annotation.presentation.type === "marker" ? "위치 코멘트를 만들었습니다." : annotation.presentation.type === "outline" ? "영역 코멘트를 만들었습니다." : annotation.presentation.type === "stroke" ? "자유선 코멘트를 만들었습니다." : "화살표 코멘트를 만들었습니다."; } diff --git a/packages/json-document-annotation/src/annotation-output.ts b/packages/json-document-annotation/src/annotation-output.ts new file mode 100644 index 00000000..c77a4e56 --- /dev/null +++ b/packages/json-document-annotation/src/annotation-output.ts @@ -0,0 +1,58 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import type { JSONDocument } from "@interactive-os/json-document"; +import type { AnnotationDocument, AnnotationEditor } from "@interactive-os/json-document-editing"; +import { renderWebAnnotationRaster, type WebAnnotationRasterStyle } from "@interactive-os/json-document-web"; + +export interface AnnotationOutputOptions { + /** The same document instance passed to createAnnotationEditor. */ + readonly document: JSONDocument; + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly renderImage: boolean; +} +export interface AnnotationOutput { + readonly structured: string; + readonly structuredDownloadUrl: string; + readonly renderedImage: string | null; + readonly imageError: boolean; + readonly canRestore: boolean; + save(): void; + restore(): boolean; +} + +/** Output lifecycle; the Host owns tabs, copy, links, and panel layout. */ +export function useAnnotationOutput(options: AnnotationOutputOptions): AnnotationOutput { + const { document, editor, sourceUrl, rasterStyle, renderImage } = options; + useSyncExternalStore(editor.subscribe, () => editor.snapshot.revision, () => editor.snapshot.revision); + const value = editor.snapshot.value as AnnotationDocument; + const [saved, setSaved] = useState<{ owner: JSONDocument; value: AnnotationDocument } | null>(null); + const [image, setImage] = useState<{ value: AnnotationDocument; sourceUrl: string; style: WebAnnotationRasterStyle; dataURL: string | null } | null>(null); + const { stroke, fill, lineWidth, labelFont } = rasterStyle; + useEffect(() => { + if (!renderImage) return; + let current = true; + const style = { stroke, fill, lineWidth, labelFont }; + void renderWebAnnotationRaster({ document: value, sourceId: value.sources[0]!.id, sourceURL: sourceUrl, style }) + .then((result) => { if (current) setImage({ value, sourceUrl, style, dataURL: result.ok ? result.dataURL : null }); }) + .catch(() => { if (current) setImage({ value, sourceUrl, style, dataURL: null }); }); + return () => { current = false; }; + }, [value, sourceUrl, stroke, fill, lineWidth, labelFont, renderImage]); + const currentImage = image?.value === value && image.sourceUrl === sourceUrl && image.style.stroke === stroke && image.style.fill === fill && image.style.lineWidth === lineWidth && image.style.labelFont === labelFont ? image : null; + const structured = JSON.stringify(value, null, 2); + return { + structured, + structuredDownloadUrl: `data:application/json;charset=utf-8,${encodeURIComponent(structured)}`, + renderedImage: currentImage?.dataURL ?? null, + imageError: currentImage !== null && currentImage.dataURL === null, + canRestore: saved?.owner === document, + save() { setSaved({ owner: document, value }); }, + restore() { + if (saved?.owner !== document) return false; + const result = document.commit([{ op: "replace", path: "", value: saved.value }]); + if (!result.ok) return false; + editor.dispatch({ type: "selection.set", annotationId: null, mode: "replace" }); + return true; + }, + }; +} diff --git a/packages/json-document-annotation/src/index.ts b/packages/json-document-annotation/src/index.ts new file mode 100644 index 00000000..0c9557b6 --- /dev/null +++ b/packages/json-document-annotation/src/index.ts @@ -0,0 +1,4 @@ +export { AnnotationHand, annotationTools } from "./annotation-hand.js"; +export type { AnnotationHandClassNames, AnnotationHandLabels, AnnotationHandProps, AnnotationTool } from "./annotation-hand.js"; +export { useAnnotationOutput } from "./annotation-output.js"; +export type { AnnotationOutput, AnnotationOutputOptions } from "./annotation-output.js"; diff --git a/packages/json-document-annotation/tests/annotation-hand.test.tsx b/packages/json-document-annotation/tests/annotation-hand.test.tsx new file mode 100644 index 00000000..b7b98d9d --- /dev/null +++ b/packages/json-document-annotation/tests/annotation-hand.test.tsx @@ -0,0 +1,49 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ANNOTATION_PROFILE_V1, createAnnotationEditor, type AnnotationDocument } from "@interactive-os/json-document-editing"; +import { AnnotationHand, annotationTools } from "../src/index.js"; + +const document: AnnotationDocument = { profile: ANNOTATION_PROFILE_V1, id: "test", sources: [{ id: "image", src: "/image.png", width: 100, height: 80 }], annotations: [] }; +const rasterStyle = { stroke: "red", fill: "red", lineWidth: 2, labelFont: "12px sans-serif" }; + +afterEach(cleanup); + +describe("AnnotationHand", () => { + test("publishes one descriptor for every default tool", () => { + expect(annotationTools.map(({ id, shortcut }) => [id, shortcut])).toEqual([["select", "V"], ["comment", "C"], ["draw", "D"], ["arrow", "A"], ["like", "L"], ["dislike", "K"]]); + }); + + test("renders the canonical canvas and configurable tool set", () => { + render( "next"} tool="comment" onToolChange={() => {}} rasterStyle={rasterStyle} enabledTools={["select", "comment"]} />); + expect(screen.getByRole("application", { name: "Raster annotation canvas" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Select" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Draw" })).toBeNull(); + + }); +}); + + +test("the Host controls tools; modified keys and IME do not invoke ordinary commands", () => { + const editor = createAnnotationEditor(document); + const onToolChange = vi.fn(); + const undo = vi.spyOn(editor, "undo"), redo = vi.spyOn(editor, "redo"), dispatch = vi.spyOn(editor, "dispatch"); + const props = { editor, sourceUrl: "/image.png", createId: () => "next", rasterStyle, onToolChange }; + const view = render(); + const canvas = screen.getByRole("application"); + fireEvent.click(screen.getByRole("button", { name: "Draw" })); + expect(onToolChange).toHaveBeenLastCalledWith("draw"); + expect(canvas.getAttribute("data-tool")).toBe("comment"); + view.rerender(); + expect(canvas.getAttribute("data-tool")).toBe("draw"); + onToolChange.mockClear(); dispatch.mockClear(); + for (const modifiers of [{ altKey: true }, { ctrlKey: true }, { shiftKey: true }, { isComposing: true }]) fireEvent.keyDown(canvas, { key: "c", ...modifiers }); + expect(onToolChange).not.toHaveBeenCalled(); + fireEvent.keyDown(canvas, { key: "c" }); + expect(onToolChange).toHaveBeenLastCalledWith("comment"); + fireEvent.keyDown(canvas, { key: "z", metaKey: true }); + fireEvent.keyDown(canvas, { key: "Z", ctrlKey: true, shiftKey: true }); + expect(undo).toHaveBeenCalledTimes(1); expect(redo).toHaveBeenCalledTimes(1); + fireEvent.keyDown(canvas, { key: "z", ctrlKey: true, altKey: true }); + fireEvent.keyDown(canvas, { key: "z", metaKey: true, isComposing: true }); + expect(undo).toHaveBeenCalledTimes(1); +}); diff --git a/packages/json-document-annotation/tests/annotation-output.test.tsx b/packages/json-document-annotation/tests/annotation-output.test.tsx new file mode 100644 index 00000000..ec3598f6 --- /dev/null +++ b/packages/json-document-annotation/tests/annotation-output.test.tsx @@ -0,0 +1,62 @@ +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { ANNOTATION_PROFILE_V1, createAnnotationEditor, type AnnotationDocument } from "@interactive-os/json-document-editing"; +import { renderWebAnnotationRaster, type WebAnnotationRasterResult } from "@interactive-os/json-document-web"; +import { useAnnotationOutput } from "../src/index.js"; + +vi.mock("@interactive-os/json-document-web", async (load) => ({ ...await load(), renderWebAnnotationRaster: vi.fn() })); +afterEach(() => { cleanup(); vi.clearAllMocks(); }); +const initial: AnnotationDocument = { profile: ANNOTATION_PROFILE_V1, id: "test", sources: [{ id: "image", src: "/image.png", width: 100, height: 80 }], annotations: [] }; +const rasterStyle = { stroke: "red", fill: "red", lineWidth: 2, labelFont: "12px sans-serif" }; +function setup() { + const document = createJSONDocument(initial), editor = createAnnotationEditor(document); + return { document, editor, sourceUrl: "/image.png", rasterStyle, renderImage: false }; +} + +test("serializes only the document and restores a saved snapshot with cleared selection", () => { + const options = setup(); + const { result } = renderHook(() => useAnnotationOutput(options)); + expect(result.current.restore()).toBe(false); + act(() => result.current.save()); + act(() => { options.editor.dispatch({ type: "annotation.create", annotation: { id: "note", body: { instruction: "Inspect" }, presentation: { type: "marker" }, target: { sourceId: "image", selector: { type: "point", x: 10, y: 20 } } } }); }); + expect(options.editor.snapshot.selection.primaryId).toBe("note"); + expect(JSON.parse(result.current.structured)).toEqual(options.document.value); + expect(JSON.parse(decodeURIComponent(result.current.structuredDownloadUrl.split(",")[1]!))).toEqual(options.document.value); + expect(result.current.structured).not.toContain("primaryId"); + act(() => { expect(result.current.restore()).toBe(true); }); + expect(options.document.value).toEqual(initial); + expect(options.editor.snapshot.selection.ids).toEqual([]); +}); + +test("does not restore a snapshot from a replaced document owner", () => { + const { result, rerender } = renderHook(useAnnotationOutput, { initialProps: setup() }); + act(() => result.current.save()); + rerender(setup()); + expect(result.current.canRestore).toBe(false); + expect(result.current.restore()).toBe(false); +}); + +test("renders lazily, ignores stale raster completion, and exposes the current failure", async () => { + const pending: Array<(result: WebAnnotationRasterResult) => void> = []; + vi.mocked(renderWebAnnotationRaster).mockImplementation(() => new Promise((resolve) => pending.push(resolve))); + const options = setup(); + const { result, rerender } = renderHook(useAnnotationOutput, { initialProps: options }); + expect(pending).toHaveLength(0); + rerender({ ...options, renderImage: true }); + expect(pending).toHaveLength(1); + rerender({ ...options, renderImage: true, sourceUrl: "/new.png" }); + expect(pending).toHaveLength(2); + await act(async () => pending[0]!({ ok: true, dataURL: "data:old" })); + expect(result.current.renderedImage).toBeNull(); + await act(async () => pending[1]!({ ok: false, code: "raster.decode-failed" })); + await waitFor(() => expect(result.current.imageError).toBe(true)); +}); + + +test("reports an unexpected raster rejection without an unhandled promise", async () => { + vi.mocked(renderWebAnnotationRaster).mockRejectedValue(new Error("canvas unavailable")); + const options = { ...setup(), renderImage: true }; + const { result } = renderHook(() => useAnnotationOutput(options)); + await waitFor(() => expect(result.current.imageError).toBe(true)); +}); diff --git a/packages/json-document-annotation/tsconfig.json b/packages/json-document-annotation/tsconfig.json new file mode 100644 index 00000000..a90816ee --- /dev/null +++ b/packages/json-document-annotation/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig/library-react.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo" + }, + "references": [ + { + "path": "../json-document" + }, + { + "path": "../json-document-affordance" + }, + { + "path": "../json-document-editing" + }, + { + "path": "../json-document-ui-primitives-react" + }, + { + "path": "../json-document-web" + } + ], + "include": [ + "src/**/*.ts", + "src/**/*.tsx" + ] +} diff --git a/packages/json-document-annotation/tsconfig.test.json b/packages/json-document-annotation/tsconfig.test.json new file mode 100644 index 00000000..6a4921de --- /dev/null +++ b/packages/json-document-annotation/tsconfig.test.json @@ -0,0 +1 @@ +{"extends":"./tsconfig.json","compilerOptions":{"composite":false,"noEmit":true,"rootDir":".","tsBuildInfoFile":null},"include":["src/**/*.ts","src/**/*.tsx","tests/**/*.ts","tests/**/*.tsx"]} diff --git a/packages/json-document-annotation/vitest.config.ts b/packages/json-document-annotation/vitest.config.ts new file mode 100644 index 00000000..04e0964c --- /dev/null +++ b/packages/json-document-annotation/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from "vitest/config"; +export default defineConfig({ test: { environment: "jsdom" } }); diff --git a/packages/json-document-editing/README.md b/packages/json-document-editing/README.md index 7d368264..f8655014 100644 --- a/packages/json-document-editing/README.md +++ b/packages/json-document-editing/README.md @@ -244,3 +244,11 @@ preserves document values and existing Undo/Redo records; Undo after an edit restores the recorded offset range. These contracts are exercised by [Document editor tests](tests/document-editor.test.ts) and the existing [Document Usage](https://developer-1px.github.io/json-document/demo). + + +Annotation selection preserves its public `{ kind: "annotation", ids, primaryId }` +shape and the order in which IDs were selected. Membership and primary fallback +are owned by Key Selection; document reconciliation retains surviving IDs in +that order. Undo/Redo restores both the document and the associated selection. +`transformAnnotationSelector`, `annotationSelectorBounds`, and +`annotationResizeHandle` are the canonical geometry APIs for preview and commit. diff --git a/packages/json-document-editing/src/annotation-selection.ts b/packages/json-document-editing/src/annotation-selection.ts new file mode 100644 index 00000000..97f7ec86 --- /dev/null +++ b/packages/json-document-editing/src/annotation-selection.ts @@ -0,0 +1,30 @@ +import { createKeySelectionFamily, type KeySelection, type KeySelectionContext } from "@interactive-os/json-document-selection"; +import type { AnnotationDocument, AnnotationIntent, AnnotationSelection } from "./annotation.js"; + +const family = createKeySelectionFamily(); + +// Annotation exposes insertion order. The domain supplies that traversal order; +// the Key family alone owns membership, primary fallback, and reconciliation. +function context(selection: AnnotationSelection, document: AnnotationDocument): KeySelectionContext { + const rank = new Map(selection.ids.map((id, index) => [id, index])); + return { + keys: document.annotations.map(({ id }) => id).sort((a, b) => (rank.get(a) ?? rank.size) - (rank.get(b) ?? rank.size)), + universe: document.id, + universeMismatch: "clear", + }; +} +function toKey(selection: AnnotationSelection): KeySelection { + return { kind: "explicit", keys: selection.ids, primaryKey: selection.primaryId }; +} +function fromKey(selection: KeySelection, domain: KeySelectionContext): AnnotationSelection { + return { kind: "annotation", ids: family.targets(selection, domain), primaryId: selection.primaryKey }; +} +export function reconcileAnnotationSelection(selection: AnnotationSelection, document: AnnotationDocument): AnnotationSelection { + const domain = context(selection, document); + return fromKey(family.reconcile(toKey(selection), domain).state, domain); +} +export function transitionAnnotationSelection(selection: AnnotationSelection, intent: Extract, document: AnnotationDocument): AnnotationSelection { + const domain = context(selection, document); + const command = intent.annotationId === null ? { type: "clear" as const } : { type: intent.mode, keys: [intent.annotationId], primaryKey: intent.annotationId }; + return fromKey(family.transition(toKey(selection), command, domain).state, domain); +} diff --git a/packages/json-document-editing/src/annotation.ts b/packages/json-document-editing/src/annotation.ts index 212ab8de..fdbf660d 100644 --- a/packages/json-document-editing/src/annotation.ts +++ b/packages/json-document-editing/src/annotation.ts @@ -3,6 +3,7 @@ import { resolveDocumentSource, type EditingDocumentSource } from "./document-so import type { EditingHistoryOptions } from "./history.js"; import { createEditingSession, type EditingResult, type EditingSnapshot } from "./session.js"; import { assertAnnotation, assertAnnotationDocument } from "./annotation-validation.js"; +import { reconcileAnnotationSelection, transitionAnnotationSelection } from "./annotation-selection.js"; export const ANNOTATION_PROFILE_V1 = "urn:interactive-os:json-document:annotation:1" as const; export interface AnnotationPoint extends Record { readonly x: number; readonly y: number } @@ -38,9 +39,7 @@ export function createAnnotationEditor(source: EditingDocumentSource annotation.id)); - const ids = selection.ids.filter((id) => available.has(id)); - return selectionFor(ids, selection.primaryId !== null && available.has(selection.primaryId) ? selection.primaryId : ids.at(-1) ?? null); + return reconcileAnnotationSelection(selection, value as AnnotationDocument); }, }); const value = () => session.snapshot.value as AnnotationDocument; @@ -48,11 +47,7 @@ export function createAnnotationEditor(source: EditingDocumentSource item.id === intent.annotationId)) return failure("annotation.not-found"); - const current = session.snapshot.selection.ids; - const ids = intent.mode === "toggle" && intent.annotationId !== null - ? current.includes(intent.annotationId) ? current.filter((id) => id !== intent.annotationId) : [...current, intent.annotationId] - : intent.annotationId === null ? [] : [intent.annotationId]; - return success(session.select(selectionFor(ids, ids.includes(intent.annotationId ?? "") ? intent.annotationId : ids.at(-1) ?? null))); + return success(session.select(transitionAnnotationSelection(session.snapshot.selection, intent, value()))); } if (intent.type === "annotation.create") { try { assertAnnotation(intent.annotation, new Set(value().sources.map((item) => item.id))); } catch (error) { return failure("annotation.invalid", message(error)); } @@ -70,7 +65,9 @@ export function createAnnotationEditor(source: EditingDocumentSource session.undo(), redo: () => session.redo(), subscribe: (listener) => session.subscribe(listener) }; } -function move(selector: AnnotationSelector, dx: number, dy: number): AnnotationSelector { +export type AnnotationSelectorTransform = + | { readonly type: "move"; readonly dx: number; readonly dy: number } + | { readonly type: "resize"; readonly handle: "end" | "south-east"; readonly dx: number; readonly dy: number }; + +export interface AnnotationBounds extends AnnotationPoint { readonly width: number; readonly height: number } + +export function transformAnnotationSelector(selector: AnnotationSelector, transform: AnnotationSelectorTransform): AnnotationSelector | null { + if (transform.type === "resize") return resize(selector, transform.handle, transform.dx, transform.dy); + const { dx, dy } = transform; const point = (p: AnnotationPoint) => ({ x: p.x + dx, y: p.y + dy }); if (selector.type === "point" || selector.type === "rectangle") return { ...selector, ...point(selector) }; if (selector.type === "path") return { ...selector, points: selector.points.map(point) }; return { ...selector, from: point(selector.from), to: point(selector.to) }; } + +export function annotationSelectorBounds(selector: AnnotationSelector): AnnotationBounds { + if (selector.type === "arrow") return rectangleFromPoints(selector.from, selector.to); + if (selector.type === "rectangle") return { x: selector.x, y: selector.y, width: selector.width, height: selector.height }; + if (selector.type === "path") { + const xs = selector.points.map(({ x }) => x); const ys = selector.points.map(({ y }) => y); + const x = Math.min(...xs); const y = Math.min(...ys); + return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y }; + } + return { x: selector.x, y: selector.y, width: 0, height: 0 }; +} + +export function annotationResizeHandle(selector: AnnotationSelector): "end" | "south-east" | null { + if (selector.type === "arrow") return "end"; + if (selector.type === "rectangle" || selector.type === "path") return "south-east"; + return null; +} + function resize(selector: AnnotationSelector, handle: "end" | "south-east", dx: number, dy: number): AnnotationSelector | null { if (handle === "south-east" && selector.type === "rectangle") return { ...selector, width: Math.max(1, selector.width + dx), height: Math.max(1, selector.height + dy) }; if (handle === "south-east" && selector.type === "path") { - const xs = selector.points.map((point) => point.x); const ys = selector.points.map((point) => point.y); - const x = Math.min(...xs); const y = Math.min(...ys); const width = Math.max(1, Math.max(...xs) - x); const height = Math.max(1, Math.max(...ys) - y); + const bounds = annotationSelectorBounds(selector); + const { x, y } = bounds; + const width = Math.max(1, bounds.width), height = Math.max(1, bounds.height); const scaleX = Math.max(1, width + dx) / width; const scaleY = Math.max(1, height + dy) / height; return { ...selector, points: selector.points.map((point) => ({ x: x + (point.x - x) * scaleX, y: y + (point.y - y) * scaleY })) }; } if (handle === "end" && selector.type === "arrow") { const to = { x: selector.to.x + dx, y: selector.to.y + dy }; return to.x === selector.from.x && to.y === selector.from.y ? null : { ...selector, to }; } return null; } +function rectangleFromPoints(start: AnnotationPoint, end: AnnotationPoint): AnnotationBounds { return { x: Math.min(start.x, end.x), y: Math.min(start.y, end.y), width: Math.abs(end.x - start.x), height: Math.abs(end.y - start.y) }; } function selectionFor(ids: ReadonlyArray, primaryId: string | null = ids.at(-1) ?? null): AnnotationSelection { return { kind: "annotation", ids, primaryId }; } function success(snapshot: EditingSnapshot): EditingResult { return { ok: true, snapshot }; } function failure(code: string, reason?: string): EditingResult { return { ok: false, code, ...(reason === undefined ? {} : { reason }) }; } diff --git a/packages/json-document-editing/src/index.ts b/packages/json-document-editing/src/index.ts index 470f7fcd..96439167 100644 --- a/packages/json-document-editing/src/index.ts +++ b/packages/json-document-editing/src/index.ts @@ -69,10 +69,11 @@ export { isCalendarAllDay, parseCalendarView, } from "./calendar-validation.js"; -export { ANNOTATION_PROFILE_V1, createAnnotationEditor } from "./annotation.js"; +export { ANNOTATION_PROFILE_V1, annotationResizeHandle, annotationSelectorBounds, createAnnotationEditor, transformAnnotationSelector } from "./annotation.js"; export { assertAnnotationDocument } from "./annotation-validation.js"; export type { Annotation, + AnnotationBounds, AnnotationDocument, AnnotationEditor, AnnotationIntent, @@ -80,6 +81,7 @@ export type { AnnotationPresentation, AnnotationSelection, AnnotationSelector, + AnnotationSelectorTransform, AnnotationSource, } from "./annotation.js"; export type { diff --git a/packages/json-document-editing/tests/annotation-editor.test.ts b/packages/json-document-editing/tests/annotation-editor.test.ts index d20c7d70..2fd77a2a 100644 --- a/packages/json-document-editing/tests/annotation-editor.test.ts +++ b/packages/json-document-editing/tests/annotation-editor.test.ts @@ -1,8 +1,12 @@ +import { createJSONDocument } from "@interactive-os/json-document"; import { describe, expect, test } from "vitest"; import { ANNOTATION_PROFILE_V1, + annotationResizeHandle, + annotationSelectorBounds, assertAnnotationDocument, createAnnotationEditor, + transformAnnotationSelector, type Annotation, type AnnotationDocument, } from "../src/index.js"; @@ -77,4 +81,60 @@ describe("Annotation editor", () => { expect(editor.dispatch({ type: "annotation.resize", annotationId: "path", handle: "south-east", dx: 2, dy: 3 }).ok).toBe(true); expect(editor.dispatch({ type: "annotation.resize", annotationId: "arrow", handle: "end", dx: 5, dy: -2 }).ok).toBe(true); }); + + test("projects preview geometry through the same selector contract used by commits", () => { + const transform = { type: "resize", handle: "south-east", dx: 10, dy: -20 } as const; + const projected = transformAnnotationSelector(rectangle.target.selector, transform); + const editor = createAnnotationEditor(document([rectangle])); + editor.dispatch({ type: "annotation.resize", annotationId: rectangle.id, handle: transform.handle, dx: transform.dx, dy: transform.dy }); + expect((editor.snapshot.value as AnnotationDocument).annotations[0]!.target.selector).toEqual(projected); + expect(annotationSelectorBounds(rectangle.target.selector)).toEqual({ x: 20, y: 30, width: 40, height: 50 }); + expect(annotationResizeHandle(rectangle.target.selector)).toBe("south-east"); + expect(annotationResizeHandle(point.target.selector)).toBeNull(); + }); +}); + + +describe("Annotation Key selection compatibility", () => { + test("keeps insertion order and primary across toggles, document edits, undo and redo", () => { + const editor = createAnnotationEditor(document([point, rectangle, arrow])); + editor.dispatch({ type: "selection.set", annotationId: "arrow", mode: "replace" }); + editor.dispatch({ type: "selection.set", annotationId: "point", mode: "toggle" }); + editor.dispatch({ type: "selection.set", annotationId: "rect", mode: "toggle" }); + const selected = { kind: "annotation", ids: ["arrow", "point", "rect"], primaryId: "rect" }; + expect(editor.snapshot.selection).toEqual(selected); + expect(editor.snapshot.canUndo).toBe(false); + editor.dispatch({ type: "annotation.move", annotationId: "rect", dx: 5, dy: 8 }); + expect(editor.snapshot.selection.ids).toEqual(["rect"]); + editor.undo(); + expect(editor.snapshot.selection).toEqual(selected); + editor.redo(); + expect(editor.snapshot.selection.ids).toEqual(["rect"]); + editor.undo(); + editor.dispatch({ type: "selection.set", annotationId: "rect", mode: "toggle" }); + expect(editor.snapshot.selection).toEqual({ kind: "annotation", ids: ["arrow", "point"], primaryId: "point" }); + const before = editor.snapshot; + expect(editor.dispatch({ type: "selection.set", annotationId: "missing", mode: "toggle" }).ok).toBe(false); + expect(editor.snapshot).toEqual(before); + editor.dispatch({ type: "selection.set", annotationId: null, mode: "toggle" }); + expect(editor.snapshot.selection).toEqual({ kind: "annotation", ids: [], primaryId: null }); + }); + + test("reconciles external removal without reordering surviving selections", () => { + const core = createJSONDocument(document([point, rectangle, arrow])); + const editor = createAnnotationEditor(core); + for (const annotationId of ["arrow", "rect", "point"]) editor.dispatch({ type: "selection.set", annotationId, mode: "toggle" }); + core.commit([{ op: "replace", path: "/annotations", value: [rectangle, arrow, point] }]); + expect(editor.snapshot.selection.ids).toEqual(["arrow", "rect", "point"]); + core.commit([{ op: "remove", path: "/annotations/2" }]); + expect(editor.snapshot.selection).toEqual({ kind: "annotation", ids: ["arrow", "rect"], primaryId: "rect" }); + }); + + test("rejects degenerate arrow preview and commit without changing history or selection", () => { + const editor = createAnnotationEditor(document([arrow])); + const before = editor.snapshot; + expect(transformAnnotationSelector(arrow.target.selector, { type: "resize", handle: "end", dx: -10, dy: -10 })).toBeNull(); + expect(editor.dispatch({ type: "annotation.resize", annotationId: "arrow", handle: "end", dx: -10, dy: -10 }).ok).toBe(false); + expect(editor.snapshot).toEqual(before); + }); }); diff --git a/scripts/ci-plan.mjs b/scripts/ci-plan.mjs index 90ebc9c7..b2846d1a 100644 --- a/scripts/ci-plan.mjs +++ b/scripts/ci-plan.mjs @@ -31,6 +31,7 @@ const packageBrowserSpecs = new Map([ ["@interactive-os/json-document-ajv", ["site/tests/browser/connectors/ajv.spec.ts"]], ["@interactive-os/json-document-contenteditable", ["site/tests/browser/adapters/contenteditable.spec.ts"]], ["@interactive-os/json-document-database", ["site/tests/browser/database-demo.spec.ts"]], + ["@interactive-os/json-document-annotation", ["site/tests/browser/annotation-demo.spec.ts", "site/tests/browser/copy-selection-closure.spec.ts"]], ["@interactive-os/json-document-calendar", [ "site/tests/browser/calendar-app.spec.ts", "site/tests/browser/calendar-launch.spec.ts", @@ -73,6 +74,7 @@ const routeBrowserSpecs = new Map([ ["connectors/tanstack-table", ["site/tests/browser/connectors/tanstack-table.spec.ts"]], ["connectors/zod", ["site/tests/browser/connectors/zod.spec.ts"]], ["database-demo", ["site/tests/browser/database-demo.spec.ts"]], + ["annotation-demo", ["site/tests/browser/annotation-demo.spec.ts", "site/tests/browser/copy-selection-closure.spec.ts"]], ["document-demo", ["site/tests/browser/document-demo.spec.ts"]], ["editing-demos", ["site/tests/browser/editing-demos.spec.ts"]], ["rich-text-demo", ["site/tests/browser/rich-text-demo.spec.ts"]], @@ -88,6 +90,7 @@ const firstKitWorkspaces = new Set([ "@interactive-os/json-document-react", "@interactive-os/json-document-zod", "@interactive-os/json-document-database", + "@interactive-os/json-document-annotation", "@interactive-os/json-document-calendar", "@interactive-os/json-document-file-intake", "@interactive-os/json-document-rich-text-suggestion", diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs index 719f2ea1..5efb0714 100644 --- a/scripts/ci-plan.test.mjs +++ b/scripts/ci-plan.test.mjs @@ -40,7 +40,7 @@ test("기반 패키지 변경은 모든 역방향 소비자를 선택한다", () const plan = createPlan(["packages/json-document/src/index.ts"]); assert.equal(plan.full, false); - assert.equal(plan.packageWorkspaces.length, 26); + assert.equal(plan.packageWorkspaces.length, 27); assert.equal(plan.standards, true); assert.equal(plan.externalKit, true); assert.deepEqual(plan.browserSpecs, ["site/tests/browser"]); @@ -109,6 +109,7 @@ test("선택기가 반환하는 모든 browser 경로가 존재한다", () => { "json-document-composer-react", "json-document-file-intake", "json-document-database", + "json-document-annotation", "json-document-calendar", "json-document-editing", "json-document-react", diff --git a/scripts/external-kit-plan.test.mjs b/scripts/external-kit-plan.test.mjs index 6fff03e0..46c81ba9 100644 --- a/scripts/external-kit-plan.test.mjs +++ b/scripts/external-kit-plan.test.mjs @@ -12,6 +12,7 @@ test("첫 kit package 변경은 외부 소비자 검증을 선택한다", () => "json-document-react", "json-document-zod", "json-document-database", + "json-document-annotation", "json-document-calendar", ]) { assert.equal(createPlan([`packages/${directory}/src/index.ts`]).externalKit, true, directory); diff --git a/scripts/release-package.mjs b/scripts/release-package.mjs index 70105542..661e2b80 100644 --- a/scripts/release-package.mjs +++ b/scripts/release-package.mjs @@ -23,6 +23,7 @@ export const releases = [ release("json-document-tanstack-table", "packages/json-document-tanstack-table/package.json", "@interactive-os/json-document-tanstack-table"), release("json-document-zod", "packages/json-document-zod/package.json", "@interactive-os/json-document-zod"), release("json-document-database", "packages/json-document-database/package.json", "@interactive-os/json-document-database"), + release("json-document-annotation", "packages/json-document-annotation/package.json", "@interactive-os/json-document-annotation"), release("json-document-calendar", "packages/json-document-calendar/package.json", "@interactive-os/json-document-calendar"), release("json-document-contenteditable-collaboration", "packages/contenteditable-collaboration/package.json", "@interactive-os/json-document-contenteditable-collaboration"), release("json-document-collaboration", "packages/json-document-collaboration/package.json", "@interactive-os/json-document-collaboration"), diff --git a/scripts/release-package.test.mjs b/scripts/release-package.test.mjs index 61c4c363..6331ba6c 100644 --- a/scripts/release-package.test.mjs +++ b/scripts/release-package.test.mjs @@ -27,6 +27,12 @@ const databaseHand = [ "next", "packages/json-document-database/package.json", ]; +const annotationHand = [ + "json-document-annotation-v0.1.0-rc.0", + "@interactive-os/json-document-annotation", + "next", + "packages/json-document-annotation/package.json", +]; test("첫 npm kit의 stable과 RC release stream을 구분한다", () => { for (const [tag, workspace, distTag, packageFile] of firstKit) { @@ -48,6 +54,11 @@ test("Database Hand를 next release stream으로 해석한다", () => { assert.deepEqual(resolveRelease(tag), { workspace, distTag, version: "0.1.0-rc.0", packageFile }); }); +test("Annotation Hand를 next release stream으로 해석한다", () => { + const [tag, workspace, distTag, packageFile] = annotationHand; + assert.deepEqual(resolveRelease(tag), { workspace, distTag, version: "0.1.0-rc.0", packageFile }); +}); + test("지원하지 않는 package tag를 거부한다", () => { assert.throws(() => resolveRelease("json-document-rich-text-v0.1.0-rc.0"), /unsupported release tag/); }); diff --git a/scripts/verify-external-kit.mjs b/scripts/verify-external-kit.mjs index 454d3eb6..35f54e27 100644 --- a/scripts/verify-external-kit.mjs +++ b/scripts/verify-external-kit.mjs @@ -27,6 +27,7 @@ const kitWorkspaces = [ "@interactive-os/json-document-react", "@interactive-os/json-document-zod", "@interactive-os/json-document-database", + "@interactive-os/json-document-annotation", "@interactive-os/json-document-calendar", ]; const fixtureSource = join(repositoryRoot, "fixtures", "external-kit"); diff --git a/site/config/json-document-source-aliases.ts b/site/config/json-document-source-aliases.ts index b69276c4..12c5aa01 100644 --- a/site/config/json-document-source-aliases.ts +++ b/site/config/json-document-source-aliases.ts @@ -75,6 +75,10 @@ export function jsonDocumentSourceAliases(): SourceAlias[] { find: "@interactive-os/json-document-database", replacement: sourceFile("packages/json-document-database/src/index.ts"), }, + { + find: "@interactive-os/json-document-annotation", + replacement: sourceFile("packages/json-document-annotation/src/index.ts"), + }, { find: "@interactive-os/json-document-tanstack-table", replacement: sourceFile("packages/json-document-tanstack-table/src/index.ts"), diff --git a/site/package.json b/site/package.json index be1403c4..88ec1dd0 100644 --- a/site/package.json +++ b/site/package.json @@ -49,6 +49,7 @@ "typecheck": "npm run check:ui && npm run check:icons && npm run check:primitives && npm run check:ui-roles && npm run check:choice-id && npm run check:canonical-modules && npm run check:interaction-handles && npm run check:contextual-affordance && npm run check:content-interaction && npm run check:product-shell-toolbar && npm run check:calendar-temporal && npm run check:calendar-keyboard && npm run check:calendar-rename && npm run check:calendar-viewport && npm run check:calendar-pointer-coordinate && npm run check:calendar-selection-drag && npm run check:anchored-floating-position && npm run check:calendar-year-months && npm run check:calendar-month-weeks && npm run check:calendar-period-label && npm run check:calendar-visible-date-shift && npm run check:calendar-time-label && npm run check:calendar-event-label && npm run check:calendar-interval-last-date && npm run check:calendar-date-part && npm run check:calendar-period-cells && npm run check:calendar-allday-span && npm run check:calendar-view-parser && npm run check:calendar-instant-format && npm run check:calendar-cell-interval && npm run check:calendar-date-grid && npm run check:calendar-month-grid && npm run check:calendar-time-grid && npm run check:calendar-event-inspector && npm run check:calendar-recurrence-transition && npm run check:calendar-document-calendars && npm run check:tokens && tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@interactive-os/json-document-annotation": "*", "@a2ui/web_core": "^0.10.6", "@ag-ui/core": "^0.0.59", "@ag-ui/encoder": "^0.0.59", diff --git a/site/scripts/check-canonical-module-closure.mjs b/site/scripts/check-canonical-module-closure.mjs index bbcea385..c13811f1 100644 --- a/site/scripts/check-canonical-module-closure.mjs +++ b/site/scripts/check-canonical-module-closure.mjs @@ -10,6 +10,13 @@ const databasePropertyConsumers = [ "packages/json-document-database/src/database-hands.tsx", "packages/json-document-zod/src/database-document.ts", ]; +const annotationDemo = readSource("routes/annotation-demo/AnnotationDemoRoute.tsx"); +for (const symbol of ["AnnotationHand", "useAnnotationOutput"]) { + if (!hasNamedImport(annotationDemo, "@interactive-os/json-document-annotation", symbol)) throw new Error(`Annotation Demo must consume the canonical ${symbol}`); +} +for (const localResponsibility of ["createGestureSession", "projectWebClientPointToSVG", "function AnnotationShape", "function CommentComposer", "presentStructuredSnapshot", "JSON.stringify", "JSON.parse", "renderWebAnnotationRaster"]) { + if (annotationDemo.includes(localResponsibility)) throw new Error(`Annotation Demo owns displaced behavior: ${localResponsibility}`); +} const entries = [...registrySource.matchAll(/^\s*"\/[^"]+"[^\n]+"(routes\/[^"]+)"\),?$/gm)].map((match) => match[1]); const usages = [...sourceRegistry.matchAll(/packageName:\s*["']([^"']+)["'],\s*\n\s*symbol:\s*["']([^"']+)["'],\s*\n\s*sourcePath:\s*["']([^"']+)["']/g)].map((match) => ({ packageName: match[1], diff --git a/site/scripts/check-interaction-handles.mjs b/site/scripts/check-interaction-handles.mjs index 035117ee..0f1a7cc8 100644 --- a/site/scripts/check-interaction-handles.mjs +++ b/site/scripts/check-interaction-handles.mjs @@ -9,7 +9,7 @@ const sources = { calendar: read("packages/json-document-calendar/src/use-calendar-pointer-interactions.ts"), canvas: read("site/src/routes/canvas-demo/CanvasDemoRoute.tsx"), database: read("packages/json-document-database/src/database-hand.tsx"), - annotation: read("site/src/routes/annotation-demo/AnnotationDemoRoute.tsx"), + annotation: read("packages/json-document-annotation/src/annotation-hand.tsx"), }; requireText("owner", sources.owner, "createInteractionHandleSession"); diff --git a/site/site-routes.json b/site/site-routes.json index c62b1a04..6fa7596e 100644 --- a/site/site-routes.json +++ b/site/site-routes.json @@ -404,6 +404,14 @@ "language": "ko", "navigationGroup": "Hands" }, + { + "path": "/docs/api/annotation", + "label": "API · Annotation", + "title": "Annotation API - json-document", + "description": "@interactive-os/json-document-annotation의 전체 public API입니다.", + "language": "ko", + "navigationGroup": "Hands" + }, { "path": "/docs/api/calendar", "label": "API · Calendar", diff --git a/site/src/app/routeTree.gen.ts b/site/src/app/routeTree.gen.ts index c6c571d7..4251679d 100644 --- a/site/src/app/routeTree.gen.ts +++ b/site/src/app/routeTree.gen.ts @@ -132,6 +132,7 @@ import { Route as PageDocsApiA2uiRouteImport } from "./routes/_page/docs/api/a2u import { Route as PageDocsApiAffordanceRouteImport } from "./routes/_page/docs/api/affordance"; import { Route as PageDocsApiAjvRouteImport } from "./routes/_page/docs/api/ajv"; import { Route as PageDocsApiAnimationReactRouteImport } from "./routes/_page/docs/api/animation-react"; +import { Route as PageDocsApiAnnotationRouteImport } from "./routes/_page/docs/api/annotation"; import { Route as PageDocsApiCalendarRouteImport } from "./routes/_page/docs/api/calendar"; import { Route as PageDocsApiCollaborationRouteImport } from "./routes/_page/docs/api/collaboration"; import { Route as PageDocsApiComposerRouteImport } from "./routes/_page/docs/api/composer"; @@ -812,6 +813,11 @@ const PageDocsApiAnimationReactRoute = path: "/animation-react", getParentRoute: () => PageDocsApiRoute, } as any); +const PageDocsApiAnnotationRoute = PageDocsApiAnnotationRouteImport.update({ + id: "/annotation", + path: "/annotation", + getParentRoute: () => PageDocsApiRoute, +} as any); const PageDocsApiCalendarRoute = PageDocsApiCalendarRouteImport.update({ id: "/calendar", path: "/calendar", @@ -1120,6 +1126,7 @@ export interface FileRoutesByFullPath { "/docs/api/affordance": typeof PageDocsApiAffordanceRoute; "/docs/api/ajv": typeof PageDocsApiAjvRoute; "/docs/api/animation-react": typeof PageDocsApiAnimationReactRoute; + "/docs/api/annotation": typeof PageDocsApiAnnotationRoute; "/docs/api/calendar": typeof PageDocsApiCalendarRoute; "/docs/api/collaboration": typeof PageDocsApiCollaborationRoute; "/docs/api/composer": typeof PageDocsApiComposerRoute; @@ -1277,6 +1284,7 @@ export interface FileRoutesByTo { "/docs/api/affordance": typeof PageDocsApiAffordanceRoute; "/docs/api/ajv": typeof PageDocsApiAjvRoute; "/docs/api/animation-react": typeof PageDocsApiAnimationReactRoute; + "/docs/api/annotation": typeof PageDocsApiAnnotationRoute; "/docs/api/calendar": typeof PageDocsApiCalendarRoute; "/docs/api/collaboration": typeof PageDocsApiCollaborationRoute; "/docs/api/composer": typeof PageDocsApiComposerRoute; @@ -1436,6 +1444,7 @@ export interface FileRoutesById { "/_page/docs/api/affordance": typeof PageDocsApiAffordanceRoute; "/_page/docs/api/ajv": typeof PageDocsApiAjvRoute; "/_page/docs/api/animation-react": typeof PageDocsApiAnimationReactRoute; + "/_page/docs/api/annotation": typeof PageDocsApiAnnotationRoute; "/_page/docs/api/calendar": typeof PageDocsApiCalendarRoute; "/_page/docs/api/collaboration": typeof PageDocsApiCollaborationRoute; "/_page/docs/api/composer": typeof PageDocsApiComposerRoute; @@ -1595,6 +1604,7 @@ export interface FileRouteTypes { | "/docs/api/affordance" | "/docs/api/ajv" | "/docs/api/animation-react" + | "/docs/api/annotation" | "/docs/api/calendar" | "/docs/api/collaboration" | "/docs/api/composer" @@ -1752,6 +1762,7 @@ export interface FileRouteTypes { | "/docs/api/affordance" | "/docs/api/ajv" | "/docs/api/animation-react" + | "/docs/api/annotation" | "/docs/api/calendar" | "/docs/api/collaboration" | "/docs/api/composer" @@ -1910,6 +1921,7 @@ export interface FileRouteTypes { | "/_page/docs/api/affordance" | "/_page/docs/api/ajv" | "/_page/docs/api/animation-react" + | "/_page/docs/api/annotation" | "/_page/docs/api/calendar" | "/_page/docs/api/collaboration" | "/_page/docs/api/composer" @@ -2815,6 +2827,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof PageDocsApiAnimationReactRouteImport; parentRoute: typeof PageDocsApiRoute; }; + "/_page/docs/api/annotation": { + id: "/_page/docs/api/annotation"; + path: "/annotation"; + fullPath: "/docs/api/annotation"; + preLoaderRoute: typeof PageDocsApiAnnotationRouteImport; + parentRoute: typeof PageDocsApiRoute; + }; "/_page/docs/api/calendar": { id: "/_page/docs/api/calendar"; path: "/calendar"; @@ -3054,6 +3073,7 @@ interface PageDocsApiRouteChildren { PageDocsApiAffordanceRoute: typeof PageDocsApiAffordanceRoute; PageDocsApiAjvRoute: typeof PageDocsApiAjvRoute; PageDocsApiAnimationReactRoute: typeof PageDocsApiAnimationReactRoute; + PageDocsApiAnnotationRoute: typeof PageDocsApiAnnotationRoute; PageDocsApiCalendarRoute: typeof PageDocsApiCalendarRoute; PageDocsApiCollaborationRoute: typeof PageDocsApiCollaborationRoute; PageDocsApiComposerRoute: typeof PageDocsApiComposerRoute; @@ -3086,6 +3106,7 @@ const PageDocsApiRouteChildren: PageDocsApiRouteChildren = { PageDocsApiAffordanceRoute: PageDocsApiAffordanceRoute, PageDocsApiAjvRoute: PageDocsApiAjvRoute, PageDocsApiAnimationReactRoute: PageDocsApiAnimationReactRoute, + PageDocsApiAnnotationRoute: PageDocsApiAnnotationRoute, PageDocsApiCalendarRoute: PageDocsApiCalendarRoute, PageDocsApiCollaborationRoute: PageDocsApiCollaborationRoute, PageDocsApiComposerRoute: PageDocsApiComposerRoute, diff --git a/site/src/app/routes/_page/docs/api/annotation.tsx b/site/src/app/routes/_page/docs/api/annotation.tsx new file mode 100644 index 00000000..eac48214 --- /dev/null +++ b/site/src/app/routes/_page/docs/api/annotation.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/api/annotation")({ + component: function AnnotationApiReferenceRoute() { + return ; + }, +}); diff --git a/site/src/routes/annotation-demo/AnnotationDemoRoute.tsx b/site/src/routes/annotation-demo/AnnotationDemoRoute.tsx index d7f80a6d..4b45f7a3 100644 --- a/site/src/routes/annotation-demo/AnnotationDemoRoute.tsx +++ b/site/src/routes/annotation-demo/AnnotationDemoRoute.tsx @@ -1,702 +1,61 @@ -import { - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, - type ChangeEvent, - type KeyboardEvent, - type PointerEvent, -} from "react"; +import { useState } from "react"; import { createJSONDocument } from "@interactive-os/json-document"; -import { - ANNOTATION_PROFILE_V1, - assertAnnotationDocument, - createAnnotationEditor, - type Annotation, - type AnnotationDocument, - type AnnotationPoint, - type AnnotationSource, -} from "@interactive-os/json-document-editing"; -import { createGestureSession, type InteractionHandleDescriptor, type InteractionHandleEvent } from "@interactive-os/json-document-affordance"; -import { - createWebPointerSession, - projectWebClientPointToSVG, - readWebRasterFile, - renderWebAnnotationRaster, - webSVGViewportFromElement, -} from "@interactive-os/json-document-web"; -import { - ArrowUpRight, - Download, - ImagePlus, - MessageSquare, - MousePointer2, - Pencil, - Redo2, - RotateCcw, - Save, - SendHorizontal, - ThumbsDown, - ThumbsUp, - Trash2, - Undo2, - ZoomIn, - ZoomOut, -} from "lucide-react"; +import { Command, ProductShell, Tabs } from "@interactive-os/json-document-ui-primitives-react"; +import { AnnotationHand, useAnnotationOutput, type AnnotationTool } from "@interactive-os/json-document-annotation"; +import { createAnnotationEditor } from "@interactive-os/json-document-editing"; import { DemoPage } from "../../shared/demo-workbench/DemoPage"; -import { Command, Field, Tabs, Toggle, useInteractionHandle } from "@interactive-os/json-document-ui-primitives-react"; import { PageHeader } from "../../shared/ui/primitives"; -import { ProductShell } from "@interactive-os/json-document-ui-primitives-react"; import { classes, ui } from "../../shared/ui/styles"; -import { CodeBlock } from "../../shared/ui/code-block"; -import { - initialAnnotationDocument, -} from "./annotation-state"; +import { initialAnnotationDocument } from "./annotation-state"; import { annotationDemoRecipe } from "./annotation-demo-styles"; -type Tool = "select" | "comment" | "draw" | "arrow" | "like" | "dislike"; -type Output = "structured" | "image"; -type Gesture = - | { readonly type: "create"; readonly tool: Exclude; readonly start: AnnotationPoint; readonly current: AnnotationPoint } - | { readonly type: "draw"; readonly points: ReadonlyArray } - | { readonly type: "move"; readonly id: string; readonly start: AnnotationPoint; readonly current: AnnotationPoint } - | { readonly type: "resize"; readonly id: string; readonly start: AnnotationPoint; readonly current: AnnotationPoint }; +import { Save, RotateCcw } from "lucide-react"; +import { CodeBlock } from "../../shared/ui/code-block"; -const accent = "rgb(var(--color-border-accent))"; -const annotationDemoStyles = annotationDemoRecipe(); +const styles = annotationDemoRecipe(); export function AnnotationDemoRoute() { - const [documentSource] = useState(() => createJSONDocument(initialAnnotationDocument)); - const [editor] = useState(() => createAnnotationEditor(documentSource)); - useSyncExternalStore(editor.subscribe, () => editor.snapshot.revision, () => editor.snapshot.revision); - const [tool, setTool] = useState("comment"); - const [editingId, setEditingId] = useState(null); - const [previewId, setPreviewId] = useState(null); - const [, setGestureRevision] = useState(0); - const [gestureSession] = useState(() => createGestureSession({ - onBegin: () => setGestureRevision((revision) => revision + 1), - onPreview: () => setGestureRevision((revision) => revision + 1), - onCommit: () => setGestureRevision((revision) => revision + 1), - onCancel: () => setGestureRevision((revision) => revision + 1), - })); - const [pointerSession] = useState(() => createWebPointerSession<{ readonly active: true }>()); - const [savedState, setSavedState] = useState(null); - const [output, setOutput] = useState("structured"); - const [renderedImage, setRenderedImage] = useState(null); - const [zoom, setZoom] = useState(1); + const [document] = useState(() => createJSONDocument(initialAnnotationDocument)); + const [editor] = useState(() => createAnnotationEditor(document)); + const [tool, setTool] = useState("comment"); + const [output, setOutput] = useState<"structured" | "image">("structured"); const [announcement, setAnnouncement] = useState("클릭하거나 드래그해서 수정 코멘트를 남기세요."); - const svgRef = useRef(null); - const rasterUrlsRef = useRef(new Map([[ - initialAnnotationDocument.sources[0]!.id, - sitePath(initialAnnotationDocument.sources[0]!.src), - ]])); - const documentValue = editor.snapshot.value as AnnotationDocument; - const selectedId = editor.snapshot.selection.primaryId; - const selected = documentValue.annotations.find((annotation) => annotation.id === selectedId) ?? null; - const source = documentValue.sources[0]!; - const sourceUrl = rasterUrlsRef.current.get(source.id) ?? sourcePath(source.src); - const gesture = gestureSession.getActive(); - - useEffect(() => { - if (output !== "image") return; - let active = true; - setRenderedImage(null); - void renderWebAnnotationRaster({ document: documentValue, sourceId: source.id, sourceURL: sourceUrl, style: rasterStyle() }).then((result) => { - if (active) setRenderedImage(result.ok ? result.dataURL : null); - }); - return () => { active = false; }; - }, [output, documentValue, sourceUrl]); - - const structuredOutput = useMemo(() => presentStructuredSnapshot(documentValue, selectedId), [documentValue, selectedId]); - const structuredDownloadUrl = useMemo( - () => `data:application/json;charset=utf-8,${encodeURIComponent(JSON.stringify(structuredOutput, null, 2))}`, - [structuredOutput], - ); - - function setSelected(selectedId: string | null) { - editor.dispatch({ type: "selection.set", annotationId: selectedId, mode: "replace" }); - } - - function sendComment(annotation: Annotation, instruction: string) { - const nextInstruction = instruction.trim(); - if (annotation.body.instruction === nextInstruction) { - setTool("select"); - return; - } - editor.dispatch({ type: "annotation.body.set", annotationId: annotation.id, instruction: nextInstruction }); - setTool("select"); - setAnnouncement("수정 요청을 추가했습니다."); - } - - function submitComment(annotation: Annotation, instruction: string) { - sendComment(annotation, instruction); - setEditingId(null); - } - - function cancelComment(annotation: Annotation) { - if (annotation.body.instruction === "") { - editor.dispatch({ type: "annotation.delete", annotationId: annotation.id }); - setAnnouncement("작성 중인 요청을 취소했습니다."); - setEditingId(null); - return; - } - setSelected(null); - setEditingId(null); - } - - function chooseTool(nextTool: Tool) { - setTool(nextTool); - setEditingId(null); - if (selectedId !== null) setSelected(null); - } - - function deleteSelected() { - if (selectedId === null) return; - editor.dispatch({ type: "annotation.delete", annotationId: selectedId }); - setEditingId(null); - setAnnouncement("선택한 annotation을 삭제했습니다."); - } - - function handleCanvasPointerDown(event: PointerEvent) { - if (event.target !== event.currentTarget && (event.target as Element).closest("[data-annotation-id]")) return; - const point = eventPoint(event); - if (point === null) return; - if (tool === "select") return setSelected(null); - pointerSession.begin(event.currentTarget, event.pointerId, { active: true }); - if (tool === "draw") { - gestureSession.begin({ type: "draw", points: [point] }); - return; - } - gestureSession.begin({ type: "create", tool, start: point, current: point }); - } - - function handleAnnotationInteraction( - interaction: InteractionHandleEvent, - event: PointerEvent, - annotation: Annotation, - type: "move" | "resize", - ) { - if (interaction.phase === "start") { - if (type === "move") { - setEditingId(null); - setPreviewId(null); - setSelected(annotation.id); - if (tool !== "select") return; - } - const start = eventPoint(event); - if (start !== null) gestureSession.begin({ type, id: annotation.id, start, current: start }); - return; - } - if (interaction.phase === "cancel") { - gestureSession.cancel("pointer-cancel"); - setAnnouncement("진행 중인 조작을 취소했습니다."); - return; - } - const active = gestureSession.getActive(); - if (active?.type !== type || active.id !== annotation.id) return; - const current = eventPoint(event); - if (current === null) return; - gestureSession.preview({ ...active, current }); - if (interaction.phase === "commit") commitActiveGesture(); - } - - function handlePointerMove(event: PointerEvent) { - if (gesture === null || pointerSession.getSnapshot()?.pointerId !== event.pointerId) return; - const point = eventPoint(event); - if (point === null) return; - if (gesture.type === "draw") { - const previous = gesture.points.at(-1); - if (previous !== undefined && distance(previous, point) >= 4) { - gestureSession.preview({ type: "draw", points: [...gesture.points, point] }); - } - return; - } - if (gesture.type === "create") { - gestureSession.preview({ ...gesture, current: point }); - return; - } - gestureSession.preview({ ...gesture, current: point }); - } - - function handlePointerUp(event: PointerEvent) { - if (pointerSession.commit(event.pointerId) === null) return; - commitActiveGesture(); - } - - function commitActiveGesture() { - const gesture = gestureSession.commit(); - if (gesture === null) return; - if (gesture.type === "draw") { - const annotation = createDrawAnnotation(source.id, gesture.points); - if (annotation !== null) { - editor.dispatch({ type: "annotation.create", annotation }); - setTool("select"); - setEditingId(annotation.presentation.type === "reaction" ? null : annotation.id); - setAnnouncement("자유선 코멘트를 만들었습니다."); - } - } else if (gesture.type === "create") { - const annotation = createAnnotation(source.id, gesture.tool, gesture.start, gesture.current); - if (annotation !== null) { - editor.dispatch({ type: "annotation.create", annotation }); - setTool("select"); - setEditingId(annotation.presentation.type === "reaction" ? null : annotation.id); - setAnnouncement(annotationAnnouncement(annotation)); - } - } else { - const dx = gesture.current.x - gesture.start.x; - const dy = gesture.current.y - gesture.start.y; - if (gesture.type === "move" && Math.hypot(dx, dy) < 4) { - const annotation = documentValue.annotations.find((item) => item.id === gesture.id); - if (annotation !== undefined && annotation.presentation.type !== "reaction") setEditingId(gesture.id); - return; - } - editor.dispatch(gesture.type === "move" - ? { type: "annotation.move", annotationId: gesture.id, dx, dy } - : { type: "annotation.resize", annotationId: gesture.id, handle: resizeHandle(documentValue, gesture.id), dx, dy }); - setAnnouncement(gesture.type === "move" ? "Annotation을 이동했습니다." : "Target을 resize했습니다."); - } - } - - function cancelPointerGesture(event: PointerEvent, reason: "pointer-cancel" | "lost-capture") { - const cancelled = pointerSession.cancel(event.pointerId, reason === "lost-capture" ? "lost-capture" : "cancel"); - if (cancelled === null) return; - gestureSession.cancel(reason); - setAnnouncement("진행 중인 조작을 취소했습니다."); - } - - function handleKeyDown(event: KeyboardEvent) { - const command = event.metaKey || event.ctrlKey; - if (command && event.key.toLowerCase() === "z") { - event.preventDefault(); - if (event.shiftKey) editor.redo(); - else editor.undo(); - return; - } - if (!command) { - const shortcutTool = toolFromShortcut(event.key); - if (shortcutTool !== null) { - event.preventDefault(); - chooseTool(shortcutTool); - return; - } - } - if (event.key === "Escape") { - event.preventDefault(); - const pointer = pointerSession.getSnapshot(); - if (pointer !== null) pointerSession.cancel(pointer.pointerId); - gestureSession.cancel("cancel"); - chooseTool("select"); - return; - } - if (event.key === "Delete" || event.key === "Backspace") { - event.preventDefault(); - deleteSelected(); - } - } - - function saveState() { - setSavedState(JSON.stringify(documentValue)); - setAnnouncement("Structured annotation state를 저장했습니다."); - } - - function restoreState() { - if (savedState === null) return; - const restored = JSON.parse(savedState) as AnnotationDocument; - assertAnnotationDocument(restored); - documentSource.commit([{ op: "replace", path: "", value: restored }]); - setSelected(null); - setAnnouncement("저장한 state에서 overlay를 복원했습니다."); - } - - async function replaceImage(event: ChangeEvent) { - const file = event.target.files?.[0]; - event.target.value = ""; - if (file === undefined) return; - try { - const raster = await readWebRasterFile(file); - if (!raster.ok) throw new Error(raster.reason ?? raster.code); - const nextSource: AnnotationSource = { id: `upload-${file.name}-${file.lastModified}`, src: file.name, width: raster.width, height: raster.height }; - const url = raster.dataURL; - rasterUrlsRef.current.set(nextSource.id, url); - documentSource.commit([{ op: "replace", path: "", value: { - profile: ANNOTATION_PROFILE_V1, - id: documentValue.id, - sources: [nextSource], - annotations: [], - } }]); - setZoom(1); - setAnnouncement(`${file.name} 이미지로 교체했습니다.`); - } catch { - setAnnouncement("지원하는 raster 이미지를 불러오지 못했습니다."); - } - } - - async function downloadImage() { - const result = await renderWebAnnotationRaster({ document: documentValue, sourceId: source.id, sourceURL: sourceUrl, style: rasterStyle() }); - if (!result.ok) { setAnnouncement("Annotation 이미지를 만들지 못했습니다."); return; } - const link = document.createElement("a"); - link.href = result.dataURL; - link.download = "annotation-request.png"; - link.click(); - setAnnouncement("Annotation이 적용된 이미지를 다운로드했습니다."); - } - - return ( - {announcement}

}> - 이미지 위에서 위치를 표시하고 수정 요청을 남겨 보세요. - - )}> - -
-
- cancelPointerGesture(event, "lost-capture")} - onPointerCancel={(event) => cancelPointerGesture(event, "pointer-cancel")} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - role="application" - tabIndex={0} - viewBox={`0 0 ${source.width} ${source.height}`} - > - - {documentValue.annotations.map((annotation, index) => ( - setPreviewId(visible ? annotation.id : null)} /> - ))} - {gesture?.type === "create" ? : null} - {gesture?.type === "draw" ? : null} - - {documentValue.annotations.map((annotation, index) => gesture === null && previewId === annotation.id && annotation.body.instruction.trim() !== "" && editingId !== annotation.id ? ( - - ) : null)} - {selected && editingId === selected.id ? ( - cancelComment(selected)} onSave={(instruction) => sendComment(selected, instruction)} onSubmit={(instruction) => submitComment(selected, instruction)} /> - ) : null} -
- -
-
-
- Annotation output -
- -
-
-
- ); -} - -function CommentComposer(props: { - readonly annotation: Annotation; - readonly index: number; - readonly source: AnnotationSource; - readonly onCancel: () => void; - readonly onSave: (instruction: string) => void; - readonly onSubmit: (instruction: string) => void; -}) { - const [draft, setDraft] = useState(props.annotation.body.instruction); - const inputRef = useRef(null); - useEffect(() => setDraft(props.annotation.body.instruction), [props.annotation.id, props.annotation.body.instruction]); - useEffect(() => { - const frame = requestAnimationFrame(() => { - const input = inputRef.current; - if (input === null) return; - input.focus(); - input.setSelectionRange(input.value.length, input.value.length); - }); - return () => cancelAnimationFrame(frame); - }, [props.annotation.id]); - const dock = composerDock(props.annotation, props.source); - return ( -
- { - if (draft.trim() !== "") props.onSave(draft); - }} - onValueChange={setDraft} - onKeyDown={(event) => { - if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { - event.preventDefault(); - if (draft.trim() !== "") props.onSubmit(draft); - } - if (event.key === "Escape") props.onCancel(); - }} - placeholder="수정 요청을 입력하세요…" - rows={1} - value={draft} - /> - props.onSubmit(draft)} - onMouseDown={(event) => event.preventDefault()} - > - -
- ); -} - -function ToolIcon(props: { readonly tool: Tool }) { - if (props.tool === "select") return
AB
CD
')?.parts).toEqual([{ type: "text", text: "A\tB\nC\tD" }]); + expect(parseWebClipboardHTML(``)?.parts).toHaveLength(2); +}); + +test("fragment parsing stays in the template document and excludes active/foreign content", () => { + const fragment = parseWebHTMLFragment('foreign

Kept

')!; + const nodes = Array.from(fragment.childNodes) as Node[]; + expect(nodes.map((node) => node.nodeName)).toEqual(["P", "IMG"]); + expect(nodes.every((node) => !node.isConnected && node.ownerDocument !== document)).toBe(true); + expect(parseWebClipboardHTML('

Kept

')?.parts).toEqual([{ type: "text", text: "Kept" }]); +}); + +test("HTML image capture is opt-in, synchronous, and does not claim text-only HTML", () => { + const legacy = captureWebClipboardPaste(event(), { files: true, text: true }); + expectTypeOf(legacy).toEqualTypeOf>(); + expect(legacy).toMatchObject({ type: "text", text: "Fallback" }); + const mixed = event(); + expect(captureWebClipboardPaste(mixed, { html: "images" })).toMatchObject({ type: "html", content: { parts: [{ type: "text" }, { type: "image" }, { type: "text" }] } }); + expect(mixed.preventDefault).toHaveBeenCalledOnce(); + const plain = event("

Formatted text

"); + expect(captureWebClipboardPaste(plain, { files: true, html: "images" })).toMatchObject({ ok: false }); + expect(plain.preventDefault).not.toHaveBeenCalled(); +}); + +test("structured and native files retain priority; equivalent HTML is never inserted twice", () => { + const withFiles = event('', [new File(["image"], "native.png", { type: "image/png" })]); + expect(captureWebClipboardPaste(withFiles, { files: true, html: "images", text: true })).toMatchObject({ type: "files" }); + const invalid = event(); invalid.clipboardData.types.unshift(objectClipboardCodec.mimeType); + invalid.clipboardData.getData = () => "{"; + expect(captureWebClipboardPaste(invalid, { codec: objectClipboardCodec, files: true, html: "images", text: true })).toMatchObject({ ok: false, code: "clipboard.invalid" }); + expect(invalid.preventDefault).toHaveBeenCalledOnce(); +}); + +test("a delegated structured format stays with its existing editor binding", () => { + const delegated = event(); delegated.clipboardData.types.unshift("application/editor+json"); + expect(captureWebClipboardPaste(delegated, { files: true, html: "images", delegatedMimeTypes: ["application/editor+json"] })).toMatchObject({ ok: false, code: "clipboard.empty" }); + expect(delegated.preventDefault).not.toHaveBeenCalled(); +}); + +test.each(["https://example.invalid/image", "/image.png", "blob:https://example.invalid/id", "cid:image", "javascript:alert(1)", "data:image/svg+xml;base64,AQID", "data:image/png;base64,broken", ""])("unavailable HTML source %s fails without reading or fallback", async (src) => { + const readRaster = vi.fn(); + expect(await readWebHTMLClipboard(parseWebClipboardHTML(`

TextAfter

`)!, { ...options, readRaster })).toMatchObject({ ok: false, code: "raster.source-unsupported" }); + expect(readRaster).not.toHaveBeenCalled(); +}); + +test("HTML image preparation owns candidate bytes and preserves every ordered part", async () => { + const readRaster = vi.fn().mockResolvedValue(image); + const result = await readWebHTMLClipboard(parseWebClipboardHTML(markup)!, { ...options, readRaster }); + expect(result).toEqual({ ok: true, parts: [ + { type: "text", text: "앞 문장 & 글" }, + { type: "image", candidate: { name: "사진", size: 3, mediaType: "image/png" }, image: { source, width: 100, height: 50 } }, + { type: "text", text: "뒤\n문장" }, + ] }); + expect(readRaster.mock.calls[0]?.[0]).toBeInstanceOf(File); +}); + +test("policy rejects before byte allocation, including the current attachment count", async () => { + const atob = vi.spyOn(globalThis, "atob"); + const readRaster = vi.fn(); + try { + expect(await readWebHTMLClipboard(parseWebClipboardHTML(markup)!, { ...options, currentCount: 4, readRaster })).toMatchObject({ ok: false, code: "file-intake.limit" }); + expect(await readWebHTMLClipboard(parseWebClipboardHTML(markup)!, { ...options, policy: { ...options.policy, maxBytesPerFile: 2 }, readRaster })).toMatchObject({ ok: false, code: "file-intake.size" }); + expect(atob).not.toHaveBeenCalled(); expect(readRaster).not.toHaveBeenCalled(); + } finally { atob.mockRestore(); } +}); + +test("a later decode failure returns neither prepared images nor partial text", async () => { + const readRaster = vi.fn().mockResolvedValueOnce(image).mockResolvedValueOnce({ ok: false, code: "raster.decode-failed" }); + expect(await readWebHTMLClipboard(parseWebClipboardHTML(`${markup}`)!, { ...options, readRaster })).toEqual({ ok: false, code: "raster.decode-failed" }); +}); + +test("cancellation ignores a late decode and prevents later HTML image reads", async () => { + let resolve!: (value: WebRasterSourceResult) => void; + const readRaster = vi.fn(() => new Promise((done) => { resolve = done; })); + const controller = new AbortController(); + const pending = readWebHTMLClipboard(parseWebClipboardHTML(`${markup}`)!, { ...options, readRaster, signal: controller.signal }); + controller.abort(); resolve(image); + expect(await pending).toMatchObject({ ok: false, code: "raster.cancelled" }); expect(readRaster).toHaveBeenCalledOnce(); +}); + +test("oversized HTML fails as an owned input rather than falling back to partial text", () => { + const oversized = event(``.repeat(257)); + expect(captureWebClipboardPaste(oversized, { html: "images", text: true })).toMatchObject({ ok: false, code: "clipboard.invalid" }); + expect(oversized.preventDefault).toHaveBeenCalledOnce(); + expect(() => parseWebClipboardHTML(" ".repeat(16 * 1024 * 1024 + 1))).toThrow(RangeError); + expect(() => parseWebClipboardHTML("
".repeat(10_001))).toThrow(RangeError); +}); diff --git a/site/src/shared/composer/ComposerDemo.tsx b/site/src/shared/composer/ComposerDemo.tsx index 37050faa..dcb0bc98 100644 --- a/site/src/shared/composer/ComposerDemo.tsx +++ b/site/src/shared/composer/ComposerDemo.tsx @@ -117,7 +117,11 @@ export function ComposerDemo(props: { 이미지를 준비하고 있습니다. 계속 입력할 수 있어요. : null} - {composer.attachmentError ?

첨부하지 못했습니다: {composer.attachmentError.reason ?? composer.attachmentError.code}

: null} + {composer.attachmentError ?

{composer.attachmentError.code === "composer.clipboard.mixed-unsupported" + ? "글과 이미지가 함께 있는 HTML 붙여넣기는 아직 지원하지 않습니다. 내용을 나누어 붙여넣어 주세요." + : composer.attachmentError.code === "raster.source-unsupported" + ? "이 HTML 이미지의 내용을 읽을 수 없습니다. PNG·JPEG·WebP 파일을 첨부해 주세요." + : `첨부하지 못했습니다: ${composer.attachmentError.reason ?? composer.attachmentError.code}`}

: null}
([ ["packages/json-document-editing/src/preparation-queue.ts", editingPreparationQueueSource], ["packages/json-document-web/src/raster-files.ts", webRasterFilesSource], + ["packages/json-document-web/src/html-fragment.ts", webHTMLFragmentSource], + ["packages/json-document-web/src/html-clipboard.ts", webHTMLClipboardSource], ["packages/json-document-file-intake/src/raster-content.ts", rasterContentSource], ["packages/json-document-composer-react/src/attachments.ts", composerAttachmentsSource], ["packages/json-document-object-document/src/object-model.ts", objectModelSource], @@ -360,6 +364,11 @@ const registeredUsageSources = new Map([ ["packages/json-document-zod/src/index.ts", zodSource], ]); const registeredPublicUsages = [ + { packageName: "@interactive-os/json-document-web", symbol: "parseWebHTMLFragment", sourcePath: "packages/json-document-web/src/html-fragment.ts" }, + ...["captureWebClipboardPaste", "parseWebClipboardHTML", "readWebHTMLClipboard"].flatMap((symbol) => [ + { packageName: "@interactive-os/json-document-web", symbol, sourcePath: "packages/json-document-web/src/html-clipboard.ts" }, + { packageName: "@interactive-os/json-document-web", symbol, sourcePath: "packages/json-document-web/src/html-fragment.ts" }, + ]), { packageName: "@interactive-os/json-document-editing", symbol: "createEditingPreparationQueue", sourcePath: "packages/json-document-editing/src/preparation-queue.ts" }, { packageName: "@interactive-os/json-document-editing", symbol: "createObjectPasteSession", sourcePath: "packages/json-document-editing/src/preparation-queue.ts" }, { packageName: "@interactive-os/json-document-web", symbol: "readWebRasterFiles", sourcePath: "packages/json-document-web/src/raster-files.ts" }, diff --git a/site/tests/unit/composer-demo.test.tsx b/site/tests/unit/composer-demo.test.tsx index d5dd33a6..cc4b8ef8 100644 --- a/site/tests/unit/composer-demo.test.tsx +++ b/site/tests/unit/composer-demo.test.tsx @@ -19,6 +19,8 @@ describe("Agent Chat Composer Hands", () => { expect(composerDemoSource).toContain("useComposer("); expect(composerDemoSource).not.toContain("addComposerAttachments("); expect(composerDemoSource).not.toContain("fileCandidatesFromWebFiles("); + expect(composerDemoSource).not.toContain("parseWebClipboardHTML("); + expect(composerDemoSource).not.toContain("readWebHTMLClipboard("); expect(composerDemoSource).not.toContain("composerAttachmentCandidatesFromWebFiles("); expect(composerDemoSource).not.toContain("composer-placeholder-box"); expect(composerDemoSource).toContain('placeholder="작업을 입력하세요"'); @@ -41,6 +43,7 @@ describe("Agent Chat Composer Hands", () => { expect(composerAttachmentsSource).toContain("addComposerAttachments("); expect(composerAttachmentsSource).toContain("fileCandidatesFromWebFiles("); expect(composerAttachmentsSource).toContain("readWebRasterFiles("); + expect(composerAttachmentsSource).toContain("readWebHTMLClipboard("); expect(composerAttachmentsSource).toContain("createEditingPreparationQueue<"); expect(composerCommandMenuSource).toContain("useRichTextSuggestion("); expect(composerCommandMenuSource).toContain("useRichTextMentionSuggestions("); @@ -54,6 +57,8 @@ describe("Agent Chat Composer Hands", () => { "packages/json-document-composer/src/commands.ts", "packages/json-document-file-intake/src/raster-content.ts", "packages/json-document-web/src/raster-files.ts", + "packages/json-document-web/src/html-clipboard.ts", + "packages/json-document-web/src/html-fragment.ts", "packages/json-document-editing/src/preparation-queue.ts", ]) { const source = sources.find((entry) => entry.path === path); @@ -116,6 +121,17 @@ describe("Agent Chat Composer Hands", () => { expect(screen.queryByText(/HCX/)).toBeNull(); }); + test("shows mixed HTML as unsupported without silently dropping part of the draft", async () => { + render(); + const before = screen.getByTestId("composer-draft-json").textContent; + fireEvent.paste(screen.getByLabelText("Agent Chat Composer"), { clipboardData: { + types: ["text/html", "text/plain"], files: [], + getData: (type: string) => type === "text/html" ? '

보존할 글

' : "보존할 글", + } }); + expect(await screen.findByText("글과 이미지가 함께 있는 HTML 붙여넣기는 아직 지원하지 않습니다. 내용을 나누어 붙여넣어 주세요.")).toBeTruthy(); + expect(screen.getByTestId("composer-draft-json").textContent).toBe(before); + }); + test("routes dropped files through the same canonical attachment context", () => { render(); const composer = screen.getByTestId("agent-chat-composer"); diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index 870a3bca..6cf55958 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -73,6 +73,8 @@ describe("Demo definition and source discovery", () => { ["packages/json-document-editing/src/preparation-queue.ts", "/docs/api/editing"], ["packages/json-document-web/src/raster-source.ts", "/docs/api/web"], ["packages/json-document-web/src/raster-files.ts", "/docs/api/web"], + ["packages/json-document-web/src/html-clipboard.ts", "/docs/api/web"], + ["packages/json-document-web/src/html-fragment.ts", "/docs/api/web"], ["packages/json-document-file-intake/src/raster-content.ts", "/docs/api/file-intake"], ["packages/json-document-file-intake/src/index.ts", "/docs/api/file-intake"], ]) { diff --git a/standards/editing-grammar.md b/standards/editing-grammar.md index 7f46e7d8..b6a85ddb 100644 --- a/standards/editing-grammar.md +++ b/standards/editing-grammar.md @@ -286,12 +286,13 @@ EG-COPY/CUT/PASTE/HISTORY의 구체화이며 새로운 Stable profile이나 Core | PI-ORDER | 준비 완료 순서가 달라도 요청 순서로 한 batch씩 반영 | Editing | Object·Composer가 공통 queue 소비; [순서·재진입 사례](../packages/json-document-editing/tests/preparation-queue.test.ts) | | PI-CANCEL | 취소·History 작업·unmount 뒤 늦은 결과는 무효 | Editing·각 Hand | Object 자동 무효화, Composer Escape·binding History·unmount 취소 구현; [취소 사례](../packages/json-document-composer-react/tests/composer-attachments.test.tsx) | | PI-TYPING | 첨부 준비 중 typing/caret 이동 허용; 완료 시 최신 첨부 목록 뒤에 추가 | Composer·React Connector | 구현; [연속 첨부·typing·caret 사례](../packages/json-document-composer-react/tests/composer-attachments.test.tsx). inline anchor mapping은 아님 | -| PI-HTML | 대체 MIME 선택과 HTML 내부의 글+이미지 순서 보존을 구분 | Web·Rich Text Web·각 Hand | TBD: HTML 이미지, 불가한 source의 실패와 안전한 변환 | +| PI-HTML | 대체 MIME 선택과 HTML 내부의 글+이미지 순서 보존을 구분 | Web·Rich Text Web·각 Hand | 부분 구현: [inert HTML·우선순위·source 실패](../packages/json-document-web/tests/html-clipboard.test.ts), [Canvas 흐름 변환](../packages/json-document-editing/tests/canvas-clipboard.test.ts), [Composer 이미지-only·mixed 거절](../packages/json-document-composer-react/tests/composer-attachments.test.tsx); TBD: files↔HTML 대응, 외부 source, Composer inline 혼합 의미 | | PI-PLAIN | 명시적인 plain paste와 지원 서식 paste를 분리 | Web·Affordance·각 Hand | TBD: 실제 modifier/native editable 사례 | | PI-EXPORT | 선택한 Canvas 객체를 PNG로 복사; write 실패는 문서 불변 | Canvas·Web | TBD: 혼합 선택·투명 배경·권한 실패·외부 앱 확인 | | PI-NATIVE | OS screenshot, 브라우저 Copy Image, Docs/Slides HTML, 외부 앱 왕복 | Web·제품 경로 | TBD: DOM 합성 이벤트를 OS-native 증거로 계산하지 않음 | -첫 이미지 slice는 서버 upload·임의 URL fetch·HTML import를 추가하지 않는다. +서버 upload·임의 URL fetch는 추가하지 않는다. 첫 파일 slice에 이어 HTML slice는 +포함된 PNG/JPEG/WebP와 순서 있는 일반 글만 변환하며 원본 CSS·Office layout을 재현하지 않는다. TBD를 위해 미동작 public stub이나 범용 registry를 만들지 않는다. Canvas의 외부 문서/선택 변경 시 취소 정책과 Composer의 typing 중 첨부 준비 유지 정책은 서로 다른 profile 선택으로 유지한다. 동일한 준비 queue·raster 검증/읽기 책임만 From ec524d78e2f1ea05ce7c16c70d79c9abb66503ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Wed, 9 Sep 2026 23:57:16 +0900 Subject: [PATCH 20/24] =?UTF-8?q?fix(canvas):=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=ED=81=AC=EA=B8=B0=EA=B0=80=20=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=20=EC=A0=84=EC=97=90=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/json-document-canvas/docs/api.md | 4 + .../json-document-canvas/src/canvas-hand.tsx | 2 +- .../src/use-canvas-hand.ts | 17 ++-- .../tests/canvas-hand.test.tsx | 85 ++++++++++++++++++- 4 files changed, 98 insertions(+), 10 deletions(-) diff --git a/packages/json-document-canvas/docs/api.md b/packages/json-document-canvas/docs/api.md index d06fdce0..9e6d4e40 100644 --- a/packages/json-document-canvas/docs/api.md +++ b/packages/json-document-canvas/docs/api.md @@ -28,6 +28,10 @@ Host: 한 장 fixture, 크기·색상 정책, 레이아웃 ### 입력과 History - 도구를 고르고 클릭하면 기본 크기, 드래그하면 지정한 크기로 생성합니다. + 사각형·타원·글자는 누르거나 작게 흔들리는 동안 기본 크기를 미리 표시하지 않습니다. + 시작점에서 3 문서 단위 이상 움직이면 실제 드래그 상자만 표시하며, 다시 시작점 근처로 + 돌아와도 클릭 크기로 바뀌지 않습니다. 클릭 기본 크기는 놓을 때 press 위치에만 생성합니다. + 드래그 후 시작점에 정확히 돌아와 놓으면 객체나 History를 만들지 않습니다. 생성 후 Select로 돌아가며 새 객체를 선택합니다. 펜은 최소 두 지점이 필요합니다. - 객체 click은 단일 선택, Shift+click은 toggle입니다. 빈 곳 click은 clear, drag는 marquee replace, Shift+marquee는 add입니다. Mod+A를 반복해도 전체 선택을 유지합니다. diff --git a/packages/json-document-canvas/src/canvas-hand.tsx b/packages/json-document-canvas/src/canvas-hand.tsx index b9999d46..b7795f98 100644 --- a/packages/json-document-canvas/src/canvas-hand.tsx +++ b/packages/json-document-canvas/src/canvas-hand.tsx @@ -50,7 +50,7 @@ export function CanvasHand(props: CanvasHandProps) { onSelect={(shiftKey) => hand.select(object.id, shiftKey)} onEdit={() => hand.editText(object.id)} onHandle={(interaction, event) => hand.interaction(interaction, event, object, "drag")} /> )} {copyOriginals.size > 0 && {hand.objects.filter((object) => selectedKeys.has(object.id)).map((object) => )}} - {hand.preview && } + {hand.preview && } {hand.tool === "select" && hand.objects.filter((object) => selectedKeys.has(object.id)).map((object) => )} {selected && hand.tool === "select" && diff --git a/packages/json-document-canvas/src/use-canvas-hand.ts b/packages/json-document-canvas/src/use-canvas-hand.ts index d8721998..77f1b761 100644 --- a/packages/json-document-canvas/src/use-canvas-hand.ts +++ b/packages/json-document-canvas/src/use-canvas-hand.ts @@ -15,7 +15,7 @@ export interface CanvasCreationStyle { } type Gesture = { readonly base: CanvasDocument } & ( - | { readonly type: "create"; readonly tool: Exclude; readonly start: ObjectPoint; readonly point: ObjectPoint } + | { readonly type: "create"; readonly tool: Exclude; readonly start: ObjectPoint; readonly point: ObjectPoint; readonly dragged: boolean } | { readonly type: "draw"; readonly points: ReadonlyArray } | { readonly type: "resize"; readonly object: CanvasObject; readonly start: ObjectPoint; readonly point: ObjectPoint; readonly edge: ResizeEdge } ); @@ -138,12 +138,15 @@ export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, return point && Number.isFinite(point.x) && Number.isFinite(point.y) ? { x: point.x, y: point.y } : null; } - function createPreview(gesture: Extract) { + function createPreview(gesture: Extract, committing = false) { if (gesture.type === "draw") return gesture.points.length < 2 ? null : createCanvasPath(gesture.points, { color: style.textColor, label: "Drawing", strokeWidth: style.strokeWidth }); const { start, point } = gesture; - const click = Math.hypot(point.x - start.x, point.y - start.y) < 3; + const click = !gesture.dragged; + // A default-sized object belongs to the completed click, never its press preview. + if (click && !committing) return null; + if (!click && start.x === point.x && start.y === point.y) return null; return createCanvasObject(gesture.tool, { - x: Math.min(start.x, point.x), y: Math.min(start.y, point.y), + x: click ? start.x : Math.min(start.x, point.x), y: click ? start.y : Math.min(start.y, point.y), width: click ? (gesture.tool === "text" ? 280 : 160) : Math.abs(point.x - start.x), height: click ? (gesture.tool === "text" ? 64 : 100) : Math.abs(point.y - start.y), }, { color: gesture.tool === "text" ? style.textColor : style.color, label: gesture.tool === "text" ? "Text" : "", fontSize: style.fontSize }); @@ -159,7 +162,7 @@ export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, const active = gestures.commit(); if (!active || active.base !== current()) return; if (active.type === "create" || active.type === "draw") { - const object = createPreview(active); + const object = createPreview(active, true); if (!object) return; const result = dispatch({ type: "object.create", object }); if (result.ok) { @@ -183,7 +186,7 @@ export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, pointer.begin(event.currentTarget, event.pointerId, true); if (tool === "select") { beginSelect(point, null, event); return; } const base = current(); - gestures.begin(tool === "path" ? { type: "draw", points: [point], base } : { type: "create", tool, start: point, point, base }); + gestures.begin(tool === "path" ? { type: "draw", points: [point], base } : { type: "create", tool, start: point, point, dragged: false, base }); } function pointerMove(event: PointerEvent) { @@ -194,6 +197,8 @@ export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, if (active.type === "draw") { const last = active.points.at(-1)!; if (last.x !== point.x || last.y !== point.y) gestures.preview({ ...active, points: [...active.points, point] }); + } else if (active.type === "create") { + gestures.preview({ ...active, point, dragged: active.dragged || Math.hypot(point.x - active.start.x, point.y - active.start.y) >= 3 }); } else gestures.preview({ ...active, point }); } diff --git a/packages/json-document-canvas/tests/canvas-hand.test.tsx b/packages/json-document-canvas/tests/canvas-hand.test.tsx index 5808a26f..5174ba31 100644 --- a/packages/json-document-canvas/tests/canvas-hand.test.tsx +++ b/packages/json-document-canvas/tests/canvas-hand.test.tsx @@ -349,6 +349,83 @@ test("every toolbar control shares icon, accessible name and canonical tooltip w expect(toolbar.getByRole("button", { name: "선택" }).getAttribute("aria-pressed")).toBe("false"); }); +const creationTools = [ + { tool: "사각형", kind: "rectangle", width: 160, height: 100 }, + { tool: "타원", kind: "ellipse", width: 160, height: 100 }, + { tool: "글자", kind: "text", width: 280, height: 64 }, +] as const; + +function creationPreview(container: HTMLElement) { + const view = container.querySelector("[data-canvas-preview]")?.firstElementChild; + if (!view) return null; + return { + width: Number(view.getAttribute(view.tagName === "ellipse" ? "rx" : "width")) * (view.tagName === "ellipse" ? 2 : 1), + height: Number(view.getAttribute(view.tagName === "ellipse" ? "ry" : "height")) * (view.tagName === "ellipse" ? 2 : 1), + }; +} + +test.each(creationTools)("$kind press stays empty and a drag previews only its current bounds", ({ tool, kind }) => { + const { svg, container, value, commits } = setup(); + fireEvent.click(screen.getByRole("button", { name: tool })); + fireEvent.pointerDown(svg, event(40, 50)); + expect(creationPreview(container)).toBeNull(); + fireEvent.pointerMove(svg, event(40.5, 50.5)); + expect(creationPreview(container)).toBeNull(); + fireEvent.pointerMove(svg, event(42, 51.5)); + expect(creationPreview(container)).toEqual({ width: 4, height: 3 }); + fireEvent.pointerMove(svg, event(44, 53)); + expect(creationPreview(container)).toEqual({ width: 8, height: 6 }); + expect(value()).toEqual(blank); expect(commits).not.toHaveBeenCalled(); + fireEvent.pointerUp(svg, event(140, 100)); + expect(creationPreview(container)).toBeNull(); + expect(value().objects[0]).toMatchObject({ kind, x: 80, y: 100, width: 200, height: 100 }); + expect(commits).toHaveBeenCalledOnce(); +}); + +test.each(creationTools)("$kind click creates its default size only on release at the press anchor", ({ tool, kind, width, height }) => { + const { svg, container, value, commits } = setup(); + fireEvent.click(screen.getByRole("button", { name: tool })); + fireEvent.pointerDown(svg, event(40, 50)); + fireEvent.pointerMove(svg, event(39.5, 49.5)); + expect(creationPreview(container)).toBeNull(); expect(value()).toEqual(blank); + fireEvent.pointerUp(svg, event(39.5, 49.5)); + expect(value().objects[0]).toMatchObject({ kind, x: 80, y: 100, width, height }); + expect(commits).toHaveBeenCalledOnce(); + expect(screen.queryByRole("textbox", { name: "Canvas text" }) !== null).toBe(kind === "text"); +}); + +test.each(creationTools)("$kind stays a drag near the origin and an exact return creates nothing", ({ tool, width, height }) => { + const { svg, container, value, editor, commits } = setup(); + fireEvent.click(screen.getByRole("button", { name: tool })); + fireEvent.pointerDown(svg, event(40, 50)); fireEvent.pointerMove(svg, event(50, 60)); + fireEvent.pointerMove(svg, event(40.5, 50.5)); + expect(creationPreview(container)).toEqual({ width: 1, height: 1 }); + fireEvent.pointerMove(svg, event(40, 50)); + expect(creationPreview(container)).toBeNull(); + fireEvent.pointerUp(svg, event(40, 50)); + expect(value()).toEqual(blank); expect(editor.snapshot.canUndo).toBe(false); expect(commits).not.toHaveBeenCalled(); + fireEvent.pointerDown(svg, event(80, 60)); fireEvent.pointerUp(svg, event(80, 60)); + expect(value().objects[0]).toMatchObject({ id: "object-1", width, height }); +}); + +test("a foreign pointer cannot change a pending click into a drag", () => { + const { svg, container, value } = setup(); + fireEvent.click(screen.getByRole("button", { name: "사각형" })); + fireEvent.pointerDown(svg, event(40, 50)); + fireEvent.pointerMove(svg, event(140, 100, 9)); fireEvent.pointerUp(svg, event(140, 100, 9)); + expect(creationPreview(container)).toBeNull(); expect(value()).toEqual(blank); + fireEvent.pointerUp(svg, event(40, 50)); + expect(value().objects[0]).toMatchObject({ x: 80, y: 100, width: 160, height: 100 }); +}); + +test("release alone can establish a reverse drag using its final coordinates", () => { + const { svg, value, commits } = setup(); + fireEvent.click(screen.getByRole("button", { name: "사각형" })); + fireEvent.pointerDown(svg, event(140, 100)); fireEvent.pointerUp(svg, event(40, 50)); + expect(value().objects[0]).toMatchObject({ x: 80, y: 100, width: 200, height: 100 }); + expect(commits).toHaveBeenCalledOnce(); +}); + test("creation is transient until release, scales coordinates and selects the result with one undo step", () => { const { svg, value, editor, commits } = setup(); fireEvent.click(screen.getByRole("button", { name: "사각형" })); @@ -364,14 +441,16 @@ test("creation is transient until release, scales coordinates and selects the re fireEvent.click(screen.getByRole("button", { name: "다시 실행" })); expect(value().objects).toHaveLength(1); }); -test.each(["escape", "pointercancel", "lostpointercapture"])("%s cancels creation without a commit or history", (reason) => { - const { svg, value, editor } = setup(); +test.each(["escape", "pointercancel", "lostpointercapture"].flatMap((reason) => [false, true].map((drag) => ({ reason, drag }))))("$reason cancels creation (drag=$drag) without a commit or history", ({ reason, drag }) => { + const { svg, container, value, editor } = setup(); fireEvent.click(screen.getByRole("button", { name: "타원" })); - fireEvent.pointerDown(svg, event(40, 50)); fireEvent.pointerMove(svg, event(140, 100)); + fireEvent.pointerDown(svg, event(40, 50)); + if (drag) fireEvent.pointerMove(svg, event(140, 100)); if (reason === "escape") fireEvent.keyDown(svg, { key: "Escape" }); else if (reason === "pointercancel") fireEvent.pointerCancel(svg, event(140, 100)); else fireEvent.lostPointerCapture(svg, event(140, 100)); fireEvent.pointerUp(svg, event(140, 100)); + expect(creationPreview(container)).toBeNull(); expect(value()).toEqual(blank); expect(editor.snapshot.canUndo).toBe(false); }); From ed116d81601b3c143d0b42d987e8a2715d90001d Mon Sep 17 00:00:00 2001 From: "teo.v" <66650330+developer-1px@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:23:26 +0900 Subject: [PATCH 21/24] =?UTF-8?q?feat(canvas):=20=EB=B3=80=EA=B3=BC=20?= =?UTF-8?q?=EB=AA=A8=EC=84=9C=EB=A6=AC=EC=9D=98=20=EA=B3=A0=EC=A0=95=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20resize=EB=A5=BC=20=EC=99=84=EC=84=B1?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 선택 박스의 모양을 유지하면서 네 변·네 모서리 resize, 반대편 고정, Shift 비율 유지, Alt 중심 기준과 최소 크기 고정을 정본 Affordance에 연결한다. Canvas의 modifier·취소·no-op·Undo 연결과 owner 문서, Usage/Source, 회귀 검증을 포함한다. Closes #757. --- docs/api-reference/affordance.md | 2 +- docs/public/affordance-resize.md | 10 ++- packages/json-document-affordance/README.md | 5 ++ .../json-document-affordance/docs/resize.md | 55 ++++++++++++ packages/json-document-affordance/src/drag.ts | 42 +++++---- .../tests/resize.test.ts | 72 ++++++++++++++++ packages/json-document-canvas/docs/api.md | 12 ++- .../json-document-canvas/src/canvas-hand.tsx | 2 +- .../src/canvas-object-view.tsx | 7 ++ .../src/use-canvas-hand.ts | 35 +++++--- .../tests/canvas-hand.test.tsx | 86 ++++++++++++++++++- site/src/routes/docs/doc-pages.ts | 3 +- site/tests/unit/demo-workbench.test.tsx | 3 + 13 files changed, 296 insertions(+), 38 deletions(-) create mode 100644 packages/json-document-affordance/docs/resize.md create mode 100644 packages/json-document-affordance/tests/resize.test.ts diff --git a/docs/api-reference/affordance.md b/docs/api-reference/affordance.md index 7cc3063c..090db4a4 100644 --- a/docs/api-reference/affordance.md +++ b/docs/api-reference/affordance.md @@ -855,7 +855,7 @@ interface RenameSessionSnapshot { ## `resizeAffordance` ```ts -resizeAffordance(origin: Point, point: Point, edge: ResizeEdge, modifiers?: { readonly shiftKey?: boolean; readonly altKey?: boolean; }): AffordancePreview +resizeAffordance(origin: Point, point: Point, edge: ResizeEdge, modifiers?: { readonly shiftKey?: boolean; readonly altKey?: boolean; }, size?: Pick): AffordancePreview ``` ## `ResizeEdge` diff --git a/docs/public/affordance-resize.md b/docs/public/affordance-resize.md index eacf7a43..a2653fef 100644 --- a/docs/public/affordance-resize.md +++ b/docs/public/affordance-resize.md @@ -36,7 +36,7 @@ import { import { applyAffordance, commitAffordance, resizeAffordance } from "@interactive-os/json-document-affordance"; function onPointerMove(event: PointerEvent, edge: "se") { - applyAffordance(resizeAffordance(origin, { x: event.clientX, y: event.clientY }, edge, event), { + applyAffordance(resizeAffordance(origin, { x: event.clientX, y: event.clientY }, edge, event, initialSize), { cursor: (cursor) => { event.currentTarget.style.cursor = cursor; }, @@ -48,7 +48,7 @@ function onPointerMove(event: PointerEvent, edge: "se") { function onPointerUp(event: PointerEvent, objectId: string, edge: "se") { const committed = commitAffordance( - resizeAffordance(origin, { x: event.clientX, y: event.clientY }, edge, event), + resizeAffordance(origin, { x: event.clientX, y: event.clientY }, edge, event, initialSize), ); if (!committed) return; applyAffordance(committed, { @@ -67,6 +67,12 @@ function onPointerUp(event: PointerEvent, objectId: string, edge: "se") { } ``` +`initialSize`는 press 시점의 `{ width, height }`입니다. `origin`·`point`와 크기는 +같은 좌표계를 사용합니다. 초기 크기를 제공해야 Shift가 실제 객체 비율을 유지하고 +최소 크기에서도 반대편 고정점이 보존됩니다. 네 변은 한 축을 조절하고, Shift와 함께 +잡으면 다른 축은 중심 기준으로 조절합니다. 자세한 API와 실제 Canvas Usage는 +[소유 패키지의 Resize 계약](/docs/api/affordance)에서 확인할 수 있습니다. + 커서는 호스트 화면 상태이고, 확정된 크기만 json-document로 갑니다. 분할선 화살표는 APG Window Splitter와 같고, Shift는 비율, Alt는 가운데 기준입니다. diff --git a/packages/json-document-affordance/README.md b/packages/json-document-affordance/README.md index 810586c9..765840c9 100644 --- a/packages/json-document-affordance/README.md +++ b/packages/json-document-affordance/README.md @@ -48,6 +48,11 @@ useEditing({ `historyAffordance(snapshot).hand` exposes the typed Undo/Redo availability map directly. The editing runtime still owns history state and execution. +`resizeAffordance(origin, point, edge, modifiers?, size?)` owns eight-direction +anchored resizing. Supply the initial size for true Shift aspect-ratio locking, +Alt center resizing and anchor-preserving minimum bounds. See the +[Resize API contract](docs/resize.md) and its Canvas Usage/Source. + `contentInteractionAffordance` is the canonical product-content state model. It distinguishes persistent selection, transient active feedback, movement, drop targets, and insertion positions without owning DOM or product color. diff --git a/packages/json-document-affordance/docs/resize.md b/packages/json-document-affordance/docs/resize.md new file mode 100644 index 00000000..54262f0f --- /dev/null +++ b/packages/json-document-affordance/docs/resize.md @@ -0,0 +1,55 @@ +## Resize · 고정 기준과 초기 크기 + +`resizeAffordance(origin, point, edge, modifiers?, size?)`는 8방향 resize의 정본입니다. +같은 좌표계의 시작점·현재 점, `ResizeEdge`, Shift/Alt 상태와 **시작할 때의** +`{ width, height }`를 받아 `{ hand: { type: "resize", dx, dy, dw, dh, edge }, cursor }`를 +반환합니다. 문서, DOM, pointer capture와 History를 소유하지 않습니다. + +| 입력 | 고정 기준과 크기 | +| --- | --- | +| `n` / `s` | 반대편 변 고정, 높이 조절 | +| `e` / `w` | 반대편 변 고정, 너비 조절 | +| `ne` / `se` / `sw` / `nw` | 반대 모서리 고정, 너비·높이 조절 | +| Shift | 초기 너비/높이 비율 유지. 변에서는 나머지 축의 중심을 고정 | +| Alt / Option | 객체 중심 고정, 잡은 방향의 반대편도 대칭 조절 | +| Shift + Alt | 초기 비율과 중심 모두 고정 | + +`size`를 주면 결과 크기를 최소 1좌표 단위로 제한하고, 그 결과로 위치 delta를 +계산하므로 최소 크기를 지나쳐도 고정점이 밀리거나 뒤집히지 않습니다. 비율 유지 시 +두 축 모두 최소 크기를 만족합니다. 시작 크기는 유한한 양수여야 하며 아니면 +`RangeError`입니다. 1보다 작은 유효한 입력도 정지한 grab에서는 변하지 않고, +실제 resize가 발생했을 때만 최소 크기를 적용합니다. + +모서리 Shift는 두 축의 상대 크기 변화 중 절댓값이 큰 값을 사용합니다. +각 preview와 release는 같은 초기 크기에서 다시 계산해야 합니다. 이미 바뀐 preview +크기를 다음 입력의 `size`로 쓰지 않습니다. 포인터가 정지해도 modifier가 바뀌면 +마지막 point로 재계산하고, release의 최종 point와 modifier로 결과를 확정합니다. +`commitAffordance`는 0 delta를 null로 거르며 자체적으로 문서를 변경하지 않습니다. + +```ts +import { commitAffordance, resizeAffordance } from "@interactive-os/json-document-affordance"; + +const origin = { x: 300, y: 200 }; +const size = { width: 200, height: 100 }; +const preview = resizeAffordance(origin, { x: 340, y: 210 }, "se", { shiftKey: true }, size); +// dx=0, dy=0, dw=40, dh=20: 반대 모서리와 2:1 비율을 유지 +const result = commitAffordance(preview); +// Hand가 preview를 렌더링하고, release에서만 result를 Object Editing Intent로 전달 +``` + +`size`를 생략한 기존 호출은 기존 delta-only 의미를 유지합니다. 크기를 알 수 없으므로 +최소 크기 제한이나 객체 비율을 보장하지 않으며, Shift+모서리는 x/y 이동량을 같게 +맞추는 기존 규칙입니다. 객체의 비율을 유지하려는 소비자는 반드시 초기 `size`를 넘깁니다. + +### Usage와 Source + +[Canvas Usage](/demo/canvas)와 [Canvas Widget](/widgets/canvas)는 같은 `CanvasHand`를 +사용합니다. `useCanvasHand`가 이 공개 API에 초기 객체 크기와 현재 modifier를 전달하며, +Source에서 Hand → `resizeAffordance` → Object projection/Editing을 확인할 수 있습니다. +네 변의 연속 hit 영역과 네 모서리는 React의 `useInteractionHandle`을 공유하고, +pointer capture·장치별 이벤트 수명은 Web binding에 남깁니다. 선택 집합 중 primary만 +resize하며, 그룹 resize·회전·flip·snap·crop은 이 계약에 포함하지 않습니다. + +```live-demo +/demo/canvas +``` diff --git a/packages/json-document-affordance/src/drag.ts b/packages/json-document-affordance/src/drag.ts index 207030c3..5b120b19 100644 --- a/packages/json-document-affordance/src/drag.ts +++ b/packages/json-document-affordance/src/drag.ts @@ -147,36 +147,42 @@ export function resizeAffordance( point: Point, edge: ResizeEdge, modifiers?: { readonly shiftKey?: boolean; readonly altKey?: boolean }, + size?: Pick, ): AffordancePreview { let dx = point.x - origin.x; let dy = point.y - origin.y; const corner = edge.length === 2; - if (modifiers?.shiftKey && corner) { + if (modifiers?.shiftKey && corner && !size) { const mag = Math.max(Math.abs(dx), Math.abs(dy)); dx = (dx === 0 ? 1 : Math.sign(dx)) * mag; dy = (dy === 0 ? 1 : Math.sign(dy)) * mag; } - let left = 0; - let top = 0; - let right = 0; - let bottom = 0; - if (edge.includes("e")) right = dx; - if (edge.includes("w")) left = dx; - if (edge.includes("s")) bottom = dy; - if (edge.includes("n")) top = dy; - if (modifiers?.altKey) { - if (edge.includes("e")) left = -dx; - if (edge.includes("w")) right = -dx; - if (edge.includes("s")) top = -dy; - if (edge.includes("n")) bottom = -dy; + const west = edge.includes("w"), north = edge.includes("n"); + const horizontal = west || edge.includes("e"), vertical = north || edge.includes("s"); + const centered = modifiers?.altKey ?? false, factor = centered ? 2 : 1; + let dw = (west ? -dx : horizontal ? dx : 0) * factor; + let dh = (north ? -dy : vertical ? dy : 0) * factor; + if (size) { + const { width, height } = size; + if (![width, height].every((value) => Number.isFinite(value) && value > 0)) throw new RangeError("Resize dimensions must be finite and positive."); + // A stationary grab is a no-op, including valid imported sub-unit objects. + if ((dw !== 0 || dh !== 0) && modifiers?.shiftKey) { + const sx = dw / width, sy = dh / height; + const growth = horizontal && (!vertical || Math.abs(sx) >= Math.abs(sy)) ? sx : sy; + const scaleDelta = Math.max(1 / width - 1, 1 / height - 1, growth); + dw = width * scaleDelta; dh = height * scaleDelta; + } else if (dw !== 0 || dh !== 0) { + dw = Math.max(1 - width, dw); dh = Math.max(1 - height, dh); + } } + // Derive translation from the constrained size so the opposite edge or center stays fixed. return { hand: { type: "resize", - dx: left, - dy: top, - dw: right - left, - dh: bottom - top, + dx: dw === 0 ? 0 : centered || !horizontal ? -dw / 2 : west ? -dw : 0, + dy: dh === 0 ? 0 : centered || !vertical ? -dh / 2 : north ? -dh : 0, + dw, + dh, edge, }, cursor: interactionHandleCursor({ kind: "resize", edge }), diff --git a/packages/json-document-affordance/tests/resize.test.ts b/packages/json-document-affordance/tests/resize.test.ts new file mode 100644 index 00000000..3df08f67 --- /dev/null +++ b/packages/json-document-affordance/tests/resize.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "vitest"; +import { commitAffordance, resizeAffordance, type ResizeEdge } from "../src/index.js"; + +const size = { width: 200, height: 100 }; +const origin = { x: 0, y: 0 }; +const edges = ["n", "ne", "e", "se", "s", "sw", "w", "nw"] as const; +const modes = [ + { shiftKey: false, altKey: false }, { shiftKey: true, altKey: false }, + { shiftKey: false, altKey: true }, { shiftKey: true, altKey: true }, +]; + +function bounds(edge: ResizeEdge, point: typeof origin, modifiers = modes[0]!, initial = size) { + const result = resizeAffordance(origin, point, edge, modifiers, initial); + expect(result.cursor).toBe(`${edge}-resize`); + const hand = result.hand; + if (hand?.type !== "resize") throw new Error("Expected a resize result"); + return { x: hand.dx, y: hand.dy, width: initial.width + hand.dw, height: initial.height + hand.dh }; +} + +describe("size-aware Resize", () => { + test.each([ + ["n", 0, -10, 200, 110], ["ne", 40, -10, 240, 110], + ["e", 40, 0, 240, 100], ["se", 40, 10, 240, 110], + ["s", 0, 10, 200, 110], ["sw", -40, 10, 240, 110], + ["w", -40, 0, 240, 100], ["nw", -40, -10, 240, 110], + ] as const)("%s resizes only its axes and fixes the opposite edge/corner", (edge, dx, dy, width, height) => { + expect(bounds(edge, { x: dx || 75, y: dy || 75 })).toEqual({ + x: edge.includes("w") ? 200 - width : 0, y: edge.includes("n") ? 100 - height : 0, width, height, + }); + }); + + test.each(edges)("%s uses the initial non-square ratio and Alt fixes the center", (edge) => { + const x = edge.includes("w") ? -40 : edge.includes("e") ? 40 : 0; + const y = edge.includes("n") ? -10 : edge.includes("s") ? 10 : 0; + const width = x === 0 ? 220 : 240, height = width / 2; + const resized = bounds(edge, { x, y }, { shiftKey: true, altKey: false }); + expect(resized.width).toBeCloseTo(width); expect(resized.height).toBeCloseTo(height); + expect(resized.x).toBeCloseTo(x === 0 ? (200 - width) / 2 : x < 0 ? 200 - width : 0); + expect(resized.y).toBeCloseTo(y === 0 ? (100 - height) / 2 : y < 0 ? 100 - height : 0); + for (const shiftKey of [false, true]) { + const centered = bounds(edge, { x, y }, { shiftKey, altKey: true }); + expect(centered.x + centered.width / 2).toBeCloseTo(100); + expect(centered.y + centered.height / 2).toBeCloseTo(50); + expect(centered.width).toBeCloseTo(shiftKey ? 200 + (width - 200) * 2 : 200 + Math.abs(x) * 2); + expect(centered.height).toBeCloseTo(shiftKey ? centered.width / 2 : 100 + Math.abs(y) * 2); + } + }); + + test.each(edges.flatMap((edge) => modes.map((mode) => ({ edge, ...mode }))))("$edge clamps without moving its anchor (Shift=$shiftKey Alt=$altKey)", ({ edge, shiftKey, altKey }) => { + const horizontal = edge.includes("e") || edge.includes("w"), vertical = edge.includes("n") || edge.includes("s"); + const result = bounds(edge, { x: edge.includes("w") ? 1000 : -1000, y: edge.includes("n") ? 1000 : -1000 }, { shiftKey, altKey }); + expect(result.width).toBeCloseTo(shiftKey ? 2 : horizontal ? 1 : 200); + expect(result.height).toBeCloseTo(shiftKey || vertical ? 1 : 100); + expect(result.x + result.width * (altKey || !horizontal ? 0.5 : edge.includes("w") ? 1 : 0)).toBeCloseTo(altKey || !horizontal ? 100 : edge.includes("w") ? 200 : 0); + expect(result.y + result.height * (altKey || !vertical ? 0.5 : edge.includes("n") ? 1 : 0)).toBeCloseTo(altKey || !vertical ? 50 : edge.includes("n") ? 100 : 0); + }); + + test.each(edges)("%s keeps a stationary input a no-op in every modifier mode", (edge) => { + for (const mode of modes) expect(commitAffordance(resizeAffordance(origin, origin, edge, mode, size))).toBeNull(); + }); + + test("a tall object's ratio, shrinking and initially sub-unit dimensions remain well-defined", () => { + expect(bounds("se", { x: -10, y: -80 }, { shiftKey: true, altKey: false }, { width: 100, height: 400 })).toEqual({ x: 0, y: 0, width: 80, height: 320 }); + const small = { width: 0.5, height: 0.25 }; + expect(bounds("nw", origin, modes[0], small)).toEqual({ x: 0, y: 0, ...small }); + expect(bounds("nw", { x: 10, y: 10 }, modes[0], small)).toEqual({ x: -0.5, y: -0.75, width: 1, height: 1 }); + }); + + test.each([0, -1, Infinity, NaN])("rejects invalid initial dimensions (%s)", (width) => { + expect(() => resizeAffordance(origin, origin, "e", undefined, { width, height: 100 })).toThrow(RangeError); + }); +}); diff --git a/packages/json-document-canvas/docs/api.md b/packages/json-document-canvas/docs/api.md index 9e6d4e40..e9e97c1f 100644 --- a/packages/json-document-canvas/docs/api.md +++ b/packages/json-document-canvas/docs/api.md @@ -37,7 +37,7 @@ Host: 한 장 fixture, 크기·색상 정책, 레이아웃 drag는 marquee replace, Shift+marquee는 add입니다. Mod+A를 반복해도 전체 선택을 유지합니다. 선택된 객체 press는 집합을 유지하고 release까지 drag가 없으면 단일 선택으로 바꿉니다. - 선택된 객체를 끌면 집합 전체가 같은 delta로 이동합니다. 마지막 객체가 위에 표시됩니다. - 선택 윤곽은 모두 그리지만 네 모서리 resize handles는 primary 하나에만 붙습니다. + 선택 윤곽은 모두 그리지만 네 변·네 모서리 resize targets는 primary 하나에만 붙습니다. Delete는 집합 전체를 한 번 삭제하며 primary resize/text 편집은 기존 선택 집합을 보존합니다. Focus만으로 선택하지 않으며, focused 객체에서 Space/Shift+Space로 선택/toggle합니다. focused 객체의 Enter는 그 객체를 선택하고 글자라면 편집합니다. 슬라이드 자체의 @@ -51,6 +51,14 @@ Host: 한 장 fixture, 크기·색상 정책, 레이아웃 Mod+D 또는 아이콘 툴바의 복제는 24단위 offset으로 복제하고 사본 집합·대응 primary를 선택합니다. - 방향키는 선택 집합을 1단위, Shift+방향키는 10단위 이동합니다. 수정 키 없는 입력만 처리하며 text/JSON 입력과 IME의 키보드 소유권은 보존합니다. +- 네 모서리 손잡이는 기존 사각 모양을 유지하며 네 변 전체에도 보이지 않는 resize 영역이 + 있습니다. 방향 커서로 구분하며 모서리가 변보다 우선합니다. 변은 한 축만 조절하고 반대편 + 변을, 모서리는 반대 모서리를 고정합니다. Shift는 초기 객체 비율을 유지하고, 변에서 비율을 + 유지할 때 다른 축은 중심 기준입니다. Alt/Option은 중심 기준, Shift+Alt는 중심·비율을 + 함께 고정합니다. 포인터가 멈춰 있어도 modifier 전환을 반영합니다. 최소 1 문서 단위까지 + 줄여도 고정점은 움직이지 않고 뒤집히지 않습니다. 정지한 grab은 크기·History를 바꾸지 않습니다. + [Resize 정본 계약](/docs/api/affordance)을 소비하며, 글자 크기·path 정규화 좌표·이미지 원본은 + 그대로 두고 객체 상자만 조절합니다. - 이동·resize·생성 중에는 문서를 변경하지 않습니다. pointerup의 최종 좌표로 한 번 commit합니다. Escape, pointercancel, capture loss, 외부 문서 변경, unmount는 preview를 버립니다. 다른 pointer의 release는 조작을 완료하지 못합니다. marquee 선택 preview도 @@ -78,7 +86,7 @@ text/JSON textarea의 native clipboard는 가로채지 않습니다. 줄바꿈·Unicode를 그대로 보존합니다. PNG/JPEG/WebP는 문서 내부 base64 image 객체로 넣습니다. 기본은 한 paste당 최대 4개, 파일당 10 MiB, decode 후 이미지당 16,000,000픽셀입니다. 이미지는 비율을 유지해 슬라이드 75% 상자에 맞추고 확대하지 않습니다. 후속 resize는 일반 -객체와 같은 자유 상자 변환입니다. `policy.files`와 `maxImagePixels`로 입력 정책을 지정할 수 +객체와 같은 상자 변환이며 Shift로 초기 비율을 유지할 수 있습니다. `policy.files`와 `maxImagePixels`로 입력 정책을 지정할 수 있지만 Object 모델이 지원하지 않는 이미지 표현까지 허용되는 것은 아닙니다. HTML은 Web의 inert parser와 이미지 준비 API를 사용합니다. 포함된 PNG/JPEG/WebP data URL과 diff --git a/packages/json-document-canvas/src/canvas-hand.tsx b/packages/json-document-canvas/src/canvas-hand.tsx index b7795f98..5664db1a 100644 --- a/packages/json-document-canvas/src/canvas-hand.tsx +++ b/packages/json-document-canvas/src/canvas-hand.tsx @@ -54,7 +54,7 @@ export function CanvasHand(props: CanvasHandProps) { {hand.tool === "select" && hand.objects.filter((object) => selectedKeys.has(object.id)).map((object) => )} {selected && hand.tool === "select" && - {!hand.draft && (["nw", "ne", "se", "sw"] as const).map((edge) => hand.interaction(interaction, event, selected, "resize", edge)} />)} + {!hand.draft && (["n", "e", "s", "w", "nw", "ne", "se", "sw"] as const).map((edge) => hand.interaction(interaction, event, selected, "resize", edge)} />)} } {hand.marquee && } {selected?.kind === "text" && hand.draft?.id === selected.id && ; + } return ; } diff --git a/packages/json-document-canvas/src/use-canvas-hand.ts b/packages/json-document-canvas/src/use-canvas-hand.ts index 77f1b761..4ab17ce9 100644 --- a/packages/json-document-canvas/src/use-canvas-hand.ts +++ b/packages/json-document-canvas/src/use-canvas-hand.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useReducer, useRef, useState, type ClipboardEvent, type KeyboardEvent, type PointerEvent } from "react"; -import { createGestureSession, createPlaneSelectProfile, resizeAffordance, type InteractionHandleEvent, type PlaneSelectProfile, type PlaneSelectSelection, type ResizeEdge } from "@interactive-os/json-document-affordance"; +import { commitAffordance, createGestureSession, createPlaneSelectProfile, resizeAffordance, type InteractionHandleEvent, type PlaneSelectProfile, type PlaneSelectSelection, type ResizeEdge } from "@interactive-os/json-document-affordance"; import { assertCanvasDocument, createCanvasObject, createCanvasPath, parseCanvasDocument, transformObject, type CanvasDocument, type CanvasObject, type CanvasObjectKind, type ObjectPoint } from "@interactive-os/json-document-object-document"; import type { EditingResult, ObjectEditor, ObjectIntent, ObjectSelection } from "@interactive-os/json-document-editing"; import { useEditingSnapshot } from "@interactive-os/json-document-react"; @@ -17,7 +17,8 @@ export interface CanvasCreationStyle { type Gesture = { readonly base: CanvasDocument } & ( | { readonly type: "create"; readonly tool: Exclude; readonly start: ObjectPoint; readonly point: ObjectPoint; readonly dragged: boolean } | { readonly type: "draw"; readonly points: ReadonlyArray } - | { readonly type: "resize"; readonly object: CanvasObject; readonly start: ObjectPoint; readonly point: ObjectPoint; readonly edge: ResizeEdge } + | { readonly type: "resize"; readonly object: CanvasObject; readonly start: ObjectPoint; readonly point: ObjectPoint; readonly edge: ResizeEdge; + readonly pointerId: number; readonly shiftKey: boolean; readonly altKey: boolean; readonly selection: ObjectSelection } ); type TextDraft = { readonly id: string; readonly text: string; readonly base: CanvasDocument }; @@ -65,7 +66,8 @@ export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, useEffect(() => { // A replacement/external edit invalidates previews, even when an ID survives. const release = editor.subscribe((next) => { - if ((gestures.getActive() && gestures.getActive()!.base !== next.value) + const active = gestures.getActive(); + if ((active && (active.base !== next.value || (active.type === "resize" && active.selection !== next.selection))) || (selecting.current && (selecting.current.base !== next.value || selecting.current.selection !== next.selection)) || (draft.current && draft.current.base !== next.value)) cancel(); }); @@ -153,9 +155,9 @@ export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, } function transform(gesture: Extract) { - const result = resizeAffordance(gesture.start, gesture.point, gesture.edge); - const hand = result.hand; - return hand?.type === "translate" || hand?.type === "resize" ? hand : { dx: 0, dy: 0 }; + const result = commitAffordance(resizeAffordance(gesture.start, gesture.point, gesture.edge, gesture, gesture.object)); + const hand = result?.hand; + return hand?.type === "resize" ? hand : { dx: 0, dy: 0 }; } function commitGesture() { @@ -172,7 +174,7 @@ export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, } } else { const delta = transform(active); - dispatch({ type: "object.resize", objectIds: [active.object.id], dx: delta.dx, dy: delta.dy, dw: "dw" in delta ? delta.dw : 0, dh: "dh" in delta ? delta.dh : 0 }); + if ("dw" in delta) dispatch({ type: "object.resize", objectIds: [active.object.id], dx: delta.dx, dy: delta.dy, dw: delta.dw, dh: delta.dh }); } } @@ -205,7 +207,10 @@ export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, function interaction(interaction: InteractionHandleEvent, event: PointerEvent, object: CanvasObject, type: "drag" | "resize", edge: ResizeEdge = "se") { if (interaction.phase === "cancel") { if (type === "drag" && selecting.current?.key === object.id && selecting.current.pointerId === event.pointerId) { profile.cancel("pointer-cancel"); selecting.current = null; redraw(); } - else if (type === "resize") gestures.cancel("pointer-cancel"); + else if (type === "resize") { + const active = gestures.getActive(); + if (active?.type === "resize" && active.pointerId === event.pointerId && active.edge === edge && active.object.id === object.id) gestures.cancel("pointer-cancel"); + } return; } const point = eventPoint(event); @@ -214,7 +219,8 @@ export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, commitText(); cancel(); surface.current?.focus(); if (type === "drag") { beginSelect(point, object.id, event); return; } const latest = current().objects.find((item) => item.id === object.id); - if (latest) gestures.begin({ type, object: latest, start: point, point, edge, base: current() }); + if (latest) gestures.begin({ type, object: latest, start: point, point, edge, base: current(), selection: editor.snapshot.selection, + pointerId: event.pointerId, shiftKey: event.shiftKey, altKey: event.altKey }); } else { if (type === "drag") { if (selecting.current?.key !== object.id || selecting.current.pointerId !== event.pointerId) return; @@ -222,8 +228,8 @@ export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, return; } const active = gestures.getActive(); - if (!active || active.type !== type || active.object.id !== object.id) return; - gestures.preview({ ...active, point }); + if (!active || active.type !== type || active.object.id !== object.id || active.pointerId !== event.pointerId || active.edge !== edge) return; + gestures.preview({ ...active, point, shiftKey: event.shiftKey, altKey: event.altKey }); if (interaction.phase === "commit") commitGesture(); } } @@ -240,7 +246,12 @@ export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, } function updateModifiers(event: KeyboardEvent) { - if (!event.nativeEvent.isComposing && !isWebEditableTarget(event.target) && profile.updateModifiers(event)) redraw(); + if (event.nativeEvent.isComposing || isWebEditableTarget(event.target)) return; + if (profile.updateModifiers(event)) redraw(); + const active = gestures.getActive(); + if (active?.type === "resize" && (active.shiftKey !== event.shiftKey || active.altKey !== event.altKey)) { + gestures.preview({ ...active, shiftKey: event.shiftKey, altKey: event.altKey }); + } } function keyDown(event: KeyboardEvent) { diff --git a/packages/json-document-canvas/tests/canvas-hand.test.tsx b/packages/json-document-canvas/tests/canvas-hand.test.tsx index 5174ba31..3942b152 100644 --- a/packages/json-document-canvas/tests/canvas-hand.test.tsx +++ b/packages/json-document-canvas/tests/canvas-hand.test.tsx @@ -291,7 +291,7 @@ test.each(["Escape", "pointercancel", "lostpointercapture"])("%s cancels marquee test("only primary resizes and edits text while retaining the selected set", () => { const { container, svg, editor, value, commits } = setup(populated); act(() => { editor.dispatch({ type: "selection.set", objectIds: ["a", "b"], primaryKey: "a" }); }); - expect(container.querySelectorAll("[data-resize-edge]")).toHaveLength(4); + expect(container.querySelectorAll("[data-resize-edge]")).toHaveLength(8); const handle = container.querySelector('[data-resize-edge="se"]')!; fireEvent.pointerDown(handle, event(100, 100)); fireEvent.pointerMove(window, event(120, 115)); fireEvent.pointerUp(window, event(120, 115)); expect(value().objects[0]!.width).toBeCloseTo(140); expect(value().objects[0]!.height).toBeCloseTo(130); @@ -471,6 +471,90 @@ test("move and resize share preview geometry, cancel safely and commit exactly o expect(value().objects[0]).toMatchObject({ width: 260, height: 160 }); expect(commits).toHaveBeenCalledTimes(2); }); +test.each([ + ["n", 80, 80, 200, 120], ["e", 80, 100, 240, 100], + ["s", 80, 100, 200, 80], ["w", 120, 100, 160, 100], +] as const)("%s offers an invisible full-edge target and an anchored single-axis resize", (edge, x, y, width, height) => { + const { svg, container, value, commits } = setup(); rectangle(svg); commits.mockClear(); + const handle = container.querySelector(`[data-resize-edge="${edge}"]`)!; + expect(handle).not.toBeNull(); expect(handle.getAttribute("fill")).toBe("transparent"); + expect(handle.getAttribute("stroke")).toBeNull(); expect((handle as SVGElement).style.cursor).toBe(`${edge}-resize`); + expect(Number(handle.getAttribute(edge === "n" || edge === "s" ? "width" : "height"))).toBe(edge === "n" || edge === "s" ? 200 : 100); + expect([...container.querySelectorAll("[data-resize-edge]")].slice(-4).map((node) => node.getAttribute("data-resize-edge"))).toEqual(["nw", "ne", "se", "sw"]); + const before = value(); + fireEvent.pointerDown(handle, event(80, 80)); fireEvent.pointerMove(window, event(100, 70)); + const preview = container.querySelector("[data-canvas-object]")!; + expect(["x", "y", "width", "height"].map((name) => Number(preview.getAttribute(name)))).toEqual([x, y, width, height]); + expect(value()).toBe(before); expect(commits).not.toHaveBeenCalled(); + fireEvent.pointerUp(window, event(100, 70)); expect(value().objects[0]).toMatchObject({ x, y, width, height }); + expect(commits).toHaveBeenCalledOnce(); + fireEvent.keyDown(svg, { key: "z", metaKey: true }); expect(value()).toEqual(before); + fireEvent.keyDown(svg, { key: "z", metaKey: true, shiftKey: true }); expect(value().objects[0]).toMatchObject({ x, y, width, height }); +}); + +test("resize switches Shift/Alt live and commits the release coordinates and modifiers once", () => { + const { svg, container, value, commits } = setup(); rectangle(svg); commits.mockClear(); + const handle = container.querySelector('[data-resize-edge="se"]')!; + const target = container.querySelector("[data-canvas-object]")!; + const bounds = () => ["x", "y", "width", "height"].map((key) => Number(target.getAttribute(key))); + fireEvent.pointerDown(handle, event(140, 100)); fireEvent.pointerMove(window, event(160, 105)); + expect(bounds()).toEqual([80, 100, 240, 110]); + fireEvent.keyDown(svg, { key: "Shift", shiftKey: true }); expect(bounds()).toEqual([80, 100, 240, 120]); + fireEvent.keyDown(svg, { key: "Alt", shiftKey: true, altKey: true }); expect(bounds()).toEqual([40, 80, 280, 140]); + fireEvent.keyUp(svg, { key: "Shift", altKey: true }); expect(bounds()).toEqual([40, 90, 280, 120]); + fireEvent.keyUp(svg, { key: "Alt" }); expect(bounds()).toEqual([80, 100, 240, 110]); + expect(commits).not.toHaveBeenCalled(); + fireEvent.pointerUp(window, { ...event(170, 110), shiftKey: true, altKey: true }); + expect(value().objects[0]).toMatchObject({ x: 20, y: 70, width: 320, height: 160 }); + expect(commits).toHaveBeenCalledOnce(); expect(value().objects).toHaveLength(1); +}); + +test.each(["rectangle", "ellipse", "text", "path", "image"] as const)("%s uses the same west-edge clamp without changing its content", (kind) => { + const object = { id: "a", x: 100, y: 100, width: 200, height: 100, color: "blue", label: "Content", kind, + ...(kind === "text" ? { fontSize: 24 } : kind === "path" ? { points: [{ x: 0, y: 0 }, { x: 1, y: 1 }], strokeWidth: 3 } : kind === "image" ? { source: "data:image/png;base64,AQID" } : {}) } as CanvasDocument["objects"][number]; + const { container, value } = setup({ ...blank, objects: [object] }); + const handle = container.querySelector('[data-resize-edge="w"]')!; + fireEvent.pointerDown(handle, event(50, 70)); fireEvent.pointerMove(window, event(250, 200)); + expect(container.querySelector("[data-canvas-object]")?.getAttribute("x")).toBe("299"); + fireEvent.pointerUp(window, event(250, 200)); + expect(value().objects[0]).toEqual({ ...object, x: 299, width: 1 }); +}); + +test.each(["Escape", "pointercancel", "lostpointercapture", "document", "selection", "unmount"])("resize cancellation (%s) discards preview and ignores stale release", (reason) => { + const { container, svg, editor, source, value, commits, unmount } = setup(populated); + const handle = container.querySelector('[data-resize-edge="e"]')!; + fireEvent.pointerDown(handle, event(100, 75)); fireEvent.pointerMove(window, event(150, 80)); + if (reason === "Escape") fireEvent.keyDown(svg, { key: "Escape" }); + else if (reason === "pointercancel") fireEvent.pointerCancel(window, event(150, 80)); + else if (reason === "lostpointercapture") fireEvent.lostPointerCapture(handle, event(150, 80)); + else if (reason === "document") act(() => { source.commit([{ op: "replace", path: "/objects/0/color", value: "red" }]); }); + else if (reason === "selection") act(() => { editor.dispatch({ type: "selection.set", objectIds: ["b"] }); }); + else unmount(); + commits.mockClear(); fireEvent.pointerUp(window, event(180, 90)); + expect(value().objects[0]!.width).toBe(100); expect(commits).not.toHaveBeenCalled(); + if (reason !== "unmount") expect(container.querySelector('[data-canvas-object="a"]')?.getAttribute("width")).toBe("100"); +}); + +test("resize ignores foreign pointers and a return to the original bounds adds no History", () => { + const { container, editor, value, commits } = setup(populated); + const handle = container.querySelector('[data-resize-edge="n"]')!; + fireEvent.pointerDown(handle, event(75, 50)); + fireEvent.pointerMove(window, event(100, 100, 9)); fireEvent.pointerUp(window, event(100, 100, 9)); + expect(container.querySelector('[data-canvas-object="a"]')?.getAttribute("height")).toBe("100"); + fireEvent.pointerMove(window, event(75, 30)); fireEvent.pointerUp(window, event(75, 50)); + expect(value()).toEqual(populated); expect(commits).not.toHaveBeenCalled(); expect(editor.snapshot.canUndo).toBe(false); +}); + +test("an imported sub-unit object does not grow on a stationary resize grab", () => { + const object = { ...populated.objects[1]!, width: 0.5, height: 0.25 }; + const { container, value, commits, editor } = setup({ ...blank, objects: [object] }); + const handle = container.querySelector('[data-resize-edge="nw"]')!; + fireEvent.pointerDown(handle, event(150, 50)); fireEvent.pointerUp(window, event(150, 50)); + expect(value().objects[0]).toEqual(object); expect(editor.snapshot.canUndo).toBe(false); expect(commits).not.toHaveBeenCalled(); + fireEvent.pointerDown(handle, event(150, 50)); fireEvent.pointerUp(window, event(200, 100)); + expect(value().objects[0]).toMatchObject({ x: 299.5, y: 99.25, width: 1, height: 1 }); +}); + test("text draft supports IME, cancellation and one commit without capturing native editing shortcuts", () => { const { svg, value, commits, container } = setup(); fireEvent.click(screen.getByRole("button", { name: "글자" })); diff --git a/site/src/routes/docs/doc-pages.ts b/site/src/routes/docs/doc-pages.ts index 88f5b807..6f62e9af 100644 --- a/site/src/routes/docs/doc-pages.ts +++ b/site/src/routes/docs/doc-pages.ts @@ -12,6 +12,7 @@ import zodApiMarkdown from "../../../../docs/api-reference/zod.md?raw"; import tanStackTableApiMarkdown from "../../../../docs/api-reference/tanstack-table.md?raw"; import affordanceApiMarkdown from "../../../../docs/api-reference/affordance.md?raw"; import planeSelectContractMarkdown from "../../../../packages/json-document-affordance/docs/plane-select.md?raw"; +import resizeContractMarkdown from "../../../../packages/json-document-affordance/docs/resize.md?raw"; import objectSelectionContractMarkdown from "../../../../packages/json-document-editing/docs/object-selection.md?raw"; import uiPrimitivesApiMarkdown from "../../../../docs/api-reference/ui-primitives-react.md?raw"; import animationApiMarkdown from "../../../../docs/api-reference/animation-react.md?raw"; @@ -194,7 +195,7 @@ export const docPages = { connectorA2ui: docPage("/docs/connector-a2ui", connectorA2uiMarkdown), zodApi: docPage("/docs/api/zod", zodApiMarkdown), tanStackTableApi: docPage("/docs/api/tanstack-table", tanStackTableApiMarkdown), - affordanceApi: docPage("/docs/api/affordance", `${planeSelectContractMarkdown}\n\n${affordanceApiMarkdown}`), + affordanceApi: docPage("/docs/api/affordance", `${planeSelectContractMarkdown}\n\n${resizeContractMarkdown}\n\n${affordanceApiMarkdown}`), uiPrimitivesApi: docPage("/docs/api/ui-primitives-react", uiPrimitivesApiMarkdown), animationApi: docPage("/docs/api/animation-react", animationApiMarkdown), markdownReactApi: docPage("/docs/api/markdown-react", markdownReactApiMarkdown), diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index 6cf55958..42427305 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -360,8 +360,11 @@ describe("Demo definition and source discovery", () => { "packages/json-document-object-document/src/object-projection.ts", "packages/json-document-editing/src/object.ts", "packages/json-document-affordance/src/gesture-session.ts", + "packages/json-document-affordance/src/drag.ts", "packages/json-document-react/src/editing-snapshot.ts", ])); + const hand = (await discoverDemoSources(entry)).find((file) => file.path === "packages/json-document-canvas/src/use-canvas-hand.ts")!; + expect(await hand.load()).toContain("resizeAffordance(gesture.start, gesture.point, gesture.edge, gesture, gesture.object)"); } }); From d8502c9c57ad3b0cba42c19c1faf678ee01a5c0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Thu, 10 Sep 2026 02:02:14 +0900 Subject: [PATCH 22/24] =?UTF-8?q?feat(canvas):=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=ED=8E=B8=EC=A7=91=EC=9D=84=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B8=20Object=20=EA=B3=84=EC=95=BD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=97=B0=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #759 --- audits/document-types.json | 26 +++++- docs/api-reference/canvas.md | 2 +- docs/api-reference/editing.md | 1 + docs/api-reference/object-document.md | 37 +++++++- docs/api-reference/ui-primitives-react.md | 2 +- docs/public/intent.md | 1 + docs/public/object.md | 2 +- packages/json-document-canvas/docs/api.md | 25 ++++- .../json-document-canvas/src/canvas-hand.tsx | 2 + .../src/canvas-object-view.tsx | 12 ++- .../src/canvas-style-controls.tsx | 67 +++++++++++++ .../src/use-canvas-hand.ts | 6 +- .../tests/canvas-hand.test.tsx | 93 +++++++++++++++++++ .../docs/object-selection.md | 18 ++++ packages/json-document-editing/src/object.ts | 6 +- .../tests/object-style.test.ts | 66 +++++++++++++ .../json-document-object-document/docs/api.md | 43 ++++++++- .../src/index.ts | 1 + .../src/object-model.ts | 4 +- .../src/object-operation.ts | 9 ++ .../src/object-style.ts | 50 ++++++++++ .../src/object-validation.ts | 3 + .../tests/object-style.test.ts | 68 ++++++++++++++ .../docs/popover.md | 22 +++++ .../package.json | 2 +- .../src/presentations.tsx | 23 ++++- .../tests/primitives.test.tsx | 19 ++++ site/src/app/index.css | 4 + .../routes/canvas-demo/CanvasDemoRoute.tsx | 2 +- site/src/routes/docs/doc-pages.ts | 3 +- .../src/shared/demo-workbench/demo-sources.ts | 10 ++ site/tailwind.config.cjs | 1 + site/tests/unit/demo-workbench.test.tsx | 6 ++ 33 files changed, 604 insertions(+), 32 deletions(-) create mode 100644 packages/json-document-canvas/src/canvas-style-controls.tsx create mode 100644 packages/json-document-editing/tests/object-style.test.ts create mode 100644 packages/json-document-object-document/src/object-style.ts create mode 100644 packages/json-document-object-document/tests/object-style.test.ts create mode 100644 packages/json-document-ui-primitives-react/docs/popover.md diff --git a/audits/document-types.json b/audits/document-types.json index 057820f9..422acb3b 100644 --- a/audits/document-types.json +++ b/audits/document-types.json @@ -32,15 +32,17 @@ }, "object": { "why": "캔버스 object는 identity, geometry와 presentation 값을 하나의 의미 단위로 유지해야 하기 때문입니다.", - "does": "object의 위치·크기·색상 모델을 정의하고 translate·resize·fill 같은 공간 연산의 기반을 제공합니다.", - "schema": "interface DocumentObject {\n readonly id: string;\n readonly label: string;\n readonly x: number;\n readonly y: number;\n readonly width: number;\n readonly height: number;\n readonly color: string;\n}\n\ninterface ObjectDocument {\n readonly objects: ReadonlyArray;\n}\n\ntype CanvasObject = DocumentObject & (\n | { kind: \"text\"; fontSize: number }\n | { kind: \"rectangle\" | \"ellipse\" }\n | { kind: \"path\"; points: ReadonlyArray<{ x: number; y: number }>; strokeWidth: number }\n | { kind: \"image\"; source: string }\n);\ninterface CanvasDocument extends ObjectDocument {\n readonly profile: \"canvas/1\";\n readonly width: number; readonly height: number;\n readonly objects: ReadonlyArray;\n}", + "does": "object의 위치·크기·스타일 모델을 정의하고 translate·resize·fill·style의 원자적 연산을 제공합니다.", + "schema": "interface DocumentObject {\n readonly id: string;\n readonly label: string;\n readonly x: number;\n readonly y: number;\n readonly width: number;\n readonly height: number;\n readonly color: string;\n}\n\ninterface ObjectDocument {\n readonly objects: ReadonlyArray;\n}\n\ntype CanvasObject = DocumentObject & (\n | { kind: \"text\"; fontSize: number; fontWeight?: 400 | 700; textAlign?: \"left\" | \"center\" | \"right\" }\n | { kind: \"rectangle\" | \"ellipse\"; strokeColor?: string; strokeWidth?: number }\n | { kind: \"path\"; points: ReadonlyArray<{ x: number; y: number }>; strokeWidth: number }\n | { kind: \"image\"; source: string }\n);\ninterface CanvasDocument extends ObjectDocument {\n readonly profile: \"canvas/1\";\n readonly width: number; readonly height: number;\n readonly objects: ReadonlyArray;\n}", "fields": [ { "name": "objects", "description": "같은 공간에서 배치·선택되는 object의 ordered collection입니다." }, { "name": "objects[].id", "description": "selection, transform과 clipboard가 object를 안정적으로 참조하는 identity입니다." }, { "name": "objects[].label", "description": "object의 사용자 가시 이름입니다." }, { "name": "x / y", "description": "문서 좌표계에서 object 좌상단의 위치입니다." }, { "name": "width / height", "description": "resize 이후에도 유효해야 하는 object의 공간 크기입니다." }, - { "name": "color", "description": "object가 소유하는 현재 presentation 값입니다." } + { "name": "color", "description": "도형의 채우기·글자색·path 선 색입니다." }, + { "name": "fontSize / fontWeight / textAlign", "description": "글자의 크기·굵기·정렬입니다. 생략된 굵기는 400, 정렬은 left입니다." }, + { "name": "strokeColor / strokeWidth", "description": "도형 테두리는 기본 #000000·0이며 path는 color와 양의 strokeWidth를 사용합니다." } ], "sourcePath": "packages/json-document-object-document/src/object-model.ts", "symbol": "ObjectDocument" @@ -136,7 +138,7 @@ "audits": { "object": { "status": "owner-closed", - "denominator": 14, + "denominator": 15, "horizon": { "enumerators": [ "packages/json-document-object-document/src/index.ts", @@ -151,6 +153,22 @@ ] }, "occurrences": [ + { + "id": "style", + "role": "Document Style", + "knowledge": "종류별 스타일·유효 기본값·혼합 선택·요청 검증", + "decision": "표시와 편집이 같은 스타일 의미를 소비", + "changeReason": "스타일 속성과 적용 가능성 변경", + "stateLifecycle": "문서 값 또는 stateless 계산", + "inputsOutputs": "readObjectStyle의 public 계약", + "currentOwner": "@interactive-os/json-document-object-document", + "canonicalEvidence": "packages/json-document-object-document/src/object-style.ts와 공개 API·소유 API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-object-document/src/object-style.ts", + "symbol": "readObjectStyle", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-object-document", + "nextCheck": "혼합 스타일·no-op·원자적 거절 회귀와 두 Canvas Host" + }, { "id": "model", "role": "Document Model", diff --git a/docs/api-reference/canvas.md b/docs/api-reference/canvas.md index b2159cef..261f92b8 100644 --- a/docs/api-reference/canvas.md +++ b/docs/api-reference/canvas.md @@ -57,5 +57,5 @@ createCanvasClipboardBinding(editor: ObjectEditor, policy: CanvasClipboardPolicy ## `useCanvasHand` ```ts -useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, selectProfile?: PlaneSelectProfile): { document: CanvasDocument; snapshot: import("/packages/json-document-editing/src/session").EditingSnapshot; ... 21 more ...; surfaceProps: { ...; }; } +useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, selectProfile?: PlaneSelectProfile): { document: CanvasDocument; snapshot: import("/packages/json-document-editing/src/session").EditingSnapshot; ... 23 more ...; surfaceProps: { ...; }; } ``` diff --git a/docs/api-reference/editing.md b/docs/api-reference/editing.md index f4589e0f..ef8fa704 100644 --- a/docs/api-reference/editing.md +++ b/docs/api-reference/editing.md @@ -1299,6 +1299,7 @@ type ObjectIntent = } | { readonly type: "selection.remove" } | { readonly type: "selection.fill"; readonly color: string } + | { readonly type: "selection.style"; readonly style: Partial } | { readonly type: "object.translate"; readonly objectIds: ReadonlyArray; diff --git a/docs/api-reference/object-document.md b/docs/api-reference/object-document.md index f7264869..e8ba6f5f 100644 --- a/docs/api-reference/object-document.md +++ b/docs/api-reference/object-document.md @@ -21,6 +21,11 @@ assertCanvasImageSource(source: unknown): asserts source is string ```ts assertObjectDocument(value: unknown): void ``` +## `assertObjectStyle` + +```ts +assertObjectStyle(value: unknown): asserts value is Partial +``` ## `CanvasDocument` ```ts @@ -40,8 +45,8 @@ type CanvasObject = CanvasObjectDraft & { readonly id: string }; ```ts type CanvasObjectDraft = ObjectDraft & ( - | { readonly kind: "text"; readonly fontSize: number } - | { readonly kind: "rectangle" | "ellipse" } + | { readonly kind: "text"; readonly fontSize: number; readonly fontWeight?: 400 | 700; readonly textAlign?: "left" | "center" | "right" } + | { readonly kind: "rectangle" | "ellipse"; readonly strokeColor?: string; readonly strokeWidth?: number } | { readonly kind: "path"; readonly points: ReadonlyArray; readonly strokeWidth: number } | { readonly kind: "image"; readonly source: string } ); @@ -73,6 +78,11 @@ interface DocumentObject extends ObjectDraft { readonly id: string; } ``` +## `getObjectStyle` + +```ts +getObjectStyle(object: DocumentObject): Partial +``` ## `ObjectBounds` ```ts @@ -105,6 +115,7 @@ type ObjectOperation = | { readonly type: "insert"; readonly objects: ReadonlyArray } | { readonly type: "transform"; readonly objectIds: ReadonlyArray; readonly transform: ObjectTransform } | { readonly type: "fill"; readonly objectIds: ReadonlyArray; readonly color: string } + | { readonly type: "style"; readonly objectIds: ReadonlyArray; readonly style: Partial } | { readonly type: "remove"; readonly objectIds: ReadonlyArray } | { readonly type: "text"; readonly objectId: string; readonly text: string } | { readonly type: "replace"; readonly document: ObjectDocument }; @@ -124,6 +135,23 @@ interface ObjectPoint extends Record { readonly y: number; } ``` +## `ObjectStyle` + +```ts +interface ObjectStyle { + readonly color: string; + readonly fontSize: number; + readonly fontWeight: 400 | 700; + readonly textAlign: "left" | "center" | "right"; + readonly strokeColor: string; + readonly strokeWidth: number; +} +``` +## `ObjectStyleSelection` + +```ts +type ObjectStyleSelection = { readonly [Key in keyof ObjectStyle]?: ObjectStyle[Key] | null }; +``` ## `ObjectTransform` ```ts @@ -149,6 +177,11 @@ planObjectOperation(document: ObjectDocument, operation: ObjectOperation): Objec ```ts projectObject(object: DocumentObject): CanvasObject ``` +## `readObjectStyle` + +```ts +readObjectStyle(objects: ReadonlyArray): ObjectStyleSelection +``` ## `serializeCanvasDocument` ```ts diff --git a/docs/api-reference/ui-primitives-react.md b/docs/api-reference/ui-primitives-react.md index d6897ed0..2fa215d1 100644 --- a/docs/api-reference/ui-primitives-react.md +++ b/docs/api-reference/ui-primitives-react.md @@ -223,7 +223,7 @@ type MenuItem = { ## `Popover` ```ts -Popover(props: { readonly label: string; readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly trigger: ReactNode; readonly children: ReactNode; readonly className?: string; readonly panelClassName?: string; }): ReactNode +Popover(props: { readonly label: string; readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly trigger: ReactNode; readonly triggerPresentation?: "label" | "icon"; readonly children: ReactNode; readonly className?: string; readonly panelClassName?: string; }): ReactNode ``` ## `ProductCanvas` diff --git a/docs/public/intent.md b/docs/public/intent.md index 9a4ea6d3..e28f05b4 100644 --- a/docs/public/intent.md +++ b/docs/public/intent.md @@ -116,6 +116,7 @@ commit은 되돌아가지 않습니다. Selection mapping/reconciliation callbac | `selection.set` | `objectIds`, `mode?` | 객체 선택 변경 | | `selection.remove` | | 선택한 객체 제거 | | `selection.fill` | `color` | 선택한 객체 색 변경 | +| `selection.style` | `style` | 지원하는 선택 객체의 색·글자 서식·테두리를 한 번 변경 | | `object.translate` | `objectIds`, `dx`, `dy` | 선택한 객체 위치 이동 | | `object.resize` | `objectIds`, `dx`, `dy`, `dw`, `dh` | 선택한 객체 크기 | | `clipboard.paste` | `clipboard` | 붙여넣기 | diff --git a/docs/public/object.md b/docs/public/object.md index cab60211..528d7f62 100644 --- a/docs/public/object.md +++ b/docs/public/object.md @@ -21,7 +21,7 @@ ID 정책을 Host가 주입하는 자리입니다. ### `ObjectIntent` `dispatch`가 받는 Object domain command입니다. 공개 variant는 -`selection.set`, `selection.remove`, `selection.fill`, `object.create`, `object.text`, +`selection.set`, `selection.remove`, `selection.fill`, `selection.style`, `object.create`, `object.text`, `object.translate`, `object.resize`, `object.duplicate`, `object.remove`, `document.replace`, `clipboard.paste`입니다. DOM event, pointer 좌표, clipboard event를 Intent에 넣지 않습니다. diff --git a/packages/json-document-canvas/docs/api.md b/packages/json-document-canvas/docs/api.md index e9e97c1f..13c87d2a 100644 --- a/packages/json-document-canvas/docs/api.md +++ b/packages/json-document-canvas/docs/api.md @@ -1,7 +1,7 @@ ## Canvas Hand 계약 · RC `CanvasHand`는 `ObjectEditor`와 `CanvasCreationStyle`을 받아 글자·사각형·타원·자유 -그리기, 이미지·텍스트 붙여넣기, 다중 선택, 집합 이동·복제·삭제, native Clipboard, primary resize, Undo/Redo, JSON 재열기를 연결합니다. +그리기, 이미지·텍스트 붙여넣기, 다중 선택, 선택 스타일, 집합 이동·복제·삭제, native Clipboard, primary resize, Undo/Redo, JSON 재열기를 연결합니다. `useCanvasHand`는 같은 입력 조합을 custom UI에서 사용할 수 있게 공개합니다. 툴바의 모든 도구·명령은 Lucide 아이콘과 공통 `Toggle`/`Command`의 `label`을 @@ -67,6 +67,29 @@ Host: 한 장 fixture, 크기·색상 정책, 레이아웃 한 번의 Undo로 되돌리며 삭제 Undo는 객체와 선택을 함께 복원합니다. Mod+Z/Mod+Shift+Z는 입력 필드 밖에서 문서 Undo/Redo를 실행합니다. +### 선택 스타일 + +Select 도구에서 스타일을 지원하는 객체가 선택되면 팔레트 아이콘 하나가 나타납니다. +공통 Popover와 Command 툴팁을 사용하며, 선택한 종류에 필요한 속성만 엽니다. +색상 팔레트와 굵게·정렬 버튼은 즉시 확정합니다. 직접 입력한 CSS 색·글자 크기·선 굵기는 +Enter 또는 적용 아이콘으로 확정하고, Escape·바깥 클릭으로 닫으면 미확정 입력은 버립니다. +이미지만 선택한 경우에는 스타일 컨트롤이 없습니다. + +혼합 선택은 `readObjectStyle`의 `null`을 `혼합`으로 드러냅니다. 색·크기·정렬을 임의의 +primary 값으로 표시하지 않습니다. 속성은 이를 지원하는 선택 객체에만 적용하고 전체 +선택과 primary를 보존합니다. 굵기가 모두 0인 도형에 테두리색을 고르면 2 단위로 함께 +켭니다. 투명한 테두리색이나 도형의 0 굵기로 테두리를 없앨 수 있습니다. path는 양의 +굵기가 필요하므로 path가 포함된 선택에 0을 입력하면 전체를 거절합니다. + +`useCanvasHand`의 `selectedStyle`과 `setStyle(style)`로 같은 기능을 custom UI에 연결할 +수 있습니다. `setStyle`은 `selection.style` Intent의 결과를 반환합니다. 스타일을 열거나 +적용할 때 글자 draft는 먼저 확정하고 진행 중인 gesture·paste는 취소합니다. 글자 편집 +textarea도 표시와 같은 크기·굵기·정렬을 사용합니다. 스타일 확정당 한 번의 Undo이며 +기본값·동일값은 문서와 History를 바꾸지 않습니다. + +스타일은 저장 객체에만 적용하며 `creationStyle`의 제품 생성 기본값을 변경하지 않습니다. +글자 자동 크기, 부분 문자열 서식, 상시 inspector는 이번 범위 밖입니다. + ### Native Clipboard 선택 객체의 Mod+C/X/V 또는 브라우저 native copy/cut/paste 이벤트를 Web Clipboard diff --git a/packages/json-document-canvas/src/canvas-hand.tsx b/packages/json-document-canvas/src/canvas-hand.tsx index 5664db1a..3f533ab7 100644 --- a/packages/json-document-canvas/src/canvas-hand.tsx +++ b/packages/json-document-canvas/src/canvas-hand.tsx @@ -6,6 +6,7 @@ import { serializeCanvasDocument } from "@interactive-os/json-document-object-do import { Command, Field, ProductShell, Toggle, ToolbarGroup } from "@interactive-os/json-document-ui-primitives-react"; import { CanvasObjectTarget, CanvasObjectView, CanvasResizeTarget, CanvasTextInput } from "./canvas-object-view.js"; import { useCanvasHand, type CanvasCreationStyle, type CanvasTool } from "./use-canvas-hand.js"; +import { CanvasStyleControls } from "./canvas-style-controls.js"; export interface CanvasHandProps { readonly editor: ObjectEditor; @@ -37,6 +38,7 @@ export function CanvasHand(props: CanvasHandProps) { hand.duplicate()}> + {hand.tool === "select" && { hand.commitText(); hand.cancel(); }} />} { hand.commitText(); hand.cancel(); setJSON(json === null ? serializeCanvasDocument(props.editor.snapshot.value as typeof hand.document) : null); }}> }> ; if (object.kind === "path") { return `${object.x + point.x * object.width},${object.y + point.y * object.height}`).join(" ")} fill="none" stroke={object.color} strokeWidth={object.strokeWidth} strokeLinecap="round" strokeLinejoin="round" />; } if (object.kind === "text") return ( -
{object.label}
+
{object.label}
); return object.kind === "ellipse" - ? - : ; + ? + : ; } export function CanvasObjectTarget(props: { @@ -76,10 +77,11 @@ export function CanvasTextInput(props: { const input = useRef(null); useEffect(() => { input.current?.focus(); input.current?.select(); }, [props.object.id]); const { object } = props; + const style = getObjectStyle(object); return ( { event.stopPropagation(); diff --git a/packages/json-document-canvas/src/canvas-style-controls.tsx b/packages/json-document-canvas/src/canvas-style-controls.tsx new file mode 100644 index 00000000..d747cc23 --- /dev/null +++ b/packages/json-document-canvas/src/canvas-style-controls.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { AlignCenter, AlignLeft, AlignRight, Bold, Check, Palette, Square, type LucideIcon } from "lucide-react"; +import type { ObjectStyle, ObjectStyleSelection } from "@interactive-os/json-document-object-document"; +import { Command, Field, Popover, Toggle, ToolbarGroup } from "@interactive-os/json-document-ui-primitives-react"; + +const colors = [ + ["검정", "#253044"], ["흰색", "#ffffff"], ["파랑", "#3b82f6"], ["빨강", "#ef4444"], + ["주황", "#f59e0b"], ["초록", "#22c55e"], ["보라", "#a855f7"], ["투명", "transparent"], +] as const; +const alignments: ReadonlyArray<{ readonly value: ObjectStyle["textAlign"]; readonly label: string; readonly icon: LucideIcon }> = [ + { value: "left", label: "왼쪽 정렬", icon: AlignLeft }, { value: "center", label: "가운데 정렬", icon: AlignCenter }, { value: "right", label: "오른쪽 정렬", icon: AlignRight }, +]; + +/** Selection-only UI; the Document Type owns supported properties and mixed values. */ +export function CanvasStyleControls(props: { + readonly value: ObjectStyleSelection; + readonly onStyle: (style: Partial) => { readonly ok: boolean }; + readonly onOpen: () => void; +}) { + const [open, setOpen] = useState(false); + const value = props.value; + if (Object.keys(value).length === 0) return null; + const apply = (style: Partial) => props.onStyle(style).ok; + return ; +} + +function StyleValue(props: { + readonly label: string; + readonly value: string | number | null; + readonly color?: boolean; + readonly onApply: (value: string) => boolean; +}) { + const [draft, setDraft] = useState(props.value === null ? "" : String(props.value)); + const [error, setError] = useState(null); + function apply() { + const value = draft.trim(); + if (!value || (props.color && !CSS.supports("color", value))) { setError(props.color ? "유효한 색상을 입력하세요." : "값을 입력하세요."); return; } + if (props.onApply(value)) setError(null); + } + return
+ {props.label} + {props.color && + {colors.map(([label, color]) => { props.onApply(color); setDraft(color); setError(null); }}> + )} + } +
{ event.preventDefault(); apply(); }}> + { if (event.nativeEvent.isComposing) { if (event.key === "Enter") event.preventDefault(); event.stopPropagation(); } }} + aria-invalid={error !== null} onValueChange={(value) => { setDraft(value); setError(null); }} style={{ width: "100%", minWidth: 0 }} /> + + + {error && {error}} +
; +} diff --git a/packages/json-document-canvas/src/use-canvas-hand.ts b/packages/json-document-canvas/src/use-canvas-hand.ts index 4ab17ce9..07888876 100644 --- a/packages/json-document-canvas/src/use-canvas-hand.ts +++ b/packages/json-document-canvas/src/use-canvas-hand.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo, useReducer, useRef, useState, type ClipboardEvent, type KeyboardEvent, type PointerEvent } from "react"; import { commitAffordance, createGestureSession, createPlaneSelectProfile, resizeAffordance, type InteractionHandleEvent, type PlaneSelectProfile, type PlaneSelectSelection, type ResizeEdge } from "@interactive-os/json-document-affordance"; -import { assertCanvasDocument, createCanvasObject, createCanvasPath, parseCanvasDocument, transformObject, type CanvasDocument, type CanvasObject, type CanvasObjectKind, type ObjectPoint } from "@interactive-os/json-document-object-document"; +import { assertCanvasDocument, createCanvasObject, createCanvasPath, parseCanvasDocument, readObjectStyle, transformObject, type CanvasDocument, type CanvasObject, type CanvasObjectKind, type ObjectPoint, type ObjectStyle } from "@interactive-os/json-document-object-document"; import type { EditingResult, ObjectEditor, ObjectIntent, ObjectSelection } from "@interactive-os/json-document-editing"; import { useEditingSnapshot } from "@interactive-os/json-document-react"; import { createWebKeyboardAdapter, createWebPointerSession, isWebEditableTarget, projectWebClientPointToSVG, webSVGViewportFromElement } from "@interactive-os/json-document-web"; @@ -237,6 +237,7 @@ export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, function remove() { commitText(); cancel(); dispatch({ type: "selection.remove" }); surface.current?.focus(); } function duplicate(keys = editor.snapshot.selection.keys) { commitText(); cancel(); dispatch({ type: "object.duplicate", objectIds: keys }); surface.current?.focus(); } function history(direction: "undo" | "redo") { commitText(); cancel(); report(editor[direction]()); surface.current?.focus(); } + function setStyle(style: Partial) { commitText(); cancel(); return dispatch({ type: "selection.style", style }); } function handleClipboard(operation: "copy" | "cut" | "paste", event: ClipboardEvent) { if (isWebEditableTarget(event.target)) return; @@ -287,7 +288,8 @@ export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, return { document, snapshot, selection, marquee: selectionPreview?.marquee ?? null, objects, copyOriginals, preview, surface, tool, error, pastePending: clipboard.pending, draft: draft.current, - choose, select, interaction, editText, commitText, cancel, remove, duplicate, history, + choose, select, interaction, editText, commitText, cancel, remove, duplicate, history, setStyle, + selectedStyle: readObjectStyle(editor.selectedObjects), changeText(text: string) { if (draft.current) { draft.current = { ...draft.current, text }; redraw(); } }, openJSON(json: string) { try { diff --git a/packages/json-document-canvas/tests/canvas-hand.test.tsx b/packages/json-document-canvas/tests/canvas-hand.test.tsx index 3942b152..8ebaf208 100644 --- a/packages/json-document-canvas/tests/canvas-hand.test.tsx +++ b/packages/json-document-canvas/tests/canvas-hand.test.tsx @@ -609,3 +609,96 @@ test("external replacement cancels an in-flight transform and unmount releases p window.dispatchEvent(end); window.removeEventListener("pointerup", observer); expect(observer).toHaveBeenCalled(); expect(commits).not.toHaveBeenCalled(); }); + +test("selection style uses an icon tooltip and only exposes supported properties", () => { + const { editor } = setup(populated); + const trigger = screen.getByRole("button", { name: "스타일" }); + expect(document.getElementById(trigger.getAttribute("aria-describedby")!)?.textContent).toBe("스타일"); + fireEvent.click(trigger); + expect(screen.getByRole("textbox", { name: "글자 크기" })).toBeTruthy(); + expect(screen.queryByRole("textbox", { name: "테두리 색" })).toBeNull(); + act(() => { editor.dispatch({ type: "selection.set", objectIds: [] }); }); + expect(screen.queryByRole("button", { name: "스타일" })).toBeNull(); + expect(screen.queryByRole("dialog", { name: "스타일" })).toBeNull(); +}); + +test("mixed color applies to the whole set once, preserves primary and leaves image data unchanged", () => { + const initial: CanvasDocument = { ...populated, objects: [...populated.objects, { id: "image", kind: "image", source: "data:image/png;base64,AQID", label: "Image", color: "transparent", x: 800, y: 100, width: 100, height: 100 }] }; + const { editor, value, commits } = setup(initial); + act(() => { editor.dispatch({ type: "selection.set", objectIds: ["a", "b", "image"], primaryKey: "image" }); }); + const selection = editor.snapshot.selection; + fireEvent.click(screen.getByRole("button", { name: "스타일" })); + expect((screen.getByRole("textbox", { name: "색상" }) as HTMLInputElement).placeholder).toBe("혼합"); + fireEvent.click(screen.getByRole("button", { name: "색상: 빨강" })); + expect(value().objects.slice(0, 2).map((object) => object.color)).toEqual(["#ef4444", "#ef4444"]); + expect(value().objects[3]).toEqual(initial.objects[3]); expect(editor.snapshot.selection).toEqual(selection); + expect(commits).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole("button", { name: "색상: 빨강" })); expect(commits).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole("button", { name: "실행 취소" })); expect(value()).toEqual(initial); + expect(editor.snapshot.selection).toEqual(selection); + act(() => { editor.dispatch({ type: "selection.set", objectIds: ["image"] }); }); + expect(screen.queryByRole("button", { name: "스타일" })).toBeNull(); +}); + +test("text font size, bold and alignment render identically in display and native editing", () => { + const { container, value, commits } = setup(populated); + fireEvent.click(screen.getByRole("button", { name: "스타일" })); + const font = screen.getByRole("textbox", { name: "글자 크기" }); + fireEvent.change(font, { target: { value: "48" } }); + expect(value().objects[0]!.fontSize).toBe(24); expect(commits).not.toHaveBeenCalled(); + fireEvent.submit(font.closest("form")!); + fireEvent.click(screen.getByRole("button", { name: "굵게" })); + fireEvent.click(screen.getByRole("button", { name: "가운데 정렬" })); + expect(value().objects[0]).toMatchObject({ fontSize: 48, fontWeight: 700, textAlign: "center" }); + expect(commits).toHaveBeenCalledTimes(3); + const display = container.querySelector("foreignObject > div") as HTMLElement; + expect(display.style.fontSize).toBe("48px"); expect(display.style.fontWeight).toBe("700"); expect(display.style.textAlign).toBe("center"); + fireEvent.keyDown(screen.getByRole("dialog", { name: "스타일" }), { key: "Escape" }); + fireEvent.doubleClick(container.querySelector('[data-canvas-object="a"]')!); + const input = screen.getByRole("textbox", { name: "Canvas text" }) as HTMLTextAreaElement; + expect(input.style.fontSize).toBe(display.style.fontSize); expect(input.style.fontWeight).toBe("700"); expect(input.style.textAlign).toBe("center"); + fireEvent.change(input, { target: { value: "Updated" } }); + fireEvent.click(screen.getByRole("button", { name: "스타일" })); + expect(value().objects[0]!.label).toBe("Updated"); expect(commits).toHaveBeenCalledTimes(4); + fireEvent.click(screen.getByRole("button", { name: "굵게" })); + expect(value().objects[0]).toMatchObject({ label: "Updated", fontWeight: 400 }); +}); + +test("shape outlines and path widths render without replacing geometry or primary selection", () => { + const path = { id: "p", kind: "path", label: "Line", color: "black", strokeWidth: 3, points: [{ x: 0, y: 0 }, { x: 1, y: 1 }], x: 100, y: 400, width: 100, height: 80 } as const; + const { container, editor, value } = setup({ ...populated, objects: [...populated.objects, path] }); + pick(container, "b"); + fireEvent.click(screen.getByRole("button", { name: "스타일" })); + fireEvent.click(screen.getByRole("button", { name: "테두리 색: 빨강" })); + expect(value().objects[1]).toMatchObject({ strokeColor: "#ef4444", strokeWidth: 2 }); + expect(container.querySelector('rect[stroke="#ef4444"]')?.getAttribute("stroke-width")).toBe("2"); + act(() => { editor.dispatch({ type: "selection.set", objectIds: ["b", "p"], primaryKey: "b" }); }); + fireEvent.click(screen.getByRole("button", { name: "스타일" })); + const width = screen.getByRole("textbox", { name: "선 굵기" }); + expect((width as HTMLInputElement).placeholder).toBe("혼합"); + fireEvent.change(width, { target: { value: "8" } }); fireEvent.submit(width.closest("form")!); + expect(value().objects[3]).toEqual({ ...path, strokeWidth: 8 }); + expect(container.querySelector("polyline")?.getAttribute("stroke-width")).toBe("8"); + expect(editor.snapshot.selection).toMatchObject({ keys: ["b", "p"], primaryKey: "b" }); +}); + +test("invalid style and cancelled field drafts do not create partial state or history", () => { + const { value, editor, commits } = setup(populated); + fireEvent.click(screen.getByRole("button", { name: "스타일" })); + const field = screen.getByRole("textbox", { name: "글자 크기" }); + fireEvent.change(field, { target: { value: "-1" } }); fireEvent.submit(field.closest("form")!); + expect(screen.getByRole("alert")).toBeTruthy(); expect(value()).toEqual(populated); expect(commits).not.toHaveBeenCalled(); + fireEvent.change(field, { target: { value: "99" } }); fireEvent.keyDown(field, { key: "Escape" }); + expect(screen.queryByRole("dialog", { name: "스타일" })).toBeNull(); + expect(editor.snapshot.canUndo).toBe(false); expect(value()).toEqual(populated); +}); + +test("opening style cancels a resize preview and stale pointer release cannot overwrite a style", () => { + const { container, value, commits } = setup(populated); + const handle = container.querySelector('[data-resize-edge="se"]')!; + fireEvent.pointerDown(handle, event(100, 100)); fireEvent.pointerMove(window, event(140, 140)); + fireEvent.click(screen.getByRole("button", { name: "스타일" })); + fireEvent.click(screen.getByRole("button", { name: "색상: 빨강" })); + fireEvent.pointerUp(window, event(150, 150)); + expect(value().objects[0]).toEqual({ ...populated.objects[0], color: "#ef4444" }); expect(commits).toHaveBeenCalledOnce(); +}); diff --git a/packages/json-document-editing/docs/object-selection.md b/packages/json-document-editing/docs/object-selection.md index 5c4adc42..0536c64b 100644 --- a/packages/json-document-editing/docs/object-selection.md +++ b/packages/json-document-editing/docs/object-selection.md @@ -22,6 +22,24 @@ Intent를 사용합니다. Undo/Redo는 해당 문서 변경과 함께 원인 실제 public API 소비: [Canvas Usage와 Source](/demo/canvas). +### 선택 스타일 + +`selection.style`은 `{ style: Partial }`을 받아 선택한 객체에 한 번 적용합니다. +스타일의 값·기본값·종류별 지원 여부·검증은 [Object Document Type](/docs/api/object-document)의 +`style` 연산에 위임합니다. 예를 들어 글자·도형·이미지를 함께 선택한 뒤 fontSize를 바꾸면 +글자만 바뀌며 이미지와 선택 집합·primary는 유지됩니다. + +```ts +editor.dispatch({ type: "selection.style", style: { color: "#3b82f6", fontSize: 48, fontWeight: 700 } }); +``` + +한 번의 변경은 한 번의 commit/Undo입니다. Undo/Redo는 그때의 선택도 복원합니다. +같은 유효값과 지원 대상이 없는 속성은 문서·History·redo branch를 바꾸지 않습니다. +일반 Editing session의 관찰 revision 의미는 그대로이며 no-op publication을 금지하는 +계약은 아닙니다. 빈 선택은 `selection.empty`, 잘못된 스타일은 실패를 반환합니다. +복제·Clipboard·JSON은 저장된 스타일 필드를 그대로 보존합니다. 기존 `selection.fill`은 +종류에 관계없이 color를 쓰는 호환 동작을 유지합니다. + ### 복제와 Clipboard `object.duplicate`는 `objectIds` 집합을 문서 순서로 복제합니다. 생략한 `placement`는 diff --git a/packages/json-document-editing/src/object.ts b/packages/json-document-editing/src/object.ts index e04654b2..21086846 100644 --- a/packages/json-document-editing/src/object.ts +++ b/packages/json-document-editing/src/object.ts @@ -17,7 +17,7 @@ import type { EditingHistoryOptions } from "./history.js"; import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; import { assertObjectDocument, planObjectOperation, transformObject, - type DocumentObject, type ObjectDocument, type ObjectDraft, type ObjectOperation, + type DocumentObject, type ObjectDocument, type ObjectDraft, type ObjectOperation, type ObjectStyle, } from "@interactive-os/json-document-object-document"; export type { DocumentObject, ObjectDocument } from "@interactive-os/json-document-object-document"; @@ -70,6 +70,7 @@ export type ObjectIntent = } | { readonly type: "selection.remove" } | { readonly type: "selection.fill"; readonly color: string } + | { readonly type: "selection.style"; readonly style: Partial } | { readonly type: "object.translate"; readonly objectIds: ReadonlyArray; @@ -209,6 +210,9 @@ export function createObjectEditor( if (intent.type === "selection.fill") { return apply({ type: "fill", objectIds: selected.map((object) => object.id), color: intent.color }, session.snapshot.selection, intent.type); } + if (intent.type === "selection.style") { + return apply({ type: "style", objectIds: selected.map((object) => object.id), style: intent.style }, session.snapshot.selection, intent.type); + } return intent.type === "selection.remove" ? removeSelected(selected.map((object) => object.id)) : failure("object.unsupported-intent"); } diff --git a/packages/json-document-editing/tests/object-style.test.ts b/packages/json-document-editing/tests/object-style.test.ts new file mode 100644 index 00000000..7e1adaf2 --- /dev/null +++ b/packages/json-document-editing/tests/object-style.test.ts @@ -0,0 +1,66 @@ +import { expect, test, vi } from "vitest"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { parseCanvasDocument, serializeCanvasDocument, type CanvasDocument } from "@interactive-os/json-document-object-document"; +import { createObjectEditor, objectClipboardFormat } from "../src/index.js"; + +const bounds = { x: 10, y: 20, width: 100, height: 80, label: "Text", color: "blue" }; +const initial: CanvasDocument = { profile: "canvas/1", width: 1280, height: 720, objects: [ + { ...bounds, id: "text", kind: "text", fontSize: 32 }, + { ...bounds, id: "rect", kind: "rectangle" }, + { ...bounds, id: "image", kind: "image", source: "data:image/png;base64,AQID", color: "transparent" }, +] }; + +test("mixed selection styling is one commit and Undo restores the exact document and causal selection", () => { + const document = createJSONDocument(initial), commits = vi.fn(); document.subscribe(commits); + const editor = createObjectEditor(document); + editor.dispatch({ type: "selection.set", objectIds: ["text", "rect", "image"], primaryKey: "image" }); + const selection = editor.snapshot.selection; + expect(editor.dispatch({ type: "selection.style", style: { color: "red", fontSize: 48, fontWeight: 700, textAlign: "center", strokeColor: "black", strokeWidth: 4 } }).ok).toBe(true); + expect(commits).toHaveBeenCalledOnce(); expect(editor.snapshot.selection).toEqual(selection); + const styled = editor.snapshot.value; + expect((styled as CanvasDocument).objects[2]).toEqual(initial.objects[2]); + editor.dispatch({ type: "selection.set", objectIds: ["rect"] }); + editor.undo(); expect(editor.snapshot.value).toEqual(initial); expect(editor.snapshot.selection).toEqual(selection); + editor.redo(); expect(editor.snapshot.value).toEqual(styled); expect(editor.snapshot.selection).toEqual(selection); +}); + +test("style no-ops and rejected requests preserve history including a redo branch", () => { + const editor = createObjectEditor(initial); + editor.dispatch({ type: "selection.style", style: { color: "red" } }); editor.undo(); + const before = editor.snapshot; + for (const style of [{ fontWeight: 400, textAlign: "left" }, { strokeWidth: 10 }, {}] as const) { + expect(editor.dispatch({ type: "selection.style", style }).ok).toBe(true); + expect(editor.snapshot).toMatchObject({ value: before.value, selection: before.selection, canUndo: false, canRedo: true }); + } + const beforeReject = editor.snapshot; + expect(editor.dispatch({ type: "selection.style", style: { color: "red", fontSize: -1 } }).ok).toBe(false); + expect(editor.snapshot).toEqual(beforeReject); expect(editor.snapshot.canRedo).toBe(true); + editor.dispatch({ type: "selection.set", objectIds: [] }); + expect(editor.dispatch({ type: "selection.style", style: { color: "red" } })).toMatchObject({ ok: false, code: "selection.empty" }); +}); + +test("all style fields survive duplicate, native payload serialization, fresh-ID paste and JSON reopen", () => { + let id = 0; + const editor = createObjectEditor(initial, { createId: () => `new-${++id}` }); + editor.dispatch({ type: "selection.set", objectIds: ["text", "rect"], primaryKey: "text" }); + editor.dispatch({ type: "selection.style", style: { color: "red", fontWeight: 700, textAlign: "right", strokeColor: "black", strokeWidth: 2 } }); + const originals = editor.selectedObjects; + editor.dispatch({ type: "object.duplicate", objectIds: ["text", "rect"] }); + const clipboard = objectClipboardFormat.parse(JSON.parse(JSON.stringify(editor.copy())))!; + expect(clipboard).not.toBeNull(); + editor.dispatch({ type: "clipboard.paste", clipboard }); + editor.selectedObjects.forEach((object, index) => expect(object).toEqual({ ...originals[index], id: object.id, x: originals[index]!.x + 24, y: originals[index]!.y + 24 })); + const saved = serializeCanvasDocument(editor.snapshot.value as CanvasDocument); + expect(createObjectEditor(parseCanvasDocument(saved)).snapshot.value).toEqual(editor.snapshot.value); + editor.undo(); expect((editor.snapshot.value as CanvasDocument).objects).toHaveLength(5); +}); + +test("legacy fill keeps its contract and general style does not paint image metadata", () => { + const editor = createObjectEditor(initial); + editor.dispatch({ type: "selection.set", objectIds: ["image"] }); + expect(editor.dispatch({ type: "selection.fill", color: "purple" }).ok).toBe(true); + expect(editor.selectedObjects[0]!.color).toBe("purple"); + const before = editor.snapshot; + expect(editor.dispatch({ type: "selection.style", style: { color: "green" } }).ok).toBe(true); + expect(editor.snapshot).toMatchObject({ value: before.value, selection: before.selection, canUndo: before.canUndo, canRedo: before.canRedo }); +}); diff --git a/packages/json-document-object-document/docs/api.md b/packages/json-document-object-document/docs/api.md index 6828fd75..4d66e2ca 100644 --- a/packages/json-document-object-document/docs/api.md +++ b/packages/json-document-object-document/docs/api.md @@ -20,9 +20,9 @@ Object도 계속 유효합니다. legacy Object의 생략된 color는 계속 수 | kind | 추가 문서 값 | 표현 | | --- | --- | --- | -| text | 양의 유한수 fontSize | label이 실제 내용인 plain text. 별도의 text 복사본 없음 | -| rectangle | 없음 | color로 채운 사각형 | -| ellipse | 없음 | 경계 상자에 내접하는 타원 | +| text | 양의 유한수 fontSize, 선택적 fontWeight·textAlign | label이 실제 내용인 plain text. 별도의 text 복사본 없음 | +| rectangle | 선택적 strokeColor·strokeWidth | color로 채운 사각형 | +| ellipse | 선택적 strokeColor·strokeWidth | 경계 상자에 내접하는 타원 | | path | points, 양의 유한수 strokeWidth | color로 그린 열린 선 | | image | source | 문서에 포함한 PNG/JPEG/WebP의 base64 data URL | @@ -53,7 +53,7 @@ path points는 최소 두 개의 `{ x, y }`이며 각 좌표는 `[0, 1]`입니 ### 의미 연산과 projection -`planObjectOperation(document, operation)`은 insert, transform, fill, remove, +`planObjectOperation(document, operation)`은 insert, transform, fill, style, remove, text, replace를 검증된 JSON Patch로 계획합니다. 성공은 `{ ok: true, operations }`, 실패는 `{ ok: false, code, reason? }`입니다. 계획은 입력을 변경하거나 commit하지 않으며 선택과 History를 알지 못합니다. 없는 대상·중복 ID·유효하지 않은 결과는 @@ -64,6 +64,41 @@ text, replace를 검증된 JSON Patch로 계획합니다. 성공은 `{ ok: true, 반대쪽을 통과해도 뒤집지 않습니다. 화면 바깥 좌표는 허용하며 Hand가 슬라이드 밖을 clip합니다. 위치를 자동 보정하거나 snap하지 않습니다. +### 객체 스타일 + +`getObjectStyle(object)`는 적용 가능한 속성의 유효값을 반환합니다. 글자의 생략된 +`fontWeight`는 400, `textAlign`은 `left`입니다. 굵기는 400/700, 정렬은 +`left`/`center`/`right`를 지원합니다. 도형의 생략된 `strokeColor`는 `#000000`, +`strokeWidth`는 0이므로 기존 문서는 테두리 없이 그대로 열립니다. 기본값을 읽는 것만으로 +문서를 바꾸지 않으며, 기존 kind 없는 Object는 color만 지원합니다. + +`ObjectStyle`의 color는 도형의 채우기·글자색·path의 선 색입니다. strokeColor는 +사각형·타원의 테두리색이며 strokeWidth는 도형과 path의 선 굵기입니다. 이미지에는 +스타일 속성이 없습니다. 새 스타일의 색 값은 비어 있지 않은 문자열이고, 도형의 굵기는 +0 이상, path의 굵기와 글자 크기는 양의 유한수여야 합니다. 색 문자열의 CSS 해석은 +렌더링 플랫폼의 책임입니다. 부분 문자열 서식이나 Rich Text 모델은 아닙니다. + +`readObjectStyle(objects)`는 속성을 지원하는 객체끼리만 비교합니다. 공통 값이면 그 값, +서로 다르면 `null`, 지원하는 객체가 없으면 속성을 생략합니다. `null`은 저장값이 아니라 +혼합 선택의 읽기 결과입니다. + +```ts +import { readObjectStyle, planObjectOperation } from "@interactive-os/json-document-object-document"; + +const style = readObjectStyle(document.objects); +const plan = planObjectOperation(document, { + type: "style", objectIds: ["title", "rectangle"], + style: { color: "#3b82f6", fontWeight: 700, strokeWidth: 2 }, +}); +``` + +`style` 연산은 `Partial`을 받아 지원하는 대상 필드에만 적용합니다. +`assertObjectStyle`로 전체 요청을 먼저 검증하므로 지원하지 않는 속성도 잘못된 값이면 +전체 거절합니다. 없는 ID와 잘못된 결과도 전체 거절합니다. 특히 path가 섞인 집합에 +strokeWidth 0을 적용하면 도형만 바꾸지 않고 전체를 거절합니다. 같은 유효값, 빈 요청, +지원 대상이 없는 요청은 빈 patch이며 생략된 기본값을 저장하지 않습니다. +기존 `fill`은 이미지의 color 메타데이터를 포함한 기존 동작을 유지하는 호환 명령입니다. + ### Usage와 남은 범위 [Canvas Hand의 실제 Usage/Source](/docs/api/canvas)와 [Object Editing](/docs/object)가 diff --git a/packages/json-document-object-document/src/index.ts b/packages/json-document-object-document/src/index.ts index 4e295ccb..6a13cbbe 100644 --- a/packages/json-document-object-document/src/index.ts +++ b/packages/json-document-object-document/src/index.ts @@ -2,3 +2,4 @@ export type { ObjectBounds, ObjectPoint, ObjectDraft, DocumentObject, ObjectDocu export { assertObjectDocument, assertCanvasDocument, assertCanvasImageSource, parseCanvasDocument, serializeCanvasDocument } from "./object-validation.js"; export { createCanvasObject, createCanvasPath, createCanvasImage, projectObject, transformObject, type ObjectTransform } from "./object-projection.js"; export { planObjectOperation, type ObjectOperation, type ObjectOperationPlan } from "./object-operation.js"; +export { getObjectStyle, readObjectStyle, assertObjectStyle, type ObjectStyle, type ObjectStyleSelection } from "./object-style.js"; diff --git a/packages/json-document-object-document/src/object-model.ts b/packages/json-document-object-document/src/object-model.ts index 25becd97..6bbae248 100644 --- a/packages/json-document-object-document/src/object-model.ts +++ b/packages/json-document-object-document/src/object-model.ts @@ -29,8 +29,8 @@ export interface ObjectDocument extends Record { export type CanvasObjectKind = "text" | "rectangle" | "ellipse" | "path" | "image"; export type CanvasObjectDraft = ObjectDraft & ( - | { readonly kind: "text"; readonly fontSize: number } - | { readonly kind: "rectangle" | "ellipse" } + | { readonly kind: "text"; readonly fontSize: number; readonly fontWeight?: 400 | 700; readonly textAlign?: "left" | "center" | "right" } + | { readonly kind: "rectangle" | "ellipse"; readonly strokeColor?: string; readonly strokeWidth?: number } | { readonly kind: "path"; readonly points: ReadonlyArray; readonly strokeWidth: number } | { readonly kind: "image"; readonly source: string } ); diff --git a/packages/json-document-object-document/src/object-operation.ts b/packages/json-document-object-document/src/object-operation.ts index e0aa6c9e..a95012e4 100644 --- a/packages/json-document-object-document/src/object-operation.ts +++ b/packages/json-document-object-document/src/object-operation.ts @@ -2,11 +2,13 @@ import { applyPatch, buildPointer, jsonEqual, type JSONPatchOperation } from "@i import type { DocumentObject, ObjectDocument } from "./object-model.js"; import { transformObject, type ObjectTransform } from "./object-projection.js"; import { assertObjectDocument } from "./object-validation.js"; +import { assertObjectStyle, getObjectStyle, type ObjectStyle } from "./object-style.js"; export type ObjectOperation = | { readonly type: "insert"; readonly objects: ReadonlyArray } | { readonly type: "transform"; readonly objectIds: ReadonlyArray; readonly transform: ObjectTransform } | { readonly type: "fill"; readonly objectIds: ReadonlyArray; readonly color: string } + | { readonly type: "style"; readonly objectIds: ReadonlyArray; readonly style: Partial } | { readonly type: "remove"; readonly objectIds: ReadonlyArray } | { readonly type: "text"; readonly objectId: string; readonly text: string } | { readonly type: "replace"; readonly document: ObjectDocument }; @@ -19,6 +21,7 @@ export type ObjectOperationPlan = export function planObjectOperation(document: ObjectDocument, operation: ObjectOperation): ObjectOperationPlan { try { assertObjectDocument(document); + if (operation.type === "style") assertObjectStyle(operation.style); const objects = document.objects; const operations: JSONPatchOperation[] = []; if (operation.type === "replace") { @@ -42,6 +45,12 @@ export function planObjectOperation(document: ObjectDocument, operation: ObjectO } } else if (operation.type === "fill") { if (operation.color !== object.color) operations.push({ op: "replace", path: buildPointer(["objects", index, "color"]), value: operation.color }); + } else if (operation.type === "style") { + const current = getObjectStyle(object); + for (const [key, value] of Object.entries(operation.style)) { + const effective = current[key as keyof ObjectStyle]; + if (effective !== undefined && effective !== value) operations.push({ op: "add", path: buildPointer(["objects", index, key]), value }); + } } else if (operation.type === "text") { if (object.kind !== "text") return { ok: false, code: "object.not-text" }; if (operation.text !== object.label) operations.push({ op: "replace", path: buildPointer(["objects", index, "label"]), value: operation.text }); diff --git a/packages/json-document-object-document/src/object-style.ts b/packages/json-document-object-document/src/object-style.ts new file mode 100644 index 00000000..d85beffe --- /dev/null +++ b/packages/json-document-object-document/src/object-style.ts @@ -0,0 +1,50 @@ +import type { DocumentObject } from "./object-model.js"; + +/** Object-level styles, not character-range formatting. Color is the kind's primary paint. */ +export interface ObjectStyle { + readonly color: string; + readonly fontSize: number; + readonly fontWeight: 400 | 700; + readonly textAlign: "left" | "center" | "right"; + readonly strokeColor: string; + readonly strokeWidth: number; +} + +/** Missing means unsupported by every target; null means mixed among supporting targets. */ +export type ObjectStyleSelection = { readonly [Key in keyof ObjectStyle]?: ObjectStyle[Key] | null }; + +/** Effective values and capabilities have one owner, including legacy defaults. */ +export function getObjectStyle(object: DocumentObject): Partial { + const color = object.color ?? "transparent"; + switch (object.kind) { + case "image": return {}; + case "text": return { color, fontSize: object.fontSize as number, fontWeight: (object.fontWeight ?? 400) as 400 | 700, textAlign: (object.textAlign ?? "left") as ObjectStyle["textAlign"] }; + case "rectangle": case "ellipse": return { color, strokeColor: (object.strokeColor ?? "#000000") as string, strokeWidth: (object.strokeWidth ?? 0) as number }; + case "path": return { color, strokeWidth: object.strokeWidth as number }; + default: return { color }; + } +} + +export function readObjectStyle(objects: ReadonlyArray): ObjectStyleSelection { + const selection: Record = {}; + for (const object of objects) { + for (const [key, value] of Object.entries(getObjectStyle(object))) { + selection[key] = selection[key] === undefined ? value : selection[key] === value ? value : null; + } + } + return selection; +} + +/** Validate the complete request before planning any target, even unsupported properties. */ +export function assertObjectStyle(value: unknown): asserts value is Partial { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError("Object style must be a record."); + for (const [key, field] of Object.entries(value)) { + const valid = key === "color" || key === "strokeColor" ? typeof field === "string" && field.length > 0 + : key === "fontSize" ? typeof field === "number" && Number.isFinite(field) && field > 0 + : key === "strokeWidth" ? typeof field === "number" && Number.isFinite(field) && field >= 0 + : key === "fontWeight" ? field === 400 || field === 700 + : key === "textAlign" ? field === "left" || field === "center" || field === "right" + : false; + if (!valid) throw new TypeError(`Invalid Object style: ${key}.`); + } +} diff --git a/packages/json-document-object-document/src/object-validation.ts b/packages/json-document-object-document/src/object-validation.ts index d2eedce8..b8f3b566 100644 --- a/packages/json-document-object-document/src/object-validation.ts +++ b/packages/json-document-object-document/src/object-validation.ts @@ -1,6 +1,7 @@ import { createJSONDocument, type JSONValue } from "@interactive-os/json-document"; import { assertRasterImageSource as assertCanvasImageSource } from "@interactive-os/json-document-file-intake"; import type { CanvasDocument, ObjectDocument } from "./object-model.js"; +import { assertObjectStyle } from "./object-style.js"; function record(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); @@ -29,6 +30,8 @@ export function assertObjectDocument(value: unknown): void { if (!["text", "rectangle", "ellipse", "path", "image"].includes(object.kind as string)) throw new TypeError("Unknown Object kind."); if (!positive(object.width) || !positive(object.height)) throw new TypeError("Canvas object dimensions must be positive."); if (object.kind === "text" && !positive(object.fontSize)) throw new TypeError("Text fontSize must be positive and finite."); + const styleKeys = object.kind === "text" ? ["fontWeight", "textAlign"] : object.kind === "rectangle" || object.kind === "ellipse" ? ["strokeColor", "strokeWidth"] : []; + assertObjectStyle(Object.fromEntries(styleKeys.filter((key) => Object.hasOwn(object, key)).map((key) => [key, object[key]]))); if (object.kind === "image") assertCanvasImageSource(object.source); if (object.kind === "path") { if (!positive(object.strokeWidth) || !Array.isArray(object.points) || object.points.length < 2) throw new TypeError("Path requires a positive strokeWidth and at least two points."); diff --git a/packages/json-document-object-document/tests/object-style.test.ts b/packages/json-document-object-document/tests/object-style.test.ts new file mode 100644 index 00000000..59efd0b8 --- /dev/null +++ b/packages/json-document-object-document/tests/object-style.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "vitest"; +import { applyPatch } from "@interactive-os/json-document"; +import { assertCanvasDocument, assertObjectStyle, getObjectStyle, parseCanvasDocument, planObjectOperation, readObjectStyle, serializeCanvasDocument, type CanvasDocument, type ObjectStyle } from "../src/index.js"; + +const bounds = { x: 10, y: 20, width: 100, height: 80, label: "", color: "blue" }; +const document: CanvasDocument = { profile: "canvas/1", width: 1280, height: 720, objects: [ + { ...bounds, id: "text", kind: "text", fontSize: 32 }, + { ...bounds, id: "rect", kind: "rectangle" }, + { ...bounds, id: "ellipse", kind: "ellipse", color: "red", strokeColor: "green", strokeWidth: 4 }, + { ...bounds, id: "path", kind: "path", strokeWidth: 4, points: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }, + { ...bounds, id: "image", kind: "image", source: "data:image/png;base64,AQID", color: "transparent" }, +] }; + +test("effective styles preserve old documents and expose kind-specific capabilities", () => { + expect(getObjectStyle(document.objects[0]!)).toEqual({ color: "blue", fontSize: 32, fontWeight: 400, textAlign: "left" }); + expect(getObjectStyle(document.objects[1]!)).toEqual({ color: "blue", strokeColor: "#000000", strokeWidth: 0 }); + expect(getObjectStyle(document.objects[3]!)).toEqual({ color: "blue", strokeWidth: 4 }); + expect(getObjectStyle(document.objects[4]!)).toEqual({}); + expect(getObjectStyle({ ...bounds, id: "legacy" })).toEqual({ color: "blue" }); + expect(parseCanvasDocument(serializeCanvasDocument(document))).toEqual(document); + expect(document.objects[0]).not.toHaveProperty("fontWeight"); +}); + +test("mixed values only compare supporting targets and retain unsupported as absent", () => { + expect(readObjectStyle([])).toEqual({}); + expect(readObjectStyle(document.objects)).toEqual({ color: null, fontSize: 32, fontWeight: 400, textAlign: "left", strokeColor: null, strokeWidth: null }); + expect(readObjectStyle([document.objects[0]!, document.objects[4]!])).toEqual(getObjectStyle(document.objects[0]!)); + expect(readObjectStyle([document.objects[0]!, { ...document.objects[0]!, id: "bold", fontWeight: 700, textAlign: "right" }])).toMatchObject({ fontWeight: null, textAlign: null }); +}); + +test("one atomic style plan changes only applicable fields without replacing extension data or geometry", () => { + const style = { color: "purple", fontSize: 48, fontWeight: 700, textAlign: "center", strokeColor: "orange", strokeWidth: 6 } as const; + const plan = planObjectOperation(document, { type: "style", objectIds: document.objects.map((object) => object.id), style }); + expect(plan.ok).toBe(true); + if (!plan.ok) return; + const result = applyPatch(document, plan.operations); + expect(result.ok).toBe(true); + if (!result.ok) return; + const next = result.value as CanvasDocument; + expect(next.objects[0]).toEqual({ ...document.objects[0], color: "purple", fontSize: 48, fontWeight: 700, textAlign: "center" }); + expect(next.objects[1]).toEqual({ ...document.objects[1], color: "purple", strokeColor: "orange", strokeWidth: 6 }); + expect(next.objects[3]).toEqual({ ...document.objects[3], color: "purple", strokeWidth: 6 }); + expect(next.objects[4]).toEqual(document.objects[4]); + expect(parseCanvasDocument(serializeCanvasDocument(next))).toEqual(next); +}); + +test("effective default and unsupported properties are no-ops, while missing targets reject the whole plan", () => { + expect(planObjectOperation(document, { type: "style", objectIds: ["text"], style: { fontWeight: 400, textAlign: "left", strokeColor: "red" } })).toEqual({ ok: true, operations: [] }); + expect(planObjectOperation(document, { type: "style", objectIds: ["rect"], style: { strokeWidth: 0, strokeColor: "#000000" } })).toEqual({ ok: true, operations: [] }); + expect(planObjectOperation(document, { type: "style", objectIds: ["image"], style: { color: "red", fontSize: 42 } })).toEqual({ ok: true, operations: [] }); + expect(planObjectOperation(document, { type: "style", objectIds: ["text", "missing"], style: { color: "red" } })).toMatchObject({ ok: false, code: "selection.object-not-found" }); +}); + +test.each([null, [], { color: "" }, { color: 1 }, { strokeColor: "" }, { fontSize: 0 }, { fontSize: Infinity }, { fontSize: "32" }, { fontWeight: 500 }, { textAlign: "justify" }, { strokeWidth: -1 }, { strokeWidth: NaN }, { unknown: 1 }, { color: "red", fontSize: undefined }])("rejects malformed style requests before partial or unsupported updates %#", (style) => { + expect(() => assertObjectStyle(style)).toThrow(); + expect(planObjectOperation(document, { type: "style", objectIds: ["rect", "image"], style: style as Partial })).toMatchObject({ ok: false }); +}); + +test.each([{ fontWeight: 500 }, { textAlign: "justify" }])("validates optional persisted text fields %#", (style) => { + expect(() => assertCanvasDocument({ ...document, objects: [{ ...document.objects[0], ...style }] })).toThrow(); +}); + +test("stroke removal is valid for shapes but a path still requires positive width", () => { + expect(planObjectOperation(document, { type: "style", objectIds: ["ellipse"], style: { strokeWidth: 0 } }).ok).toBe(true); + expect(planObjectOperation(document, { type: "style", objectIds: ["ellipse", "path"], style: { strokeWidth: 0, color: "red" } })).toMatchObject({ ok: false }); + expect(() => assertCanvasDocument({ ...document, objects: [{ ...document.objects[1], strokeWidth: -1 }] })).toThrow(); + expect(() => assertCanvasDocument({ ...document, objects: [{ ...document.objects[1], strokeColor: 0 }] })).toThrow(); +}); diff --git a/packages/json-document-ui-primitives-react/docs/popover.md b/packages/json-document-ui-primitives-react/docs/popover.md new file mode 100644 index 00000000..7ec92023 --- /dev/null +++ b/packages/json-document-ui-primitives-react/docs/popover.md @@ -0,0 +1,22 @@ +## Popover · 아이콘 trigger + +`Popover`는 `label`, `trigger`, `open`, `onOpenChange`와 panel 내용을 받습니다. +`triggerPresentation="icon"`이면 trigger를 공통 `Command`의 아이콘·툴팁으로 표시합니다. +label이 접근성 이름과 툴팁의 정본이며 icon은 `aria-hidden`으로 렌더링합니다. +기본 trigger presentation은 기존 label 표현입니다. + +열 때 panel로 focus를 옮기고, Escape로 닫으면 trigger로 복귀합니다. 바깥 pointerdown과 +panel 밖으로의 focus 이동은 panel만 닫고 사용자의 새 focus를 가로채지 않습니다. +panel 내부의 입력과 선택은 열린 상태를 유지합니다. Dialog와 달리 focus를 가두지 않습니다. +입력 draft를 언제 확정할지는 소비하는 Hand가 정합니다. + +```tsx +import { Popover } from "@interactive-os/json-document-ui-primitives-react"; + + +``` + +[Canvas Usage/Source](/demo/canvas)의 선택 스타일이 이 API를 사용합니다. diff --git a/packages/json-document-ui-primitives-react/package.json b/packages/json-document-ui-primitives-react/package.json index 3009b343..04f58684 100644 --- a/packages/json-document-ui-primitives-react/package.json +++ b/packages/json-document-ui-primitives-react/package.json @@ -13,7 +13,7 @@ "directory": "packages/json-document-ui-primitives-react" }, "publishConfig": { "access": "public", "provenance": true, "tag": "next" }, - "files": ["dist", "!dist/.tsbuildinfo", "README.md", "LICENSE"], + "files": ["dist", "!dist/.tsbuildinfo", "docs", "README.md", "LICENSE"], "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, "./content-interaction.css": "./dist/content-interaction.css" diff --git a/packages/json-document-ui-primitives-react/src/presentations.tsx b/packages/json-document-ui-primitives-react/src/presentations.tsx index 6642971d..4b532365 100644 --- a/packages/json-document-ui-primitives-react/src/presentations.tsx +++ b/packages/json-document-ui-primitives-react/src/presentations.tsx @@ -1,26 +1,39 @@ import { useEffect, useId, useRef, type ReactNode } from "react"; +import { Command } from "./controls.js"; export function Popover(props: { readonly label: string; readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly trigger: ReactNode; + readonly triggerPresentation?: "label" | "icon"; readonly children: ReactNode; readonly className?: string; readonly panelClassName?: string; }): ReactNode { const id = `json-document-popover-${useId().replaceAll(":", "")}`; - const triggerRef = useRef(null); + const rootRef = useRef(null); const panelRef = useRef(null); useEffect(() => { if (props.open) panelRef.current?.focus(); }, [props.open]); + useEffect(() => { + if (!props.open) return; + const dismissOutside = (event: PointerEvent) => { + if (event.target instanceof Node && !rootRef.current?.contains(event.target)) props.onOpenChange(false); + }; + document.addEventListener("pointerdown", dismissOutside, true); + return () => document.removeEventListener("pointerdown", dismissOutside, true); + }, [props.open, props.onOpenChange]); const close = () => { props.onOpenChange(false); - queueMicrotask(() => triggerRef.current?.focus()); + queueMicrotask(() => rootRef.current?.querySelector("button")?.focus()); }; + const triggerProps = { "aria-label": props.label, "aria-haspopup": "dialog", "aria-controls": id, "aria-expanded": props.open, onClick: () => props.onOpenChange(!props.open) } as const; return ( - - - {props.open ? : null} + { + if (props.open && event.relatedTarget instanceof Node && !event.currentTarget.contains(event.relatedTarget)) props.onOpenChange(false); + }}> + {props.triggerPresentation === "icon" ? {props.trigger} : } + {props.open ? : null} ); } diff --git a/packages/json-document-ui-primitives-react/tests/primitives.test.tsx b/packages/json-document-ui-primitives-react/tests/primitives.test.tsx index 53728579..12c341bf 100644 --- a/packages/json-document-ui-primitives-react/tests/primitives.test.tsx +++ b/packages/json-document-ui-primitives-react/tests/primitives.test.tsx @@ -111,6 +111,25 @@ describe("UI Primitives", () => { expect(screen.queryByRole("dialog", { name: "Delete document" })).toBeNull(); }); + test("icon Popover reuses Command tooltips, dismisses outside, and restores focus only on Escape", async () => { + const user = userEvent.setup(); + function Harness() { + const [open, setOpen] = useState(false); + return <>} triggerPresentation="icon" open={open} onOpenChange={setOpen}>; + } + render(); + const trigger = screen.getByRole("button", { name: "Style" }); + expect(trigger.getAttribute("data-ui-control")).toBe("command"); + expect(document.getElementById(trigger.getAttribute("aria-describedby")!)?.textContent).toBe("Style"); + await user.click(trigger); await user.click(screen.getByRole("button", { name: "Inside" })); + expect(screen.getByRole("dialog", { name: "Style" })).toBeTruthy(); + await user.keyboard("{Escape}"); expect(document.activeElement).toBe(trigger); + await user.click(trigger); const outside = screen.getByRole("button", { name: "Outside" }); await user.click(outside); + expect(screen.queryByRole("dialog", { name: "Style" })).toBeNull(); expect(document.activeElement).toBe(outside); + await user.click(trigger); await user.tab(); await user.tab(); + expect(screen.queryByRole("dialog", { name: "Style" })).toBeNull(); expect(document.activeElement).toBe(outside); + }); + test("Dialog moves focus inside, traps Tab, and restores the invoking control", async () => { const user = userEvent.setup(); function Harness() { diff --git a/site/src/app/index.css b/site/src/app/index.css index 847b8ee5..1ceb497f 100644 --- a/site/src/app/index.css +++ b/site/src/app/index.css @@ -295,6 +295,10 @@ @apply absolute left-0 top-full z-20 mt-1 min-w-40; } + .canvas-style-panel { + @apply grid w-64 gap-3 rounded-control border border-line-subtle bg-background-canvas p-3 text-xs text-foreground-default shadow-overlay; + } + [data-ui-presentation="dialog-backdrop"] { @apply fixed inset-0 z-40 grid place-items-center bg-foreground-strong/10; } diff --git a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx index 1e4d3d8c..9e9bc107 100644 --- a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx +++ b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx @@ -11,7 +11,7 @@ export function CanvasDemoRoute() { const [editor] = useState(() => createObjectEditor(emptyCanvasDocument)); const [selectProfile] = useState(() => createPlaneSelectProfile()); return ( - 한 장에 글자·도형·그리기. Shift+클릭으로 다중 선택하고 Alt+드래그로 복제합니다. 복사한 텍스트·이미지도 붙여넣을 수 있으며, 글자는 더블클릭으로 편집합니다.}> + 한 장에 글자·도형·그리기. Shift+클릭으로 다중 선택하고 Alt+드래그로 복제합니다. 선택한 객체의 스타일 아이콘에서 색·글자 서식·테두리를 바꾸고, 복사한 텍스트·이미지를 붙여넣습니다.}> ); diff --git a/site/src/routes/docs/doc-pages.ts b/site/src/routes/docs/doc-pages.ts index 6f62e9af..5b214a03 100644 --- a/site/src/routes/docs/doc-pages.ts +++ b/site/src/routes/docs/doc-pages.ts @@ -15,6 +15,7 @@ import planeSelectContractMarkdown from "../../../../packages/json-document-affo import resizeContractMarkdown from "../../../../packages/json-document-affordance/docs/resize.md?raw"; import objectSelectionContractMarkdown from "../../../../packages/json-document-editing/docs/object-selection.md?raw"; import uiPrimitivesApiMarkdown from "../../../../docs/api-reference/ui-primitives-react.md?raw"; +import uiPopoverContractMarkdown from "../../../../packages/json-document-ui-primitives-react/docs/popover.md?raw"; import animationApiMarkdown from "../../../../docs/api-reference/animation-react.md?raw"; import markdownReactApiMarkdown from "../../../../docs/api-reference/markdown-react.md?raw"; import databaseApiMarkdown from "../../../../docs/api-reference/database.md?raw"; @@ -196,7 +197,7 @@ export const docPages = { zodApi: docPage("/docs/api/zod", zodApiMarkdown), tanStackTableApi: docPage("/docs/api/tanstack-table", tanStackTableApiMarkdown), affordanceApi: docPage("/docs/api/affordance", `${planeSelectContractMarkdown}\n\n${resizeContractMarkdown}\n\n${affordanceApiMarkdown}`), - uiPrimitivesApi: docPage("/docs/api/ui-primitives-react", uiPrimitivesApiMarkdown), + uiPrimitivesApi: docPage("/docs/api/ui-primitives-react", `${uiPopoverContractMarkdown}\n\n${uiPrimitivesApiMarkdown}`), animationApi: docPage("/docs/api/animation-react", animationApiMarkdown), markdownReactApi: docPage("/docs/api/markdown-react", markdownReactApiMarkdown), databaseApi: docPage("/docs/api/database", databaseApiMarkdown), diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index 59004a08..5ec33a07 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -1,11 +1,13 @@ import type { CodeLanguage } from "../ui/code-tokens"; import objectModelSource from "../../../../packages/json-document-object-document/src/object-model.ts?raw"; +import objectStyleSource from "../../../../packages/json-document-object-document/src/object-style.ts?raw"; import objectValidationSource from "../../../../packages/json-document-object-document/src/object-validation.ts?raw"; import objectOperationSource from "../../../../packages/json-document-object-document/src/object-operation.ts?raw"; import objectProjectionSource from "../../../../packages/json-document-object-document/src/object-projection.ts?raw"; import canvasHandSource from "../../../../packages/json-document-canvas/src/canvas-hand.tsx?raw"; import canvasInteractionSource from "../../../../packages/json-document-canvas/src/use-canvas-hand.ts?raw"; import canvasObjectViewSource from "../../../../packages/json-document-canvas/src/canvas-object-view.tsx?raw"; +import canvasStyleControlsSource from "../../../../packages/json-document-canvas/src/canvas-style-controls.tsx?raw"; import canvasClipboardSource from "../../../../packages/json-document-canvas/src/canvas-clipboard.ts?raw"; import canvasClipboardEditingSource from "../../../../packages/json-document-editing/src/canvas-clipboard.ts?raw"; import objectPasteSessionSource from "../../../../packages/json-document-editing/src/object-paste-session.ts?raw"; @@ -218,12 +220,14 @@ const registeredUsageSources = new Map([ ["packages/json-document-file-intake/src/raster-content.ts", rasterContentSource], ["packages/json-document-composer-react/src/attachments.ts", composerAttachmentsSource], ["packages/json-document-object-document/src/object-model.ts", objectModelSource], + ["packages/json-document-object-document/src/object-style.ts", objectStyleSource], ["packages/json-document-object-document/src/object-validation.ts", objectValidationSource], ["packages/json-document-object-document/src/object-operation.ts", objectOperationSource], ["packages/json-document-object-document/src/object-projection.ts", objectProjectionSource], ["packages/json-document-canvas/src/canvas-hand.tsx", canvasHandSource], ["packages/json-document-canvas/src/use-canvas-hand.ts", canvasInteractionSource], ["packages/json-document-canvas/src/canvas-object-view.tsx", canvasObjectViewSource], + ["packages/json-document-canvas/src/canvas-style-controls.tsx", canvasStyleControlsSource], ["packages/json-document-canvas/src/canvas-clipboard.ts", canvasClipboardSource], ["packages/json-document-editing/src/canvas-clipboard.ts", canvasClipboardEditingSource], ["packages/json-document-editing/src/object-paste-session.ts", objectPasteSessionSource], @@ -393,6 +397,11 @@ const registeredPublicUsages = [ symbol: "CanvasHand", sourcePath: "packages/json-document-canvas/src/canvas-object-view.tsx", }, + { + packageName: "@interactive-os/json-document-canvas", + symbol: "CanvasHand", + sourcePath: "packages/json-document-canvas/src/canvas-style-controls.tsx", + }, { packageName: "@interactive-os/json-document-canvas", symbol: "useCanvasHand", @@ -404,6 +413,7 @@ const registeredPublicUsages = [ sourcePath: "packages/json-document-react/src/editing-snapshot.ts", }, ...(["ObjectDocument", "CanvasDocument", "DocumentObject", "CanvasObject"] as const).map((symbol) => ({ packageName: "@interactive-os/json-document-object-document", symbol, sourcePath: "packages/json-document-object-document/src/object-model.ts" })), + ...(["getObjectStyle", "readObjectStyle", "assertObjectStyle", "ObjectStyle", "ObjectStyleSelection"] as const).map((symbol) => ({ packageName: "@interactive-os/json-document-object-document", symbol, sourcePath: "packages/json-document-object-document/src/object-style.ts" })), ...(["assertObjectDocument", "assertCanvasDocument", "assertCanvasImageSource", "parseCanvasDocument", "serializeCanvasDocument"] as const).map((symbol) => ({ packageName: "@interactive-os/json-document-object-document", symbol, sourcePath: "packages/json-document-object-document/src/object-validation.ts" })), ...(["createCanvasObject", "createCanvasPath", "createCanvasImage", "projectObject", "transformObject"] as const).map((symbol) => ({ packageName: "@interactive-os/json-document-object-document", symbol, sourcePath: "packages/json-document-object-document/src/object-projection.ts" })), { diff --git a/site/tailwind.config.cjs b/site/tailwind.config.cjs index 26bae9fb..9fd7578b 100644 --- a/site/tailwind.config.cjs +++ b/site/tailwind.config.cjs @@ -3,6 +3,7 @@ module.exports = { content: [ "./index.html", "./src/**/*.{ts,tsx}", + "../packages/json-document-canvas/src/**/*.{ts,tsx}", ], theme: { extend: { diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index 42427305..cdc9d051 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -241,6 +241,7 @@ describe("Demo definition and source discovery", () => { "packages/json-document-web/src/clipboard.ts", "packages/json-document-editing/src/object.ts", "packages/json-document-object-document/src/object-model.ts", + "packages/json-document-object-document/src/object-style.ts", "packages/json-document-object-document/src/object-validation.ts", "packages/json-document-file-intake/src/raster-content.ts", "packages/json-document/src/application/document/create.ts", @@ -352,9 +353,11 @@ describe("Demo definition and source discovery", () => { const paths = (await discoverDemoSources(entry)).map((file) => file.path); expect(paths).toEqual(expect.arrayContaining([ "packages/json-document-canvas/src/canvas-hand.tsx", + "packages/json-document-canvas/src/canvas-style-controls.tsx", "packages/json-document-canvas/src/use-canvas-hand.ts", "packages/json-document-canvas/src/canvas-object-view.tsx", "packages/json-document-object-document/src/object-model.ts", + "packages/json-document-object-document/src/object-style.ts", "packages/json-document-object-document/src/object-validation.ts", "packages/json-document-object-document/src/object-operation.ts", "packages/json-document-object-document/src/object-projection.ts", @@ -365,6 +368,9 @@ describe("Demo definition and source discovery", () => { ])); const hand = (await discoverDemoSources(entry)).find((file) => file.path === "packages/json-document-canvas/src/use-canvas-hand.ts")!; expect(await hand.load()).toContain("resizeAffordance(gesture.start, gesture.point, gesture.edge, gesture, gesture.object)"); + const style = (await discoverDemoSources(entry)).find((file) => file.path === "packages/json-document-object-document/src/object-style.ts")!; + expect(style.referencePath).toBe("/docs/api/object-document"); + expect(await style.load()).toContain("export function readObjectStyle"); } }); From e8a1e47c56938711de62beed5cb08a33c9bb06b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Thu, 10 Sep 2026 02:07:44 +0900 Subject: [PATCH 23/24] =?UTF-8?q?fix(canvas):=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=ED=8C=94=EB=A0=88=ED=8A=B8=EC=9D=98=20=ED=88=AC?= =?UTF-8?q?=EB=AA=85=EC=83=89=EA=B3=BC=20=EB=B0=B0=EC=97=B4=EC=9D=84=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/json-document-canvas/src/canvas-style-controls.tsx | 6 +++--- site/src/app/index.css | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/json-document-canvas/src/canvas-style-controls.tsx b/packages/json-document-canvas/src/canvas-style-controls.tsx index d747cc23..83a5afc0 100644 --- a/packages/json-document-canvas/src/canvas-style-controls.tsx +++ b/packages/json-document-canvas/src/canvas-style-controls.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { AlignCenter, AlignLeft, AlignRight, Bold, Check, Palette, Square, type LucideIcon } from "lucide-react"; +import { AlignCenter, AlignLeft, AlignRight, Bold, Check, Palette, Square, SquareDashed, type LucideIcon } from "lucide-react"; import type { ObjectStyle, ObjectStyleSelection } from "@interactive-os/json-document-object-document"; import { Command, Field, Popover, Toggle, ToolbarGroup } from "@interactive-os/json-document-ui-primitives-react"; @@ -51,9 +51,9 @@ function StyleValue(props: { } return
{props.label} - {props.color && + {props.color && {colors.map(([label, color]) => { props.onApply(color); setDraft(color); setError(null); }}> - )} }
{ event.preventDefault(); apply(); }}> diff --git a/site/src/app/index.css b/site/src/app/index.css index 1ceb497f..496d63d3 100644 --- a/site/src/app/index.css +++ b/site/src/app/index.css @@ -296,7 +296,7 @@ } .canvas-style-panel { - @apply grid w-64 gap-3 rounded-control border border-line-subtle bg-background-canvas p-3 text-xs text-foreground-default shadow-overlay; + @apply grid w-64 gap-3 rounded-control border border-line-subtle bg-background-canvas p-3 text-xs text-foreground-default shadow-overlay outline-none; } [data-ui-presentation="dialog-backdrop"] { From 684919f0d76ab82dfbe519789e3e3abddc18c6aa Mon Sep 17 00:00:00 2001 From: "teo.v" <66650330+developer-1px@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:50:37 +0900 Subject: [PATCH 24/24] =?UTF-8?q?feat(canvas):=20=EC=8A=A4=ED=8B=B0?= =?UTF-8?q?=EC=BB=A4=20=EB=85=B8=ED=8A=B8=EC=99=80=20=EB=8F=84=ED=98=95=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=EC=9D=84=20=EA=B0=99=EC=9D=80=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EA=B3=84=EC=95=BD=EC=9C=BC=EB=A1=9C=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=ED=95=9C=EB=8B=A4=20(#762)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 유용태 --- audits/document-types.json | 16 ++-- docs/api-reference/canvas.md | 2 + docs/api-reference/object-document.md | 32 +++++++- docs/public/intent.md | 1 + docs/public/object.md | 3 + packages/json-document-canvas/README.md | 5 +- packages/json-document-canvas/docs/api.md | 19 ++++- .../json-document-canvas/src/canvas-hand.tsx | 7 +- .../src/canvas-object-view.tsx | 47 +++++++---- .../src/canvas-style-controls.tsx | 1 + .../src/use-canvas-hand.ts | 15 ++-- .../tests/canvas-hand.test.tsx | 78 ++++++++++++++++++- .../docs/object-selection.md | 8 +- .../tests/object-text.test.ts | 44 +++++++++++ .../json-document-object-document/docs/api.md | 33 ++++++-- .../src/index.ts | 4 +- .../src/object-model.ts | 12 ++- .../src/object-operation.ts | 4 +- .../src/object-projection.ts | 27 ++++++- .../src/object-style.ts | 9 ++- .../src/object-validation.ts | 8 +- .../tests/object-style.test.ts | 6 +- .../tests/object-text.test.ts | 69 ++++++++++++++++ site/src/app/index.css | 2 +- .../routes/canvas-demo/CanvasDemoRoute.tsx | 2 +- .../demo-workbench/canvas-demo-document.ts | 7 +- .../src/shared/demo-workbench/demo-sources.ts | 4 +- 27 files changed, 385 insertions(+), 80 deletions(-) create mode 100644 packages/json-document-editing/tests/object-text.test.ts create mode 100644 packages/json-document-object-document/tests/object-text.test.ts diff --git a/audits/document-types.json b/audits/document-types.json index 422acb3b..f3723c67 100644 --- a/audits/document-types.json +++ b/audits/document-types.json @@ -32,16 +32,16 @@ }, "object": { "why": "캔버스 object는 identity, geometry와 presentation 값을 하나의 의미 단위로 유지해야 하기 때문입니다.", - "does": "object의 위치·크기·스타일 모델을 정의하고 translate·resize·fill·style의 원자적 연산을 제공합니다.", - "schema": "interface DocumentObject {\n readonly id: string;\n readonly label: string;\n readonly x: number;\n readonly y: number;\n readonly width: number;\n readonly height: number;\n readonly color: string;\n}\n\ninterface ObjectDocument {\n readonly objects: ReadonlyArray;\n}\n\ntype CanvasObject = DocumentObject & (\n | { kind: \"text\"; fontSize: number; fontWeight?: 400 | 700; textAlign?: \"left\" | \"center\" | \"right\" }\n | { kind: \"rectangle\" | \"ellipse\"; strokeColor?: string; strokeWidth?: number }\n | { kind: \"path\"; points: ReadonlyArray<{ x: number; y: number }>; strokeWidth: number }\n | { kind: \"image\"; source: string }\n);\ninterface CanvasDocument extends ObjectDocument {\n readonly profile: \"canvas/1\";\n readonly width: number; readonly height: number;\n readonly objects: ReadonlyArray;\n}", + "does": "object의 위치·크기·본문·스타일 모델을 정의하고 translate·resize·fill·style·text의 원자적 연산을 제공합니다.", + "schema": "interface DocumentObject {\n readonly id: string;\n readonly label: string;\n readonly x: number;\n readonly y: number;\n readonly width: number;\n readonly height: number;\n readonly color: string;\n}\n\ninterface ObjectDocument {\n readonly objects: ReadonlyArray;\n}\n\ninterface CanvasTextFormat {\n readonly fontSize?: number;\n readonly fontWeight?: 400 | 700;\n readonly textAlign?: \"left\" | \"center\" | \"right\";\n}\ntype CanvasObject = DocumentObject & (\n | (CanvasTextFormat & { kind: \"text\"; fontSize: number })\n | (CanvasTextFormat & { kind: \"rectangle\" | \"ellipse\" | \"sticky-note\"; textColor?: string; strokeColor?: string; strokeWidth?: number })\n | { kind: \"path\"; points: ReadonlyArray<{ x: number; y: number }>; strokeWidth: number }\n | { kind: \"image\"; source: string }\n);\ninterface CanvasDocument extends ObjectDocument {\n readonly profile: \"canvas/1\";\n readonly width: number; readonly height: number;\n readonly objects: ReadonlyArray;\n}", "fields": [ { "name": "objects", "description": "같은 공간에서 배치·선택되는 object의 ordered collection입니다." }, { "name": "objects[].id", "description": "selection, transform과 clipboard가 object를 안정적으로 참조하는 identity입니다." }, - { "name": "objects[].label", "description": "object의 사용자 가시 이름입니다." }, + { "name": "objects[].label", "description": "text·도형·노트의 실제 본문이며 별도 text 복사본이 없습니다. image·path에서는 이름입니다." }, { "name": "x / y", "description": "문서 좌표계에서 object 좌상단의 위치입니다." }, { "name": "width / height", "description": "resize 이후에도 유효해야 하는 object의 공간 크기입니다." }, - { "name": "color", "description": "도형의 채우기·글자색·path 선 색입니다." }, - { "name": "fontSize / fontWeight / textAlign", "description": "글자의 크기·굵기·정렬입니다. 생략된 굵기는 400, 정렬은 left입니다." }, + { "name": "color / textColor", "description": "color는 도형·노트 채우기·독립 글자색·path 선 색이고 textColor는 도형·노트 본문 색입니다." }, + { "name": "fontSize / fontWeight / textAlign", "description": "본문의 크기·굵기·정렬입니다. 도형·노트의 생략된 크기는 24, 굵기는 400, 정렬은 도형 center·노트와 독립 글자 left입니다." }, { "name": "strokeColor / strokeWidth", "description": "도형 테두리는 기본 #000000·0이며 path는 color와 양의 strokeWidth를 사용합니다." } ], "sourcePath": "packages/json-document-object-document/src/object-model.ts", @@ -220,11 +220,11 @@ { "id": "projection", "role": "Projection", - "knowledge": "Object bounds·path 정규화", - "decision": "preview와 commit의 동일한 기하", + "knowledge": "Object bounds·path 정규화·본문 capability와 배치", + "decision": "preview/commit의 동일한 기하와 표시/입력의 동일한 본문 projection", "changeReason": "기하 표현 변경", "stateLifecycle": "문서 값 또는 stateless 계산", - "inputsOutputs": "transformObject의 public 계약", + "inputsOutputs": "transformObject와 projectObjectText의 public 계약", "currentOwner": "@interactive-os/json-document-object-document", "canonicalEvidence": "packages/json-document-object-document/src/object-projection.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", "sourcePath": "packages/json-document-object-document/src/object-projection.ts", diff --git a/docs/api-reference/canvas.md b/docs/api-reference/canvas.md index 261f92b8..a469bca9 100644 --- a/docs/api-reference/canvas.md +++ b/docs/api-reference/canvas.md @@ -24,6 +24,8 @@ interface CanvasCreationStyle { readonly textColor: string; readonly fontSize: number; readonly strokeWidth: number; + /** Sticky-note fill; omitted hosts reuse their ordinary object fill. */ + readonly stickyNoteColor?: string; } ``` ## `CanvasHand` diff --git a/docs/api-reference/object-document.md b/docs/api-reference/object-document.md index e8ba6f5f..cf9975cf 100644 --- a/docs/api-reference/object-document.md +++ b/docs/api-reference/object-document.md @@ -45,8 +45,8 @@ type CanvasObject = CanvasObjectDraft & { readonly id: string }; ```ts type CanvasObjectDraft = ObjectDraft & ( - | { readonly kind: "text"; readonly fontSize: number; readonly fontWeight?: 400 | 700; readonly textAlign?: "left" | "center" | "right" } - | { readonly kind: "rectangle" | "ellipse"; readonly strokeColor?: string; readonly strokeWidth?: number } + | (CanvasTextFormat & { readonly kind: "text"; readonly fontSize: number }) + | (CanvasTextFormat & { readonly kind: "rectangle" | "ellipse" | "sticky-note"; readonly textColor?: string; readonly strokeColor?: string; readonly strokeWidth?: number }) | { readonly kind: "path"; readonly points: ReadonlyArray; readonly strokeWidth: number } | { readonly kind: "image"; readonly source: string } ); @@ -54,7 +54,16 @@ type CanvasObjectDraft = ObjectDraft & ( ## `CanvasObjectKind` ```ts -type CanvasObjectKind = "text" | "rectangle" | "ellipse" | "path" | "image"; +type CanvasObjectKind = "text" | "rectangle" | "ellipse" | "sticky-note" | "path" | "image"; +``` +## `CanvasTextFormat` + +```ts +interface CanvasTextFormat { + readonly fontSize?: number; + readonly fontWeight?: 400 | 700; + readonly textAlign?: "left" | "center" | "right"; +} ``` ## `createCanvasImage` @@ -64,7 +73,7 @@ createCanvasImage(image: { readonly source: string; readonly width: number; read ## `createCanvasObject` ```ts -createCanvasObject(kind: Exclude, bounds: ObjectBounds, style: { readonly color: string; readonly label: string; readonly fontSize?: number; }): CanvasObjectDraft +createCanvasObject(kind: Exclude, bounds: ObjectBounds, style: { readonly color: string; readonly label: string; readonly fontSize?: number; readonly textColor?: string; }): CanvasObjectDraft ``` ## `createCanvasPath` @@ -140,6 +149,8 @@ interface ObjectPoint extends Record { ```ts interface ObjectStyle { readonly color: string; + /** Body text paint for filled objects; standalone text keeps its existing color field. */ + readonly textColor: string; readonly fontSize: number; readonly fontWeight: 400 | 700; readonly textAlign: "left" | "center" | "right"; @@ -152,6 +163,14 @@ interface ObjectStyle { ```ts type ObjectStyleSelection = { readonly [Key in keyof ObjectStyle]?: ObjectStyle[Key] | null }; ``` +## `ObjectTextProjection` + +```ts +interface ObjectTextProjection extends ObjectBounds, Pick { + readonly text: string; + readonly verticalAlign: "top" | "center"; +} +``` ## `ObjectTransform` ```ts @@ -177,6 +196,11 @@ planObjectOperation(document: ObjectDocument, operation: ObjectOperation): Objec ```ts projectObject(object: DocumentObject): CanvasObject ``` +## `projectObjectText` + +```ts +projectObjectText(object: DocumentObject): ObjectTextProjection | null +``` ## `readObjectStyle` ```ts diff --git a/docs/public/intent.md b/docs/public/intent.md index e28f05b4..9cfd24bf 100644 --- a/docs/public/intent.md +++ b/docs/public/intent.md @@ -117,6 +117,7 @@ commit은 되돌아가지 않습니다. Selection mapping/reconciliation callbac | `selection.remove` | | 선택한 객체 제거 | | `selection.fill` | `color` | 선택한 객체 색 변경 | | `selection.style` | `style` | 지원하는 선택 객체의 색·글자 서식·테두리를 한 번 변경 | +| `object.text` | `objectId`, `text` | 글자·도형·스티커 노트의 label 본문 변경 | | `object.translate` | `objectIds`, `dx`, `dy` | 선택한 객체 위치 이동 | | `object.resize` | `objectIds`, `dx`, `dy`, `dw`, `dh` | 선택한 객체 크기 | | `clipboard.paste` | `clipboard` | 붙여넣기 | diff --git a/docs/public/object.md b/docs/public/object.md index 528d7f62..118b752c 100644 --- a/docs/public/object.md +++ b/docs/public/object.md @@ -7,6 +7,9 @@ Object는 안정된 ID를 가진 객체를 집는 편집기입니다. 줄 번호 생성·글자 편집·색 채우기·이동·resize·삭제는 Intent로 들어갑니다. 문서의 기하 규칙은 Document Type이, 화면 좌표와 hit target은 Adapter와 Hand가 소유합니다. +Canvas의 스티커 노트와 도형 내부 글도 별도 편집기 없이 같은 `object.text`로 +label을 바꿉니다. 채워진 객체의 본문 글자색은 `selection.style`의 `style.textColor`로, +채우기는 기존 `color`로 구분합니다. ## API Reference diff --git a/packages/json-document-canvas/README.md b/packages/json-document-canvas/README.md index bb780a3b..939643f6 100644 --- a/packages/json-document-canvas/README.md +++ b/packages/json-document-canvas/README.md @@ -1,7 +1,8 @@ # Canvas Hand `@interactive-os/json-document-canvas`는 한 장의 Object Canvas 프로파일에서 -글자·사각형·타원·그리기를 완성하는 React Hand입니다. 별도 Canvas editor나 +글자·사각형·타원·스티커 노트·그리기를 완성하는 React Hand입니다. 도형과 노트의 +본문도 같은 plain-text 입력과 스타일 API로 편집합니다. 별도 Canvas editor나 selection/history 구현을 만들지 않고 기존 `createObjectEditor`를 사용합니다. 재사용 가능한 Plane Select profile로 다중 선택·Alt 복제·Shift 축 고정·방향키 이동을, Object Editing과 Web Clipboard로 선택 copy/cut/paste·Mod+D·Undo/Redo를 연결합니다. @@ -15,7 +16,7 @@ import { CanvasHand } from "@interactive-os/json-document-canvas"; function Slide() { const [editor] = useState(() => createObjectEditor({ profile: "canvas/1", width: 1280, height: 720, objects: [] })); - return ; + return ; } ``` diff --git a/packages/json-document-canvas/docs/api.md b/packages/json-document-canvas/docs/api.md index 13c87d2a..801a7176 100644 --- a/packages/json-document-canvas/docs/api.md +++ b/packages/json-document-canvas/docs/api.md @@ -1,6 +1,6 @@ ## Canvas Hand 계약 · RC -`CanvasHand`는 `ObjectEditor`와 `CanvasCreationStyle`을 받아 글자·사각형·타원·자유 +`CanvasHand`는 `ObjectEditor`와 `CanvasCreationStyle`을 받아 글자·사각형·타원·스티커 노트·자유 그리기, 이미지·텍스트 붙여넣기, 다중 선택, 선택 스타일, 집합 이동·복제·삭제, native Clipboard, primary resize, Undo/Redo, JSON 재열기를 연결합니다. `useCanvasHand`는 같은 입력 조합을 custom UI에서 사용할 수 있게 공개합니다. @@ -28,7 +28,7 @@ Host: 한 장 fixture, 크기·색상 정책, 레이아웃 ### 입력과 History - 도구를 고르고 클릭하면 기본 크기, 드래그하면 지정한 크기로 생성합니다. - 사각형·타원·글자는 누르거나 작게 흔들리는 동안 기본 크기를 미리 표시하지 않습니다. + 사각형·타원·글자·노트는 누르거나 작게 흔들리는 동안 기본 크기를 미리 표시하지 않습니다. 시작점에서 3 문서 단위 이상 움직이면 실제 드래그 상자만 표시하며, 다시 시작점 근처로 돌아와도 클릭 크기로 바뀌지 않습니다. 클릭 기본 크기는 놓을 때 press 위치에만 생성합니다. 드래그 후 시작점에 정확히 돌아와 놓으면 객체나 History를 만들지 않습니다. @@ -40,11 +40,17 @@ Host: 한 장 fixture, 크기·색상 정책, 레이아웃 선택 윤곽은 모두 그리지만 네 변·네 모서리 resize targets는 primary 하나에만 붙습니다. Delete는 집합 전체를 한 번 삭제하며 primary resize/text 편집은 기존 선택 집합을 보존합니다. Focus만으로 선택하지 않으며, focused 객체에서 Space/Shift+Space로 선택/toggle합니다. - focused 객체의 Enter는 그 객체를 선택하고 글자라면 편집합니다. 슬라이드 자체의 + focused 객체의 Enter는 그 객체를 선택하고 본문이 있는 글자·도형·노트라면 편집합니다. 슬라이드 자체의 Enter/F2는 현재 primary를 편집하며, F2는 객체에 focus가 있어도 primary를 대상으로 합니다. -- 글자는 생성 직후 또는 더블클릭/F2/Enter로 편집합니다. 줄바꿈·IME·선택·native +- 글자·노트는 생성 직후, 사각형·타원은 더블클릭/F2/Enter로 편집합니다. + 기존 글자·노트도 같은 더블클릭/F2/Enter를 사용합니다. 줄바꿈·IME·선택·native 입력 Undo는 textarea에 남습니다. blur 또는 Mod+Enter가 전체 draft를 한 번 commit하고 Escape는 draft만 버립니다. 객체의 label이 실제 문자열 값입니다. + 노트 클릭 기본 크기는 200×200이며 드래그로 자유 크기를 지정합니다. 생성과 이후 + 본문 확정은 각각 한 번의 Undo입니다. 새 노트에서 Escape하면 빈 노트는 남습니다. + 도형은 내부 중앙, 노트는 여백을 둔 상단이며 `projectObjectText`를 표시와 입력이 공유합니다. + 편집 중에도 채우기와 테두리는 유지합니다. 상자 밖 본문은 clip하며 입력 중에는 native + textarea 스크롤로 긴 내용을 편집할 수 있습니다. 자동 글자 축소나 상자 자동 확대는 하지 않습니다. - Alt/Option+drag는 선택 집합을 복제합니다. 원본을 남기고 사본 위치를 preview하며 release에 새 ID를 할당합니다. Alt를 도중에 누르거나 놓으면 copy/move가 전환됩니다. Shift+drag는 큰 delta 축을 고정하며 Shift+click toggle과 구분합니다. @@ -75,6 +81,9 @@ Select 도구에서 스타일을 지원하는 객체가 선택되면 팔레트 Enter 또는 적용 아이콘으로 확정하고, Escape·바깥 클릭으로 닫으면 미확정 입력은 버립니다. 이미지만 선택한 경우에는 스타일 컨트롤이 없습니다. +도형·노트에는 `색상`(채우기)과 `글자색`이 따로 나타납니다. 글자 크기·굵기·정렬도 +같은 선택 스타일 API로 적용합니다. 독립 글자는 기존 `색상`을 글자색으로 씁니다. + 혼합 선택은 `readObjectStyle`의 `null`을 `혼합`으로 드러냅니다. 색·크기·정렬을 임의의 primary 값으로 표시하지 않습니다. 속성은 이를 지원하는 선택 객체에만 적용하고 전체 선택과 primary를 보존합니다. 굵기가 모두 0인 도형에 테두리색을 고르면 2 단위로 함께 @@ -142,6 +151,8 @@ JSON 버튼은 현재 문서 문자열을 노출합니다. 이 문자열을 저 단일 슬라이드를 컨테이너에 맞춰 표시합니다. 확대/축소·페이지·팬·다중 resize·그룹·회전· snap·레이어·PPTX·collaboration은 이번 Hand의 지원 범위가 아닙니다. `creationStyle`은 새 객체에만 적용하는 제품 기본값이며 저장 객체의 스타일을 덮어쓰지 않습니다. +`stickyNoteColor`로 새 노트의 채우기를 지정하며 생략하면 `color`를 사용합니다. +노트·도형의 본문은 `textColor`와 `fontSize` 생성 기본값을 받습니다. 선택은 Affordance의 [평면 Select 프로파일](/docs/api/affordance)을 소비합니다. `selectProfile`을 주입하거나 생략하여 기본 instance를 만들 수 있습니다. instance는 Hand마다 diff --git a/packages/json-document-canvas/src/canvas-hand.tsx b/packages/json-document-canvas/src/canvas-hand.tsx index 3f533ab7..9e7647a5 100644 --- a/packages/json-document-canvas/src/canvas-hand.tsx +++ b/packages/json-document-canvas/src/canvas-hand.tsx @@ -1,5 +1,5 @@ import { useState, type CSSProperties } from "react"; -import { Braces, Circle, CopyPlus, MousePointer2, Pencil, RectangleHorizontal, Redo2, Trash2, Type, Undo2, type LucideIcon } from "lucide-react"; +import { Braces, Circle, CopyPlus, MousePointer2, Pencil, RectangleHorizontal, Redo2, StickyNote, Trash2, Type, Undo2, type LucideIcon } from "lucide-react"; import type { ObjectEditor } from "@interactive-os/json-document-editing"; import type { PlaneSelectProfile } from "@interactive-os/json-document-affordance"; import { serializeCanvasDocument } from "@interactive-os/json-document-object-document"; @@ -20,6 +20,7 @@ export interface CanvasHandProps { const tools: ReadonlyArray<{ readonly id: CanvasTool; readonly label: string; readonly icon: LucideIcon }> = [ { id: "select", label: "선택", icon: MousePointer2 }, { id: "text", label: "글자", icon: Type }, + { id: "sticky-note", label: "스티커 노트", icon: StickyNote }, { id: "rectangle", label: "사각형", icon: RectangleHorizontal }, { id: "ellipse", label: "타원", icon: Circle }, { id: "path", label: "그리기", icon: Pencil }, ]; @@ -46,7 +47,7 @@ export function CanvasHand(props: CanvasHandProps) { data-canvas-slide="true" data-tool={hand.tool} viewBox={`0 0 ${hand.document.width} ${hand.document.height}`} preserveAspectRatio="none" style={{ display: "block", width: "100%", aspectRatio: `${hand.document.width} / ${hand.document.height}`, touchAction: "none", userSelect: "none", overflow: "hidden", ...props.slideStyle }}> {hand.objects.map((object) => - {hand.draft?.id !== object.id && } + 0 && selectedKeys.has(object.id)} onSelect={(shiftKey) => hand.select(object.id, shiftKey)} onEdit={() => hand.editText(object.id)} onHandle={(interaction, event) => hand.interaction(interaction, event, object, "drag")} /> @@ -59,7 +60,7 @@ export function CanvasHand(props: CanvasHandProps) { {!hand.draft && (["n", "e", "s", "w", "nw", "ne", "se", "sw"] as const).map((edge) => hand.interaction(interaction, event, selected, "resize", edge)} />)} } {hand.marquee && } - {selected?.kind === "text" && hand.draft?.id === selected.id && { hand.commitText(); hand.surface.current?.focus(); }} onCancel={() => { hand.cancel(); hand.surface.current?.focus(); }} />} {hand.pastePending &&

붙여넣는 중… Escape로 취소

} diff --git a/packages/json-document-canvas/src/canvas-object-view.tsx b/packages/json-document-canvas/src/canvas-object-view.tsx index da22823d..b683f20d 100644 --- a/packages/json-document-canvas/src/canvas-object-view.tsx +++ b/packages/json-document-canvas/src/canvas-object-view.tsx @@ -1,22 +1,35 @@ -import { useEffect, useRef, type PointerEvent, type ReactNode } from "react"; -import { getObjectStyle, type CanvasObject } from "@interactive-os/json-document-object-document"; +import { useEffect, useRef, type CSSProperties, type PointerEvent, type ReactNode } from "react"; +import { getObjectStyle, projectObjectText, type CanvasObject, type ObjectTextProjection } from "@interactive-os/json-document-object-document"; import type { InteractionHandleEvent, ResizeEdge } from "@interactive-os/json-document-affordance"; import { contentInteractionAttributes, Field, useInteractionHandle } from "@interactive-os/json-document-ui-primitives-react"; -export function CanvasObjectView({ object }: { readonly object: CanvasObject }): ReactNode { +export function CanvasObjectView({ object, hideText = false }: { readonly object: CanvasObject; readonly hideText?: boolean }): ReactNode { const style = getObjectStyle(object); if (object.kind === "image") return ; if (object.kind === "path") { return `${object.x + point.x * object.width},${object.y + point.y * object.height}`).join(" ")} fill="none" stroke={object.color} strokeWidth={object.strokeWidth} strokeLinecap="round" strokeLinejoin="round" />; } - if (object.kind === "text") return ( - -
{object.label}
-
- ); - return object.kind === "ellipse" - ? - : ; + const text = projectObjectText(object); + return <> + {object.kind === "ellipse" + ? + : object.kind !== "text" && } + {!hideText && text &&
{text.text + "\u200b"}
} + ; +} + +function textPresentation(value: ObjectTextProjection): CSSProperties { + return { color: value.color, fontSize: value.fontSize, fontWeight: value.fontWeight, textAlign: value.textAlign, + fontFamily: "inherit", lineHeight: 1.2, whiteSpace: "pre-wrap", overflowWrap: "anywhere", letterSpacing: "normal" }; +} + +/** The same body box and line wrapping serve display and the native textarea. */ +function CanvasTextBox({ value, children }: { readonly value: ObjectTextProjection; readonly children: ReactNode }) { + return +
+
{children}
+
+
; } export function CanvasObjectTarget(props: { @@ -68,7 +81,7 @@ export function CanvasResizeTarget(props: { } export function CanvasTextInput(props: { - readonly object: Extract; + readonly object: CanvasObject; readonly text: string; readonly onChange: (text: string) => void; readonly onCommit: () => void; @@ -77,11 +90,13 @@ export function CanvasTextInput(props: { const input = useRef(null); useEffect(() => { input.current?.focus(); input.current?.select(); }, [props.object.id]); const { object } = props; - const style = getObjectStyle(object); + const text = projectObjectText(object); + if (!text) return null; return ( - + + { event.stopPropagation(); @@ -89,6 +104,6 @@ export function CanvasTextInput(props: { if (event.key === "Escape") { event.preventDefault(); props.onCancel(); } else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); props.onCommit(); } }} /> - + ); } diff --git a/packages/json-document-canvas/src/canvas-style-controls.tsx b/packages/json-document-canvas/src/canvas-style-controls.tsx index 83a5afc0..00fde91b 100644 --- a/packages/json-document-canvas/src/canvas-style-controls.tsx +++ b/packages/json-document-canvas/src/canvas-style-controls.tsx @@ -24,6 +24,7 @@ export function CanvasStyleControls(props: { return