@zenuml/core 3.47.9 → 3.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cloud-icons-eHuugVSv.js.map +1 -0
- package/dist/zenuml.esm.mjs +2153 -2156
- package/dist/zenuml.esm.mjs.map +1 -0
- package/dist/zenuml.js +82 -82
- package/dist/zenuml.js.map +1 -0
- package/package.json +11 -1
- package/src/cli/zenuml.ts +1164 -0
- package/.agents/skills/babysit-pr/SKILL.md +0 -223
- package/.agents/skills/babysit-pr/agents/openai.yaml +0 -7
- package/.agents/skills/dia-scoring/SKILL.md +0 -139
- package/.agents/skills/dia-scoring/agents/openai.yaml +0 -7
- package/.agents/skills/dia-scoring/references/selectors-and-keys.md +0 -253
- package/.agents/skills/land-pr/SKILL.md +0 -120
- package/.agents/skills/propagate-core-release/SKILL.md +0 -205
- package/.agents/skills/propagate-core-release/agents/openai.yaml +0 -7
- package/.agents/skills/propagate-core-release/references/downstreams.md +0 -42
- package/.agents/skills/ship-branch/SKILL.md +0 -105
- package/.agents/skills/submit-branch/SKILL.md +0 -76
- package/.agents/skills/validate-branch/SKILL.md +0 -72
- package/.claude/commands/README.md +0 -162
- package/.claude/commands/analyze.md +0 -101
- package/.claude/commands/clarify.md +0 -158
- package/.claude/commands/code-review.md +0 -322
- package/.claude/commands/constitution.md +0 -73
- package/.claude/commands/create-docs.md +0 -309
- package/.claude/commands/full-context.md +0 -121
- package/.claude/commands/gemini-consult.md +0 -164
- package/.claude/commands/handoff.md +0 -146
- package/.claude/commands/implement.md +0 -56
- package/.claude/commands/plan.md +0 -43
- package/.claude/commands/refactor.md +0 -188
- package/.claude/commands/specify.md +0 -21
- package/.claude/commands/tasks.md +0 -62
- package/.claude/commands/update-docs.md +0 -314
- package/.claude/hooks/README.md +0 -270
- package/.claude/hooks/config/sensitive-patterns.json +0 -86
- package/.claude/hooks/gemini-context-injector.sh +0 -129
- package/.claude/hooks/mcp-security-scan.sh +0 -147
- package/.claude/hooks/notify.sh +0 -103
- package/.claude/hooks/setup/hook-setup.md +0 -96
- package/.claude/hooks/setup/settings.json.template +0 -63
- package/.claude/hooks/sounds/complete.wav +0 -0
- package/.claude/hooks/sounds/input-needed.wav +0 -0
- package/.claude/hooks/subagent-context-injector.sh +0 -65
- package/.claude/skills/babysit-pr/SKILL.md +0 -223
- package/.claude/skills/babysit-pr/agents/openai.yaml +0 -7
- package/.claude/skills/dia-scoring/SKILL.md +0 -139
- package/.claude/skills/dia-scoring/agents/openai.yaml +0 -7
- package/.claude/skills/dia-scoring/references/selectors-and-keys.md +0 -253
- package/.claude/skills/emoji-eval/SKILL.md +0 -187
- package/.claude/skills/land-pr/SKILL.md +0 -120
- package/.claude/skills/propagate-core-release/SKILL.md +0 -205
- package/.claude/skills/propagate-core-release/agents/openai.yaml +0 -7
- package/.claude/skills/propagate-core-release/references/downstreams.md +0 -42
- package/.claude/skills/ship-branch/SKILL.md +0 -105
- package/.claude/skills/submit-branch/SKILL.md +0 -76
- package/.claude/skills/validate-branch/SKILL.md +0 -72
- package/.claude/skills/zenuml-ux-research/SKILL.md +0 -183
- package/.claude/skills/zenuml-ux-research/references/assertion-catalog.md +0 -261
- package/.claude/skills/zenuml-ux-research/references/best-practices-overview.md +0 -56
- package/.claude/skills/zenuml-ux-research/references/report-template.md +0 -89
- package/.claude/skills/zenuml-ux-research/references/scenarios/edit-message-label.md +0 -37
- package/.claude/skills/zenuml-ux-research/references/scenarios/insert-message.md +0 -36
- package/.claude/skills/zenuml-ux-research/references/scenarios/insert-participant.md +0 -31
- package/.claude/skills/zenuml-ux-research/references/scenarios/rename-participant.md +0 -33
- package/.claude/skills/zenuml-ux-research/references/scenarios/undo-insert.md +0 -35
- package/.devcontainer/devcontainer.json +0 -21
- package/.dockerignore +0 -19
- package/.eslintrc.js +0 -39
- package/.git-blame-ignore-revs +0 -6
- package/.kiro/hooks/README.md +0 -38
- package/.kiro/hooks/session-sound-notification.js +0 -44
- package/.kiro/hooks/session-sound-notification.json +0 -23
- package/.mcp.json.example +0 -17
- package/.nvmrc +0 -1
- package/.prettierignore +0 -4
- package/.prettierrc +0 -1
- package/.specify/memory/constitution.md +0 -33
- package/.specify/scripts/bash/check-prerequisites.sh +0 -166
- package/.specify/scripts/bash/common.sh +0 -113
- package/.specify/scripts/bash/create-new-feature.sh +0 -97
- package/.specify/scripts/bash/setup-plan.sh +0 -60
- package/.specify/scripts/bash/update-agent-context.sh +0 -728
- package/.specify/templates/agent-file-template.md +0 -23
- package/.specify/templates/plan-template.md +0 -219
- package/.specify/templates/spec-template.md +0 -116
- package/.specify/templates/tasks-template.md +0 -127
- package/.storybook/main.ts +0 -25
- package/.storybook/preview.ts +0 -29
- package/.watchmanconfig +0 -3
- package/AGENTS.md +0 -26
- package/CLAUDE.md +0 -124
- package/DEPLOYMENT.md +0 -62
- package/Dockerfile +0 -36
- package/IMPLEMENTATION_PLAN.md +0 -163
- package/Integration/vanilla-js/index.html +0 -42
- package/MCP-ASSISTANT-RULES.md +0 -85
- package/README_CN.md +0 -15
- package/TUTORIAL.md +0 -116
- package/antlr/antlr-4.11.1-complete.jar +0 -0
- package/bun.lock +0 -1544
- package/bunfig.toml +0 -52
- package/docs/UNICODE_SUPPORT.md +0 -179
- package/docs/ai-context/deployment-infrastructure.md +0 -21
- package/docs/ai-context/docs-overview.md +0 -89
- package/docs/ai-context/handoff.md +0 -174
- package/docs/ai-context/project-structure.md +0 -160
- package/docs/ai-context/system-integration.md +0 -21
- package/docs/asciidoc/contributor.adoc +0 -54
- package/docs/asciidoc/create-my-own-theme.adoc +0 -149
- package/docs/asciidoc/images/creation-component.png +0 -0
- package/docs/asciidoc/images/creation-rtl.png +0 -0
- package/docs/asciidoc/images/message-arrow-rtl.png +0 -0
- package/docs/asciidoc/images/occurrence.png +0 -0
- package/docs/asciidoc/images/return-message-conflict.png +0 -0
- package/docs/asciidoc/images/shift-up-half-the-height.png +0 -0
- package/docs/asciidoc/images/three-layer-info-arch.png +0 -0
- package/docs/asciidoc/images/vertical-alignment.svg +0 -1
- package/docs/asciidoc/images/vertically-aligning.png +0 -0
- package/docs/asciidoc/index.adoc +0 -277
- package/docs/asciidoc/theme-debug-web-app.png +0 -0
- package/docs/asciidoc/tutorial.adoc +0 -22
- package/docs/asciidoc/user-css.png +0 -0
- package/docs/async-vs-sync-parser-rules.md +0 -81
- package/docs/divider-parser-allow-spaces.md +0 -38
- package/docs/highlighting-messages.md +0 -52
- package/docs/images/editor-sample.png +0 -0
- package/docs/inherited-vs-provided-from.md +0 -64
- package/docs/parser/Assignment.md +0 -8
- package/docs/parser/PARSER_IMPROVEMENTS_CC.md +0 -425
- package/docs/parser/grammar_review_gemini.md +0 -116
- package/docs/participants-function.md +0 -25
- package/docs/responsive-participant-margin.md +0 -52
- package/docs/starter.md +0 -9
- package/docs/superpowers/plans/2026-03-27-e2e-test-reorg.md +0 -698
- package/docs/superpowers/plans/2026-03-30-emoji-support.md +0 -1220
- package/docs/superpowers/plans/2026-03-30-self-correcting-scoring.md +0 -206
- package/docs/superpowers/plans/2026-04-15-keyboard-editing-on-diagram.md +0 -1992
- package/docs/superpowers/plans/2026-04-15-zenuml-ux-research-skill.md +0 -1452
- package/docs/ux-research/.gitkeep +0 -0
- package/docs/ux-research/2026-04-15-rename-participant.md +0 -156
- package/docs/ux-research/2026-04-18-insert-participant.md +0 -151
- package/docs/width-translate-and-offsets.md +0 -62
- package/docs/xss.md +0 -59
- package/e2e/data/compare-cases.js +0 -1090
- package/e2e/data/diff-algorithm.js +0 -199
- package/e2e/fixtures/create-message.html +0 -26
- package/e2e/fixtures/editable-label.html +0 -35
- package/e2e/fixtures/editable-span.html +0 -122
- package/e2e/fixtures/empty-diagram.html +0 -23
- package/e2e/fixtures/fixture.html +0 -31
- package/e2e/fixtures/insert-participant.html +0 -23
- package/e2e/fixtures/reorder-cross-fragment.html +0 -31
- package/e2e/fixtures/reorder-fragment.html +0 -29
- package/e2e/fixtures/reorder-message.html +0 -27
- package/e2e/fixtures/svg-test.html +0 -21
- package/e2e/fixtures/type-switch.html +0 -29
- package/e2e/tools/canonical-history.html +0 -908
- package/e2e/tools/compare-case.html +0 -371
- package/e2e/tools/compare.html +0 -35
- package/e2e/tools/native-diff-ext/background.js +0 -60
- package/e2e/tools/native-diff-ext/bridge.js +0 -26
- package/e2e/tools/native-diff-ext/content.js +0 -194
- package/e2e/tools/svg-preview.html +0 -56
- package/embed.html +0 -193
- package/eslint.config.mjs +0 -35
- package/firebase-debug.log +0 -108
- package/iframe-container-demo/diagram.html +0 -124
- package/iframe-container-demo/host.html +0 -817
- package/index.html +0 -771
- package/mermaid-zenuml-async-spa-auth.png +0 -0
- package/mermaid-zenuml-async-spa-auth.snapshot.md +0 -96
- package/newsletter/unicode-support-announcement.md +0 -134
- package/playground/creation.html +0 -53
- package/playground/message.html +0 -63
- package/playwright.config.ts +0 -40
- package/renderer.html +0 -366
- package/scripts/analyze-compare-case/collect-data.mjs +0 -1134
- package/scripts/analyze-compare-case/config.mjs +0 -102
- package/scripts/analyze-compare-case/geometry.mjs +0 -101
- package/scripts/analyze-compare-case/native-diff.mjs +0 -224
- package/scripts/analyze-compare-case/output.mjs +0 -74
- package/scripts/analyze-compare-case/panel-diff.mjs +0 -114
- package/scripts/analyze-compare-case/report.mjs +0 -162
- package/scripts/analyze-compare-case/residual-scopes.mjs +0 -347
- package/scripts/analyze-compare-case/scoring.mjs +0 -829
- package/scripts/analyze-compare-case.mjs +0 -149
- package/scripts/bump-version.js +0 -117
- package/scripts/snapshot-dual.js +0 -173
- package/scripts/update-snapshots.js +0 -70
- package/skills/dia-scoring/SKILL.md +0 -129
- package/skills/dia-scoring/agents/openai.yaml +0 -7
- package/skills/dia-scoring/references/selectors-and-keys.md +0 -253
- package/tailwind.config.js +0 -126
- package/test-compression.html +0 -274
- package/test-mermaid-zenuml.html +0 -57
- package/test-setup.ts +0 -124
- package/test-url-params.html +0 -192
- package/tsconfig.app.json +0 -31
- package/tsconfig.node.json +0 -24
- package/tsconfig.test.json +0 -9
- package/vite.config.lib.ts +0 -93
- package/vite.config.ts +0 -84
- package/wrangler.toml +0 -18
|
@@ -1,1992 +0,0 @@
|
|
|
1
|
-
# Keyboard-Only Editing on Diagram — Implementation Plan
|
|
2
|
-
|
|
3
|
-
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
-
|
|
5
|
-
**Goal:** Add a keyboard-only editing layer (arrow-key navigation + Miro-style Tab-to-sibling) on top of the existing mouse-driven edit-on-diagram feature. Scope: add participant, rename participant, add message, rename message.
|
|
6
|
-
|
|
7
|
-
**Architecture:** Two independent focus rings (participants, messages) backed by Jotai atoms. Arrow keys move focus between elements via a top-level `useDiagramKeyboard` hook installed on `SeqDiagram`. Inside edit mode, `Tab` is intercepted by `EditableSpan` via a new `onTabCreateSibling` prop and routed to `insertParticipantIntoDsl` / `insertMessageInDsl`. Ring order is computed by pure functions that walk the ANTLR parse tree in DSL order.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** React 19, Jotai, TypeScript, Vitest (unit), Playwright (E2E), Tailwind, Bun.
|
|
10
|
-
|
|
11
|
-
**Spec:** `docs/superpowers/specs/2026-04-15-keyboard-editing-on-diagram-design.md`
|
|
12
|
-
|
|
13
|
-
**Base branch:** `feat/keyboard-editing` (forked from `origin/feat/sequence-editor-interactions-v3`)
|
|
14
|
-
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
## File map
|
|
18
|
-
|
|
19
|
-
### New files
|
|
20
|
-
|
|
21
|
-
- `src/store/keyboardAtoms.ts` — `focusedParticipantAtom`, `focusedMessageAtom`, paired setter atoms. Kept separate from `Store.ts` to avoid bloating it.
|
|
22
|
-
- `src/components/DiagramFrame/SeqDiagram/keyboard/rings.ts` — pure ring builders. `buildParticipantRing(rootContext)`, `buildMessageRing(rootContext)`.
|
|
23
|
-
- `src/components/DiagramFrame/SeqDiagram/keyboard/rings.spec.ts` — unit tests.
|
|
24
|
-
- `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts` — top-level keydown handler.
|
|
25
|
-
- `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx` — unit tests.
|
|
26
|
-
- `src/components/DiagramFrame/SeqDiagram/keyboard/EmptyDiagramPlaceholder.tsx` — focusable phantom for empty diagrams.
|
|
27
|
-
- `e2e/fixtures/keyboard-editing.html` — Playwright fixture page.
|
|
28
|
-
- `tests/keyboard-editing.spec.ts` — Playwright spec covering the full flow.
|
|
29
|
-
|
|
30
|
-
### Modified files
|
|
31
|
-
|
|
32
|
-
- `src/store/Store.ts` — re-export the keyboard atoms so consumers keep importing from `@/store/Store` (DRY).
|
|
33
|
-
- `src/components/common/EditableSpan/EditableSpan.tsx` — add `onTabCreateSibling?: (direction: "before" | "after") => void` prop; intercept Tab in edit mode when provided.
|
|
34
|
-
- `src/components/common/EditableSpan/EditableSpan.spec.tsx` — add test coverage for the new prop (create file if it does not exist).
|
|
35
|
-
- `src/utils/participantInsertTransform.ts` — change `insertParticipantIntoDsl` return type from `string` to `{ code: string; labelPosition: [number, number] }` so we can auto-open the new participant in edit mode. Update call sites.
|
|
36
|
-
- `src/utils/participantInsertTransform.spec.ts` — update tests for new return shape.
|
|
37
|
-
- `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantInsertControls.tsx` — update to new return shape.
|
|
38
|
-
- `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.tsx` — wire `onTabCreateSibling`; render focus ring from `focusedParticipantAtom`.
|
|
39
|
-
- `src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.tsx` — wire `onTabCreateSibling` with inherited from/to; render focus ring from `focusedMessageAtom`.
|
|
40
|
-
- `src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx` — install `useDiagramKeyboard`; `tabIndex={0}` on container; auto-focus first participant on container focus; render `EmptyDiagramPlaceholder` when empty.
|
|
41
|
-
- `src/components/DiagramFrame/SeqDiagram/SeqDiagram.css` — focus-ring styles.
|
|
42
|
-
|
|
43
|
-
---
|
|
44
|
-
|
|
45
|
-
## Task 1: Add focus atoms
|
|
46
|
-
|
|
47
|
-
**Files:**
|
|
48
|
-
- Create: `src/store/keyboardAtoms.ts`
|
|
49
|
-
- Modify: `src/store/Store.ts`
|
|
50
|
-
- Test: `src/store/keyboardAtoms.spec.ts`
|
|
51
|
-
|
|
52
|
-
- [ ] **Step 1: Write the failing test**
|
|
53
|
-
|
|
54
|
-
Create `src/store/keyboardAtoms.spec.ts`:
|
|
55
|
-
|
|
56
|
-
```typescript
|
|
57
|
-
import { createStore } from "jotai";
|
|
58
|
-
import {
|
|
59
|
-
focusedParticipantAtom,
|
|
60
|
-
focusedMessageAtom,
|
|
61
|
-
setFocusedParticipantAtom,
|
|
62
|
-
setFocusedMessageAtom,
|
|
63
|
-
} from "./keyboardAtoms";
|
|
64
|
-
|
|
65
|
-
describe("keyboardAtoms", () => {
|
|
66
|
-
it("stores focused participant", () => {
|
|
67
|
-
const store = createStore();
|
|
68
|
-
store.set(setFocusedParticipantAtom, "A");
|
|
69
|
-
expect(store.get(focusedParticipantAtom)).toBe("A");
|
|
70
|
-
expect(store.get(focusedMessageAtom)).toBeNull();
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("setting focused message clears focused participant", () => {
|
|
74
|
-
const store = createStore();
|
|
75
|
-
store.set(setFocusedParticipantAtom, "A");
|
|
76
|
-
store.set(setFocusedMessageAtom, { start: 10, end: 20 });
|
|
77
|
-
expect(store.get(focusedParticipantAtom)).toBeNull();
|
|
78
|
-
expect(store.get(focusedMessageAtom)).toEqual({ start: 10, end: 20 });
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("setting focused participant clears focused message", () => {
|
|
82
|
-
const store = createStore();
|
|
83
|
-
store.set(setFocusedMessageAtom, { start: 10, end: 20 });
|
|
84
|
-
store.set(setFocusedParticipantAtom, "B");
|
|
85
|
-
expect(store.get(focusedParticipantAtom)).toBe("B");
|
|
86
|
-
expect(store.get(focusedMessageAtom)).toBeNull();
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("setting null on either clears that ring only", () => {
|
|
90
|
-
const store = createStore();
|
|
91
|
-
store.set(setFocusedParticipantAtom, "A");
|
|
92
|
-
store.set(setFocusedParticipantAtom, null);
|
|
93
|
-
expect(store.get(focusedParticipantAtom)).toBeNull();
|
|
94
|
-
expect(store.get(focusedMessageAtom)).toBeNull();
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
100
|
-
|
|
101
|
-
Run: `bun run test src/store/keyboardAtoms.spec.ts`
|
|
102
|
-
Expected: FAIL — module does not exist.
|
|
103
|
-
|
|
104
|
-
- [ ] **Step 3: Implement the atoms**
|
|
105
|
-
|
|
106
|
-
Create `src/store/keyboardAtoms.ts`:
|
|
107
|
-
|
|
108
|
-
```typescript
|
|
109
|
-
import { atom } from "jotai";
|
|
110
|
-
|
|
111
|
-
export type FocusedMessageKey = {
|
|
112
|
-
start: number;
|
|
113
|
-
end: number;
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
export const focusedParticipantAtom = atom<string | null>(null);
|
|
117
|
-
export const focusedMessageAtom = atom<FocusedMessageKey | null>(null);
|
|
118
|
-
|
|
119
|
-
export const setFocusedParticipantAtom = atom(
|
|
120
|
-
null,
|
|
121
|
-
(_get, set, name: string | null) => {
|
|
122
|
-
set(focusedParticipantAtom, name);
|
|
123
|
-
if (name !== null) {
|
|
124
|
-
set(focusedMessageAtom, null);
|
|
125
|
-
}
|
|
126
|
-
},
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
export const setFocusedMessageAtom = atom(
|
|
130
|
-
null,
|
|
131
|
-
(_get, set, key: FocusedMessageKey | null) => {
|
|
132
|
-
set(focusedMessageAtom, key);
|
|
133
|
-
if (key !== null) {
|
|
134
|
-
set(focusedParticipantAtom, null);
|
|
135
|
-
}
|
|
136
|
-
},
|
|
137
|
-
);
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
- [ ] **Step 4: Re-export from Store.ts**
|
|
141
|
-
|
|
142
|
-
Edit `src/store/Store.ts`. At the bottom of the file, add:
|
|
143
|
-
|
|
144
|
-
```typescript
|
|
145
|
-
export {
|
|
146
|
-
focusedParticipantAtom,
|
|
147
|
-
focusedMessageAtom,
|
|
148
|
-
setFocusedParticipantAtom,
|
|
149
|
-
setFocusedMessageAtom,
|
|
150
|
-
} from "./keyboardAtoms";
|
|
151
|
-
export type { FocusedMessageKey } from "./keyboardAtoms";
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
- [ ] **Step 5: Run tests to verify pass**
|
|
155
|
-
|
|
156
|
-
Run: `bun run test src/store/keyboardAtoms.spec.ts`
|
|
157
|
-
Expected: PASS (all 4 cases).
|
|
158
|
-
|
|
159
|
-
- [ ] **Step 6: Commit**
|
|
160
|
-
|
|
161
|
-
```bash
|
|
162
|
-
git add src/store/keyboardAtoms.ts src/store/keyboardAtoms.spec.ts src/store/Store.ts
|
|
163
|
-
git commit -m "feat(store): add focused participant/message atoms for keyboard editing"
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
---
|
|
167
|
-
|
|
168
|
-
## Task 2: Ring builders
|
|
169
|
-
|
|
170
|
-
**Files:**
|
|
171
|
-
- Create: `src/components/DiagramFrame/SeqDiagram/keyboard/rings.ts`
|
|
172
|
-
- Create: `src/components/DiagramFrame/SeqDiagram/keyboard/rings.spec.ts`
|
|
173
|
-
|
|
174
|
-
Goal: two pure functions that walk the parse tree in DSL order. The participant ring returns participant names (strings). The message ring returns entries shaped to match `focusedMessageAtom` (`{ start, end }`) — the text position of the message's label in the DSL, so the ring items can be matched back to `EditableSpan`s via `pendingEditableRangeAtom`-style coordinates.
|
|
175
|
-
|
|
176
|
-
- [ ] **Step 1: Write the failing test**
|
|
177
|
-
|
|
178
|
-
Create `src/components/DiagramFrame/SeqDiagram/keyboard/rings.spec.ts`:
|
|
179
|
-
|
|
180
|
-
```typescript
|
|
181
|
-
import { Fixture } from "@/../test/unit/parser/fixture/Fixture";
|
|
182
|
-
import { RootContext } from "@/parser";
|
|
183
|
-
import { buildParticipantRing, buildMessageRing } from "./rings";
|
|
184
|
-
|
|
185
|
-
describe("rings.buildParticipantRing", () => {
|
|
186
|
-
it("returns explicit participants in DSL order", () => {
|
|
187
|
-
const root = RootContext("A\nB\nC\nA->B.m1\n");
|
|
188
|
-
expect(buildParticipantRing(root)).toEqual(["A", "B", "C"]);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("skips the implicit _STARTER_", () => {
|
|
192
|
-
const root = RootContext("A->B.m1\n");
|
|
193
|
-
const ring = buildParticipantRing(root);
|
|
194
|
-
expect(ring).not.toContain("_STARTER_");
|
|
195
|
-
expect(ring).toEqual(["A", "B"]);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("returns empty for empty code", () => {
|
|
199
|
-
expect(buildParticipantRing(null)).toEqual([]);
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
describe("rings.buildMessageRing", () => {
|
|
204
|
-
it("returns top-level messages in DSL order", () => {
|
|
205
|
-
const root = RootContext("A->B.m1\nA->B.m2\n");
|
|
206
|
-
const ring = buildMessageRing(root);
|
|
207
|
-
expect(ring).toHaveLength(2);
|
|
208
|
-
expect(ring[0].signature).toBe("m1");
|
|
209
|
-
expect(ring[1].signature).toBe("m2");
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("walks into alt/loop fragments in order", () => {
|
|
213
|
-
const code = "A->B.m1\nalt x\n A->B.m2\nelse y\n A->B.m3\nend\nA->B.m4\n";
|
|
214
|
-
const root = RootContext(code);
|
|
215
|
-
const ring = buildMessageRing(root);
|
|
216
|
-
const sigs = ring.map((r) => r.signature);
|
|
217
|
-
expect(sigs).toEqual(["m1", "m2", "m3", "m4"]);
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it("each ring entry exposes label start/end and from/to", () => {
|
|
221
|
-
const root = RootContext("A->B.m1\n");
|
|
222
|
-
const ring = buildMessageRing(root);
|
|
223
|
-
expect(ring[0].from).toBe("A");
|
|
224
|
-
expect(ring[0].to).toBe("B");
|
|
225
|
-
expect(typeof ring[0].labelStart).toBe("number");
|
|
226
|
-
expect(typeof ring[0].labelEnd).toBe("number");
|
|
227
|
-
expect(ring[0].labelEnd).toBeGreaterThanOrEqual(ring[0].labelStart);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it("returns empty for null rootContext", () => {
|
|
231
|
-
expect(buildMessageRing(null)).toEqual([]);
|
|
232
|
-
});
|
|
233
|
-
});
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
237
|
-
|
|
238
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/rings.spec.ts`
|
|
239
|
-
Expected: FAIL — module does not exist.
|
|
240
|
-
|
|
241
|
-
- [ ] **Step 3: Implement `buildParticipantRing`**
|
|
242
|
-
|
|
243
|
-
Create `src/components/DiagramFrame/SeqDiagram/keyboard/rings.ts`:
|
|
244
|
-
|
|
245
|
-
```typescript
|
|
246
|
-
import { OrderedParticipants, _STARTER_ } from "@/parser/OrderedParticipants";
|
|
247
|
-
import { AllMessages } from "@/parser/MessageCollector";
|
|
248
|
-
|
|
249
|
-
export type MessageRingEntry = {
|
|
250
|
-
from: string;
|
|
251
|
-
to: string;
|
|
252
|
-
signature: string;
|
|
253
|
-
labelStart: number;
|
|
254
|
-
labelEnd: number;
|
|
255
|
-
blockContext: any;
|
|
256
|
-
insertIndex: number;
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
export const buildParticipantRing = (rootContext: any): string[] => {
|
|
260
|
-
if (!rootContext) return [];
|
|
261
|
-
return OrderedParticipants(rootContext)
|
|
262
|
-
.map((p) => p.name)
|
|
263
|
-
.filter((name) => name !== _STARTER_);
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
export const buildMessageRing = (rootContext: any): MessageRingEntry[] => {
|
|
267
|
-
if (!rootContext) return [];
|
|
268
|
-
// Walk the parse tree (not AllMessages — we need ctx to compute positions
|
|
269
|
-
// and the containing block). Recursive walk covers fragments.
|
|
270
|
-
const out: MessageRingEntry[] = [];
|
|
271
|
-
walkBlock(rootContext.block?.(), out);
|
|
272
|
-
return out;
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
const walkBlock = (blockCtx: any, out: MessageRingEntry[]) => {
|
|
276
|
-
if (!blockCtx) return;
|
|
277
|
-
const stats: any[] = blockCtx.stat?.() ?? [];
|
|
278
|
-
stats.forEach((stat, index) => {
|
|
279
|
-
const message = stat.message?.();
|
|
280
|
-
if (message) {
|
|
281
|
-
const from = message.From?.()?.getText?.() ?? "";
|
|
282
|
-
const to = message.Owner?.()?.getText?.() ?? "";
|
|
283
|
-
const sigCtx = message.signature?.() ?? message;
|
|
284
|
-
const signature = sigCtx?.getText?.() ?? "";
|
|
285
|
-
const labelStart = sigCtx?.start?.start ?? -1;
|
|
286
|
-
const labelEnd = sigCtx?.stop?.stop ?? -1;
|
|
287
|
-
if (labelStart !== -1) {
|
|
288
|
-
out.push({
|
|
289
|
-
from,
|
|
290
|
-
to,
|
|
291
|
-
signature,
|
|
292
|
-
labelStart,
|
|
293
|
-
labelEnd,
|
|
294
|
-
blockContext: blockCtx,
|
|
295
|
-
insertIndex: index,
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
// Recurse into fragment children (alt/loop/par/opt/section/critical/tcf)
|
|
300
|
-
const fragments = [
|
|
301
|
-
stat.alt?.(),
|
|
302
|
-
stat.loop?.(),
|
|
303
|
-
stat.par?.(),
|
|
304
|
-
stat.opt?.(),
|
|
305
|
-
stat.section?.(),
|
|
306
|
-
stat.critical?.(),
|
|
307
|
-
stat.tcf?.(),
|
|
308
|
-
].filter(Boolean);
|
|
309
|
-
for (const frag of fragments) {
|
|
310
|
-
// Fragment children expose one or more nested blocks — walk each.
|
|
311
|
-
const nestedBlocks = collectFragmentBlocks(frag);
|
|
312
|
-
for (const block of nestedBlocks) {
|
|
313
|
-
walkBlock(block, out);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
});
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
const collectFragmentBlocks = (fragment: any): any[] => {
|
|
320
|
-
const blocks: any[] = [];
|
|
321
|
-
// Standard fragments expose .block() (single) or .braceBlock() (alt/par with multiple)
|
|
322
|
-
const single = fragment?.block?.();
|
|
323
|
-
if (single) blocks.push(single);
|
|
324
|
-
const multi = fragment?.braceBlock?.() ?? [];
|
|
325
|
-
if (Array.isArray(multi)) blocks.push(...multi);
|
|
326
|
-
else if (multi) blocks.push(multi);
|
|
327
|
-
return blocks;
|
|
328
|
-
};
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
- [ ] **Step 4: Run tests to verify pass**
|
|
332
|
-
|
|
333
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/rings.spec.ts`
|
|
334
|
-
Expected: PASS (all 6 cases).
|
|
335
|
-
|
|
336
|
-
- [ ] **Step 5: If any fragment-walk test fails**
|
|
337
|
-
|
|
338
|
-
The ANTLR generated accessors may differ between fragment types. Fix by reading `src/g4/sequenceParser.g4` (or the generated parser) to find the actual child accessor names. Update `collectFragmentBlocks` accordingly. Re-run.
|
|
339
|
-
|
|
340
|
-
- [ ] **Step 6: Commit**
|
|
341
|
-
|
|
342
|
-
```bash
|
|
343
|
-
git add src/components/DiagramFrame/SeqDiagram/keyboard/rings.ts src/components/DiagramFrame/SeqDiagram/keyboard/rings.spec.ts
|
|
344
|
-
git commit -m "feat(keyboard): add participant and message ring builders"
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
---
|
|
348
|
-
|
|
349
|
-
## Task 3: Extend `insertParticipantIntoDsl` to return label position
|
|
350
|
-
|
|
351
|
-
**Files:**
|
|
352
|
-
- Modify: `src/utils/participantInsertTransform.ts`
|
|
353
|
-
- Modify: `src/utils/participantInsertTransform.spec.ts` (or create)
|
|
354
|
-
- Modify: `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantInsertControls.tsx`
|
|
355
|
-
|
|
356
|
-
The helper currently returns the new code as a string. To auto-open the new participant in edit mode after a Tab-sibling insertion, we need the label range.
|
|
357
|
-
|
|
358
|
-
- [ ] **Step 1: Find the existing spec or create one**
|
|
359
|
-
|
|
360
|
-
Run: `ls src/utils/participantInsertTransform.spec.ts 2>/dev/null && echo exists || echo missing`
|
|
361
|
-
If missing, create it in Step 2.
|
|
362
|
-
|
|
363
|
-
- [ ] **Step 2: Write the failing test**
|
|
364
|
-
|
|
365
|
-
In `src/utils/participantInsertTransform.spec.ts` add (or create with):
|
|
366
|
-
|
|
367
|
-
```typescript
|
|
368
|
-
import { RootContext } from "@/parser";
|
|
369
|
-
import { insertParticipantIntoDsl } from "./participantInsertTransform";
|
|
370
|
-
|
|
371
|
-
describe("insertParticipantIntoDsl", () => {
|
|
372
|
-
it("returns the new code plus the label position of the inserted name", () => {
|
|
373
|
-
const code = "A\nB\nA->B.m1\n";
|
|
374
|
-
const rootContext = RootContext(code);
|
|
375
|
-
const result = insertParticipantIntoDsl({
|
|
376
|
-
code,
|
|
377
|
-
rootContext,
|
|
378
|
-
insertIndex: 1,
|
|
379
|
-
name: "X",
|
|
380
|
-
type: "default",
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
expect(typeof result).toBe("object");
|
|
384
|
-
expect(result.code).toContain("X");
|
|
385
|
-
const [start, end] = result.labelPosition;
|
|
386
|
-
expect(result.code.slice(start, end + 1)).toBe("X");
|
|
387
|
-
});
|
|
388
|
-
});
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
- [ ] **Step 3: Run test to verify it fails**
|
|
392
|
-
|
|
393
|
-
Run: `bun run test src/utils/participantInsertTransform.spec.ts`
|
|
394
|
-
Expected: FAIL — `result.code` is undefined (currently the function returns a string).
|
|
395
|
-
|
|
396
|
-
- [ ] **Step 4: Update `insertParticipantIntoDsl`**
|
|
397
|
-
|
|
398
|
-
Edit `src/utils/participantInsertTransform.ts`. Replace the function body's return statements with a shape that includes `labelPosition`. The label position is the range of the newly-inserted `name` (which goes through `normalizeName`, so it may be quoted). Compute it by finding the new line in the returned code and locating `normalizedName` within it.
|
|
399
|
-
|
|
400
|
-
Replace the two `return` branches at the bottom of the function with:
|
|
401
|
-
|
|
402
|
-
```typescript
|
|
403
|
-
const normalizedName = normalizeName(name);
|
|
404
|
-
|
|
405
|
-
if (head) {
|
|
406
|
-
const headStart = head.start.start;
|
|
407
|
-
const headEnd = head.stop.stop + 1;
|
|
408
|
-
const starter = head.starterExp?.();
|
|
409
|
-
const starterText = starter
|
|
410
|
-
? code.slice(starter.start.start, starter.stop.stop + 1)
|
|
411
|
-
: "";
|
|
412
|
-
const nextHead = starterText
|
|
413
|
-
? `${participantLines}\n${starterText}`
|
|
414
|
-
: participantLines;
|
|
415
|
-
const nextCode = code.slice(0, headStart) + nextHead + code.slice(headEnd);
|
|
416
|
-
const labelStart = nextCode.indexOf(normalizedName, headStart);
|
|
417
|
-
return {
|
|
418
|
-
code: nextCode,
|
|
419
|
-
labelPosition: [labelStart, labelStart + normalizedName.length - 1] as [
|
|
420
|
-
number,
|
|
421
|
-
number,
|
|
422
|
-
],
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
const insertionPoint = block
|
|
427
|
-
? block.start.start
|
|
428
|
-
: title
|
|
429
|
-
? title.stop.stop + 1
|
|
430
|
-
: 0;
|
|
431
|
-
const prefix = code.slice(0, insertionPoint);
|
|
432
|
-
const suffix = code.slice(insertionPoint);
|
|
433
|
-
const separator =
|
|
434
|
-
prefix.length > 0 && !prefix.endsWith("\n") ? "\n" : "";
|
|
435
|
-
const nextCode = `${prefix}${separator}${participantLines}\n${suffix}`;
|
|
436
|
-
const labelStart = nextCode.indexOf(normalizedName, insertionPoint);
|
|
437
|
-
return {
|
|
438
|
-
code: nextCode,
|
|
439
|
-
labelPosition: [labelStart, labelStart + normalizedName.length - 1] as [
|
|
440
|
-
number,
|
|
441
|
-
number,
|
|
442
|
-
],
|
|
443
|
-
};
|
|
444
|
-
```
|
|
445
|
-
|
|
446
|
-
Also update the return type annotation on the exported function to:
|
|
447
|
-
|
|
448
|
-
```typescript
|
|
449
|
-
): { code: string; labelPosition: [number, number] } => {
|
|
450
|
-
```
|
|
451
|
-
|
|
452
|
-
- [ ] **Step 5: Run tests to verify pass**
|
|
453
|
-
|
|
454
|
-
Run: `bun run test src/utils/participantInsertTransform.spec.ts`
|
|
455
|
-
Expected: PASS.
|
|
456
|
-
|
|
457
|
-
- [ ] **Step 6: Update call site in `ParticipantInsertControls.tsx`**
|
|
458
|
-
|
|
459
|
-
Edit `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantInsertControls.tsx`. Change:
|
|
460
|
-
|
|
461
|
-
```typescript
|
|
462
|
-
const nextCode = insertParticipantIntoDsl({
|
|
463
|
-
code,
|
|
464
|
-
rootContext,
|
|
465
|
-
insertIndex,
|
|
466
|
-
name,
|
|
467
|
-
type: "default",
|
|
468
|
-
});
|
|
469
|
-
setCode(nextCode);
|
|
470
|
-
onContentChange(nextCode);
|
|
471
|
-
```
|
|
472
|
-
|
|
473
|
-
to:
|
|
474
|
-
|
|
475
|
-
```typescript
|
|
476
|
-
const { code: nextCode } = insertParticipantIntoDsl({
|
|
477
|
-
code,
|
|
478
|
-
rootContext,
|
|
479
|
-
insertIndex,
|
|
480
|
-
name,
|
|
481
|
-
type: "default",
|
|
482
|
-
});
|
|
483
|
-
setCode(nextCode);
|
|
484
|
-
onContentChange(nextCode);
|
|
485
|
-
```
|
|
486
|
-
|
|
487
|
-
- [ ] **Step 7: Run the full unit test suite to catch regressions**
|
|
488
|
-
|
|
489
|
-
Run: `bun run test`
|
|
490
|
-
Expected: PASS.
|
|
491
|
-
|
|
492
|
-
- [ ] **Step 8: Commit**
|
|
493
|
-
|
|
494
|
-
```bash
|
|
495
|
-
git add src/utils/participantInsertTransform.ts src/utils/participantInsertTransform.spec.ts src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantInsertControls.tsx
|
|
496
|
-
git commit -m "refactor(insert): return labelPosition from insertParticipantIntoDsl"
|
|
497
|
-
```
|
|
498
|
-
|
|
499
|
-
---
|
|
500
|
-
|
|
501
|
-
## Task 4: Extend `EditableSpan` with `onTabCreateSibling`
|
|
502
|
-
|
|
503
|
-
**Files:**
|
|
504
|
-
- Modify: `src/components/common/EditableSpan/EditableSpan.tsx`
|
|
505
|
-
- Create or modify: `src/components/common/EditableSpan/EditableSpan.spec.tsx`
|
|
506
|
-
|
|
507
|
-
Goal: when in edit mode and the parent provides `onTabCreateSibling`, pressing `Tab` should save the current text and call the callback with `"after"`; `Shift+Tab` with `"before"`. If current text is empty, Tab is a no-op (empty-text guard from the spec).
|
|
508
|
-
|
|
509
|
-
- [ ] **Step 1: Check if spec file exists**
|
|
510
|
-
|
|
511
|
-
Run: `ls src/components/common/EditableSpan/EditableSpan.spec.tsx 2>/dev/null || echo missing`
|
|
512
|
-
|
|
513
|
-
If missing, create it in Step 2 with the full content. If it exists, append the new test cases.
|
|
514
|
-
|
|
515
|
-
- [ ] **Step 2: Write the failing test**
|
|
516
|
-
|
|
517
|
-
Create or extend `src/components/common/EditableSpan/EditableSpan.spec.tsx`:
|
|
518
|
-
|
|
519
|
-
```typescript
|
|
520
|
-
import { render, screen } from "@testing-library/react";
|
|
521
|
-
import userEvent from "@testing-library/user-event";
|
|
522
|
-
import { EditableSpan } from "./EditableSpan";
|
|
523
|
-
|
|
524
|
-
describe("EditableSpan onTabCreateSibling", () => {
|
|
525
|
-
it("calls callback with 'after' on Tab in edit mode", async () => {
|
|
526
|
-
const onSave = vi.fn();
|
|
527
|
-
const onTabCreateSibling = vi.fn();
|
|
528
|
-
render(
|
|
529
|
-
<EditableSpan
|
|
530
|
-
text="hello"
|
|
531
|
-
onSave={onSave}
|
|
532
|
-
onTabCreateSibling={onTabCreateSibling}
|
|
533
|
-
autoEditToken={1}
|
|
534
|
-
/>,
|
|
535
|
-
);
|
|
536
|
-
const span = screen.getByText("hello");
|
|
537
|
-
const user = userEvent.setup();
|
|
538
|
-
await user.keyboard("world");
|
|
539
|
-
await user.keyboard("{Tab}");
|
|
540
|
-
expect(onSave).toHaveBeenCalled();
|
|
541
|
-
expect(onTabCreateSibling).toHaveBeenCalledWith("after");
|
|
542
|
-
// the parent's span content should still be "world" (value at save)
|
|
543
|
-
expect(onSave.mock.calls.at(-1)?.[0]).toContain("world");
|
|
544
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
545
|
-
span;
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
it("calls callback with 'before' on Shift+Tab in edit mode", async () => {
|
|
549
|
-
const onSave = vi.fn();
|
|
550
|
-
const onTabCreateSibling = vi.fn();
|
|
551
|
-
render(
|
|
552
|
-
<EditableSpan
|
|
553
|
-
text="hello"
|
|
554
|
-
onSave={onSave}
|
|
555
|
-
onTabCreateSibling={onTabCreateSibling}
|
|
556
|
-
autoEditToken={1}
|
|
557
|
-
/>,
|
|
558
|
-
);
|
|
559
|
-
const user = userEvent.setup();
|
|
560
|
-
await user.keyboard("x");
|
|
561
|
-
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
|
562
|
-
expect(onTabCreateSibling).toHaveBeenCalledWith("before");
|
|
563
|
-
});
|
|
564
|
-
|
|
565
|
-
it("empty-text guard: Tab on empty content is a no-op", async () => {
|
|
566
|
-
const onSave = vi.fn();
|
|
567
|
-
const onTabCreateSibling = vi.fn();
|
|
568
|
-
render(
|
|
569
|
-
<EditableSpan
|
|
570
|
-
text=""
|
|
571
|
-
onSave={onSave}
|
|
572
|
-
onTabCreateSibling={onTabCreateSibling}
|
|
573
|
-
autoEditToken={1}
|
|
574
|
-
/>,
|
|
575
|
-
);
|
|
576
|
-
const user = userEvent.setup();
|
|
577
|
-
await user.keyboard("{Tab}");
|
|
578
|
-
expect(onTabCreateSibling).not.toHaveBeenCalled();
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
it("falls back to default Tab behavior when prop not provided", async () => {
|
|
582
|
-
const onSave = vi.fn();
|
|
583
|
-
render(<EditableSpan text="hello" onSave={onSave} autoEditToken={1} />);
|
|
584
|
-
const user = userEvent.setup();
|
|
585
|
-
await user.keyboard("x");
|
|
586
|
-
await user.keyboard("{Tab}");
|
|
587
|
-
expect(onSave).toHaveBeenCalled();
|
|
588
|
-
});
|
|
589
|
-
});
|
|
590
|
-
```
|
|
591
|
-
|
|
592
|
-
- [ ] **Step 3: Run test to verify it fails**
|
|
593
|
-
|
|
594
|
-
Run: `bun run test src/components/common/EditableSpan/EditableSpan.spec.tsx`
|
|
595
|
-
Expected: FAIL — prop does not exist, callback never invoked.
|
|
596
|
-
|
|
597
|
-
- [ ] **Step 4: Extend `EditableSpan`**
|
|
598
|
-
|
|
599
|
-
Edit `src/components/common/EditableSpan/EditableSpan.tsx`.
|
|
600
|
-
|
|
601
|
-
a) Update `EditableSpanProps`:
|
|
602
|
-
|
|
603
|
-
```typescript
|
|
604
|
-
export interface EditableSpanProps {
|
|
605
|
-
text: string;
|
|
606
|
-
isEditable?: boolean;
|
|
607
|
-
className?: string;
|
|
608
|
-
onSave: (newText: string) => void;
|
|
609
|
-
title?: string;
|
|
610
|
-
autoEditToken?: number;
|
|
611
|
-
selectAllOnEdit?: boolean;
|
|
612
|
-
onTabCreateSibling?: (direction: "before" | "after") => void;
|
|
613
|
-
}
|
|
614
|
-
```
|
|
615
|
-
|
|
616
|
-
b) Destructure the new prop in the component signature:
|
|
617
|
-
|
|
618
|
-
```typescript
|
|
619
|
-
export const EditableSpan = ({
|
|
620
|
-
text,
|
|
621
|
-
isEditable = true,
|
|
622
|
-
className = "",
|
|
623
|
-
onSave,
|
|
624
|
-
title = "Click to edit",
|
|
625
|
-
autoEditToken,
|
|
626
|
-
selectAllOnEdit = false,
|
|
627
|
-
onTabCreateSibling,
|
|
628
|
-
}: EditableSpanProps) => {
|
|
629
|
-
```
|
|
630
|
-
|
|
631
|
-
c) Replace the existing `handleKeydown` Tab branch. The current `handleKeydown` does not handle Tab explicitly (Tab is handled in `handleKeyup` which saves). We need to intercept Tab in `handleKeydown` BEFORE the browser moves focus. Inside the `if (editing)` block in `handleKeydown`, add this BEFORE the existing `Enter` handler:
|
|
632
|
-
|
|
633
|
-
```typescript
|
|
634
|
-
if (e.key === "Tab" && onTabCreateSibling) {
|
|
635
|
-
e.preventDefault();
|
|
636
|
-
e.stopPropagation();
|
|
637
|
-
const currentText = spanRef.current?.innerText?.trim() ?? "";
|
|
638
|
-
if (currentText === "") {
|
|
639
|
-
// Empty-text guard — do nothing.
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
cancelRef.current = true;
|
|
643
|
-
setEditing(false);
|
|
644
|
-
setIsHovered(false);
|
|
645
|
-
saveText();
|
|
646
|
-
onTabCreateSibling(e.shiftKey ? "before" : "after");
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
|
-
```
|
|
650
|
-
|
|
651
|
-
d) Also update `handleKeyup` so that when `onTabCreateSibling` is provided, Tab in keyup is ignored (keydown already handled it):
|
|
652
|
-
|
|
653
|
-
```typescript
|
|
654
|
-
const handleKeyup = (e: KeyboardEvent) => {
|
|
655
|
-
if (!isEditable) return;
|
|
656
|
-
if (!editing) return;
|
|
657
|
-
|
|
658
|
-
if (e.key === "Tab" && onTabCreateSibling) {
|
|
659
|
-
// Handled in keydown.
|
|
660
|
-
return;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
if (e.key === "Enter" || e.key === "Tab") {
|
|
664
|
-
cancelRef.current = true;
|
|
665
|
-
setEditing(false);
|
|
666
|
-
setIsHovered(false);
|
|
667
|
-
saveText();
|
|
668
|
-
}
|
|
669
|
-
};
|
|
670
|
-
```
|
|
671
|
-
|
|
672
|
-
- [ ] **Step 5: Run tests to verify pass**
|
|
673
|
-
|
|
674
|
-
Run: `bun run test src/components/common/EditableSpan/EditableSpan.spec.tsx`
|
|
675
|
-
Expected: PASS (all 4 cases).
|
|
676
|
-
|
|
677
|
-
- [ ] **Step 6: Run the full unit test suite to catch regressions**
|
|
678
|
-
|
|
679
|
-
Run: `bun run test`
|
|
680
|
-
Expected: PASS. `EditableSpan` is used by several components — this step confirms no existing tests regressed.
|
|
681
|
-
|
|
682
|
-
- [ ] **Step 7: Commit**
|
|
683
|
-
|
|
684
|
-
```bash
|
|
685
|
-
git add src/components/common/EditableSpan/EditableSpan.tsx src/components/common/EditableSpan/EditableSpan.spec.tsx
|
|
686
|
-
git commit -m "feat(editable-span): add onTabCreateSibling prop for Miro-style sibling creation"
|
|
687
|
-
```
|
|
688
|
-
|
|
689
|
-
---
|
|
690
|
-
|
|
691
|
-
## Task 5: Wire `onTabCreateSibling` into `ParticipantLabel`
|
|
692
|
-
|
|
693
|
-
**Files:**
|
|
694
|
-
- Modify: `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.tsx`
|
|
695
|
-
- Modify: `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.spec.tsx`
|
|
696
|
-
|
|
697
|
-
Goal: when the user Tabs while editing a participant label, insert a new participant before/after the current one and auto-open it in edit mode.
|
|
698
|
-
|
|
699
|
-
- [ ] **Step 1: Write the failing test**
|
|
700
|
-
|
|
701
|
-
Append to `ParticipantLabel.spec.tsx`:
|
|
702
|
-
|
|
703
|
-
```typescript
|
|
704
|
-
describe("ParticipantLabel keyboard sibling creation", () => {
|
|
705
|
-
it("Tab while editing inserts a new participant after and auto-opens it", async () => {
|
|
706
|
-
// Fixture: a diagram with participants A and B
|
|
707
|
-
const Fixture = ({ code }: { code: string }) => {
|
|
708
|
-
const store = createStore();
|
|
709
|
-
store.set(codeAtom, code);
|
|
710
|
-
return (
|
|
711
|
-
<Provider store={store}>
|
|
712
|
-
<DiagramFrame>
|
|
713
|
-
<SeqDiagram />
|
|
714
|
-
</DiagramFrame>
|
|
715
|
-
</Provider>
|
|
716
|
-
);
|
|
717
|
-
};
|
|
718
|
-
const { container } = render(<Fixture code="A\nB\n" />);
|
|
719
|
-
const firstLabel = container.querySelector<HTMLElement>(
|
|
720
|
-
".participant[data-participant-id='A'] .editable-span-base",
|
|
721
|
-
);
|
|
722
|
-
expect(firstLabel).toBeTruthy();
|
|
723
|
-
firstLabel!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
724
|
-
const user = userEvent.setup();
|
|
725
|
-
await user.keyboard("{Enter}Alpha{Tab}");
|
|
726
|
-
// After Tab: a new participant should be inserted after A (auto-named).
|
|
727
|
-
const all = container.querySelectorAll(".participant");
|
|
728
|
-
expect(all.length).toBeGreaterThanOrEqual(3);
|
|
729
|
-
});
|
|
730
|
-
});
|
|
731
|
-
```
|
|
732
|
-
|
|
733
|
-
*(The existing `ParticipantLabel.spec.tsx` imports and test harness can be reused — read the file first and adapt the fixture helper to match its style.)*
|
|
734
|
-
|
|
735
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
736
|
-
|
|
737
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.spec.tsx`
|
|
738
|
-
Expected: FAIL — Tab is saved into the existing participant, no new one created.
|
|
739
|
-
|
|
740
|
-
- [ ] **Step 3: Add a helper for ring-aware insertion**
|
|
741
|
-
|
|
742
|
-
Edit `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.tsx`. Add imports at top:
|
|
743
|
-
|
|
744
|
-
```typescript
|
|
745
|
-
import {
|
|
746
|
-
codeAtom,
|
|
747
|
-
modeAtom,
|
|
748
|
-
onContentChangeAtom,
|
|
749
|
-
pendingEditableRangeAtom,
|
|
750
|
-
rootContextAtom,
|
|
751
|
-
setFocusedParticipantAtom,
|
|
752
|
-
RenderMode,
|
|
753
|
-
} from "@/store/Store";
|
|
754
|
-
import { insertParticipantIntoDsl } from "@/utils/participantInsertTransform";
|
|
755
|
-
import { OrderedParticipants, _STARTER_ } from "@/parser/OrderedParticipants";
|
|
756
|
-
```
|
|
757
|
-
|
|
758
|
-
- [ ] **Step 4: Implement the sibling creator**
|
|
759
|
-
|
|
760
|
-
Inside the component body, after the existing atom hooks, add:
|
|
761
|
-
|
|
762
|
-
```typescript
|
|
763
|
-
const rootContext = useAtomValue(rootContextAtom);
|
|
764
|
-
const setPendingEditableRange = useSetAtom(pendingEditableRangeAtom);
|
|
765
|
-
const setFocusedParticipant = useSetAtom(setFocusedParticipantAtom);
|
|
766
|
-
|
|
767
|
-
const handleTabCreateSibling = (direction: "before" | "after") => {
|
|
768
|
-
if (!rootContext) return;
|
|
769
|
-
const names = OrderedParticipants(rootContext)
|
|
770
|
-
.map((p) => p.name)
|
|
771
|
-
.filter((n) => n !== _STARTER_);
|
|
772
|
-
const existing = new Set(names);
|
|
773
|
-
const currentIndex = names.indexOf(props.labelText);
|
|
774
|
-
if (currentIndex === -1) return;
|
|
775
|
-
const insertIndex =
|
|
776
|
-
direction === "after" ? currentIndex + 1 : currentIndex;
|
|
777
|
-
const autoName = generateParticipantName(existing);
|
|
778
|
-
const { code: nextCode, labelPosition } = insertParticipantIntoDsl({
|
|
779
|
-
code,
|
|
780
|
-
rootContext,
|
|
781
|
-
insertIndex,
|
|
782
|
-
name: autoName,
|
|
783
|
-
type: "default",
|
|
784
|
-
});
|
|
785
|
-
setCode(nextCode);
|
|
786
|
-
onContentChange(nextCode);
|
|
787
|
-
setPendingEditableRange({
|
|
788
|
-
start: labelPosition[0],
|
|
789
|
-
end: labelPosition[1],
|
|
790
|
-
token: Date.now(),
|
|
791
|
-
});
|
|
792
|
-
setFocusedParticipant(autoName);
|
|
793
|
-
};
|
|
794
|
-
```
|
|
795
|
-
|
|
796
|
-
Also add the helper at module scope (above the component):
|
|
797
|
-
|
|
798
|
-
```typescript
|
|
799
|
-
const generateParticipantName = (existing: Set<string>): string => {
|
|
800
|
-
for (let i = 1; ; i++) {
|
|
801
|
-
if (i <= 26) {
|
|
802
|
-
const candidate = String.fromCharCode(64 + i);
|
|
803
|
-
if (!existing.has(candidate)) return candidate;
|
|
804
|
-
continue;
|
|
805
|
-
}
|
|
806
|
-
const fallback = `P${i - 26}`;
|
|
807
|
-
if (!existing.has(fallback)) return fallback;
|
|
808
|
-
}
|
|
809
|
-
};
|
|
810
|
-
```
|
|
811
|
-
|
|
812
|
-
*(This is duplicated from `ParticipantInsertControls.generateName` — it's 8 lines and shared extraction would require a new utility file, which we can do in a cleanup PR. For now, duplicate and note it.)*
|
|
813
|
-
|
|
814
|
-
- [ ] **Step 5: Pass the handler to `EditableSpan`**
|
|
815
|
-
|
|
816
|
-
In the returned JSX, add `onTabCreateSibling` to both `EditableSpan` usages:
|
|
817
|
-
|
|
818
|
-
```typescript
|
|
819
|
-
<EditableSpan
|
|
820
|
-
text={displayText}
|
|
821
|
-
isEditable={props.assignee ? assigneeIsEditable : participantIsEditable}
|
|
822
|
-
className={cn("name leading-4 right px-1")}
|
|
823
|
-
onSave={
|
|
824
|
-
props.assignee
|
|
825
|
-
? createCombinedSaveHandler(displayText)
|
|
826
|
-
: createSaveHandler(props.labelPositions ?? [], props.labelText)
|
|
827
|
-
}
|
|
828
|
-
onTabCreateSibling={handleTabCreateSibling}
|
|
829
|
-
title="Click to edit"
|
|
830
|
-
/>
|
|
831
|
-
```
|
|
832
|
-
|
|
833
|
-
- [ ] **Step 6: Run test to verify it passes**
|
|
834
|
-
|
|
835
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.spec.tsx`
|
|
836
|
-
Expected: PASS.
|
|
837
|
-
|
|
838
|
-
- [ ] **Step 7: Run full unit suite**
|
|
839
|
-
|
|
840
|
-
Run: `bun run test`
|
|
841
|
-
Expected: PASS.
|
|
842
|
-
|
|
843
|
-
- [ ] **Step 8: Commit**
|
|
844
|
-
|
|
845
|
-
```bash
|
|
846
|
-
git add src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.tsx src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.spec.tsx
|
|
847
|
-
git commit -m "feat(keyboard): Tab in participant label creates a sibling participant"
|
|
848
|
-
```
|
|
849
|
-
|
|
850
|
-
---
|
|
851
|
-
|
|
852
|
-
## Task 6: Wire `onTabCreateSibling` into `EditableLabelField` (messages)
|
|
853
|
-
|
|
854
|
-
**Files:**
|
|
855
|
-
- Modify: `src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.tsx`
|
|
856
|
-
- Create: `src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.spec.tsx`
|
|
857
|
-
|
|
858
|
-
Goal: when the user Tabs while editing a message label, insert a new message in the same block, inheriting from/to from the current message, and auto-open the new label.
|
|
859
|
-
|
|
860
|
-
This requires the caller (e.g. `MessageLabel`) to pass additional context: `blockContext`, `insertIndex`, `from`, `to`. We extend `EditableLabelFieldProps` with an optional `siblingContext` object that carries those fields.
|
|
861
|
-
|
|
862
|
-
- [ ] **Step 1: Write the failing test**
|
|
863
|
-
|
|
864
|
-
Create `src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.spec.tsx`:
|
|
865
|
-
|
|
866
|
-
```typescript
|
|
867
|
-
import { render } from "@testing-library/react";
|
|
868
|
-
import userEvent from "@testing-library/user-event";
|
|
869
|
-
import { Provider, createStore } from "jotai";
|
|
870
|
-
import { codeAtom } from "@/store/Store";
|
|
871
|
-
import { DiagramFrame } from "@/components/DiagramFrame/DiagramFrame";
|
|
872
|
-
import { SeqDiagram } from "@/components/DiagramFrame/SeqDiagram/SeqDiagram";
|
|
873
|
-
|
|
874
|
-
describe("EditableLabelField Tab-create-sibling", () => {
|
|
875
|
-
it("Tab while editing a message creates another message A->B", async () => {
|
|
876
|
-
const store = createStore();
|
|
877
|
-
store.set(codeAtom, "A->B.m1\n");
|
|
878
|
-
const { container } = render(
|
|
879
|
-
<Provider store={store}>
|
|
880
|
-
<DiagramFrame>
|
|
881
|
-
<SeqDiagram />
|
|
882
|
-
</DiagramFrame>
|
|
883
|
-
</Provider>,
|
|
884
|
-
);
|
|
885
|
-
const label = container.querySelector<HTMLElement>(
|
|
886
|
-
".message .editable-span-base",
|
|
887
|
-
);
|
|
888
|
-
expect(label).toBeTruthy();
|
|
889
|
-
// Click to select, then click to edit (branch behavior)
|
|
890
|
-
label!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
891
|
-
label!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
892
|
-
const user = userEvent.setup();
|
|
893
|
-
await user.keyboard("renamed{Tab}newLabel{Enter}");
|
|
894
|
-
expect(store.get(codeAtom)).toMatch(/A->B\.renamed/);
|
|
895
|
-
expect(store.get(codeAtom)).toMatch(/A->B\.newLabel/);
|
|
896
|
-
});
|
|
897
|
-
});
|
|
898
|
-
```
|
|
899
|
-
|
|
900
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
901
|
-
|
|
902
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.spec.tsx`
|
|
903
|
-
Expected: FAIL — the second message is not created.
|
|
904
|
-
|
|
905
|
-
- [ ] **Step 3: Extend `EditableLabelFieldProps`**
|
|
906
|
-
|
|
907
|
-
Edit `src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.tsx`. Update the interface:
|
|
908
|
-
|
|
909
|
-
```typescript
|
|
910
|
-
interface EditableLabelFieldProps {
|
|
911
|
-
text: string;
|
|
912
|
-
position: [number, number];
|
|
913
|
-
normalizeText?: (text: string) => string;
|
|
914
|
-
className?: string;
|
|
915
|
-
title?: string;
|
|
916
|
-
siblingContext?: {
|
|
917
|
-
from: string;
|
|
918
|
-
to: string;
|
|
919
|
-
blockContext: any;
|
|
920
|
-
insertIndex: number;
|
|
921
|
-
};
|
|
922
|
-
}
|
|
923
|
-
```
|
|
924
|
-
|
|
925
|
-
- [ ] **Step 4: Implement the handler**
|
|
926
|
-
|
|
927
|
-
Add imports at the top of the file:
|
|
928
|
-
|
|
929
|
-
```typescript
|
|
930
|
-
import { insertMessageInDsl } from "@/utils/insertMessageInDsl";
|
|
931
|
-
```
|
|
932
|
-
|
|
933
|
-
Inside the component body (after the existing hooks), add:
|
|
934
|
-
|
|
935
|
-
```typescript
|
|
936
|
-
const handleTabCreateSibling = (direction: "before" | "after") => {
|
|
937
|
-
if (!siblingContext) return;
|
|
938
|
-
const { from, to, blockContext, insertIndex } = siblingContext;
|
|
939
|
-
const effectiveIndex = direction === "after" ? insertIndex + 1 : insertIndex;
|
|
940
|
-
const result = insertMessageInDsl({
|
|
941
|
-
code,
|
|
942
|
-
from,
|
|
943
|
-
to,
|
|
944
|
-
blockContext,
|
|
945
|
-
insertIndex: effectiveIndex,
|
|
946
|
-
});
|
|
947
|
-
setCode(result.code);
|
|
948
|
-
onContentChange(result.code);
|
|
949
|
-
setPendingEditableRange({
|
|
950
|
-
start: result.labelPosition[0],
|
|
951
|
-
end: result.labelPosition[1],
|
|
952
|
-
token: Date.now(),
|
|
953
|
-
});
|
|
954
|
-
};
|
|
955
|
-
```
|
|
956
|
-
|
|
957
|
-
And add `siblingContext` to the destructured props, and `onTabCreateSibling` to the `EditableSpan` render:
|
|
958
|
-
|
|
959
|
-
```typescript
|
|
960
|
-
export const EditableLabelField = ({
|
|
961
|
-
text,
|
|
962
|
-
position,
|
|
963
|
-
normalizeText,
|
|
964
|
-
className,
|
|
965
|
-
title = "Click to edit",
|
|
966
|
-
siblingContext,
|
|
967
|
-
}: EditableLabelFieldProps) => {
|
|
968
|
-
// ...existing hooks...
|
|
969
|
-
return (
|
|
970
|
-
<EditableSpan
|
|
971
|
-
text={formattedText}
|
|
972
|
-
isEditable={isEditable}
|
|
973
|
-
className={cn(className)}
|
|
974
|
-
onSave={handleSave}
|
|
975
|
-
title={title}
|
|
976
|
-
autoEditToken={shouldAutoEdit}
|
|
977
|
-
onTabCreateSibling={siblingContext ? handleTabCreateSibling : undefined}
|
|
978
|
-
/>
|
|
979
|
-
);
|
|
980
|
-
};
|
|
981
|
-
```
|
|
982
|
-
|
|
983
|
-
- [ ] **Step 5: Pass `siblingContext` from `MessageLabel`**
|
|
984
|
-
|
|
985
|
-
Edit `src/components/DiagramFrame/SeqDiagram/MessageLayer/MessageLabel.tsx`. Extend its props to accept the sibling context and forward it:
|
|
986
|
-
|
|
987
|
-
```typescript
|
|
988
|
-
export const MessageLabel = (props: {
|
|
989
|
-
labelText: string;
|
|
990
|
-
labelPosition: [number, number];
|
|
991
|
-
normalizeText?: (text: string) => string;
|
|
992
|
-
className?: string;
|
|
993
|
-
siblingContext?: {
|
|
994
|
-
from: string;
|
|
995
|
-
to: string;
|
|
996
|
-
blockContext: any;
|
|
997
|
-
insertIndex: number;
|
|
998
|
-
};
|
|
999
|
-
}) => {
|
|
1000
|
-
return (
|
|
1001
|
-
<EditableLabelField
|
|
1002
|
-
text={props.labelText}
|
|
1003
|
-
position={props.labelPosition}
|
|
1004
|
-
normalizeText={props.normalizeText}
|
|
1005
|
-
className={cn("px-1 right", props.className)}
|
|
1006
|
-
siblingContext={props.siblingContext}
|
|
1007
|
-
/>
|
|
1008
|
-
);
|
|
1009
|
-
};
|
|
1010
|
-
```
|
|
1011
|
-
|
|
1012
|
-
- [ ] **Step 6: Forward context from `Interaction` and `InteractionAsync`**
|
|
1013
|
-
|
|
1014
|
-
Read `Interaction.tsx`, find where `MessageLabel` is rendered, and add:
|
|
1015
|
-
|
|
1016
|
-
```typescript
|
|
1017
|
-
siblingContext={{
|
|
1018
|
-
from: /* owner / from name for this message */,
|
|
1019
|
-
to: /* target / to name for this message */,
|
|
1020
|
-
blockContext: props.blockContext,
|
|
1021
|
-
insertIndex: props.insertIndex,
|
|
1022
|
-
}}
|
|
1023
|
-
```
|
|
1024
|
-
|
|
1025
|
-
The blockContext and insertIndex should already be available as props in `Interaction.tsx` (they are used by GapHandleZone). Verify by reading `Statement.tsx` which renders `Interaction`. If `insertIndex`/`blockContext` are not present as props, thread them through from `Block.tsx` → `Statement.tsx` → `Interaction.tsx` (Block already has the block context and the index of each child).
|
|
1026
|
-
|
|
1027
|
-
Apply the same change to `InteractionAsync.tsx` / `Interaction-async.tsx`.
|
|
1028
|
-
|
|
1029
|
-
- [ ] **Step 7: Run the new spec**
|
|
1030
|
-
|
|
1031
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.spec.tsx`
|
|
1032
|
-
Expected: PASS.
|
|
1033
|
-
|
|
1034
|
-
- [ ] **Step 8: Run the full unit test suite**
|
|
1035
|
-
|
|
1036
|
-
Run: `bun run test`
|
|
1037
|
-
Expected: PASS.
|
|
1038
|
-
|
|
1039
|
-
- [ ] **Step 9: Commit**
|
|
1040
|
-
|
|
1041
|
-
```bash
|
|
1042
|
-
git add src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.tsx \
|
|
1043
|
-
src/components/DiagramFrame/SeqDiagram/MessageLayer/MessageLabel.tsx \
|
|
1044
|
-
src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.spec.tsx \
|
|
1045
|
-
src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Interaction.tsx \
|
|
1046
|
-
src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/InteractionAsync/Interaction-async.tsx
|
|
1047
|
-
git commit -m "feat(keyboard): Tab in message label creates a sibling message with same from/to"
|
|
1048
|
-
```
|
|
1049
|
-
|
|
1050
|
-
---
|
|
1051
|
-
|
|
1052
|
-
## Task 7: `useDiagramKeyboard` hook
|
|
1053
|
-
|
|
1054
|
-
**Files:**
|
|
1055
|
-
- Create: `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts`
|
|
1056
|
-
- Create: `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
|
|
1057
|
-
|
|
1058
|
-
Goal: a hook that installs a keydown listener on a provided container `ref`, reading the current focused atom and dispatching arrow-key navigation. Only active when no element in the container is in edit mode (detected via `document.activeElement?.isContentEditable`).
|
|
1059
|
-
|
|
1060
|
-
- [ ] **Step 1: Write the failing test**
|
|
1061
|
-
|
|
1062
|
-
Create `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`:
|
|
1063
|
-
|
|
1064
|
-
```typescript
|
|
1065
|
-
import { render, fireEvent } from "@testing-library/react";
|
|
1066
|
-
import { Provider, createStore } from "jotai";
|
|
1067
|
-
import {
|
|
1068
|
-
codeAtom,
|
|
1069
|
-
focusedParticipantAtom,
|
|
1070
|
-
focusedMessageAtom,
|
|
1071
|
-
} from "@/store/Store";
|
|
1072
|
-
import { SeqDiagram } from "../SeqDiagram";
|
|
1073
|
-
import { DiagramFrame } from "@/components/DiagramFrame/DiagramFrame";
|
|
1074
|
-
|
|
1075
|
-
const renderWith = (code: string) => {
|
|
1076
|
-
const store = createStore();
|
|
1077
|
-
store.set(codeAtom, code);
|
|
1078
|
-
const utils = render(
|
|
1079
|
-
<Provider store={store}>
|
|
1080
|
-
<DiagramFrame>
|
|
1081
|
-
<SeqDiagram />
|
|
1082
|
-
</DiagramFrame>
|
|
1083
|
-
</Provider>,
|
|
1084
|
-
);
|
|
1085
|
-
return { store, ...utils };
|
|
1086
|
-
};
|
|
1087
|
-
|
|
1088
|
-
describe("useDiagramKeyboard", () => {
|
|
1089
|
-
it("Right arrow moves to next participant", () => {
|
|
1090
|
-
const { store, container } = renderWith("A\nB\nC\nA->B.m1\n");
|
|
1091
|
-
store.set(focusedParticipantAtom, "A");
|
|
1092
|
-
const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
|
|
1093
|
-
expect(diagram).toBeTruthy();
|
|
1094
|
-
fireEvent.keyDown(diagram!, { key: "ArrowRight" });
|
|
1095
|
-
expect(store.get(focusedParticipantAtom)).toBe("B");
|
|
1096
|
-
});
|
|
1097
|
-
|
|
1098
|
-
it("Right arrow from last participant wraps to first", () => {
|
|
1099
|
-
const { store, container } = renderWith("A\nB\nC\nA->B.m1\n");
|
|
1100
|
-
store.set(focusedParticipantAtom, "C");
|
|
1101
|
-
const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
|
|
1102
|
-
fireEvent.keyDown(diagram!, { key: "ArrowRight" });
|
|
1103
|
-
expect(store.get(focusedParticipantAtom)).toBe("A");
|
|
1104
|
-
});
|
|
1105
|
-
|
|
1106
|
-
it("Left arrow wraps backwards", () => {
|
|
1107
|
-
const { store, container } = renderWith("A\nB\nC\n");
|
|
1108
|
-
store.set(focusedParticipantAtom, "A");
|
|
1109
|
-
const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
|
|
1110
|
-
fireEvent.keyDown(diagram!, { key: "ArrowLeft" });
|
|
1111
|
-
expect(store.get(focusedParticipantAtom)).toBe("C");
|
|
1112
|
-
});
|
|
1113
|
-
|
|
1114
|
-
it("Down from participant drops into messages ring", () => {
|
|
1115
|
-
const { store, container } = renderWith("A\nB\nA->B.m1\n");
|
|
1116
|
-
store.set(focusedParticipantAtom, "A");
|
|
1117
|
-
const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
|
|
1118
|
-
fireEvent.keyDown(diagram!, { key: "ArrowDown" });
|
|
1119
|
-
expect(store.get(focusedParticipantAtom)).toBeNull();
|
|
1120
|
-
expect(store.get(focusedMessageAtom)).not.toBeNull();
|
|
1121
|
-
});
|
|
1122
|
-
|
|
1123
|
-
it("Down in messages moves to next message", () => {
|
|
1124
|
-
const { store, container } = renderWith("A->B.m1\nA->B.m2\n");
|
|
1125
|
-
// Focus first message
|
|
1126
|
-
const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
|
|
1127
|
-
fireEvent.keyDown(diagram!, { key: "ArrowDown" });
|
|
1128
|
-
const first = store.get(focusedMessageAtom);
|
|
1129
|
-
fireEvent.keyDown(diagram!, { key: "ArrowDown" });
|
|
1130
|
-
const second = store.get(focusedMessageAtom);
|
|
1131
|
-
expect(second).not.toEqual(first);
|
|
1132
|
-
});
|
|
1133
|
-
|
|
1134
|
-
it("Up from first message jumps back to the source participant", () => {
|
|
1135
|
-
const { store, container } = renderWith("A->B.m1\n");
|
|
1136
|
-
const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
|
|
1137
|
-
fireEvent.keyDown(diagram!, { key: "ArrowDown" }); // into messages
|
|
1138
|
-
fireEvent.keyDown(diagram!, { key: "ArrowUp" });
|
|
1139
|
-
expect(store.get(focusedParticipantAtom)).toBe("A");
|
|
1140
|
-
expect(store.get(focusedMessageAtom)).toBeNull();
|
|
1141
|
-
});
|
|
1142
|
-
|
|
1143
|
-
it("arrow keys are ignored when a contenteditable has focus", () => {
|
|
1144
|
-
const { store, container } = renderWith("A\nB\n");
|
|
1145
|
-
store.set(focusedParticipantAtom, "A");
|
|
1146
|
-
// Create a contenteditable and focus it
|
|
1147
|
-
const editable = document.createElement("span");
|
|
1148
|
-
editable.contentEditable = "true";
|
|
1149
|
-
container.appendChild(editable);
|
|
1150
|
-
editable.focus();
|
|
1151
|
-
const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
|
|
1152
|
-
fireEvent.keyDown(diagram!, { key: "ArrowRight" });
|
|
1153
|
-
expect(store.get(focusedParticipantAtom)).toBe("A"); // unchanged
|
|
1154
|
-
});
|
|
1155
|
-
});
|
|
1156
|
-
```
|
|
1157
|
-
|
|
1158
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
1159
|
-
|
|
1160
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
|
|
1161
|
-
Expected: FAIL — hook not yet implemented.
|
|
1162
|
-
|
|
1163
|
-
- [ ] **Step 3: Implement the hook**
|
|
1164
|
-
|
|
1165
|
-
Create `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts`:
|
|
1166
|
-
|
|
1167
|
-
```typescript
|
|
1168
|
-
import { useEffect, RefObject } from "react";
|
|
1169
|
-
import { useAtomValue, useSetAtom } from "jotai";
|
|
1170
|
-
import {
|
|
1171
|
-
rootContextAtom,
|
|
1172
|
-
focusedParticipantAtom,
|
|
1173
|
-
focusedMessageAtom,
|
|
1174
|
-
setFocusedParticipantAtom,
|
|
1175
|
-
setFocusedMessageAtom,
|
|
1176
|
-
} from "@/store/Store";
|
|
1177
|
-
import { buildParticipantRing, buildMessageRing } from "./rings";
|
|
1178
|
-
|
|
1179
|
-
export const useDiagramKeyboard = (
|
|
1180
|
-
containerRef: RefObject<HTMLElement | null>,
|
|
1181
|
-
) => {
|
|
1182
|
-
const rootContext = useAtomValue(rootContextAtom);
|
|
1183
|
-
const focusedParticipant = useAtomValue(focusedParticipantAtom);
|
|
1184
|
-
const focusedMessage = useAtomValue(focusedMessageAtom);
|
|
1185
|
-
const setFocusedParticipant = useSetAtom(setFocusedParticipantAtom);
|
|
1186
|
-
const setFocusedMessage = useSetAtom(setFocusedMessageAtom);
|
|
1187
|
-
|
|
1188
|
-
useEffect(() => {
|
|
1189
|
-
const container = containerRef.current;
|
|
1190
|
-
if (!container) return;
|
|
1191
|
-
|
|
1192
|
-
const onKeyDown = (event: KeyboardEvent) => {
|
|
1193
|
-
// Ignore if a contenteditable is focused (edit mode).
|
|
1194
|
-
const active = document.activeElement as HTMLElement | null;
|
|
1195
|
-
if (active?.isContentEditable) return;
|
|
1196
|
-
|
|
1197
|
-
const key = event.key;
|
|
1198
|
-
if (
|
|
1199
|
-
key !== "ArrowLeft" &&
|
|
1200
|
-
key !== "ArrowRight" &&
|
|
1201
|
-
key !== "ArrowUp" &&
|
|
1202
|
-
key !== "ArrowDown"
|
|
1203
|
-
) {
|
|
1204
|
-
return;
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
const participants = buildParticipantRing(rootContext);
|
|
1208
|
-
const messages = buildMessageRing(rootContext);
|
|
1209
|
-
|
|
1210
|
-
// Participant ring
|
|
1211
|
-
if (focusedParticipant !== null) {
|
|
1212
|
-
const idx = participants.indexOf(focusedParticipant);
|
|
1213
|
-
if (idx === -1) return;
|
|
1214
|
-
if (key === "ArrowRight") {
|
|
1215
|
-
event.preventDefault();
|
|
1216
|
-
setFocusedParticipant(participants[(idx + 1) % participants.length]);
|
|
1217
|
-
return;
|
|
1218
|
-
}
|
|
1219
|
-
if (key === "ArrowLeft") {
|
|
1220
|
-
event.preventDefault();
|
|
1221
|
-
setFocusedParticipant(
|
|
1222
|
-
participants[(idx - 1 + participants.length) % participants.length],
|
|
1223
|
-
);
|
|
1224
|
-
return;
|
|
1225
|
-
}
|
|
1226
|
-
if (key === "ArrowDown") {
|
|
1227
|
-
if (messages.length === 0) return;
|
|
1228
|
-
event.preventDefault();
|
|
1229
|
-
setFocusedMessage({
|
|
1230
|
-
start: messages[0].labelStart,
|
|
1231
|
-
end: messages[0].labelEnd,
|
|
1232
|
-
});
|
|
1233
|
-
return;
|
|
1234
|
-
}
|
|
1235
|
-
return;
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
// Message ring
|
|
1239
|
-
if (focusedMessage !== null) {
|
|
1240
|
-
const idx = messages.findIndex(
|
|
1241
|
-
(m) =>
|
|
1242
|
-
m.labelStart === focusedMessage.start &&
|
|
1243
|
-
m.labelEnd === focusedMessage.end,
|
|
1244
|
-
);
|
|
1245
|
-
if (idx === -1) return;
|
|
1246
|
-
if (key === "ArrowDown") {
|
|
1247
|
-
event.preventDefault();
|
|
1248
|
-
const next = messages[(idx + 1) % messages.length];
|
|
1249
|
-
setFocusedMessage({ start: next.labelStart, end: next.labelEnd });
|
|
1250
|
-
return;
|
|
1251
|
-
}
|
|
1252
|
-
if (key === "ArrowUp") {
|
|
1253
|
-
event.preventDefault();
|
|
1254
|
-
if (idx === 0) {
|
|
1255
|
-
// Jump to the source participant of the first message.
|
|
1256
|
-
setFocusedParticipant(messages[0].from);
|
|
1257
|
-
return;
|
|
1258
|
-
}
|
|
1259
|
-
const prev = messages[idx - 1];
|
|
1260
|
-
setFocusedMessage({ start: prev.labelStart, end: prev.labelEnd });
|
|
1261
|
-
return;
|
|
1262
|
-
}
|
|
1263
|
-
return;
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
// Nothing focused — ArrowDown enters the messages ring at index 0,
|
|
1267
|
-
// ArrowRight/Left enters the participant ring at index 0.
|
|
1268
|
-
if (key === "ArrowDown" && messages.length > 0) {
|
|
1269
|
-
event.preventDefault();
|
|
1270
|
-
setFocusedMessage({
|
|
1271
|
-
start: messages[0].labelStart,
|
|
1272
|
-
end: messages[0].labelEnd,
|
|
1273
|
-
});
|
|
1274
|
-
return;
|
|
1275
|
-
}
|
|
1276
|
-
if (
|
|
1277
|
-
(key === "ArrowRight" || key === "ArrowLeft") &&
|
|
1278
|
-
participants.length > 0
|
|
1279
|
-
) {
|
|
1280
|
-
event.preventDefault();
|
|
1281
|
-
setFocusedParticipant(participants[0]);
|
|
1282
|
-
return;
|
|
1283
|
-
}
|
|
1284
|
-
};
|
|
1285
|
-
|
|
1286
|
-
container.addEventListener("keydown", onKeyDown);
|
|
1287
|
-
return () => container.removeEventListener("keydown", onKeyDown);
|
|
1288
|
-
}, [
|
|
1289
|
-
containerRef,
|
|
1290
|
-
rootContext,
|
|
1291
|
-
focusedParticipant,
|
|
1292
|
-
focusedMessage,
|
|
1293
|
-
setFocusedParticipant,
|
|
1294
|
-
setFocusedMessage,
|
|
1295
|
-
]);
|
|
1296
|
-
};
|
|
1297
|
-
```
|
|
1298
|
-
|
|
1299
|
-
- [ ] **Step 4: Run the unit tests to verify pass**
|
|
1300
|
-
|
|
1301
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
|
|
1302
|
-
Expected: PASS (all 7 cases). If the "nothing focused → ArrowDown" test needs a fresh store, adjust the test setup.
|
|
1303
|
-
|
|
1304
|
-
- [ ] **Step 5: Commit**
|
|
1305
|
-
|
|
1306
|
-
```bash
|
|
1307
|
-
git add src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts \
|
|
1308
|
-
src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx
|
|
1309
|
-
git commit -m "feat(keyboard): add useDiagramKeyboard hook for arrow-key navigation"
|
|
1310
|
-
```
|
|
1311
|
-
|
|
1312
|
-
---
|
|
1313
|
-
|
|
1314
|
-
## Task 8: Install the hook on `SeqDiagram` + tabIndex + auto-focus
|
|
1315
|
-
|
|
1316
|
-
**Files:**
|
|
1317
|
-
- Modify: `src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx`
|
|
1318
|
-
|
|
1319
|
-
- [ ] **Step 1: Write the failing test**
|
|
1320
|
-
|
|
1321
|
-
Append to `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`:
|
|
1322
|
-
|
|
1323
|
-
```typescript
|
|
1324
|
-
describe("SeqDiagram container focus", () => {
|
|
1325
|
-
it("focusing the container auto-focuses the first participant", () => {
|
|
1326
|
-
const { store, container } = renderWith("A\nB\nC\n");
|
|
1327
|
-
const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
|
|
1328
|
-
expect(diagram).toBeTruthy();
|
|
1329
|
-
diagram!.focus();
|
|
1330
|
-
expect(store.get(focusedParticipantAtom)).toBe("A");
|
|
1331
|
-
});
|
|
1332
|
-
});
|
|
1333
|
-
```
|
|
1334
|
-
|
|
1335
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
1336
|
-
|
|
1337
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
|
|
1338
|
-
Expected: FAIL — container is not focusable yet.
|
|
1339
|
-
|
|
1340
|
-
- [ ] **Step 3: Wire the hook and tabIndex**
|
|
1341
|
-
|
|
1342
|
-
Edit `src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx`. Add imports:
|
|
1343
|
-
|
|
1344
|
-
```typescript
|
|
1345
|
-
import { useDiagramKeyboard } from "./keyboard/useDiagramKeyboard";
|
|
1346
|
-
import {
|
|
1347
|
-
focusedParticipantAtom,
|
|
1348
|
-
setFocusedParticipantAtom,
|
|
1349
|
-
} from "@/store/Store";
|
|
1350
|
-
import { buildParticipantRing } from "./keyboard/rings";
|
|
1351
|
-
```
|
|
1352
|
-
|
|
1353
|
-
Inside the component body, add:
|
|
1354
|
-
|
|
1355
|
-
```typescript
|
|
1356
|
-
useDiagramKeyboard(diagramRef);
|
|
1357
|
-
const setFocusedParticipant = useSetAtom(setFocusedParticipantAtom);
|
|
1358
|
-
const focusedParticipant = useAtomValue(focusedParticipantAtom);
|
|
1359
|
-
|
|
1360
|
-
const handleContainerFocus = (event: React.FocusEvent<HTMLDivElement>) => {
|
|
1361
|
-
// Only act when the container itself received focus (not a child).
|
|
1362
|
-
if (event.target !== diagramRef.current) return;
|
|
1363
|
-
if (focusedParticipant !== null) return;
|
|
1364
|
-
const ring = buildParticipantRing(rootContext);
|
|
1365
|
-
if (ring.length > 0) {
|
|
1366
|
-
setFocusedParticipant(ring[0]);
|
|
1367
|
-
}
|
|
1368
|
-
};
|
|
1369
|
-
```
|
|
1370
|
-
|
|
1371
|
-
Update the container `<div>`:
|
|
1372
|
-
|
|
1373
|
-
```typescript
|
|
1374
|
-
<div
|
|
1375
|
-
className={cn(
|
|
1376
|
-
"zenuml sequence-diagram relative box-border text-left overflow-visible px-2.5",
|
|
1377
|
-
theme,
|
|
1378
|
-
props.className,
|
|
1379
|
-
)}
|
|
1380
|
-
style={props.style}
|
|
1381
|
-
ref={diagramRef}
|
|
1382
|
-
tabIndex={0}
|
|
1383
|
-
onFocus={handleContainerFocus}
|
|
1384
|
-
>
|
|
1385
|
-
```
|
|
1386
|
-
|
|
1387
|
-
- [ ] **Step 4: Run test to verify pass**
|
|
1388
|
-
|
|
1389
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
|
|
1390
|
-
Expected: PASS.
|
|
1391
|
-
|
|
1392
|
-
- [ ] **Step 5: Run full unit suite**
|
|
1393
|
-
|
|
1394
|
-
Run: `bun run test`
|
|
1395
|
-
Expected: PASS.
|
|
1396
|
-
|
|
1397
|
-
- [ ] **Step 6: Commit**
|
|
1398
|
-
|
|
1399
|
-
```bash
|
|
1400
|
-
git add src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx \
|
|
1401
|
-
src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx
|
|
1402
|
-
git commit -m "feat(keyboard): tabIndex + auto-focus first participant on SeqDiagram"
|
|
1403
|
-
```
|
|
1404
|
-
|
|
1405
|
-
---
|
|
1406
|
-
|
|
1407
|
-
## Task 9: Focus ring visuals
|
|
1408
|
-
|
|
1409
|
-
**Files:**
|
|
1410
|
-
- Modify: `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx`
|
|
1411
|
-
- Modify: `src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.tsx`
|
|
1412
|
-
- Modify: `src/components/DiagramFrame/SeqDiagram/SeqDiagram.css`
|
|
1413
|
-
|
|
1414
|
-
Goal: show a 2px sky-blue ring on the focused participant / message label without interfering with existing `.selected` styles.
|
|
1415
|
-
|
|
1416
|
-
- [ ] **Step 1: Add CSS classes**
|
|
1417
|
-
|
|
1418
|
-
Append to `src/components/DiagramFrame/SeqDiagram/SeqDiagram.css`:
|
|
1419
|
-
|
|
1420
|
-
```css
|
|
1421
|
-
.participant.kb-focused {
|
|
1422
|
-
outline: 2px solid rgb(56, 189, 248);
|
|
1423
|
-
outline-offset: 2px;
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
.editable-span-base.kb-focused {
|
|
1427
|
-
outline: 2px solid rgb(56, 189, 248);
|
|
1428
|
-
outline-offset: 2px;
|
|
1429
|
-
border-radius: 2px;
|
|
1430
|
-
}
|
|
1431
|
-
```
|
|
1432
|
-
|
|
1433
|
-
- [ ] **Step 2: Apply class in `Participant.tsx`**
|
|
1434
|
-
|
|
1435
|
-
Edit `Participant.tsx`. Import:
|
|
1436
|
-
|
|
1437
|
-
```typescript
|
|
1438
|
-
import { focusedParticipantAtom } from "@/store/Store";
|
|
1439
|
-
```
|
|
1440
|
-
|
|
1441
|
-
In the component, add:
|
|
1442
|
-
|
|
1443
|
-
```typescript
|
|
1444
|
-
const focusedParticipant = useAtomValue(focusedParticipantAtom);
|
|
1445
|
-
const isKeyboardFocused = focusedParticipant === props.entity.name;
|
|
1446
|
-
```
|
|
1447
|
-
|
|
1448
|
-
Update the outer `div` className with `{ "kb-focused": isKeyboardFocused }` in the `cn(...)` call.
|
|
1449
|
-
|
|
1450
|
-
- [ ] **Step 3: Apply class in `EditableLabelField.tsx`**
|
|
1451
|
-
|
|
1452
|
-
Edit `EditableLabelField.tsx`. Add:
|
|
1453
|
-
|
|
1454
|
-
```typescript
|
|
1455
|
-
import { focusedMessageAtom } from "@/store/Store";
|
|
1456
|
-
```
|
|
1457
|
-
|
|
1458
|
-
In the component body:
|
|
1459
|
-
|
|
1460
|
-
```typescript
|
|
1461
|
-
const focusedMessage = useAtomValue(focusedMessageAtom);
|
|
1462
|
-
const isKeyboardFocused =
|
|
1463
|
-
focusedMessage !== null &&
|
|
1464
|
-
focusedMessage.start === position[0] &&
|
|
1465
|
-
focusedMessage.end === position[1];
|
|
1466
|
-
```
|
|
1467
|
-
|
|
1468
|
-
Update the `className` passed to `EditableSpan`:
|
|
1469
|
-
|
|
1470
|
-
```typescript
|
|
1471
|
-
className={cn(className, { "kb-focused": isKeyboardFocused })}
|
|
1472
|
-
```
|
|
1473
|
-
|
|
1474
|
-
- [ ] **Step 4: Manual smoke test**
|
|
1475
|
-
|
|
1476
|
-
Run: `bun dev`
|
|
1477
|
-
Open http://localhost:8080, focus the diagram, press `→`, confirm sky-blue ring moves between participants; press `↓`, confirm ring moves to message label.
|
|
1478
|
-
|
|
1479
|
-
- [ ] **Step 5: Run unit suite**
|
|
1480
|
-
|
|
1481
|
-
Run: `bun run test`
|
|
1482
|
-
Expected: PASS.
|
|
1483
|
-
|
|
1484
|
-
- [ ] **Step 6: Commit**
|
|
1485
|
-
|
|
1486
|
-
```bash
|
|
1487
|
-
git add src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx \
|
|
1488
|
-
src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.tsx \
|
|
1489
|
-
src/components/DiagramFrame/SeqDiagram/SeqDiagram.css
|
|
1490
|
-
git commit -m "feat(keyboard): sky-blue focus ring for keyboard navigation"
|
|
1491
|
-
```
|
|
1492
|
-
|
|
1493
|
-
---
|
|
1494
|
-
|
|
1495
|
-
## Task 10: Enter on focused element opens edit mode
|
|
1496
|
-
|
|
1497
|
-
**Files:**
|
|
1498
|
-
- Modify: `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts`
|
|
1499
|
-
- Modify: `src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.tsx`
|
|
1500
|
-
- Modify: `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.tsx`
|
|
1501
|
-
|
|
1502
|
-
Goal: `Enter` on a kb-focused participant / message opens its `EditableSpan` in edit mode. Reuse the `autoEditToken` plumbing via `pendingEditableRangeAtom` for messages, and directly via `autoEditToken` for participants.
|
|
1503
|
-
|
|
1504
|
-
Strategy: the hook dispatches Enter by setting `pendingEditableRangeAtom` to the focused element's range. `EditableLabelField` already consumes `pendingEditableRangeAtom` to auto-open. For participants, we add the same pattern: `ParticipantLabel` starts editing when `pendingEditableRangeAtom` matches any of its `labelPositions`.
|
|
1505
|
-
|
|
1506
|
-
- [ ] **Step 1: Write the failing test**
|
|
1507
|
-
|
|
1508
|
-
Append to `useDiagramKeyboard.spec.tsx`:
|
|
1509
|
-
|
|
1510
|
-
```typescript
|
|
1511
|
-
it("Enter on focused message opens its label for editing", () => {
|
|
1512
|
-
const { store, container } = renderWith("A->B.m1\n");
|
|
1513
|
-
const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
|
|
1514
|
-
fireEvent.keyDown(diagram!, { key: "ArrowDown" });
|
|
1515
|
-
fireEvent.keyDown(diagram!, { key: "Enter" });
|
|
1516
|
-
// pendingEditableRange should be set to the focused message range
|
|
1517
|
-
const pending = store.get(pendingEditableRangeAtom);
|
|
1518
|
-
expect(pending).not.toBeNull();
|
|
1519
|
-
});
|
|
1520
|
-
```
|
|
1521
|
-
|
|
1522
|
-
Add the import for `pendingEditableRangeAtom` at the top of the spec.
|
|
1523
|
-
|
|
1524
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
1525
|
-
|
|
1526
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
|
|
1527
|
-
Expected: FAIL.
|
|
1528
|
-
|
|
1529
|
-
- [ ] **Step 3: Extend the hook**
|
|
1530
|
-
|
|
1531
|
-
Edit `useDiagramKeyboard.ts`. Add Enter handling. At the top of `onKeyDown`, after the contenteditable guard, before the arrow-key early return, add:
|
|
1532
|
-
|
|
1533
|
-
```typescript
|
|
1534
|
-
if (key === "Enter") {
|
|
1535
|
-
if (focusedMessage !== null) {
|
|
1536
|
-
event.preventDefault();
|
|
1537
|
-
setPendingEditableRange({
|
|
1538
|
-
start: focusedMessage.start,
|
|
1539
|
-
end: focusedMessage.end,
|
|
1540
|
-
token: Date.now(),
|
|
1541
|
-
});
|
|
1542
|
-
return;
|
|
1543
|
-
}
|
|
1544
|
-
if (focusedParticipant !== null) {
|
|
1545
|
-
event.preventDefault();
|
|
1546
|
-
setPendingParticipantEdit({
|
|
1547
|
-
name: focusedParticipant,
|
|
1548
|
-
token: Date.now(),
|
|
1549
|
-
});
|
|
1550
|
-
return;
|
|
1551
|
-
}
|
|
1552
|
-
return;
|
|
1553
|
-
}
|
|
1554
|
-
```
|
|
1555
|
-
|
|
1556
|
-
Add the imports:
|
|
1557
|
-
|
|
1558
|
-
```typescript
|
|
1559
|
-
import {
|
|
1560
|
-
pendingEditableRangeAtom,
|
|
1561
|
-
pendingParticipantEditAtom,
|
|
1562
|
-
} from "@/store/Store";
|
|
1563
|
-
```
|
|
1564
|
-
|
|
1565
|
-
And inside the hook, add `const setPendingEditableRange = useSetAtom(pendingEditableRangeAtom);` and `const setPendingParticipantEdit = useSetAtom(pendingParticipantEditAtom);`.
|
|
1566
|
-
|
|
1567
|
-
- [ ] **Step 4: Add `pendingParticipantEditAtom` to `Store`**
|
|
1568
|
-
|
|
1569
|
-
Edit `src/store/keyboardAtoms.ts`:
|
|
1570
|
-
|
|
1571
|
-
```typescript
|
|
1572
|
-
export type PendingParticipantEdit = { name: string; token: number };
|
|
1573
|
-
export const pendingParticipantEditAtom = atom<PendingParticipantEdit | null>(
|
|
1574
|
-
null,
|
|
1575
|
-
);
|
|
1576
|
-
```
|
|
1577
|
-
|
|
1578
|
-
Re-export from `Store.ts`:
|
|
1579
|
-
|
|
1580
|
-
```typescript
|
|
1581
|
-
export {
|
|
1582
|
-
// ...
|
|
1583
|
-
pendingParticipantEditAtom,
|
|
1584
|
-
} from "./keyboardAtoms";
|
|
1585
|
-
export type { PendingParticipantEdit } from "./keyboardAtoms";
|
|
1586
|
-
```
|
|
1587
|
-
|
|
1588
|
-
- [ ] **Step 5: Consume in `ParticipantLabel`**
|
|
1589
|
-
|
|
1590
|
-
Edit `ParticipantLabel.tsx`. Add:
|
|
1591
|
-
|
|
1592
|
-
```typescript
|
|
1593
|
-
import { pendingParticipantEditAtom } from "@/store/Store";
|
|
1594
|
-
```
|
|
1595
|
-
|
|
1596
|
-
Inside the component:
|
|
1597
|
-
|
|
1598
|
-
```typescript
|
|
1599
|
-
const pendingParticipantEdit = useAtomValue(pendingParticipantEditAtom);
|
|
1600
|
-
const autoEditToken =
|
|
1601
|
-
pendingParticipantEdit?.name === props.labelText
|
|
1602
|
-
? pendingParticipantEdit.token
|
|
1603
|
-
: undefined;
|
|
1604
|
-
```
|
|
1605
|
-
|
|
1606
|
-
Pass `autoEditToken={autoEditToken}` to the `<EditableSpan>` render.
|
|
1607
|
-
|
|
1608
|
-
- [ ] **Step 6: Run tests to verify pass**
|
|
1609
|
-
|
|
1610
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.spec.tsx`
|
|
1611
|
-
Expected: PASS.
|
|
1612
|
-
|
|
1613
|
-
- [ ] **Step 7: Commit**
|
|
1614
|
-
|
|
1615
|
-
```bash
|
|
1616
|
-
git add src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts \
|
|
1617
|
-
src/store/keyboardAtoms.ts \
|
|
1618
|
-
src/store/Store.ts \
|
|
1619
|
-
src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.tsx \
|
|
1620
|
-
src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx
|
|
1621
|
-
git commit -m "feat(keyboard): Enter on focused element opens edit mode"
|
|
1622
|
-
```
|
|
1623
|
-
|
|
1624
|
-
---
|
|
1625
|
-
|
|
1626
|
-
## Task 11: Escape blurs the diagram; Tab escapes outside edit mode
|
|
1627
|
-
|
|
1628
|
-
**Files:**
|
|
1629
|
-
- Modify: `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts`
|
|
1630
|
-
|
|
1631
|
-
Goal: `Esc` outside edit mode clears focus atoms and blurs the container. `Tab` outside edit mode lets the browser's default tabbing run (no `preventDefault`) so the user Tabs out to the next page widget.
|
|
1632
|
-
|
|
1633
|
-
- [ ] **Step 1: Write the failing test**
|
|
1634
|
-
|
|
1635
|
-
Append to `useDiagramKeyboard.spec.tsx`:
|
|
1636
|
-
|
|
1637
|
-
```typescript
|
|
1638
|
-
it("Escape clears focus atoms and blurs the diagram", () => {
|
|
1639
|
-
const { store, container } = renderWith("A\nB\n");
|
|
1640
|
-
store.set(focusedParticipantAtom, "A");
|
|
1641
|
-
const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
|
|
1642
|
-
diagram!.focus();
|
|
1643
|
-
fireEvent.keyDown(diagram!, { key: "Escape" });
|
|
1644
|
-
expect(store.get(focusedParticipantAtom)).toBeNull();
|
|
1645
|
-
expect(document.activeElement).not.toBe(diagram);
|
|
1646
|
-
});
|
|
1647
|
-
```
|
|
1648
|
-
|
|
1649
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
1650
|
-
|
|
1651
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
|
|
1652
|
-
Expected: FAIL.
|
|
1653
|
-
|
|
1654
|
-
- [ ] **Step 3: Implement Escape handling**
|
|
1655
|
-
|
|
1656
|
-
Edit `useDiagramKeyboard.ts`. In `onKeyDown`, after the contenteditable guard, add:
|
|
1657
|
-
|
|
1658
|
-
```typescript
|
|
1659
|
-
if (key === "Escape") {
|
|
1660
|
-
event.preventDefault();
|
|
1661
|
-
setFocusedParticipant(null);
|
|
1662
|
-
setFocusedMessage(null);
|
|
1663
|
-
(document.activeElement as HTMLElement | null)?.blur();
|
|
1664
|
-
return;
|
|
1665
|
-
}
|
|
1666
|
-
```
|
|
1667
|
-
|
|
1668
|
-
Tab needs no explicit handling — the browser's default Tab behavior will move focus out of the diagram container naturally (since the container is `tabIndex=0` and nothing inside is captured).
|
|
1669
|
-
|
|
1670
|
-
- [ ] **Step 4: Run tests to verify pass**
|
|
1671
|
-
|
|
1672
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
|
|
1673
|
-
Expected: PASS.
|
|
1674
|
-
|
|
1675
|
-
- [ ] **Step 5: Commit**
|
|
1676
|
-
|
|
1677
|
-
```bash
|
|
1678
|
-
git add src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts \
|
|
1679
|
-
src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx
|
|
1680
|
-
git commit -m "feat(keyboard): Escape clears focus and blurs the diagram"
|
|
1681
|
-
```
|
|
1682
|
-
|
|
1683
|
-
---
|
|
1684
|
-
|
|
1685
|
-
## Task 12: Empty-diagram placeholder
|
|
1686
|
-
|
|
1687
|
-
**Files:**
|
|
1688
|
-
- Create: `src/components/DiagramFrame/SeqDiagram/keyboard/EmptyDiagramPlaceholder.tsx`
|
|
1689
|
-
- Modify: `src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx`
|
|
1690
|
-
|
|
1691
|
-
Goal: when there are no participants, render a focusable dashed box that says "Add participant". Enter creates a blank participant and auto-opens it.
|
|
1692
|
-
|
|
1693
|
-
- [ ] **Step 1: Write the failing test**
|
|
1694
|
-
|
|
1695
|
-
Append to `useDiagramKeyboard.spec.tsx`:
|
|
1696
|
-
|
|
1697
|
-
```typescript
|
|
1698
|
-
it("empty diagram renders a focusable placeholder; Enter creates a participant", () => {
|
|
1699
|
-
const { store, container } = renderWith("");
|
|
1700
|
-
const placeholder = container.querySelector<HTMLElement>(
|
|
1701
|
-
"[data-testid='empty-diagram-placeholder']",
|
|
1702
|
-
);
|
|
1703
|
-
expect(placeholder).toBeTruthy();
|
|
1704
|
-
placeholder!.focus();
|
|
1705
|
-
fireEvent.keyDown(placeholder!, { key: "Enter" });
|
|
1706
|
-
// A participant should have been inserted.
|
|
1707
|
-
const code = store.get(codeAtom);
|
|
1708
|
-
expect(code.length).toBeGreaterThan(0);
|
|
1709
|
-
});
|
|
1710
|
-
```
|
|
1711
|
-
|
|
1712
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
1713
|
-
|
|
1714
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
|
|
1715
|
-
Expected: FAIL — placeholder does not exist.
|
|
1716
|
-
|
|
1717
|
-
- [ ] **Step 3: Create the placeholder component**
|
|
1718
|
-
|
|
1719
|
-
Create `src/components/DiagramFrame/SeqDiagram/keyboard/EmptyDiagramPlaceholder.tsx`:
|
|
1720
|
-
|
|
1721
|
-
```typescript
|
|
1722
|
-
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
|
1723
|
-
import {
|
|
1724
|
-
codeAtom,
|
|
1725
|
-
onContentChangeAtom,
|
|
1726
|
-
pendingParticipantEditAtom,
|
|
1727
|
-
rootContextAtom,
|
|
1728
|
-
setFocusedParticipantAtom,
|
|
1729
|
-
} from "@/store/Store";
|
|
1730
|
-
import { insertParticipantIntoDsl } from "@/utils/participantInsertTransform";
|
|
1731
|
-
|
|
1732
|
-
export const EmptyDiagramPlaceholder = () => {
|
|
1733
|
-
const [code, setCode] = useAtom(codeAtom);
|
|
1734
|
-
const onContentChange = useAtomValue(onContentChangeAtom);
|
|
1735
|
-
const rootContext = useAtomValue(rootContextAtom);
|
|
1736
|
-
const setPending = useSetAtom(pendingParticipantEditAtom);
|
|
1737
|
-
const setFocusedParticipant = useSetAtom(setFocusedParticipantAtom);
|
|
1738
|
-
|
|
1739
|
-
const handleAdd = () => {
|
|
1740
|
-
const { code: nextCode } = insertParticipantIntoDsl({
|
|
1741
|
-
code,
|
|
1742
|
-
rootContext,
|
|
1743
|
-
insertIndex: 0,
|
|
1744
|
-
name: "A",
|
|
1745
|
-
type: "default",
|
|
1746
|
-
});
|
|
1747
|
-
setCode(nextCode);
|
|
1748
|
-
onContentChange(nextCode);
|
|
1749
|
-
setFocusedParticipant("A");
|
|
1750
|
-
setPending({ name: "A", token: Date.now() });
|
|
1751
|
-
};
|
|
1752
|
-
|
|
1753
|
-
return (
|
|
1754
|
-
<div
|
|
1755
|
-
data-testid="empty-diagram-placeholder"
|
|
1756
|
-
tabIndex={0}
|
|
1757
|
-
role="button"
|
|
1758
|
-
aria-label="Add participant"
|
|
1759
|
-
className="m-4 inline-flex items-center justify-center px-4 py-2 border-2 border-dashed border-slate-300 text-slate-500 rounded cursor-pointer focus:outline-none focus-visible:border-sky-400 focus-visible:text-sky-500"
|
|
1760
|
-
onKeyDown={(e) => {
|
|
1761
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
1762
|
-
e.preventDefault();
|
|
1763
|
-
handleAdd();
|
|
1764
|
-
}
|
|
1765
|
-
}}
|
|
1766
|
-
onClick={handleAdd}
|
|
1767
|
-
>
|
|
1768
|
-
+ Add participant
|
|
1769
|
-
</div>
|
|
1770
|
-
);
|
|
1771
|
-
};
|
|
1772
|
-
```
|
|
1773
|
-
|
|
1774
|
-
- [ ] **Step 4: Render when the diagram is empty**
|
|
1775
|
-
|
|
1776
|
-
Edit `SeqDiagram.tsx`. Import:
|
|
1777
|
-
|
|
1778
|
-
```typescript
|
|
1779
|
-
import { EmptyDiagramPlaceholder } from "./keyboard/EmptyDiagramPlaceholder";
|
|
1780
|
-
```
|
|
1781
|
-
|
|
1782
|
-
In the JSX, wrap the existing inner content. Before the existing layer rendering, add:
|
|
1783
|
-
|
|
1784
|
-
```typescript
|
|
1785
|
-
const isEmpty =
|
|
1786
|
-
coordinates.orderedParticipantNames().filter((n) => n !== "_STARTER_")
|
|
1787
|
-
.length === 0;
|
|
1788
|
-
```
|
|
1789
|
-
|
|
1790
|
-
And inside the `Dynamic` branch:
|
|
1791
|
-
|
|
1792
|
-
```tsx
|
|
1793
|
-
{isEmpty ? (
|
|
1794
|
-
<EmptyDiagramPlaceholder />
|
|
1795
|
-
) : (
|
|
1796
|
-
<>
|
|
1797
|
-
<LifeLineLayer /* ... */ />
|
|
1798
|
-
<MessageLayer /* ... */ />
|
|
1799
|
-
<LifeLineLayer /* ... */ />
|
|
1800
|
-
</>
|
|
1801
|
-
)}
|
|
1802
|
-
```
|
|
1803
|
-
|
|
1804
|
-
- [ ] **Step 5: Run tests to verify pass**
|
|
1805
|
-
|
|
1806
|
-
Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
|
|
1807
|
-
Expected: PASS.
|
|
1808
|
-
|
|
1809
|
-
- [ ] **Step 6: Run the full unit suite**
|
|
1810
|
-
|
|
1811
|
-
Run: `bun run test`
|
|
1812
|
-
Expected: PASS.
|
|
1813
|
-
|
|
1814
|
-
- [ ] **Step 7: Commit**
|
|
1815
|
-
|
|
1816
|
-
```bash
|
|
1817
|
-
git add src/components/DiagramFrame/SeqDiagram/keyboard/EmptyDiagramPlaceholder.tsx \
|
|
1818
|
-
src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx \
|
|
1819
|
-
src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx
|
|
1820
|
-
git commit -m "feat(keyboard): empty-diagram placeholder with focusable add-participant affordance"
|
|
1821
|
-
```
|
|
1822
|
-
|
|
1823
|
-
---
|
|
1824
|
-
|
|
1825
|
-
## Task 13: Playwright E2E fixture and test
|
|
1826
|
-
|
|
1827
|
-
**Files:**
|
|
1828
|
-
- Create: `e2e/fixtures/keyboard-editing.html`
|
|
1829
|
-
- Create: `tests/keyboard-editing.spec.ts`
|
|
1830
|
-
|
|
1831
|
-
- [ ] **Step 1: Create the fixture**
|
|
1832
|
-
|
|
1833
|
-
Use an existing fixture as a template. Run:
|
|
1834
|
-
|
|
1835
|
-
```bash
|
|
1836
|
-
ls e2e/fixtures/editable-label.html
|
|
1837
|
-
```
|
|
1838
|
-
|
|
1839
|
-
Copy its structure. Create `e2e/fixtures/keyboard-editing.html` — seed the diagram with `A\nB\nA->B.hello\n`. Follow the exact pattern of `e2e/fixtures/editable-label.html` (same `<script>` setup, same zenuml bootstrap).
|
|
1840
|
-
|
|
1841
|
-
- [ ] **Step 2: Write the E2E test**
|
|
1842
|
-
|
|
1843
|
-
Create `tests/keyboard-editing.spec.ts`:
|
|
1844
|
-
|
|
1845
|
-
```typescript
|
|
1846
|
-
import { test, expect } from "@playwright/test";
|
|
1847
|
-
|
|
1848
|
-
test.describe("keyboard editing", () => {
|
|
1849
|
-
test("Tab in, arrow-navigate, Enter to edit, Tab to create sibling", async ({
|
|
1850
|
-
page,
|
|
1851
|
-
}) => {
|
|
1852
|
-
await page.goto("/e2e/fixtures/keyboard-editing.html");
|
|
1853
|
-
|
|
1854
|
-
const diagram = page.locator(".sequence-diagram").first();
|
|
1855
|
-
await diagram.focus();
|
|
1856
|
-
|
|
1857
|
-
// First participant should have a kb-focused outline.
|
|
1858
|
-
await expect(
|
|
1859
|
-
page.locator(".participant[data-participant-id='A']"),
|
|
1860
|
-
).toHaveClass(/kb-focused/);
|
|
1861
|
-
|
|
1862
|
-
// Right arrow moves to B.
|
|
1863
|
-
await page.keyboard.press("ArrowRight");
|
|
1864
|
-
await expect(
|
|
1865
|
-
page.locator(".participant[data-participant-id='B']"),
|
|
1866
|
-
).toHaveClass(/kb-focused/);
|
|
1867
|
-
|
|
1868
|
-
// Enter to edit, type a new name, Tab to spawn a sibling.
|
|
1869
|
-
await page.keyboard.press("Enter");
|
|
1870
|
-
await page.keyboard.type("Bravo");
|
|
1871
|
-
await page.keyboard.press("Tab");
|
|
1872
|
-
await page.keyboard.type("Charlie");
|
|
1873
|
-
await page.keyboard.press("Enter");
|
|
1874
|
-
|
|
1875
|
-
// Three participants should exist now.
|
|
1876
|
-
await expect(page.locator(".participant")).toHaveCount(3);
|
|
1877
|
-
await expect(
|
|
1878
|
-
page.locator(".participant[data-participant-id='Charlie']"),
|
|
1879
|
-
).toBeVisible();
|
|
1880
|
-
|
|
1881
|
-
// Move into messages ring.
|
|
1882
|
-
await page.keyboard.press("ArrowLeft"); // back to Bravo
|
|
1883
|
-
await page.keyboard.press("ArrowLeft"); // back to A
|
|
1884
|
-
await page.keyboard.press("ArrowDown"); // into messages
|
|
1885
|
-
await expect(page.locator(".message.kb-focused")).toBeVisible();
|
|
1886
|
-
|
|
1887
|
-
// Enter to edit the message, then Tab to create a sibling.
|
|
1888
|
-
await page.keyboard.press("Enter");
|
|
1889
|
-
await page.keyboard.type("renamed");
|
|
1890
|
-
await page.keyboard.press("Tab");
|
|
1891
|
-
await page.keyboard.type("second");
|
|
1892
|
-
await page.keyboard.press("Enter");
|
|
1893
|
-
|
|
1894
|
-
// Two messages now.
|
|
1895
|
-
await expect(page.locator(".message")).toHaveCount(2);
|
|
1896
|
-
});
|
|
1897
|
-
|
|
1898
|
-
test("Escape blurs the diagram", async ({ page }) => {
|
|
1899
|
-
await page.goto("/e2e/fixtures/keyboard-editing.html");
|
|
1900
|
-
const diagram = page.locator(".sequence-diagram").first();
|
|
1901
|
-
await diagram.focus();
|
|
1902
|
-
await page.keyboard.press("ArrowRight");
|
|
1903
|
-
await page.keyboard.press("Escape");
|
|
1904
|
-
await expect(
|
|
1905
|
-
page.locator(".participant.kb-focused"),
|
|
1906
|
-
).toHaveCount(0);
|
|
1907
|
-
});
|
|
1908
|
-
});
|
|
1909
|
-
```
|
|
1910
|
-
|
|
1911
|
-
- [ ] **Step 3: Run the E2E test**
|
|
1912
|
-
|
|
1913
|
-
Run: `bun pw tests/keyboard-editing.spec.ts`
|
|
1914
|
-
Expected: PASS. If the CSS class names in the fixture differ (e.g. `.message` selector), adjust the spec selectors to match what the rendered DOM actually uses.
|
|
1915
|
-
|
|
1916
|
-
- [ ] **Step 4: Commit**
|
|
1917
|
-
|
|
1918
|
-
```bash
|
|
1919
|
-
git add e2e/fixtures/keyboard-editing.html tests/keyboard-editing.spec.ts
|
|
1920
|
-
git commit -m "test(e2e): keyboard editing flow — Tab, arrow nav, Tab-sibling"
|
|
1921
|
-
```
|
|
1922
|
-
|
|
1923
|
-
---
|
|
1924
|
-
|
|
1925
|
-
## Task 14: Lint, format, full test suite
|
|
1926
|
-
|
|
1927
|
-
- [ ] **Step 1: Run lint**
|
|
1928
|
-
|
|
1929
|
-
Run: `bun eslint`
|
|
1930
|
-
Expected: no errors. Fix any introduced.
|
|
1931
|
-
|
|
1932
|
-
- [ ] **Step 2: Run prettier**
|
|
1933
|
-
|
|
1934
|
-
Run: `bun prettier`
|
|
1935
|
-
Expected: no diff. If there is a diff, `git add` and amend-free commit it in Step 4.
|
|
1936
|
-
|
|
1937
|
-
- [ ] **Step 3: Run unit tests**
|
|
1938
|
-
|
|
1939
|
-
Run: `bun run test`
|
|
1940
|
-
Expected: PASS across the whole suite.
|
|
1941
|
-
|
|
1942
|
-
- [ ] **Step 4: Run E2E smoke tests**
|
|
1943
|
-
|
|
1944
|
-
Run: `bun pw:smoke`
|
|
1945
|
-
Expected: PASS.
|
|
1946
|
-
|
|
1947
|
-
- [ ] **Step 5: If formatter produced a diff, commit it**
|
|
1948
|
-
|
|
1949
|
-
```bash
|
|
1950
|
-
git add -u
|
|
1951
|
-
git commit -m "style: prettier formatting for keyboard editing"
|
|
1952
|
-
```
|
|
1953
|
-
|
|
1954
|
-
---
|
|
1955
|
-
|
|
1956
|
-
## Self-Review (for plan author)
|
|
1957
|
-
|
|
1958
|
-
**Spec coverage**
|
|
1959
|
-
|
|
1960
|
-
- ✅ Two focus rings (participants, messages) — Tasks 1, 2, 7
|
|
1961
|
-
- ✅ Arrow-key navigation — Task 7
|
|
1962
|
-
- ✅ Enter enters edit mode — Task 10
|
|
1963
|
-
- ✅ Esc blurs diagram — Task 11
|
|
1964
|
-
- ✅ Tab escapes widget outside edit mode — Task 11 (relies on browser default; explicitly not prevented)
|
|
1965
|
-
- ✅ Tab/Shift+Tab in edit mode creates sibling after/before — Tasks 4, 5, 6
|
|
1966
|
-
- ✅ Participant Tab-sibling — Task 5
|
|
1967
|
-
- ✅ Message Tab-sibling inherits from/to — Task 6
|
|
1968
|
-
- ✅ Empty-text guard — Task 4
|
|
1969
|
-
- ✅ Empty-diagram placeholder — Task 12
|
|
1970
|
-
- ✅ Auto-open new element in edit mode — Tasks 5, 6 (via `pendingEditableRangeAtom` / `pendingParticipantEditAtom`)
|
|
1971
|
-
- ✅ Focus ring visuals — Task 9
|
|
1972
|
-
- ✅ Don't touch mouse paths — confirmed (only `EditableSpan` gains an optional prop)
|
|
1973
|
-
- ✅ Tests: ring builder unit, keyboard hook unit, EditableSpan unit, E2E flow — Tasks 2, 4, 7, 13
|
|
1974
|
-
|
|
1975
|
-
**Placeholder scan** — no "TBD", no "add appropriate X", no "similar to task N". One known duplication (`generateParticipantName` in `ParticipantLabel.tsx` and `ParticipantInsertControls.tsx`) is called out explicitly in Task 5.
|
|
1976
|
-
|
|
1977
|
-
**Type consistency** — `focusedMessageAtom` stores `{ start, end }`; `MessageRingEntry` uses `labelStart` / `labelEnd`. The hook (Task 7) explicitly maps between them. `insertParticipantIntoDsl` return shape change is propagated through Task 3 and call sites are updated.
|
|
1978
|
-
|
|
1979
|
-
**Known risk:** Task 2 depends on ANTLR fragment accessor names (`.block()`, `.braceBlock()`). If the actual generated accessors differ, Task 2 Step 5 instructs the engineer to read the grammar and adjust. The ring tests will catch this immediately.
|
|
1980
|
-
|
|
1981
|
-
**Known risk:** Task 6 requires `blockContext` and `insertIndex` to be available in `Interaction.tsx`. `GapHandleZone` already receives these, so they are computed in `Block.tsx` — the engineer may need to thread them through `Statement.tsx`. This is a mechanical prop drill.
|
|
1982
|
-
|
|
1983
|
-
---
|
|
1984
|
-
|
|
1985
|
-
## Execution Handoff
|
|
1986
|
-
|
|
1987
|
-
**Plan complete and saved to `docs/superpowers/plans/2026-04-15-keyboard-editing-on-diagram.md`. Two execution options:**
|
|
1988
|
-
|
|
1989
|
-
1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.
|
|
1990
|
-
2. **Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
|
|
1991
|
-
|
|
1992
|
-
**Which approach?**
|