Skip to content

Commit 0899365

Browse files
committed
feat: add Excalidraw diagram editor widget
1 parent a4447c1 commit 0899365

20 files changed

Lines changed: 2111 additions & 3 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ storybook-static/
3939
test-results.xml
4040

4141
docsite/
42+
public/excalidraw/
4243

4344
.kilo-format-temp-*
4445
.superpowers
4546
docs/superpowers
4647
.claude
48+
.planning/

cmd/wsh/cmd/wshcmd-excalidraw.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cmd
5+
6+
import (
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"os"
11+
"path/filepath"
12+
13+
"github.com/spf13/cobra"
14+
"github.com/wavetermdev/waveterm/pkg/waveobj"
15+
"github.com/wavetermdev/waveterm/pkg/wshrpc"
16+
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
17+
)
18+
19+
var excalidrawMagnified bool
20+
21+
var excalidrawCmd = &cobra.Command{
22+
Use: "excalidraw [file]",
23+
Short: "open an Excalidraw diagram",
24+
Args: cobra.MaximumNArgs(1),
25+
RunE: excalidrawRun,
26+
PreRunE: preRunSetupRpcClient,
27+
}
28+
29+
var excalidrawPushCmd = &cobra.Command{
30+
Use: "push <blockid> [file]",
31+
Short: "push Excalidraw JSON into a block's scene",
32+
Args: cobra.RangeArgs(1, 2),
33+
RunE: excalidrawPushRun,
34+
PreRunE: preRunSetupRpcClient,
35+
}
36+
37+
var excalidrawMermaidCmd = &cobra.Command{
38+
Use: "mermaid [blockid] [file]",
39+
Short: "open or push a Mermaid diagram as Excalidraw",
40+
Args: cobra.RangeArgs(0, 2),
41+
RunE: excalidrawMermaidRun,
42+
PreRunE: preRunSetupRpcClient,
43+
}
44+
45+
func init() {
46+
excalidrawCmd.Flags().BoolVarP(&excalidrawMagnified, "magnified", "m", false, "open in magnified mode")
47+
excalidrawCmd.AddCommand(excalidrawPushCmd)
48+
excalidrawCmd.AddCommand(excalidrawMermaidCmd)
49+
rootCmd.AddCommand(excalidrawCmd)
50+
}
51+
52+
func excalidrawRun(cmd *cobra.Command, args []string) (rtnErr error) {
53+
defer func() {
54+
sendActivity("excalidraw", rtnErr == nil)
55+
}()
56+
tabId := getTabIdFromEnv()
57+
if tabId == "" {
58+
return fmt.Errorf("no WAVETERM_TABID env var set")
59+
}
60+
meta := map[string]any{
61+
waveobj.MetaKey_View: "excalidraw",
62+
}
63+
if len(args) > 0 {
64+
absFile, err := filepath.Abs(args[0])
65+
if err != nil {
66+
return fmt.Errorf("getting absolute path: %w", err)
67+
}
68+
meta[waveobj.MetaKey_File] = absFile
69+
}
70+
wshCmd := &wshrpc.CommandCreateBlockData{
71+
TabId: tabId,
72+
BlockDef: &waveobj.BlockDef{
73+
Meta: meta,
74+
},
75+
Magnified: excalidrawMagnified,
76+
Focused: true,
77+
}
78+
_, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000})
79+
if err != nil {
80+
return fmt.Errorf("creating excalidraw block: %w", err)
81+
}
82+
return nil
83+
}
84+
85+
func excalidrawPushRun(cmd *cobra.Command, args []string) (rtnErr error) {
86+
defer func() {
87+
sendActivity("excalidraw:push", rtnErr == nil)
88+
}()
89+
blockId := args[0]
90+
var jsonData []byte
91+
var err error
92+
if len(args) > 1 {
93+
jsonData, err = os.ReadFile(args[1])
94+
} else {
95+
jsonData, err = io.ReadAll(os.Stdin)
96+
}
97+
if err != nil {
98+
return fmt.Errorf("reading input: %w", err)
99+
}
100+
var sceneData any
101+
if err := json.Unmarshal(jsonData, &sceneData); err != nil {
102+
return fmt.Errorf("invalid JSON: %w", err)
103+
}
104+
pushData := wshrpc.CommandExcalidrawPushData{
105+
BlockId: blockId,
106+
SceneData: sceneData,
107+
}
108+
err = wshclient.ExcalidrawPushCommand(RpcClient, pushData, &wshrpc.RpcOpts{Timeout: 5000})
109+
if err != nil {
110+
return fmt.Errorf("push failed: %w", err)
111+
}
112+
return nil
113+
}
114+
115+
func excalidrawMermaidRun(cmd *cobra.Command, args []string) (rtnErr error) {
116+
defer func() {
117+
sendActivity("excalidraw:mermaid", rtnErr == nil)
118+
}()
119+
var blockId string
120+
var mermaidData []byte
121+
var err error
122+
switch len(args) {
123+
case 0:
124+
mermaidData, err = io.ReadAll(os.Stdin)
125+
if err != nil {
126+
return fmt.Errorf("reading stdin: %w", err)
127+
}
128+
case 1:
129+
mermaidData, err = os.ReadFile(args[0])
130+
if err != nil {
131+
if !os.IsNotExist(err) {
132+
return fmt.Errorf("reading file: %w", err)
133+
}
134+
blockId = args[0]
135+
mermaidData, err = io.ReadAll(os.Stdin)
136+
if err != nil {
137+
return fmt.Errorf("reading stdin: %w", err)
138+
}
139+
}
140+
case 2:
141+
blockId = args[0]
142+
mermaidData, err = os.ReadFile(args[1])
143+
if err != nil {
144+
return fmt.Errorf("reading file: %w", err)
145+
}
146+
}
147+
if blockId == "" {
148+
tabId := getTabIdFromEnv()
149+
if tabId == "" {
150+
return fmt.Errorf("no WAVETERM_TABID env var set")
151+
}
152+
createData := &wshrpc.CommandCreateBlockData{
153+
TabId: tabId,
154+
BlockDef: &waveobj.BlockDef{
155+
Meta: map[string]any{
156+
waveobj.MetaKey_View: "excalidraw",
157+
},
158+
},
159+
Magnified: excalidrawMagnified,
160+
Focused: true,
161+
}
162+
oref, err := wshclient.CreateBlockCommand(RpcClient, *createData, &wshrpc.RpcOpts{Timeout: 2000})
163+
if err != nil {
164+
return fmt.Errorf("creating excalidraw block: %w", err)
165+
}
166+
blockId = oref.OID
167+
}
168+
pushData := wshrpc.CommandExcalidrawPushData{
169+
BlockId: blockId,
170+
SceneData: string(mermaidData),
171+
Format: "mermaid",
172+
}
173+
err = wshclient.ExcalidrawPushCommand(RpcClient, pushData, &wshrpc.RpcOpts{Timeout: 5000})
174+
if err != nil {
175+
return fmt.Errorf("mermaid push failed: %w", err)
176+
}
177+
return nil
178+
}

