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
72 changes: 72 additions & 0 deletions orca-catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,78 @@
"official"
],
"releases": [
{
"version": "2.3.0",
"commit": "91ac97f004a0869cf8d1afe550fc66ff0b8c1e43",
"contentHash": "0021d2487d5fb04e76f14fd7a35f744bec0169b361e4bcdc6ad06fbc84ccdb71",
"execution": "sandboxed-ui",
"minimumOrca": "1.4.248",
"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"
},
{
"kind": "newFiles",
"name": "New Excalidraw SVG image",
"detail": ".excalidraw.svg"
},
{
"kind": "newFiles",
"name": "New Excalidraw PNG image",
"detail": ".excalidraw.png"
}
],
"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.2.0",
"commit": "71ea8a25892ef52edfb53671540d5dca7425388e",
Expand Down
25 changes: 21 additions & 4 deletions plugins/excalidraw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,22 @@ 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.
image. A PNG this editor saved notes them exactly. In a PNG from Excalidraw itself, dark mode shows
only through the background, so a transparent dark PNG is read as light.
- To change them, open the main menu (☰): **Picture in this file** has **Scale**, **Background**
and **Dark mode**. A change redraws this file's image, and new images start with the settings
you picked last.
- 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.
- "New Excalidraw SVG image" and "New Excalidraw PNG image" (in the tab bar's + menu and the
command palette) make an image to draw in, as does any empty `.excalidraw.svg` or
`.excalidraw.png` file. It starts at 1× with a background, or with the settings you picked last.
- To turn a drawing into an image, or one image format into the other, rename the file: for
example `diagram.excalidraw` to `diagram.excalidraw.svg`, or `.excalidraw.svg` to
`.excalidraw.png`. The editor opens the drawing, draws its image (the tab shows as changed), and
saving writes the new format. An SVG renamed to PNG keeps its settings.
- 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
Expand All @@ -59,10 +68,18 @@ same file that you edit.
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
Excalidraw and how the editor talks to Orca are in [build/README.md](build/README.md).
- `templates/`: the blank drawing behind "New Excalidraw drawing".
- `templates/`: the blank drawing behind "New Excalidraw drawing". New images start empty.

## Changelog

### 2.3.0

- Images get **Picture in this file** in the main menu, for their scale, background and dark mode;
new images start with the last ones picked. A PNG notes them, so it reopens exactly as saved.
- Renaming an `.excalidraw` file to `.excalidraw.svg` or `.excalidraw.png`, or an SVG image to PNG,
converts it on the next save.
- "New Excalidraw SVG image" and "New Excalidraw PNG image".

### 2.2.0

- Opens and saves `.excalidraw.svg` and `.excalidraw.png` images with an embedded drawing. A save
Expand Down
20 changes: 15 additions & 5 deletions plugins/excalidraw/build/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,13 @@ inside the frame until it loses focus. "Copy as PNG/SVG" needs the clipboard and
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.
`{ format, elements, appState, files, source, image, stale, look? }`: `image` is the last picture
drawn, an SVG string or PNG bytes, and `look` the export settings picked in the main menu.

- A file in another Excalidraw format than its name says (it was renamed) decodes to its scene
with `image: null` and `stale: true`: scene JSON in either editor, or an SVG (with its look) in
the PNG editor. `picture-sync.js` draws it once the session is attached, so the tab turns dirty
and the next save writes the file's own format.

- 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
Expand All @@ -114,9 +119,14 @@ an SVG string or PNG bytes.
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.
top-left pixel is opaque, and dark mode from that pixel against the scene's background colour,
unless the PNG notes its look: a second `tEXt` chunk, `orca-excalidraw-look`, with
`{ scale, background, dark }` as JSON, before `IEND`, which this editor writes and Excalidraw
ignores. `scene.look`, set from the main menu, wins over both. A new file takes the look last
picked (in `localStorage`, beside the library), else Excalidraw's defaults: 1×, background,
light.
- `merge` keeps the outside picture and marks the merged scene stale, so it is drawn again. A
look picked on this canvas since the base wins.

## Tests

Expand Down
51 changes: 50 additions & 1 deletion plugins/excalidraw/build/src/editor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
import '@excalidraw/excalidraw/index.css'
import { SAVED_APP_STATE, editedScene, openSceneSession, pickSavedAppState } from './scene-sync.js'
import { syncPicture } from './picture-sync.js'
import { drawPicture } from './picture-render.js'
import { SCALES, drawPicture, lookOf, rememberLook } from './picture-render.js'
import { libraryAdapter, onLibraryStored } from './library-storage.js'
import { canSaveFiles } from './orca-file-access.js'

Expand Down Expand Up @@ -115,6 +115,53 @@ function PersistedLibrary({ api }) {
return null
}

