Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Plugins.
| Taxi Timesheets Syntax | `plugins/taxi` | Highlighting for Taxi `.tks` timesheets |
| HTTP Request Files | `plugins/http` | Highlighting for `.http` and `.rest` request files |
| systemd Unit Files | `plugins/systemd` | Highlighting for systemd units and Podman Quadlet files |
| Excalidraw | `plugins/excalidraw` | An editor for `.excalidraw` diagrams |
| Excalidraw | `plugins/excalidraw` | An editor for `.excalidraw` diagrams and `.excalidraw.svg` and `.excalidraw.png` images |
| Word Document Viewer | `plugins/docx` | Read-only `.docx` pages with headers, footers, images and zoom |
| Image Markup | `plugins/image-markup` | Pen, arrow, box and text markup on images, saved editably into the file |
| Multipass VM Recipes | `plugins/multipass-recipes` | Disposable Multipass VM workspaces |
Expand Down
65 changes: 64 additions & 1 deletion orca-catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -443,12 +443,75 @@
"ref": "main",
"path": "plugins/excalidraw"
},
"description": "Draw and edit .excalidraw diagrams right in Orca's editor.",
"description": "Draw and edit .excalidraw diagrams, and .excalidraw.svg and .excalidraw.png images, right in Orca's editor.",
"categories": [
"editors",
"official"
],
"releases": [
{
"version": "2.2.0",
"commit": "71ea8a25892ef52edfb53671540d5dca7425388e",
"contentHash": "c73e9cb1412077d61cb2d4ab4fd746f8d01928082023941aef585e03dcd31674",
"execution": "sandboxed-ui",
"minimumOrca": "1.4.248",
"changelog": "Opens and saves .excalidraw.svg and .excalidraw.png images with an embedded drawing. A save keeps the format, draws the image again and embeds the drawing as Excalidraw does, keeping the image's scale, background and dark mode.",
"name": "Excalidraw",
"contributes": [
{
"kind": "customEditors",
"name": "Excalidraw",
"detail": ".excalidraw, .excalidraw.json"
},
{
"kind": "customEditors",
"name": "Excalidraw",
"detail": ".excalidraw.svg"
},
{
"kind": "customEditors",
"name": "Excalidraw",
"detail": ".excalidraw.png"
},
{
"kind": "newFiles",
"name": "New Excalidraw drawing",
"detail": ".excalidraw"
}
],
"fileTypes": [
"excalidraw"
],
"editorFileTypes": [
"excalidraw"
],
"images": {
"cover": {
"path": "plugins/excalidraw/images/cover.png",
"alt": "A hand-drawn architecture sketch of a web app, API gateway, services and database open in the Excalidraw editor",
"sha256": "85cc3391dbb7569b271d8367da9232b9806e0ea0bf183112e34fb304260f327d",
"width": 1600,
"height": 1000
},
"screenshots": [
{
"path": "plugins/excalidraw/images/editing.png",
"alt": "A box in the sketch selected, with Excalidraw's stroke, fill and font options beside it",
"sha256": "cf14ff5105a08d396765816de52be2eba5a0076af20e72f667a29ffcc2521152",
"width": 1600,
"height": 1000
},
{
"path": "plugins/excalidraw/images/dark.png",
"alt": "The same sketch in Excalidraw's dark theme",
"sha256": "5db71c99c28336bf8b942de84a8327bf81d23b33d4eff7788c6efed0ebc463c9",
"width": 1600,
"height": 1000
}
]
},
"license": "MIT"
},
{
"version": "2.1.0",
"commit": "411bd261a3f177618e781100ea0719d34ee80342",
Expand Down
39 changes: 35 additions & 4 deletions plugins/excalidraw/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Excalidraw

Draw and edit `.excalidraw` diagrams in Orca's editor, with Excalidraw 0.18. Orca keeps the file:
its buffer, dirty state, saving, and outside changes such as an agent writing the file. The editor
Draw and edit `.excalidraw` diagrams in Orca's editor, with Excalidraw 0.18, and
`.excalidraw.svg` and `.excalidraw.png` images that carry their drawing. Orca keeps the file: its
buffer, dirty state, saving, and outside changes such as an agent writing the file. The editor
follows Orca's light or dark theme and language, keeps its shape library across drawings, and
saves image exports (PNG, SVG) through Orca's save dialog. Requires Orca 1.4.248 or later in the
desktop app; elsewhere Orca opens the file in Source.
Expand All @@ -24,12 +25,36 @@ its file-changed banner and you pick which version to keep.

The view (scroll and zoom) is not saved. A drawing opens scrolled to its content.

## Images: `.excalidraw.svg` and `.excalidraw.png`

An SVG or PNG exported from Excalidraw with **Embed scene** on carries its drawing. Name it
`<name>.excalidraw.svg` or `<name>.excalidraw.png` and Orca opens it in this editor. It saves in the
same format: the drawing goes into the file, and the image is drawn again from it, so the file
shows what you drew and Excalidraw can open it again. A Markdown file or a web page can show the
same file that you edit.

- The image keeps the file's export settings: its scale (1×, 2× or 3×), whether it has a
background, and dark mode. Excalidraw doesn't save them in the drawing, so they are read from the
image. A PNG shows dark mode only through its background; a transparent dark PNG is drawn light.
- The image is drawn again once you pause, about 0.3 seconds after the last change; the drawing in
the file is always the current one. A save waits for the image only as long as Orca's one-second
save time allows. A drawing that is slow to draw, or that holds megabytes of pictures, may save
with its previous image, and the tab then shows as changed until it is saved again.
- An empty `.excalidraw.svg` or `.excalidraw.png` file opens as a blank drawing and saves at 1×
with a background. "New Excalidraw drawing" makes an `.excalidraw` file.
- An image without an embedded drawing does not open; export it from Excalidraw with **Embed
scene** on.
- Orca opens a PNG as a binary file. Auto Save does not save it: save with Cmd+S (Ctrl+S on Windows
and Linux). An outside change to a PNG shows Orca's file-changed banner instead of merging, and
Source shows the picture rather than its text.

## What is in this folder

- `orca-plugin.json`: what Orca loads. The editor runs only in Orca's isolated editor frame
(`"frame": "isolated"`).
- `editor/`: the built editor that Orca loads, `index.html` plus ES module chunks, a stylesheet and
fonts in `assets/`. Generated and committed: never edit it by hand.
- `editor/`: the built editor that Orca loads, `index.html` (and `image.html`, the same editor for
images) plus ES module chunks, a stylesheet and fonts in `assets/`. Generated and committed: never
edit it by hand.
- `build/`: the source of `editor/`, the build recipe and the tests. `build/src/scene-sync.js` is
the document sync: Orca's `orca.document.session()` with a three-way merge by element id, so an
outside change and this canvas's unsent change both survive. Build steps, the patches it makes to
Expand All @@ -38,6 +63,12 @@ The view (scroll and zoom) is not saved. A drawing opens scrolled to its content

## Changelog

### 2.2.0

- Opens and saves `.excalidraw.svg` and `.excalidraw.png` images with an embedded drawing. A save
keeps the format, draws the image again and embeds the drawing in Excalidraw's own format, keeping
the image's scale, background and dark mode.

### 2.1.0

- Document sync uses Orca's document session (about 200 lines of hand-written sync gone): Orca
Expand Down
48 changes: 44 additions & 4 deletions plugins/excalidraw/build/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ affected.
## Layout

- `editor/index.html`: the page. It loads the stylesheet and the entry module from `assets/`.
- `editor/image.html`: the same page marked `<html data-document="image">`, for the
`.excalidraw.svg` and `.excalidraw.png` editors.
- `editor/assets/`: the editor as ES module chunks (code-split), one per locale and lazily loaded
feature (font subsetting with its worker and WebAssembly, image resizing, Mermaid), and its
stylesheet.
Expand All @@ -33,7 +35,10 @@ affected.
No file may exceed 10 MiB, the most Orca reads of one plugin file.

Sources: `src/main.js` is the entry and runs `src/editor.jsx`, the React app. `src/scene-sync.js`
is the document sync (below); it has no Excalidraw import, so its tests run in Node.
is the document sync (below); it has no Excalidraw import, so its tests run in Node. Nor has
`src/excalidraw-image.js`, which reads and writes the scene an SVG or PNG image carries, or
`src/picture-sync.js`, which draws an image's picture again after edits; `src/picture-render.js`
draws it with Excalidraw.

What the build changes in Excalidraw (`excalidraw-patches.mjs`), and why. Every patch fails the
build when its pattern is gone:
Expand Down Expand Up @@ -82,15 +87,50 @@ Orca's frames may refuse clipboard writes. Excalidraw then falls back to the cop
`execCommand`; if those fail too, `src/clipboard-fallback.js` keeps the copied shapes for pastes
inside the frame until it loses focus. "Copy as PNG/SVG" needs the clipboard and fails there.

## Images

The `excalidraw-svg` editor takes `.excalidraw.svg` as text, and `excalidraw-png` takes
`.excalidraw.png` as bytes (`"contentKind": "bytes"`); the format follows the content kind, so an
empty file is unambiguous. Both load `image.html`, and the session's value is
`{ format, elements, appState, files, source, image, stale }`: `image` is the last picture drawn,
an SVG string or PNG bytes.

- The scene is embedded as Excalidraw does (`data/encode.ts`, `data/image.ts`): JSON deflated and
wrapped in `{ version: "1", encoding: "bstring", compressed: true, encoded }`. In an SVG that is
base64 between `<!-- payload-start -->` and `<!-- payload-end -->` in `<metadata>`, on one line
since Excalidraw's reader takes no line breaks. In a PNG it is a `tEXt` chunk with the keyword
`application/vnd.excalidraw+json`, right after `IHDR` (Excalidraw reads only the first `tEXt`).
Older payloads (plain JSON, uncompressed) are read too.
- `encode` must be synchronous and Excalidraw's export is not, so an edit is sent at once with the
new scene embedded in the last picture and `stale: true`. `picture-sync.js` then draws the
picture once the canvas has been idle for 300 ms, one drawing at a time, and sends it with
`change` only while the scene is still `doc.value`. It answers `editor.flush` too, registered
before the session so its clock starts with the save: the session sends the scene in the old
picture at once while the newest scene is drawn, and the picture goes into the save only if
sending it (the session's last encode and decode times, plus 100 ms for the host) still ends
within 800 ms of the host's 1 s. Otherwise it is sent after the save. Either way the saved scene
is current and only the picture may lag.
- Excalidraw keeps no export settings in the scene, so `picture-render.js` reads them from the
picture. An SVG gives its scale (`width` over the `viewBox` width), background (a first `<rect>`
at 0,0 after `<defs>`) and dark mode (`filter="invert(…)"`). A PNG gives its scale from its width
and its own scene's bounds plus padding, snapped to 1, 2 or 3, its background from whether its
top-left pixel is opaque, and dark mode from that pixel against the scene's background colour. A
new file takes Excalidraw's defaults: 1×, background, light.
- `merge` keeps the outside picture and marks the merged scene stale, so it is drawn again.

## Tests

`npm test` runs `test/*.test.mjs` with Node's test runner. `test/scene-sync.test.mjs` drives the
document sync against Orca's real frame session through `mockEditorHost` from `orca/testing`,
vendored in `test/orca-testing/` (copy `orca-testing.mjs` and `orca-runtime.mjs` from Orca's
`resources/plugins/declarative/`, or from `orca plugin types`, to update it).
`test/excalidraw-image.test.mjs` covers the image format and the picture sync against
`test/fixtures/`: a rectangle and an ellipse exported by Excalidraw 0.18.1 (`exportToSvg` and
`exportToBlob` with `exportEmbedScene`) as a 2× SVG with a background, a dark transparent SVG, and
PNGs at 2× light, 1× dark and 1× transparent.

## Compatibility

`engines.orca` is 1.4.248, the first release with the document session and `"frame": "isolated"`.
`orca-catalog.json` keeps 2.0.1 listed beneath 2.1.0, so older Orca still installs the version
that runs in either frame.
`engines.orca` is 1.4.248, the first release with the document session and `"frame": "isolated"`;
the image editors' `"contentKind": "bytes"` is older. `orca-catalog.json` keeps 2.0.1 listed
beneath the newer releases, so older Orca still installs the version that runs in either frame.
12 changes: 8 additions & 4 deletions plugins/excalidraw/build/build.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Builds ../editor (see README.md):
// assets/ (with assets/fonts/) the editor as ES modules, for Orca's isolated editor frame;
// index.html the page that loads it.
// index.html the page that loads it, for .excalidraw files;
// image.html the same for .excalidraw.svg and .excalidraw.png images.
// Also writes ../THIRD_PARTY_NOTICES.txt, then runs the tests in test/.
// Usage: npm ci --ignore-scripts && node build.mjs
import { build } from 'esbuild'
Expand Down Expand Up @@ -66,8 +67,8 @@ const assets = {
preload: entryOutput.imports.filter((chunk) => chunk.kind === 'import-statement').map((chunk) => guestFile(chunk.path))
}

const html = `<!doctype html>
<html>
const page = (attributes) => `<!doctype html>
<html${attributes}>
<head>
<meta charset="utf-8">
<title>Excalidraw</title>
Expand All @@ -84,7 +85,10 @@ ${assets.preload.map((href) => `<link rel="modulepreload" href="${href}">`).join
</html>
`

const written = [{ file: path.join(EDITOR, 'index.html'), bytes: Buffer.from(html) }]
const written = [
{ file: path.join(EDITOR, 'index.html'), bytes: Buffer.from(page('')) },
{ file: path.join(EDITOR, 'image.html'), bytes: Buffer.from(page(' data-document="image"')) }
]
for (const file of guest.outputFiles) {
written.push({ file: file.path, bytes: Buffer.from(file.contents) })
}
Expand Down
1 change: 1 addition & 0 deletions plugins/excalidraw/build/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions plugins/excalidraw/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"dependencies": {
"@excalidraw/excalidraw": "0.18.1",
"pako": "2.0.3",
"react": "18.3.1",
"react-dom": "18.3.1"
},
Expand Down
40 changes: 31 additions & 9 deletions plugins/excalidraw/build/src/editor.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Orca custom editor for .excalidraw files. Orca owns the buffer, dirty state and saving; the
// document session (scene-sync.js) sends the canvas's changes and merges outside ones (see
// build/README.md). `source` preservation and the load baseline follow stablyai/orca#27413 by
// Orca custom editor for .excalidraw files, and for .excalidraw.svg and .excalidraw.png images that
// embed their scene (editor/image.html). Orca owns the buffer, dirty state and saving; the
// document session (scene-sync.js) sends the canvas's changes and merges outside ones, and an
// image's picture is drawn again after each change (picture-sync.js; see build/README.md).
// `source` preservation and the load baseline follow stablyai/orca#27413 by
// Roughriver74, which first brought Excalidraw into Orca's editor. main.js imports this file.
import { useEffect, useMemo, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
Expand All @@ -19,11 +21,15 @@ import {
useHandleLibrary
} from '@excalidraw/excalidraw'
import '@excalidraw/excalidraw/index.css'
import { SAVED_APP_STATE, openSceneSession, pickSavedAppState } from './scene-sync.js'
import { SAVED_APP_STATE, editedScene, openSceneSession, pickSavedAppState } from './scene-sync.js'
import { syncPicture } from './picture-sync.js'
import { drawPicture } from './picture-render.js'
import { libraryAdapter, onLibraryStored } from './library-storage.js'
import { canSaveFiles } from './orca-file-access.js'

const EXTERNAL_LINK = /^(https?|mailto):/i
/** Set by image.html: the document is an .excalidraw.svg or .excalidraw.png picture. */
const IMAGE = document.documentElement.dataset.document === 'image'

/** Host APIs beyond the document. Read once Orca's hello has arrived, which can change them. */
function readFeatures() {
Expand Down Expand Up @@ -109,7 +115,7 @@ function PersistedLibrary({ api }) {
return null
}

function App({ doc }) {
function App({ doc, picture, onApi }) {
const theme = useTheme()
// Read once: the session started after Orca's hello brought the locale and host methods.
const [language] = useState(pickLanguage)
Expand Down Expand Up @@ -149,16 +155,17 @@ function App({ doc }) {
const version = appState.isLoading ? shown.current : sceneVersion(elements, appState, files)
// Why: Excalidraw normalizes a scene as it loads; that is not an edit, so it only sets the mark.
if (shown.current !== undefined && version !== shown.current) {
doc.change({ elements, appState: pickSavedAppState(appState), files, source: doc.value.source })
doc.change(editedScene(doc.value, { elements, appState: pickSavedAppState(appState), files }))
picture?.schedule()
}
shown.current = version
}

if (view.scene.error) {
return (
<div className="orca-excalidraw-message" role="alert">
Cannot display this file as an Excalidraw drawing: {view.scene.error}. Switch to Source to
fix it.
Cannot display this file as an Excalidraw drawing: {view.scene.error}.
{view.scene.format === 'json' && ' Switch to Source to fix it.'}
</div>
)
}
Expand All @@ -171,6 +178,7 @@ function App({ doc }) {
excalidrawAPI={(next) => {
apiRef.current = next
setApi(next)
onApi(next)
}}
onChange={onChange}
onLinkOpen={(element, event) => onLinkOpen(element, event, apiRef.current, features)}
Expand Down Expand Up @@ -206,6 +214,20 @@ function App({ doc }) {
)
}

let api = null
// Before the session, so a save is timed from its start (see picture-sync.js).
const picture = IMAGE
? syncPicture({
render: drawPicture,
onError: (error) => {
console.error(error)
api?.setToast({ message: `Couldn't update the picture in this file: ${error.message}`, closable: true })
}
})
: null
// Why after the session: it starts once Orca's hello brought the theme, so a dark Orca never shows
// a light canvas.
openSceneSession(serialize).then((doc) => createRoot(document.getElementById('root')).render(<App doc={doc} />))
openSceneSession(serialize, { image: IMAGE, timing: picture?.timing }).then((doc) => {
picture?.attach(doc)
createRoot(document.getElementById('root')).render(<App doc={doc} picture={picture} onApi={(next) => (api = next)} />)
})
Loading
Loading