@ryan_nookpi/pi-skill-excalidraw 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -10,16 +10,31 @@ pi install npm:@ryan_nookpi/pi-skill-excalidraw
10
10
 
11
11
  같은 이름의 스킬이 `~/.pi/agent/skills/excalidraw` 등 다른 위치에 있으면 Pi는 먼저 찾은 쪽 하나만 씁니다. 기존 로컬 복사본은 지우고 설치하세요.
12
12
 
13
- ## 따로 설치해야 하는 것
13
+ ## 따로 준비해야 하는 것
14
14
 
15
- Google Chrome (macOS). Linux는 Chrome 또는 Chromium
15
+ - Google Chrome (macOS). Linux는 Chrome 또는 Chromium이 PATH에 있으면 됩니다
16
+ - Node 18 이상. 패키지에 빌드된 편집기 앱이 들어 있어 추가 빌드는 필요 없습니다
16
17
 
17
- 처음 한 번 설치하는 방법은 [skills/excalidraw/references/setup.md](skills/excalidraw/references/setup.md)에 있습니다. 스킬도 전제가 빠졌을 때 이 문서를 보고 안내합니다.
18
+ 설치 순서와 동작 확인 방법은 [skills/excalidraw/references/setup.md](skills/excalidraw/references/setup.md)에 있습니다. 준비가 덜 된 상태에서 스킬을 부르면 에이전트가 빠진 단계만 짧게 알려 줍니다. macOS 기준으로 검증했습니다.
18
19
 
19
20
  ## 사용 예
20
21
 
21
22
  ```text
22
- /skill:excalidraw 로그인 흐름을 그려줘
23
+ 로그인 흐름을 플로우차트로 그려줘
24
+ 이 레포 배포 파이프라인을 아키텍처 다이어그램으로 정리해줘
25
+ 주문 API 요청 순서를 시퀀스 다이어그램으로 보여줘
26
+ 방금 그린 다이어그램에서 결제 실패 분기를 추가해줘
27
+ docs/flow.mmd에 쓴 mermaid를 Excalidraw로 가져와줘
23
28
  ```
24
29
 
25
- 요청 내용이 스킬 설명과 맞으면 에이전트가 알아서 불러오므로, 명시적으로 `/skill:excalidraw`을 쓰지 않아도 됩니다.
30
+ 요청 내용이 스킬 설명과 맞으면 에이전트가 알아서 스킬을 불러옵니다. 직접 부르려면 `/skill:excalidraw <요청>`을 쓰세요.
31
+
32
+ 파일은 기본적으로 작업 디렉터리의 `./.diagrams/<주제>.excalidraw`에 만듭니다. 창은 사용자 Chrome 프로필과 분리된 전용 프로필로 뜨고, 연결된 창 없이 30분이 지나면 서버가 스스로 꺼집니다.
33
+
34
+ ## 하지 않는 것
35
+
36
+ - Windows를 지원하지 않습니다. macOS와 Linux만 동작합니다.
37
+ - 창은 로컬 전용입니다. 공유 가능한 URL이나 Excalidraw 클라우드 연동은 없습니다.
38
+ - 같은 요소를 사용자와 에이전트가 동시에 고치면 파일 값이 이깁니다. 덮어쓴 내용은 `history` 백업으로 되돌립니다.
39
+ - mermaid를 다시 가져오면 그 사이에 손으로 고친 내용은 사라집니다.
40
+ - 패키지에 편집기 앱 자산 약 21MB가 함께 들어갑니다. 설치 용량이 작지 않습니다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryan_nookpi/pi-skill-excalidraw",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Pi skill that writes .excalidraw diagrams and opens them in a live-synced local Excalidraw window.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,7 +21,9 @@
21
21
  ],