/**
* The main menu's picture options for an image: how this file's picture is drawn. A new file's
* picture starts with the options last picked.
*/
function PictureOptions({ doc, picture }) {
const [look, setLook] = useState(null)
// Why read on each replacement: an outside change can bring a picture with another look.
const [read, setRead] = useState(0)
useEffect(() => {
const replaced = doc.onReplace(() => setRead((count) => count + 1))
return () => replaced.dispose()
}, [doc])
useEffect(() => {
let current = true
lookOf(doc.value).then((next) => current && setLook(next))
return () => {
current = false
}
}, [doc, read])
if (!look) {
return null
}
const pick = (event, change) => {
// Why: the menu stays open, so several options can be picked in a row.
event.preventDefault()
const next = { ...look, ...change }
rememberLook(next)
setLook(next)
doc.change({ ...doc.value, look: next, stale: true })
picture.schedule()
}
const nextScale = SCALES[(SCALES.indexOf(look.scale) + 1) % SCALES.length]
return (
<MainMenu.Group title="Picture in this file">
<MainMenu.Item onSelect={(event) => pick(event, { scale: nextScale })} shortcut={`${look.scale}×`}>
Scale
</MainMenu.Item>
<MainMenu.Item onSelect={(event) => pick(event, { background: !look.background })} shortcut={look.background ? 'On' : 'Off'}>
Background
</MainMenu.Item>
<MainMenu.Item onSelect={(event) => pick(event, { dark: !look.dark })} shortcut={look.dark ? 'On' : 'Off'}>
Dark mode
</MainMenu.Item>
</MainMenu.Group>
)
}