docs/docs/wsh-reference.mdx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,73 @@ wsh editconfig presets/ai.json
195195
196196
---
197197
198+
## excalidraw
199+
200+
Open an Excalidraw diagram in a new block.
201+
202+
```sh
203+
wsh excalidraw [file]
204+
```
205+
206+
Opens the specified `.excalidraw` file for editing. If the file does not exist, creates an empty canvas with that file path set for autosave. If no file is specified, opens a blank canvas.
207+
208+
Flags:
209+
210+
- `-m, --magnified` - open the block in magnified mode
211+
212+
Examples:
213+
214+
```sh
215+
# Open an existing diagram
216+
wsh excalidraw diagram.excalidraw
217+
218+
# Create a new diagram (file will be created on first save)
219+
wsh excalidraw ~/diagrams/new-design.excalidraw
220+
221+
# Open a blank canvas (no file path)
222+
wsh excalidraw
223+
224+
# Open in magnified mode
225+
wsh excalidraw -m architecture.excalidraw
226+
```
227+
228+
### push
229+
230+
```sh
231+
wsh excalidraw push <blockid> [file]
232+
```
233+
234+
Replaces the scene in an existing Excalidraw block with Excalidraw JSON read from `file`, or from stdin if no file is given. If the block is backed by a file, the pushed scene is autosaved to it.
235+
236+
```sh
237+
# Replace a block's scene from a file
238+
wsh excalidraw push <blockid> diagram.excalidraw
239+
240+
# Pipe a generated scene into a block
241+
cat scene.json | wsh excalidraw push <blockid>
242+
```
243+
244+
### mermaid
245+
246+
```sh
247+
wsh excalidraw mermaid [blockid] [file]
248+
```
249+
250+
Converts a Mermaid diagram to Excalidraw. With no `blockid`, opens the result in a new block. The Mermaid source is read from `file`, or from stdin if no file is given.
251+
252+
```sh
253+
# Convert a Mermaid file and open in a new block
254+
wsh excalidraw mermaid flowchart.mmd
255+
256+
# Push a converted Mermaid diagram into an existing block
257+
wsh excalidraw mermaid <blockid> flowchart.mmd
258+
259+
# Pipe Mermaid source into an existing block
260+
echo "graph TD; A-->B" | wsh excalidraw mermaid <blockid>
261+
```
262+
263+
---
264+
198265
## setbg
199266
200267
The `setbg` command allows you to set a background image or color for the current tab with various customization options.