22
22
  "files": [
23
23
  "skills/excalidraw/SKILL.md",
24
- "skills/excalidraw/scripts/",
24
+ "skills/excalidraw/scripts/excal.mjs",
25
+ "skills/excalidraw/scripts/lib.mjs",
26
+ "skills/excalidraw/scripts/server.mjs",
25
27
  "skills/excalidraw/references/",
26
28
  "skills/excalidraw/assets/",
27
29
  "skills/excalidraw/app/dist/",
@@ -32,6 +34,9 @@
32
34
  "skills/excalidraw/app/vite.config.js",
33
35
  "README.md"
34
36
  ],
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
35
40
  "pi": {
36
41
  "skills": [
37
42
  "./skills/excalidraw"
@@ -40,5 +45,7 @@
40
45
  "publishConfig": {
41
46
  "access": "public"
42
47
  },
43
- "scripts": {}
48
+ "scripts": {
49
+ "test": "node --test skills/excalidraw/scripts/excal.test.mjs"
50
+ }
44
51
  }
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: excalidraw
3
3
  description: "플로우차트·아키텍처도·개념도 같은 다이어그램을 그려 달라는 요청에 사용한다."
4
+ compatibility: macOS/Linux only (no Windows support). Needs Google Chrome on macOS, Chrome or Chromium on PATH for Linux. Runtime Node 18+; rebuilding app/ via `build` needs Node ^20.19 || >=22.12 (vite 8). Verified on macOS.
4
5
  ---
5
6
 
6
7
  # excalidraw
@@ -9,18 +10,29 @@ description: "플로우차트·아키텍처도·개념도 같은 다이어그램
9
10
 
10
11
  ```bash
11
12
  EXCAL="node <이 스킬 디렉터리>/scripts/excal.mjs"
12
- $EXCAL open <file> [--from-mermaid <file.mmd>] # 창 열기(없으면 빈 파일 생성). 이미 열려 있으면 재사용. --from-mermaid는 파일 내용을 mermaid 결과로 교체
13
+ $EXCAL open <file> [--from-mermaid <file.mmd>] # 창 열기(없으면 빈 파일 생성). 이미 열려 있으면 재사용. --from-mermaid는 창이 떠 있어야 변환된다
13
14
  $EXCAL lint <file> # JSON·참조·겹침·라벨 넘침 검사 (브라우저 불필요)
14
15
  $EXCAL inspect <file> # id/좌표/라벨/연결 요약. 큰 정식 파일을 통째로 읽지 말고 이걸 본다
15
16
  $EXCAL snapshot <file> [-o out.png] # 창에서 PNG를 내보내고 경로 출력 → read로 확인. 기본 경로는 파일마다 하나라 다시 찍으면 덮어쓴다
16
- $EXCAL history <file> # 외부 변경 직전 백업 목록 (최근 50개)
17
+ $EXCAL history <file> # 백업 목록 (최근 50개). 파일 외부 변경 직전 + 사용자 창 편집 5분마다
17
18
  $EXCAL status | stop
18
19
  $EXCAL build # app/ 소스를 고친 뒤 재빌드
19
20
  ```
20
21
 
21
- 패키지에 빌드된 앱(`app/dist`)이 들어 있어 바로 열린다. `build`는 `app/` 소스를 고쳤을 때만 쓴다(첫 실행 시 의존성을 설치하므로 약 30초). 서버는 연결된 창 없이 30분이 지나면 스스로 꺼지고, 다음 명령에서 자동으로 다시 뜬다.
22
+ `<이 스킬 디렉터리>`는 이 SKILL.md가 있는 디렉터리의 절대 경로다. 현재 작업 디렉터리 기준 상대 경로로 부르면 안 된다.
22
23
 
23
- 창은 Google Chrome 앱 창으로 열린다(Linux는 Chrome 또는 Chromium). `open`이 브라우저를 찾지 못하면 [references/setup.md](references/setup.md)의 최초 설치 방법을 사용자에게 안내한다. `lint`, `inspect`는 브라우저 없이 동작한다.
24
+ 패키지에 빌드된 앱(`app/dist`)이 들어 있어 바로 열린다. `build`는 `app/` 소스를 고쳤을 때만 쓴다(첫 실행 시 의존성을 내려받으므로 약 30초, 네트워크 필요). 서버는 연결된 창 없이 30분이 지나면 스스로 꺼지고, 다음 명령에서 자동으로 다시 뜬다.
25
+
26
+ 창은 Google Chrome 앱 창으로 열린다(Linux는 Chrome 또는 Chromium). `lint`, `inspect`는 브라우저 없이 동작한다. `open`이 출력하는 `window` 값으로 창 상태를 판단한다.
27
+
28
+ | `window` | 뜻과 대응 |
29
+ | --- | --- |
30
+ | `opened` | 창을 새로 열어 연결됨 |
31
+ | `already-open` | 이미 열려 있던 창을 재사용 |
32
+ | `not-opened` | `--no-window`를 줬으므로 창을 열지 않음 |
33
+ | `browser-disabled` | `EXCAL_BROWSER=none`. 출력된 URL을 사용자가 직접 연다 |
34
+ | `no-browser` | Chrome을 찾지 못함. [references/setup.md](references/setup.md)의 설치 방법을 사용자에게 안내한다 |
35
+ | `not-connected` | 브라우저는 띄웠지만 20초 안에 연결되지 않음. 창이 떴는지 사용자에게 확인하고, 안 떴으면 setup.md를 안내한다 |
24
36
 
25
37
  ## 파일 위치
26
38
 
@@ -29,10 +41,10 @@ $EXCAL build # app/ 소스를 고친 뒤 재빌드
29
41
 
30
42
  ## 새 다이어그램
31
43
 
32
- 1. 노드·연결·그룹 목록을 먼저 정하고, `references/style.md`에서 레이아웃 레시피를 고른다.
44
+ 1. 노드·연결·그룹 목록을 먼저 정하고, `references/style.md`에서 레이아웃 레시피를 고른다. 형식이 헷갈리면 `assets/examples/flowchart.excalidraw`가 완성된 최소 예제다.
33
45
  2. 작성 방식을 고른다.
34
46
  - 기본: 스켈레톤 JSON을 직접 쓴다(`references/skeleton.md`). 배치와 색을 통제할 수 있다.
35
- - 노드가 15개를 넘는 흐름, 시퀀스·클래스 다이어그램: Mermaid로 쓰고 `open --from-mermaid`로 가져온다. 자동 레이아웃이 붙는 대신 스타일 통제는 약하고, 긴 한글 라벨은 단어 중간에서 줄이 바뀔 수 있다. `.mmd`를 고쳐 다시 가져오면 같은 노드 id가 제자리에서 갱신된다. 가져온 뒤 파일을 직접 고쳤다면 다시 가져올 때 그 수정은 사라진다.
47
+ - 노드가 15개를 넘는 흐름, 시퀀스·클래스 다이어그램: Mermaid로 쓰고 `open --from-mermaid`로 가져온다. 변환은 창 안에서 일어나므로 창이 떠야 결과가 파일에 쓰인다. 창이 없으면 `pendingMermaid`만 남은 파일이 디스크에 남는다. 자동 레이아웃이 붙는 대신 스타일 통제는 약하고, 긴 한글 라벨은 단어 중간에서 줄이 바뀔 수 있다. `.mmd`를 고쳐 다시 가져오면 같은 노드 id가 제자리에서 갱신된다. 가져온 뒤 파일을 직접 고쳤다면 다시 가져올 때 그 수정은 사라진다.
36
48
  3. 파일 쓰기 도구로 `.excalidraw` 파일 작성 → `lint` → 오류를 고친 뒤 `open`.
37
49
  4. `snapshot` → PNG를 `read`로 보고 겹침·잘린 라벨·어색한 화살표를 확인한다. 고칠 게 있으면 수정 후 한 번 더 확인한다(최대 2회).
38
50
  5. 파일 경로와 한 줄 요약을 보고한다. 창은 이미 열려 있으니 URL은 붙이지 않아도 된다.
@@ -3,6 +3,9 @@
3
3
  "private": true,
4
4
  "version": "0.1.0",
5
5
  "type": "module",
6
+ "engines": {
7
+ "node": "^20.19.0 || >=22.12.0"
8
+ },
6
9
  "scripts": {
7
10
  "build": "vite build",
8
11
  "dev": "vite"
@@ -6,7 +6,7 @@
6
6
  { "type": "text", "id": "title", "x": 200, "y": 20, "text": "주문 처리 흐름", "fontSize": 28 },
7
7
  { "type": "ellipse", "id": "start", "x": 240, "y": 90, "width": 160, "height": 64, "backgroundColor": "#b2f2bb", "fillStyle": "solid", "label": { "text": "시작" } },
8
8
  { "type": "rectangle", "id": "order", "x": 220, "y": 214, "width": 200, "height": 72, "backgroundColor": "#a5d8ff", "fillStyle": "solid", "roundness": { "type": 3 }, "label": { "text": "주문 접수" } },
9
- { "type": "diamond", "id": "stock", "x": 210, "y": 346, "width": 220, "height": 120, "backgroundColor": "#ffec99", "fillStyle": "solid", "label": { "text": "재고 있음?" } },
9
+ { "type": "diamond", "id": "stock", "x": 200, "y": 346, "width": 240, "height": 120, "backgroundColor": "#ffec99", "fillStyle": "solid", "label": { "text": "재고 있음?" } },
10
10
  { "type": "rectangle", "id": "ship", "x": 220, "y": 526, "width": 200, "height": 72, "backgroundColor": "#a5d8ff", "fillStyle": "solid", "roundness": { "type": 3 }, "label": { "text": "배송 준비" } },
11
11
  { "type": "rectangle", "id": "backorder", "x": 520, "y": 370, "width": 200, "height": 72, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "roundness": { "type": 3 }, "label": { "text": "입고 대기 알림" } },
12
12
  { "type": "arrow", "id": "a1", "start": { "id": "start" }, "end": { "id": "order" } },
@@ -2,17 +2,15 @@
2
2
 
3
3
  빌드된 편집기 앱이 패키지에 들어 있어 따로 설치·빌드할 것은 없다. 창을 띄울 브라우저만 필요하다.
4
4
 
5
- ## 확인
5
+ ## 1. 브라우저 확인
6
6
 
7
7
  ```bash
8
8
  open -Ra "Google Chrome" && echo ok # macOS
9
9
  ```
10
10
 
11
- `ok`가 나오면 설정할 것이 없다. Linux는 `google-chrome`, `google-chrome-stable`, `chromium`, `chromium-browser` 중 하나가 PATH에 있으면 된다.
11
+ `ok`가 나오면 다음 단계로 간다. Linux는 `google-chrome`, `google-chrome-stable`, `chromium`, `chromium-browser` 중 하나가 PATH에 있으면 된다.
12
12
 
13
- ## 설치 (1회)
14
-
15
- macOS는 Google Chrome이 필요하다. Chromium이나 다른 브라우저로는 창이 열리지 않는다.
13
+ `ok`가 안 나오면 macOS는 Google Chrome이 필요하다. Chromium이나 다른 브라우저로는 창이 열리지 않는다.
16
14
 
17
15
  ```bash
18
16
  brew install --cask google-chrome
@@ -20,6 +18,39 @@ brew install --cask google-chrome
20
18
 
21
19
  또는 https://www.google.com/chrome/ 에서 내려받는다.
22
20
 
21
+ ## 2. 동작 확인
22
+
23
+ 동봉된 예제로 창 띄우기와 PNG 내보내기까지 한 번에 확인한다. 아래 `SKILL_DIR`은 이 문서가 들어 있는 스킬 디렉터리의 절대 경로다.
24
+
25
+ ```bash
26
+ SKILL_DIR=<이 스킬 디렉터리>
27
+ cp "$SKILL_DIR/assets/examples/flowchart.excalidraw" /tmp/excal-check.excalidraw
28
+ node "$SKILL_DIR/scripts/excal.mjs" open /tmp/excal-check.excalidraw
29
+ node "$SKILL_DIR/scripts/excal.mjs" snapshot /tmp/excal-check.excalidraw -o /tmp/excal-check.png
30
+ ```
31
+
32
+ - `open` 출력의 `"window"`가 `"opened"`이고 주문 처리 흐름도가 뜬 Chrome 창이 보이면 정상이다.
33
+ - `snapshot`이 `/tmp/excal-check.png`를 출력하면 내보내기까지 된 것이다. 이미지를 열어 한글 라벨이 깨지지 않았는지 본다.
34
+ - `"window"`가 `"no-browser"`면 1번 단계로 돌아간다. `"not-connected"`면 Chrome은 있는데 창이 안 뜬 것이니 열린 Chrome 창과 다른 데스크톱(Space)을 확인한다.
35
+
36
+ 확인이 끝나면 정리한다.
37
+
38
+ ```bash
39
+ node "$SKILL_DIR/scripts/excal.mjs" stop
40
+ rm -f /tmp/excal-check.excalidraw /tmp/excal-check.png
41
+ ```
42
+
43
+ ## 앱을 직접 빌드할 때
44
+
45
+ `build`는 `app/` 소스를 고쳤을 때만 쓴다. 이 레포 안에서 쓰는 경우 `app/`은 pnpm 워크스페이스 글롭(`packages/*`)에 걸리지 않는 비멤버라서, 설치할 때 `--ignore-workspace`가 없으면 상위 워크스페이스가 대신 설치되고 `app/node_modules`가 비어 빌드가 깨진다. `excal build`는 이 플래그를 알아서 붙인다. 손으로 돌릴 때는 이렇게 한다.
46
+
47
+ ```bash
48
+ pnpm --dir "$SKILL_DIR/app" install --ignore-workspace --frozen-lockfile
49
+ pnpm --dir "$SKILL_DIR/app" run build
50
+ ```
51
+
52
+ 빌드에는 Node `^20.19 || >=22.12`가 필요하다(vite 8). 스킬을 쓰기만 할 때는 Node 18 이상이면 된다.
53
+
23
54
  ## 참고
24
55
 
25
56
  - 창은 사용자 Chrome 프로필과 분리된 전용 프로필(`~/.cache/pi-excalidraw/chrome-profile`)로 열린다. 로그인 정보나 확장 프로그램은 공유되지 않는다.
@@ -18,15 +18,15 @@
18
18
 
19
19
  ## 크기와 간격 (20px 그리드 기준)
20
20
 
21
- - 일반 노드 `200×72`, 긴 라벨 노드 `240×80`, 판단 다이아몬드 `220×120`, 시작/끝 타원 `160×64`.
21
+ - 일반 노드 `200×72`, 긴 라벨 노드 `240×80`, 판단 다이아몬드 `240×120`(한글 5자까지. 더 길면 `280×140`), 시작/끝 타원 `160×64`.
22
22
  - 라벨 폰트 20, 제목 28–36, 보조 설명 16.
23
- - 라벨 폭 추정: 한글 한 글자 ≈ fontSize, 영문 ≈ 0.55×fontSize. 좌우 여백 48px를 더한 값이 `width`보다 크면 너비를 늘리거나 `\n`으로 줄을 나눈다. 높이는 줄 수 × fontSize × 1.25 + 32px 이상, 다이아몬드는 모서리가 좁으니 1.5배로 잡는다. 실제 폰트는 추정보다 넓게 그려지므로 빠듯하게 맞추지 않는다.
23
+ - 라벨 폭 추정: 한글 한 글자 ≈ fontSize, 영문 ≈ 0.55×fontSize. 좌우 여백 48px를 더한 값이 `width`보다 크면 너비를 늘리거나 `\n`으로 줄을 나눈다. 높이는 줄 수 × fontSize × 1.25 + 32px 이상, 다이아몬드는 모서리가 좁으니 가로·세로 모두 1.5배로 잡는다. `lint`도 같은 식으로 검사하므로 경고가 뜨면 폭을 20px 단위로 올린다. 실제 폰트는 추정보다 넓게 그려지므로 빠듯하게 맞추지 않는다.
24
24
  - 노드 사이 간격: 라벨 없는 화살표 60px 이상, 라벨 있는 화살표 120px 이상(라벨이 화살표 위에 올라간다).
25
25
  - 좌표는 20의 배수로 맞춘다. 같은 열의 노드는 `x`를, 같은 행은 `y`를 똑같이 둔다.
26
26
 
27
27
  ## 레시피
28
28
 
29
- **세로 플로우차트**: 한 열에 `x` 고정, 노드 높이 72 + 간격 60 → 행 간격 140(라벨 화살표가 있으면 180). 분기는 다이아몬드 오른쪽으로 320px 떨어진 열에 둔다. 제목은 맨 위 `y=20`.
29
+ **세로 플로우차트**: 한 열에 `x` 고정, 노드 높이 72 + 간격 60 → 행 간격 140(라벨 화살표가 있으면 180). 분기는 다이아몬드 오른쪽으로 320px 떨어진 열에 둔다. 제목은 맨 위 `y=20`. 완성 예시는 `assets/examples/flowchart.excalidraw`에 있다.
30
30
 
31
31
  **가로 파이프라인**: 한 행에 `y` 고정, 노드 폭 200 + 간격 100 → 열 간격 300. 5단계를 넘으면 두 줄로 접고 줄 끝에서 꺾인 화살표로 잇는다.
32
32
 
@@ -42,7 +42,15 @@ function ensureBuilt(force = false) {
42
42
  const pm = spawnSync("pnpm", ["--version"]).status === 0 ? "pnpm" : "npm";
43
43
  if (!fs.existsSync(path.join(APP_DIR, "node_modules"))) {
44
44
  console.error(`[excal] 첫 실행: ${pm} install (1회만)`);
45
- const r = spawnSync(pm, ["install"], { cwd: APP_DIR, stdio: ["ignore", 2, 2] });
45
+ const run = (args) => spawnSync(pm, args, { cwd: APP_DIR, stdio: ["ignore", 2, 2] });
46
+ // In the source repo app/ sits inside a pnpm workspace without being a member, so a plain
47
+ // `pnpm install` installs the workspace instead and leaves app/node_modules empty.
48
+ const pnpmArgs = ["install", "--ignore-workspace"];
49
+ let r = run(pm === "pnpm" ? [...pnpmArgs, "--frozen-lockfile"] : ["install"]);
50
+ if (r.status !== 0 && pm === "pnpm") {
51
+ console.error("[excal] frozen-lockfile 설치 실패(잠금 파일 불일치). 잠금 파일을 갱신해 재시도합니다");
52
+ r = run(pnpmArgs);
53
+ }
46
54
  if (r.status !== 0) die("[excal] install 실패");
47
55
  }
48
56
  console.error("[excal] 앱 빌드 중");
@@ -101,22 +109,29 @@ function chromeCommand(url) {
101
109
  "--window-size=1440,960",
102
110
  ...(process.env.EXCAL_CHROME_ARGS ? process.env.EXCAL_CHROME_ARGS.split(/\s+/).filter(Boolean) : []),
103
111
  ];
104
- if (process.platform === "darwin") return ["open", ["-na", "Google Chrome", "--args", ...args]];
112
+ if (process.platform === "darwin") {
113
+ // `open -na` succeeds silently even when the app is missing, so probe first.
114
+ if (spawnSync("open", ["-Ra", "Google Chrome"]).status !== 0) return null;
115
+ return ["open", ["-na", "Google Chrome", "--args", ...args]];
116
+ }
105
117
  for (const bin of ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]) {
106
118
  if (spawnSync("which", [bin]).status === 0) return [bin, args];
107
119
  }
108
120
  return null;
109
121
  }
110
122
 
123
+ /** @returns {"opened"|"not-connected"|"no-browser"|"browser-disabled"} */
111
124
  async function openWindow(srv, info) {
112
- if (process.env.EXCAL_BROWSER === "none") return false;
125
+ if (process.env.EXCAL_BROWSER === "none") return "browser-disabled";
113
126
  const cmd = chromeCommand(info.url);
114
127
  if (!cmd) {
115
- console.error(`[excal] Chrome을 찾지 못했습니다. 직접 여세요: ${info.url}`);
116
- return false;
128
+ console.error(
129
+ `[excal] Chrome을 찾지 못했습니다. references/setup.md의 설치 방법을 사용자에게 안내하세요. 직접 열려면: ${info.url}`,
130
+ );
131
+ return "no-browser";
117
132
  }
118
133
  spawn(cmd[0], cmd[1], { detached: true, stdio: "ignore" }).unref();
119
- return waitForClient(srv, info.id, 20_000);
134
+ return (await waitForClient(srv, info.id, 20_000)) ? "opened" : "not-connected";
120
135
  }
121
136
 
122
137
  async function waitForClient(srv, id, timeoutMs) {
@@ -169,6 +184,23 @@ function textWidthEstimate(text, fontSize = 20) {
169
184
  return max;
170
185
  }
171
186
 
187
+ /** Bounding box of an element, estimating text size the window has not measured yet. */
188
+ function measuredBox(e) {
189
+ if (typeof e.x !== "number" || typeof e.y !== "number") return null;
190
+ const text = String(e.text ?? "");
191
+ const fontSize = e.fontSize ?? 20;
192
+ const w =
193
+ typeof e.width === "number" ? Math.abs(e.width) : e.type === "text" ? textWidthEstimate(text, fontSize) : undefined;
194
+ const h =
195
+ typeof e.height === "number"
196
+ ? Math.abs(e.height)
197
+ : e.type === "text"
198
+ ? fontSize * 1.25 * text.split("\n").length
199
+ : undefined;
200
+ if (w === undefined && h === undefined) return null;
201
+ return { x: e.x, y: e.y, w: w ?? 0, h: h ?? 0 };
202
+ }
203
+
172
204
  function labelOf(el, byId) {
173
205
  if (el.label?.text) return el.label.text;
174
206
  if (el.type === "text") return el.text;
@@ -188,11 +220,11 @@ function inspect(file) {
188
220
  const byId = new Map(elements.map((e) => [e.id, e]));
189
221
  const visible = elements.filter((e) => !(e.type === "text" && e.containerId));
190
222
  const count = (pred) => visible.filter(pred).length;
191
- const boxes = visible.filter((e) => typeof e.x === "number" && typeof e.width === "number");
192
- const minX = Math.min(...boxes.map((e) => e.x));
193
- const minY = Math.min(...boxes.map((e) => e.y));
194
- const maxX = Math.max(...boxes.map((e) => e.x + Math.abs(e.width)));
195
- const maxY = Math.max(...boxes.map((e) => e.y + Math.abs(e.height ?? 0)));
223
+ const boxes = visible.map(measuredBox).filter(Boolean);
224
+ const minX = Math.min(...boxes.map((b) => b.x));
225
+ const minY = Math.min(...boxes.map((b) => b.y));
226
+ const maxX = Math.max(...boxes.map((b) => b.x + b.w));
227
+ const maxY = Math.max(...boxes.map((b) => b.y + b.h));
196
228
  console.log(
197
229
  `${path.basename(file)} 요소 ${visible.length}개 (도형 ${count((e) => SHAPES.has(e.type))}, 화살표/선 ${count((e) => LINEAR.has(e.type))}, 텍스트 ${count((e) => e.type === "text")})` +
198
230
  (boxes.length ? ` 범위 x ${round(minX)}~${round(maxX)}, y ${round(minY)}~${round(maxY)}` : "") +
@@ -276,23 +308,10 @@ function lint(file) {
276
308
  }
277
309
  // partial overlaps between top-level boxes (containment is treated as intentional grouping)
278
310
  const boxes = elements
279
- .filter((e) => (SHAPES.has(e.type) || (e.type === "text" && !e.containerId)) && typeof e.x === "number")
311
+ .filter((e) => SHAPES.has(e.type) || (e.type === "text" && !e.containerId))
280
312
  .map((e) => {
281
- const w =
282
- typeof e.width === "number"
283
- ? e.width
284
- : e.type === "text"
285
- ? textWidthEstimate(e.text, e.fontSize ?? 20)
286
- : undefined;
287
- const h =
288
- typeof e.height === "number"
289
- ? e.height
290
- : e.type === "text"
291
- ? (e.fontSize ?? 20) * 1.25 * String(e.text).split("\n").length
292
- : undefined;
293
- return w === undefined || h === undefined
294
- ? null
295
- : { id: e.id ?? e.type, x1: e.x, y1: e.y, x2: e.x + w, y2: e.y + h };
313
+ const b = measuredBox(e);
314
+ return b && { id: e.id ?? e.type, x1: b.x, y1: b.y, x2: b.x + b.w, y2: b.y + b.h };
296
315
  })
297
316
  .filter(Boolean);
298
317
  const contains = (a, b) => a.x1 <= b.x1 && a.y1 <= b.y1 && a.x2 >= b.x2 && a.y2 >= b.y2;
@@ -338,22 +357,34 @@ switch (cmd) {
338
357
  case "open": {
339
358
  const mermaid = flag("--from-mermaid");
340
359
  const noWindow = bool("--no-window");
341
- const file = resolveFile(rest[0], { create: true });
360
+ // Read the mermaid source before resolveFile(create) so a typo does not leave an empty diagram behind.
361
+ let mermaidSrc;
342
362
  if (mermaid) {
343
- const src = fs.readFileSync(path.resolve(mermaid), "utf8");
363
+ const src = path.resolve(mermaid);
364
+ if (!fs.existsSync(src)) die(`[excal] mermaid 파일이 없습니다: ${src}`);
365
+ try {
366
+ mermaidSrc = fs.readFileSync(src, "utf8");
367
+ } catch (err) {
368
+ die(`[excal] mermaid 파일을 읽지 못했습니다: ${src} (${err.message})`);
369
+ }
370
+ }
371
+ const file = resolveFile(rest[0], { create: true });
372
+ if (mermaidSrc !== undefined) {
344
373
  const data = JSON.parse(fs.readFileSync(file, "utf8"));
345
- data.pendingMermaid = src;
374
+ data.pendingMermaid = mermaidSrc;
346
375
  writeFileAtomic(file, JSON.stringify(data, null, 2) + "\n");
347
376
  }
348
377
  const srv = await ensureServer();
349
378
  const info = await register(srv, file);
350
379
  let windowState = "already-open";
351
- if (info.clients === 0) {
352
- windowState = noWindow ? "not-opened" : (await openWindow(srv, info)) ? "opened" : "not-connected";
353
- }
380
+ if (info.clients === 0) windowState = noWindow ? "not-opened" : await openWindow(srv, info);
354
381
  console.log(JSON.stringify({ file, url: info.url, window: windowState }, null, 2));
355
- if (mermaid && windowState !== "not-opened")
356
- console.error("[excal] mermaid 변환은 창에서 수행됩니다. 변환 후 파일이 정식 포맷으로 다시 저장됩니다.");
382
+ if (mermaid)
383
+ console.error(
384
+ windowState === "opened" || windowState === "already-open"
385
+ ? "[excal] mermaid 변환은 창에서 수행됩니다. 변환 후 파일이 정식 포맷으로 다시 저장됩니다."
386
+ : `[excal] 창이 없어 mermaid 변환이 보류됩니다 (window: ${windowState}). 파일에는 pendingMermaid만 남습니다.`,
387
+ );
357
388
  break;
358
389
  }
359
390
  case "inspect":
@@ -367,8 +398,11 @@ switch (cmd) {
367
398
  const file = resolveFile(rest[0]);
368
399
  const srv = await ensureServer();
369
400
  const info = await register(srv, file);
370
- if (info.clients === 0 && !(await openWindow(srv, info)))
371
- die("[excal] 창에 연결하지 못해 스냅샷을 찍을 수 없습니다");
401
+ if (info.clients === 0) {
402
+ const windowState = await openWindow(srv, info);
403
+ if (windowState !== "opened")
404
+ die(`[excal] 창에 연결하지 못해 스냅샷을 찍을 수 없습니다 (window: ${windowState})`);
405
+ }
372
406
  const r = await call(srv.port, srv.token, `/api/files/${info.id}/snapshot`, { method: "POST" });
373
407
  if (!r.ok) die(`[excal] 스냅샷 실패: ${await r.text()}`);
374
408
  const target = out