function App({ doc, picture, onApi }) {
const theme = useTheme()
// Read once: the session started after Orca's hello brought the locale and host methods.
Expand Down Expand Up @@ -197,6 +244,8 @@ function App({ doc, picture, onApi }) {
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.DefaultItems.ChangeCanvasBackground />
{picture && !readOnly && <MainMenu.Separator />}
{picture && !readOnly && <PictureOptions doc={doc} picture={picture} />}
</MainMenu>
<WelcomeScreen>
<WelcomeScreen.Hints.ToolbarHint />
Expand Down
36 changes: 33 additions & 3 deletions plugins/excalidraw/build/src/excalidraw-image.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,12 @@ function crc32(bytes) {

const latin1 = (bytes) => toByteString(bytes)

/** Whether `bytes` start as a PNG file does. */
export const isPng = (bytes) => bytes.length >= 8 && PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)

/** A PNG's chunks as `{ name, data }`. Throws a readable Error when it is no PNG. */
export function readPngChunks(bytes) {
if (bytes.length < 8 || PNG_SIGNATURE.some((byte, index) => bytes[index] !== byte)) {
if (!isPng(bytes)) {
throw new Error('the file is not a PNG image')
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
Expand Down Expand Up @@ -193,13 +196,14 @@ export function writePngChunks(chunks) {
return out
}

const sceneChunk = (chunk) => {
const textChunk = (keyword) => (chunk) => {
if (chunk.name !== 'tEXt') return undefined
const separator = chunk.data.indexOf(0)
return separator !== -1 && latin1(chunk.data.subarray(0, separator)) === EXCALIDRAW_MIME
return separator !== -1 && latin1(chunk.data.subarray(0, separator)) === keyword
? latin1(chunk.data.subarray(separator + 1))
: undefined
}
const sceneChunk = textChunk(EXCALIDRAW_MIME)

/** The scene JSON an Excalidraw PNG embeds. Throws a readable Error when there is none. */
export function readPngScene(bytes) {
Expand All @@ -220,6 +224,32 @@ export function embedPngScene(bytes, sceneJson) {
return writePngChunks(chunks)
}

// Why its own chunk: unlike an SVG, a PNG's pixels do not say exactly how it was exported, so the
// editor notes it beside the scene. Excalidraw ignores it (it reads only the first tEXt chunk).
const LOOK_KEYWORD = 'orca-excalidraw-look'
const lookChunk = textChunk(LOOK_KEYWORD)

/** The PNG noting `look` (`{ scale, background, dark }`) as how it was drawn. */
export function notePngLook(bytes, look) {
const chunks = readPngChunks(bytes).filter((chunk) => lookChunk(chunk) === undefined)
const { scale, background, dark } = look
const data = fromByteString(`${LOOK_KEYWORD}\0${JSON.stringify({ scale, background, dark })}`)
chunks.splice(chunks.length - 1, 0, { name: 'tEXt', data })
return writePngChunks(chunks)
}

/** The look a PNG notes it was drawn with, or undefined when it notes none. */
export function readNotedPngLook(bytes) {
const text = readPngChunks(bytes).map(lookChunk).find((found) => found !== undefined)
try {
const { scale, background, dark } = JSON.parse(text)
if ([1, 2, 3].includes(scale) && typeof background === 'boolean' && typeof dark === 'boolean') {
return { scale, background, dark }
}
} catch {}
return undefined
}

/** A PNG's size in pixels, from its header. */
export function readPngSize(bytes) {
const header = readPngChunks(bytes)[0].data
Expand Down
48 changes: 39 additions & 9 deletions plugins/excalidraw/build/src/picture-render.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,37 @@
// Draws the picture of an `.excalidraw.svg` or `.excalidraw.png` drawing as Excalidraw exports it,
// keeping the file's export options: its scale, background and dark mode. Excalidraw keeps none
// of them in the scene, so they are read from the picture itself.
// keeping the file's export options (its look): scale, background and dark mode. Excalidraw keeps
// none of them in the scene, so they are read from the picture itself, unless the person picked
// others for this file (`scene.look`). A file without a picture yet gets the last look picked.
import { MIME_TYPES, exportToBlob, exportToSvg, getCommonBounds, getNonDeletedElements, restoreElements } from '@excalidraw/excalidraw'
import { readPngScene, readPngSize, readSvgLook } from './excalidraw-image.js'
import { notePngLook, readNotedPngLook, readPngScene, readPngSize, readSvgLook } from './excalidraw-image.js'
import { parseExcalidrawScene } from './excalidraw-scene.js'

// Excalidraw's DEFAULT_EXPORT_PADDING and its export dialog's scales.
const PADDING = 10
const SCALES = [1, 2, 3]
/** How a new, empty file is drawn: Excalidraw's export defaults. */
const DEFAULT_LOOK = { scale: 1, background: true, dark: false }
export const SCALES = [1, 2, 3]
/** Excalidraw's export defaults, for a new file until the person picks a look. */
const DEFAULT_LOOK = Object.freeze({ scale: 1, background: true, dark: false })
const LOOK_KEY = 'orca-excalidraw-picture-look'

const isLook = (look) =>
SCALES.includes(look?.scale) && typeof look.background === 'boolean' && typeof look.dark === 'boolean'

/** The look a new picture gets: the last one picked in any file (kept like the library). */
function newLook() {
try {
const look = JSON.parse(localStorage.getItem(LOOK_KEY))
return isLook(look) ? look : DEFAULT_LOOK
} catch {
return DEFAULT_LOOK
}
}

/** Keeps `look` for the pictures of new files too. */
export function rememberLook(look) {
try {
localStorage.setItem(LOOK_KEY, JSON.stringify(look))
} catch {}
}

// Why by identity: a PNG's look takes decoding it, and each picture keeps its look.
const pngLooks = new WeakMap()
Expand All @@ -28,6 +50,10 @@ function pixelOf(source) {

/** A PNG export's look: its scale from its size and scene, background and dark mode from a corner. */
async function readPngLook(bytes) {
const noted = readNotedPngLook(bytes)
if (noted) {
return noted
}
const { width } = readPngSize(bytes)
let scale = DEFAULT_LOOK.scale
let viewBackgroundColor = '#ffffff'
Expand Down Expand Up @@ -57,9 +83,13 @@ async function readPngLook(bytes) {
return { scale, background, dark: background && distance > 96 }
}

function lookOf(scene) {
/** How the scene's picture is drawn: as picked for this file, as its picture was, or as new. */
export function lookOf(scene) {
if (isLook(scene.look)) {
return Promise.resolve(scene.look)
}
if (!scene.image) {
return Promise.resolve(DEFAULT_LOOK)
return Promise.resolve(newLook())
}
if (scene.format === 'svg') {
return Promise.resolve(readSvgLook(scene.image))
Expand Down Expand Up @@ -98,7 +128,7 @@ export async function drawPicture(scene) {
exportPadding: PADDING,
getDimensions: (width, height) => ({ width: width * look.scale, height: height * look.scale, scale: look.scale })
})
const image = new Uint8Array(await blob.arrayBuffer())
const image = notePngLook(new Uint8Array(await blob.arrayBuffer()), look)
pngLooks.set(image, Promise.resolve(look))
return image
}
4 changes: 4 additions & 0 deletions plugins/excalidraw/build/src/picture-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ export function syncPicture({ render, onError, idleMs = IDLE_MS, budgetMs = FLUS
schedule,
attach(session) {
doc = session
// A file renamed from another format opens without its picture.
if (needsDrawing(doc.value)) {
schedule()
}
replaced = doc.onReplace((scene) => {
if (needsDrawing(scene)) {
schedule()
Expand Down
Loading
Loading