electron.vite.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ export default defineConfig({
123123
},
124124
renderer: {
125125
root: ".",
126+
define: {
127+
"process.env.IS_PREACT": JSON.stringify("false"),
128+
},
126129
build: {
127130
target: CHROME,
128131
sourcemap: true,
@@ -142,6 +145,8 @@ export default defineConfig({
142145
}
143146
if (p.includes("node_modules/cytoscape") || p.includes("node_modules/@cytoscape"))
144147
return "cytoscape";
148+
if (p.includes("node_modules/excalidraw") || p.includes("node_modules/@excalidraw"))
149+
return "excalidraw";
145150
return undefined;
146151
},
147152
},

frontend/app/block/blockregistry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { TabModel } from "@/app/store/tab-model";
66
import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff";
77
import { LauncherViewModel } from "@/app/view/launcher/launcher";
88
import { PreviewModel } from "@/app/view/preview/preview-model";
9+
import { ExcalidrawModel } from "@/app/view/excalidraw/excalidraw-model";
910
import { ProcessViewerViewModel } from "@/app/view/processviewer/processviewer";
1011
import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo";
1112
import { TsunamiViewModel } from "@/app/view/tsunami/tsunami";
@@ -35,6 +36,7 @@ BlockRegistry.set("tsunami", TsunamiViewModel);
3536
BlockRegistry.set("aifilediff", AiFileDiffViewModel);
3637
BlockRegistry.set("waveconfig", WaveConfigViewModel);
3738
BlockRegistry.set("processviewer", ProcessViewerViewModel);
39+
BlockRegistry.set("excalidraw", ExcalidrawModel);
3840

3941
function makeDefaultViewModel(viewType: string): ViewModel {
4042
const viewModel: ViewModel = {

frontend/app/block/blockutil.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ export function blockViewToIcon(view: string): string {
4545
if (view == "processviewer") {
4646
return "microchip";
4747
}
48+
if (view == "excalidraw") {
49+
return "pen-ruler";
50+
}
4851
return "square";
4952
}
5053

@@ -73,6 +76,9 @@ export function blockViewToName(view: string): string {
7376
if (view == "processviewer") {
7477
return "Processes";
7578
}
79+
if (view == "excalidraw") {
80+
return "Excalidraw";
81+
}
7682
return view;
7783
}
7884

frontend/app/store/wshclientapi.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,12 @@ export class RpcApiType {
294294
return client.wshRpcCall("eventunsuball", null, opts);
295295
}
296296

297+
// command "excalidrawpush" [call]
298+
ExcalidrawPushCommand(client: WshClient, data: CommandExcalidrawPushData, opts?: RpcOpts): Promise<void> {
299+
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "excalidrawpush", data, opts);
300+
return client.wshRpcCall("excalidrawpush", data, opts);
301+
}
302+
297303
// command "fetchsuggestions" [call]
298304
FetchSuggestionsCommand(client: WshClient, data: FetchSuggestionsData, opts?: RpcOpts): Promise<FetchSuggestionsResponse> {
299305
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "fetchsuggestions", data, opts);

0 commit comments

Comments
 (0)