Skip to content

Commit 1de0086

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

20 files changed

Lines changed: 2172 additions & 3 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ storybook-static/
3939
test-results.xml
4040

4141
docsite/
42+
public/excalidraw/
4243

4344
.kilo-format-temp-*
4445
.superpowers

cmd/wsh/cmd/wshcmd-excalidraw.go

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

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)