@ryan_nookpi/pi-skill-excalidraw 0.1.3 → 0.1.4
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 +20 -5
- package/package.json +8 -2
- package/skills/excalidraw/SKILL.md +18 -6
- package/skills/excalidraw/app/package.json +3 -0
- package/skills/excalidraw/assets/examples/flowchart.excalidraw +1 -1
- package/skills/excalidraw/references/setup.md +36 -5
- package/skills/excalidraw/references/style.md +3 -3
- package/skills/excalidraw/scripts/excal.mjs +71 -37
- package/skills/excalidraw/scripts/excal.test.mjs +64 -0
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
|
-
|
|
18
|
+
설치 순서와 동작 확인 방법은 [skills/excalidraw/references/setup.md](skills/excalidraw/references/setup.md)에 있습니다. 준비가 덜 된 상태에서 스킬을 부르면 에이전트가 빠진 단계만 짧게 알려 줍니다. macOS 기준으로 검증했습니다.
|
|
18
19
|
|
|
19
20
|
## 사용 예
|
|
20
21
|
|
|
21
22
|
```text
|
|
22
|
-
|
|
23
|
+
로그인 흐름을 플로우차트로 그려줘
|
|
24
|
+
이 레포 배포 파이프라인을 아키텍처 다이어그램으로 정리해줘
|
|
25
|
+
주문 API 요청 순서를 시퀀스 다이어그램으로 보여줘
|
|
26
|
+
방금 그린 다이어그램에서 결제 실패 분기를 추가해줘
|
|
27
|
+
docs/flow.mmd에 쓴 mermaid를 Excalidraw로 가져와줘
|
|
23
28
|
```
|
|
24
29
|
|
|
25
|
-
요청 내용이 스킬 설명과 맞으면 에이전트가 알아서
|
|
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
|
+
"version": "0.1.4",
|
|
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": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"skills/excalidraw/SKILL.md",
|
|
24
24
|
"skills/excalidraw/scripts/",
|
|
25
|
+
"!skills/excalidraw/scripts/*.test.mjs",
|
|
25
26
|
"skills/excalidraw/references/",
|
|
26
27
|
"skills/excalidraw/assets/",
|
|
27
28
|
"skills/excalidraw/app/dist/",
|
|
@@ -32,6 +33,9 @@
|
|
|
32
33
|
"skills/excalidraw/app/vite.config.js",
|
|
33
34
|
"README.md"
|
|
34
35
|
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
35
39
|
"pi": {
|
|
36
40
|
"skills": [
|
|
37
41
|
"./skills/excalidraw"
|
|
@@ -40,5 +44,7 @@
|
|
|
40
44
|
"publishConfig": {
|
|
41
45
|
"access": "public"
|
|
42
46
|
},
|
|
43
|
-
"scripts": {
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node --test skills/excalidraw/scripts/excal.test.mjs"
|
|
49
|
+
}
|
|
44
50
|
}
|
|
@@ -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는
|
|
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> #
|
|
17
|
+
$EXCAL history <file> # 백업 목록 (최근 50개). 파일 외부 변경 직전 + 사용자 창 편집 5분마다
|
|
17
18
|
$EXCAL status | stop
|
|
18
19
|
$EXCAL build # app/ 소스를 고친 뒤 재빌드
|
|
19
20
|
```
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
`<이 스킬 디렉터리>`는 이 SKILL.md가 있는 디렉터리의 절대 경로다. 현재 작업 디렉터리 기준 상대 경로로 부르면 안 된다.
|
|
22
23
|
|
|
23
|
-
|
|
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은 붙이지 않아도 된다.
|
|
@@ -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":
|
|
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`가 나오면
|
|
11
|
+
`ok`가 나오면 다음 단계로 간다. Linux는 `google-chrome`, `google-chrome-stable`, `chromium`, `chromium-browser` 중 하나가 PATH에 있으면 된다.
|
|
12
12
|
|
|
13
|
-
|
|
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`, 판단 다이아몬드 `
|
|
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
|
|
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")
|
|
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
|
|
125
|
+
if (process.env.EXCAL_BROWSER === "none") return "browser-disabled";
|
|
113
126
|
const cmd = chromeCommand(info.url);
|
|
114
127
|
if (!cmd) {
|
|
115
|
-
console.error(
|
|
116
|
-
|
|
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.
|
|
192
|
-
const minX = Math.min(...boxes.map((
|
|
193
|
-
const minY = Math.min(...boxes.map((
|
|
194
|
-
const maxX = Math.max(...boxes.map((
|
|
195
|
-
const maxY = Math.max(...boxes.map((
|
|
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) =>
|
|
311
|
+
.filter((e) => SHAPES.has(e.type) || (e.type === "text" && !e.containerId))
|
|
280
312
|
.map((e) => {
|
|
281
|
-
const
|
|
282
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
356
|
-
console.error(
|
|
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
|
|
371
|
-
|
|
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
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Browser-free regression tests for excal.mjs. Commands that need the sync server
|
|
2
|
+
// (open with a window, snapshot) are covered by the manual smoke in references/setup.md.
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const CLI = path.join(SCRIPTS_DIR, "excal.mjs");
|
|
13
|
+
const EXAMPLE = path.join(SCRIPTS_DIR, "..", "assets", "examples", "flowchart.excalidraw");
|
|
14
|
+
|
|
15
|
+
const run = (args, cwd) =>
|
|
16
|
+
spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: "utf8", env: { ...process.env } });
|
|
17
|
+
|
|
18
|
+
const withTempDir = (fn) => {
|
|
19
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "excal-test-"));
|
|
20
|
+
try {
|
|
21
|
+
return fn(dir);
|
|
22
|
+
} finally {
|
|
23
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
test("bundled example passes the bundled linter", () => {
|
|
28
|
+
const r = run(["lint", EXAMPLE]);
|
|
29
|
+
assert.equal(r.status, 0, r.stdout + r.stderr);
|
|
30
|
+
assert.match(r.stdout, /^OK$/m);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("open --from-mermaid with a missing file fails without creating the diagram", () => {
|
|
34
|
+
withTempDir((dir) => {
|
|
35
|
+
const target = path.join(dir, "out.excalidraw");
|
|
36
|
+
const r = run(["open", target, "--from-mermaid", path.join(dir, "nope.mmd")], dir);
|
|
37
|
+
assert.equal(r.status, 1);
|
|
38
|
+
assert.match(r.stderr, /mermaid 파일이 없습니다/);
|
|
39
|
+
assert.doesNotMatch(r.stderr, /at .*excal\.mjs/);
|
|
40
|
+
assert.equal(fs.existsSync(target), false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("inspect bounds cover text elements the window has not measured yet", () => {
|
|
45
|
+
withTempDir((dir) => {
|
|
46
|
+
const file = path.join(dir, "scene.excalidraw");
|
|
47
|
+
fs.writeFileSync(
|
|
48
|
+
file,
|
|
49
|
+
JSON.stringify({
|
|
50
|
+
type: "excalidraw",
|
|
51
|
+
version: 2,
|
|
52
|
+
elements: [
|
|
53
|
+
{ type: "text", id: "title", x: 100, y: 20, text: "제목", fontSize: 28 },
|
|
54
|
+
{ type: "rectangle", id: "box", x: 400, y: 200, width: 200, height: 72 },
|
|
55
|
+
],
|
|
56
|
+
appState: {},
|
|
57
|
+
files: {},
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
const r = run(["inspect", file], dir);
|
|
61
|
+
assert.equal(r.status, 0, r.stdout + r.stderr);
|
|
62
|
+
assert.match(r.stdout, /범위 x 100~600, y 20~272/);
|
|
63
|
+
});
|
|
64
|
+
});
|