diff options
Diffstat (limited to 'packages/excalidraw/data')
20 files changed, 8517 insertions, 0 deletions
diff --git a/packages/excalidraw/data/EditorLocalStorage.ts b/packages/excalidraw/data/EditorLocalStorage.ts new file mode 100644 index 0000000..bb6eeb4 --- /dev/null +++ b/packages/excalidraw/data/EditorLocalStorage.ts @@ -0,0 +1,51 @@ +import type { EDITOR_LS_KEYS } from "../constants"; +import type { JSONValue } from "../types"; + +export class EditorLocalStorage { + static has(key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS]) { + try { + return !!window.localStorage.getItem(key); + } catch (error: any) { + console.warn(`localStorage.getItem error: ${error.message}`); + return false; + } + } + + static get<T extends JSONValue>( + key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS], + ) { + try { + const value = window.localStorage.getItem(key); + if (value) { + return JSON.parse(value) as T; + } + return null; + } catch (error: any) { + console.warn(`localStorage.getItem error: ${error.message}`); + return null; + } + } + + static set = ( + key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS], + value: JSONValue, + ) => { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch (error: any) { + console.warn(`localStorage.setItem error: ${error.message}`); + return false; + } + }; + + static delete = ( + name: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS], + ) => { + try { + window.localStorage.removeItem(name); + } catch (error: any) { + console.warn(`localStorage.removeItem error: ${error.message}`); + } + }; +} diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap new file mode 100644 index 0000000..917f3d9 --- /dev/null +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -0,0 +1,2691 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 1`] = ` +{ + "angle": 0, + "backgroundColor": "#d8f5a2", + "boundElements": [ + { + "id": "id47", + "type": "arrow", + }, + { + "id": "id48", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 300, + "id": Any<String>, + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#66a80f", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "ellipse", + "updated": 1, + "version": 4, + "versionNonce": Any<Number>, + "width": 300, + "x": 630, + "y": 316, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 2`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id48", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": Any<String>, + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#9c36b5", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "diamond", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "width": 140, + "x": 96, + "y": 400, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 3`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": { + "elementId": "ellipse-1", + "focus": -0.007519379844961235, + "gap": 11.562288374879595, + }, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 35, + "id": Any<String>, + "index": "a2", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0.5, + 0.5, + ], + [ + 394.5, + 34.5, + ], + ], + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "startArrowhead": null, + "startBinding": { + "elementId": "id49", + "focus": -0.0813953488372095, + "gap": 1, + }, + "strokeColor": "#1864ab", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 4, + "versionNonce": Any<Number>, + "width": 395, + "x": 247, + "y": 420, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 4`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": { + "elementId": "ellipse-1", + "focus": 0.10666666666666667, + "gap": 3.8343264684446097, + }, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any<String>, + "index": "a3", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0.5, + 0, + ], + [ + 399.5, + 0, + ], + ], + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "startArrowhead": null, + "startBinding": { + "elementId": "diamond-1", + "focus": 0, + "gap": 4.545343408287929, + }, + "strokeColor": "#e67700", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 4, + "versionNonce": Any<Number>, + "width": 400, + "x": 227, + "y": 450, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 5`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id47", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 300, + "id": Any<String>, + "index": "a4", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "width": 300, + "x": -53, + "y": 270, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id50", + "type": "arrow", + }, + ], + "containerId": null, + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any<String>, + "index": "a0", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "HEYYYYY", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#c2255c", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "HEYYYYY", + "textAlign": "left", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "top", + "width": 70, + "x": 185, + "y": 226.5, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 2`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id50", + "type": "arrow", + }, + ], + "containerId": null, + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any<String>, + "index": "a1", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "Whats up ?", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "Whats up ?", + "textAlign": "left", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "top", + "width": 100, + "x": 560, + "y": 226.5, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 3`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id51", + "type": "text", + }, + ], + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": { + "elementId": "text-2", + "focus": 0, + "gap": 14, + }, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any<String>, + "index": "a2", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0.5, + 0, + ], + [ + 99.5, + 0, + ], + ], + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "startArrowhead": null, + "startBinding": { + "elementId": "text-1", + "focus": 0, + "gap": 1, + }, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 4, + "versionNonce": Any<Number>, + "width": 100, + "x": 255, + "y": 239, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 4`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "id50", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any<String>, + "index": "a3", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "HELLO WORLD!!", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "HELLO WORLD!!", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 130, + "x": 240, + "y": 226.5, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id40", + "type": "text", + }, + ], + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": { + "elementId": "id42", + "focus": -0, + "gap": 1, + }, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any<String>, + "index": "a0", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0.5, + 0, + ], + [ + 99.5, + 0, + ], + ], + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "startArrowhead": null, + "startBinding": { + "elementId": "id41", + "focus": 0, + "gap": 1, + }, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 4, + "versionNonce": Any<Number>, + "width": 100, + "x": 255, + "y": 239, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "id39", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any<String>, + "index": "a1", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "HELLO WORLD!!", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "HELLO WORLD!!", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 130, + "x": 240, + "y": 226.5, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 3`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id39", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": Any<String>, + "index": "a2", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "width": 100, + "x": 155, + "y": 189, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 4`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id39", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": Any<String>, + "index": "a3", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "ellipse", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "width": 100, + "x": 355, + "y": 189, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id44", + "type": "text", + }, + ], + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": { + "elementId": "id46", + "focus": -0, + "gap": 1, + }, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any<String>, + "index": "a0", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0.5, + 0, + ], + [ + 99.5, + 0, + ], + ], + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "startArrowhead": null, + "startBinding": { + "elementId": "id45", + "focus": 0, + "gap": 1, + }, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 4, + "versionNonce": Any<Number>, + "width": 100, + "x": 255, + "y": 239, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "id43", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any<String>, + "index": "a1", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "HELLO WORLD!!", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "HELLO WORLD!!", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 130, + "x": 240, + "y": 226.5, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id43", + "type": "arrow", + }, + ], + "containerId": null, + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any<String>, + "index": "a2", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "HEYYYYY", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "HEYYYYY", + "textAlign": "left", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "top", + "width": 70, + "x": 185, + "y": 226.5, +} +`; + +exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id43", + "type": "arrow", + }, + ], + "containerId": null, + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any<String>, + "index": "a3", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "WHATS UP ?", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "WHATS UP ?", + "textAlign": "left", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "top", + "width": 100, + "x": 355, + "y": 226.5, +} +`; + +exports[`Test Transform > should not allow duplicate ids 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 200, + "id": "rect-1", + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 100, + "x": 300, + "y": 100, +} +`; + +exports[`Test Transform > should transform linear elements 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": null, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any<String>, + "index": "a0", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0.5, + 0, + ], + [ + 99.5, + 0, + ], + ], + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "startArrowhead": null, + "startBinding": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 100, + "x": 100, + "y": 20, +} +`; + +exports[`Test Transform > should transform linear elements 2`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "elbowed": false, + "endArrowhead": "triangle", + "endBinding": null, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any<String>, + "index": "a1", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0.5, + 0, + ], + [ + 99.5, + 0, + ], + ], + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "startArrowhead": "dot", + "startBinding": null, + "strokeColor": "#1971c2", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 100, + "x": 450, + "y": 20, +} +`; + +exports[`Test Transform > should transform linear elements 3`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "endArrowhead": null, + "endBinding": null, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any<String>, + "index": "a2", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0, + 0, + ], + [ + 100, + 0, + ], + ], + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "startArrowhead": null, + "startBinding": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "line", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 100, + "x": 100, + "y": 60, +} +`; + +exports[`Test Transform > should transform linear elements 4`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "endArrowhead": null, + "endBinding": null, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any<String>, + "index": "a3", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0, + 0, + ], + [ + 100, + 0, + ], + ], + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "startArrowhead": null, + "startBinding": null, + "strokeColor": "#2f9e44", + "strokeStyle": "dotted", + "strokeWidth": 2, + "type": "line", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 100, + "x": 450, + "y": 60, +} +`; + +exports[`Test Transform > should transform regular shapes 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": Any<String>, + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 100, + "x": 100, + "y": 100, +} +`; + +exports[`Test Transform > should transform regular shapes 2`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": Any<String>, + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "ellipse", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 100, + "x": 100, + "y": 250, +} +`; + +exports[`Test Transform > should transform regular shapes 3`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": Any<String>, + "index": "a2", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "diamond", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 100, + "x": 100, + "y": 400, +} +`; + +exports[`Test Transform > should transform regular shapes 4`] = ` +{ + "angle": 0, + "backgroundColor": "#c0eb75", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": Any<String>, + "index": "a3", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 200, + "x": 300, + "y": 100, +} +`; + +exports[`Test Transform > should transform regular shapes 5`] = ` +{ + "angle": 0, + "backgroundColor": "#ffc9c9", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": Any<String>, + "index": "a4", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "dotted", + "strokeWidth": 2, + "type": "ellipse", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 200, + "x": 300, + "y": 250, +} +`; + +exports[`Test Transform > should transform regular shapes 6`] = ` +{ + "angle": 0, + "backgroundColor": "#a5d8ff", + "boundElements": null, + "customData": undefined, + "fillStyle": "cross-hatch", + "frameId": null, + "groupIds": [], + "height": 100, + "id": Any<String>, + "index": "a5", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1971c2", + "strokeStyle": "dashed", + "strokeWidth": 2, + "type": "diamond", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 200, + "x": 300, + "y": 400, +} +`; + +exports[`Test Transform > should transform text element 1`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": null, + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any<String>, + "index": "a0", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "HELLO WORLD!", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "HELLO WORLD!", + "textAlign": "left", + "type": "text", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "verticalAlign": "top", + "width": 120, + "x": 100, + "y": 100, +} +`; + +exports[`Test Transform > should transform text element 2`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": null, + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any<String>, + "index": "a1", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "STYLED HELLO WORLD!", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#5f3dc4", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "STYLED HELLO WORLD!", + "textAlign": "left", + "type": "text", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "verticalAlign": "top", + "width": 190, + "x": 100, + "y": 150, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id56", + "type": "text", + }, + { + "id": "Bob_B", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "subgraph_group_B", + ], + "height": 163, + "id": Any<String>, + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "width": 166.03125, + "x": 0, + "y": 0, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 2`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id57", + "type": "text", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "subgraph_group_A", + ], + "height": 114, + "id": Any<String>, + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 120.265625, + "x": 364.546875, + "y": 0, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 3`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id58", + "type": "text", + }, + { + "id": "Bob_Alice", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "subgraph_group_A", + ], + "height": 44, + "id": Any<String>, + "index": "a2", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "width": 70.265625, + "x": 389.546875, + "y": 35, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 4`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id59", + "type": "text", + }, + { + "id": "Bob_Alice", + "type": "arrow", + }, + { + "id": "Bob_B", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "subgraph_group_B", + ], + "height": 44, + "id": Any<String>, + "index": "a3", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 4, + "versionNonce": Any<Number>, + "width": 56.4921875, + "x": 54.76953125, + "y": 35, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 5`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id60", + "type": "text", + }, + ], + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": { + "elementId": "Alice", + "focus": -0, + "gap": 5.299874999999986, + }, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any<String>, + "index": "a4", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0.5, + 0, + ], + [ + 272.485, + 0, + ], + ], + "roughness": 1, + "roundness": { + "type": 2, + }, + "seed": Any<Number>, + "startArrowhead": null, + "startBinding": { + "elementId": "Bob", + "focus": 0, + "gap": 1, + }, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 4, + "versionNonce": Any<Number>, + "width": 272.985, + "x": 111.262, + "y": 57, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 6`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id61", + "type": "text", + }, + ], + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": { + "elementId": "B", + "focus": 0, + "gap": 14, + }, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any<String>, + "index": "a5", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0, + 0, + ], + ], + "roughness": 1, + "roundness": { + "type": 2, + }, + "seed": Any<Number>, + "startArrowhead": null, + "startBinding": { + "elementId": "Bob", + "focus": 0, + "gap": 1, + }, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 4, + "versionNonce": Any<Number>, + "width": 0, + "x": 77.017, + "y": 79, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 7`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "B", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [ + "subgraph_group_B", + ], + "height": 25, + "id": Any<String>, + "index": "a6", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "B", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "B", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "top", + "width": 10, + "x": 78.015625, + "y": 5, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 8`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "A", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [ + "subgraph_group_A", + ], + "height": 25, + "id": Any<String>, + "index": "a7", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "A", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "A", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "top", + "width": 10, + "x": 419.6796875, + "y": 5, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 9`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "Alice", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [ + "subgraph_group_A", + ], + "height": 25, + "id": Any<String>, + "index": "a8", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "Alice", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "Alice", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 50, + "x": 399.6796875, + "y": 44.5, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 10`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "Bob", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [ + "subgraph_group_B", + ], + "height": 25, + "id": Any<String>, + "index": "a9", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "Bob", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "Bob", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 30, + "x": 68.015625, + "y": 44.5, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 11`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "Bob_Alice", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any<String>, + "index": "aA", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "How are you?", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "How are you?", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 120, + "x": 187.7545, + "y": 44.5, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 12`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "Bob_B", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any<String>, + "index": "aB", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "Friendship", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "Friendship", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 100, + "x": 27.016999999999996, + "y": 66.5, +} +`; + +exports[`Test Transform > should transform to labelled arrows when label provided for arrows 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id29", + "type": "text", + }, + ], + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": null, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any<String>, + "index": "a0", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0.5, + 0, + ], + [ + 99.5, + 0, + ], + ], + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "startArrowhead": null, + "startBinding": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 100, + "x": 100, + "y": 100, +} +`; + +exports[`Test Transform > should transform to labelled arrows when label provided for arrows 2`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id30", + "type": "text", + }, + ], + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": null, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any<String>, + "index": "a1", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0.5, + 0, + ], + [ + 99.5, + 0, + ], + ], + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "startArrowhead": null, + "startBinding": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 100, + "x": 100, + "y": 200, +} +`; + +exports[`Test Transform > should transform to labelled arrows when label provided for arrows 3`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id31", + "type": "text", + }, + ], + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": null, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any<String>, + "index": "a2", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0.5, + 0, + ], + [ + 99.5, + 0, + ], + ], + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "startArrowhead": null, + "startBinding": null, + "strokeColor": "#1098ad", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 100, + "x": 100, + "y": 300, +} +`; + +exports[`Test Transform > should transform to labelled arrows when label provided for arrows 4`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id32", + "type": "text", + }, + ], + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": null, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any<String>, + "index": "a3", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0.5, + 0, + ], + [ + 99.5, + 0, + ], + ], + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "startArrowhead": null, + "startBinding": null, + "strokeColor": "#1098ad", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 2, + "versionNonce": Any<Number>, + "width": 100, + "x": 100, + "y": 400, +} +`; + +exports[`Test Transform > should transform to labelled arrows when label provided for arrows 5`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "id25", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any<String>, + "index": "a4", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "LABELED ARROW", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "LABELED ARROW", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 130, + "x": 85, + "y": 87.5, +} +`; + +exports[`Test Transform > should transform to labelled arrows when label provided for arrows 6`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "id26", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any<String>, + "index": "a5", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "STYLED LABELED ARROW", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#099268", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "STYLED LABELED ARROW", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 200, + "x": 50, + "y": 187.5, +} +`; + +exports[`Test Transform > should transform to labelled arrows when label provided for arrows 7`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "id27", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 50, + "id": Any<String>, + "index": "a6", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "ANOTHER STYLED LABELLED ARROW", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1098ad", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "ANOTHER STYLED +LABELLED ARROW", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 140, + "x": 80, + "y": 275, +} +`; + +exports[`Test Transform > should transform to labelled arrows when label provided for arrows 8`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "id28", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 50, + "id": Any<String>, + "index": "a7", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "ANOTHER STYLED LABELLED ARROW", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#099268", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "ANOTHER STYLED +LABELLED ARROW", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 140, + "x": 80, + "y": 375, +} +`; + +exports[`Test Transform > should transform to text containers when label provided 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id19", + "type": "text", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 35, + "id": Any<String>, + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 4, + "versionNonce": Any<Number>, + "width": 250, + "x": 100, + "y": 100, +} +`; + +exports[`Test Transform > should transform to text containers when label provided 2`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id20", + "type": "text", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 85, + "id": Any<String>, + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "ellipse", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "width": 200, + "x": 500, + "y": 100, +} +`; + +exports[`Test Transform > should transform to text containers when label provided 3`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id21", + "type": "text", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 170, + "id": Any<String>, + "index": "a2", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "diamond", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "width": 280, + "x": 100, + "y": 150, +} +`; + +exports[`Test Transform > should transform to text containers when label provided 4`] = ` +{ + "angle": 0, + "backgroundColor": "#fff3bf", + "boundElements": [ + { + "id": "id22", + "type": "text", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 120, + "id": Any<String>, + "index": "a3", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "diamond", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "width": 300, + "x": 100, + "y": 400, +} +`; + +exports[`Test Transform > should transform to text containers when label provided 5`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id23", + "type": "text", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 85, + "id": Any<String>, + "index": "a4", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#c2255c", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "width": 200, + "x": 500, + "y": 300, +} +`; + +exports[`Test Transform > should transform to text containers when label provided 6`] = ` +{ + "angle": 0, + "backgroundColor": "#ffec99", + "boundElements": [ + { + "id": "id24", + "type": "text", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 120, + "id": Any<String>, + "index": "a5", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#f08c00", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "ellipse", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "width": 200, + "x": 500, + "y": 500, +} +`; + +exports[`Test Transform > should transform to text containers when label provided 7`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "id13", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any<String>, + "index": "a6", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "RECTANGLE TEXT CONTAINER", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "RECTANGLE TEXT CONTAINER", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 240, + "x": 105, + "y": 105, +} +`; + +exports[`Test Transform > should transform to text containers when label provided 8`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "id14", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 50, + "id": Any<String>, + "index": "a7", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "ELLIPSE TEXT CONTAINER", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "ELLIPSE TEXT +CONTAINER", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 120, + "x": 539.7893218813452, + "y": 117.44796179957173, +} +`; + +exports[`Test Transform > should transform to text containers when label provided 9`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "id15", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 75, + "id": Any<String>, + "index": "a8", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "DIAMOND +TEXT CONTAINER", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "DIAMOND +TEXT +CONTAINER", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 90, + "x": 195, + "y": 197.5, +} +`; + +exports[`Test Transform > should transform to text containers when label provided 10`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "id16", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 50, + "id": Any<String>, + "index": "a9", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "STYLED DIAMOND TEXT CONTAINER", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#099268", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "STYLED DIAMOND +TEXT CONTAINER", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 140, + "x": 180, + "y": 435, +} +`; + +exports[`Test Transform > should transform to text containers when label provided 11`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "id17", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 75, + "id": Any<String>, + "index": "aA", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#c2255c", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "TOP LEFT ALIGNED +RECTANGLE TEXT +CONTAINER", + "textAlign": "left", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "top", + "width": 160, + "x": 505, + "y": 305, +} +`; + +exports[`Test Transform > should transform to text containers when label provided 12`] = ` +{ + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "id18", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 75, + "id": Any<String>, + "index": "aB", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "STYLED ELLIPSE TEXT CONTAINER", + "roughness": 1, + "roundness": null, + "seed": Any<Number>, + "strokeColor": "#c2255c", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "STYLED +ELLIPSE TEXT +CONTAINER", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any<Number>, + "verticalAlign": "middle", + "width": 120, + "x": 539.7893218813452, + "y": 522.5735931288071, +} +`; diff --git a/packages/excalidraw/data/ai/types.ts b/packages/excalidraw/data/ai/types.ts new file mode 100644 index 0000000..4ea0310 --- /dev/null +++ b/packages/excalidraw/data/ai/types.ts @@ -0,0 +1,300 @@ +export namespace OpenAIInput { + type ChatCompletionContentPart = + | ChatCompletionContentPartText + | ChatCompletionContentPartImage; + + interface ChatCompletionContentPartImage { + image_url: ChatCompletionContentPartImage.ImageURL; + + /** + * The type of the content part. + */ + type: "image_url"; + } + + namespace ChatCompletionContentPartImage { + export interface ImageURL { + /** + * Either a URL of the image or the base64 encoded image data. + */ + url: string; + + /** + * Specifies the detail level of the image. + */ + detail?: "auto" | "low" | "high"; + } + } + + interface ChatCompletionContentPartText { + /** + * The text content. + */ + text: string; + + /** + * The type of the content part. + */ + type: "text"; + } + + interface ChatCompletionUserMessageParam { + /** + * The contents of the user message. + */ + content: string | Array<ChatCompletionContentPart> | null; + + /** + * The role of the messages author, in this case `user`. + */ + role: "user"; + } + + interface ChatCompletionSystemMessageParam { + /** + * The contents of the system message. + */ + content: string | null; + + /** + * The role of the messages author, in this case `system`. + */ + role: "system"; + } + + export interface ChatCompletionCreateParamsBase { + /** + * A list of messages comprising the conversation so far. + * [Example Python code](https://cookbook.openai.com/examples/how_to_format_inputs_to_chatgpt_models). + */ + messages: Array< + ChatCompletionUserMessageParam | ChatCompletionSystemMessageParam + >; + + /** + * ID of the model to use. See the + * [model endpoint compatibility](https://platform.openai.com/docs/models/model-endpoint-compatibility) + * table for details on which models work with the Chat API. + */ + model: + | (string & {}) + | "gpt-4-1106-preview" + | "gpt-4-vision-preview" + | "gpt-4" + | "gpt-4-0314" + | "gpt-4-0613" + | "gpt-4-32k" + | "gpt-4-32k-0314" + | "gpt-4-32k-0613" + | "gpt-3.5-turbo" + | "gpt-3.5-turbo-16k" + | "gpt-3.5-turbo-0301" + | "gpt-3.5-turbo-0613" + | "gpt-3.5-turbo-16k-0613"; + + /** + * Number between -2.0 and 2.0. Positive values penalize new tokens based on their + * existing frequency in the text so far, decreasing the model's likelihood to + * repeat the same line verbatim. + * + * [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details) + */ + frequency_penalty?: number | null; + + /** + * Modify the likelihood of specified tokens appearing in the completion. + * + * Accepts a JSON object that maps tokens (specified by their token ID in the + * tokenizer) to an associated bias value from -100 to 100. Mathematically, the + * bias is added to the logits generated by the model prior to sampling. The exact + * effect will vary per model, but values between -1 and 1 should decrease or + * increase likelihood of selection; values like -100 or 100 should result in a ban + * or exclusive selection of the relevant token. + */ + logit_bias?: Record<string, number> | null; + + /** + * The maximum number of [tokens](/tokenizer) to generate in the chat completion. + * + * The total length of input tokens and generated tokens is limited by the model's + * context length. + * [Example Python code](https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken) + * for counting tokens. + */ + max_tokens?: number | null; + + /** + * How many chat completion choices to generate for each input message. + */ + n?: number | null; + + /** + * Number between -2.0 and 2.0. Positive values penalize new tokens based on + * whether they appear in the text so far, increasing the model's likelihood to + * talk about new topics. + * + * [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details) + */ + presence_penalty?: number | null; + + /** + * This feature is in Beta. If specified, our system will make a best effort to + * sample deterministically, such that repeated requests with the same `seed` and + * parameters should return the same result. Determinism is not guaranteed, and you + * should refer to the `system_fingerprint` response parameter to monitor changes + * in the backend. + */ + seed?: number | null; + + /** + * Up to 4 sequences where the API will stop generating further tokens. + */ + stop?: string | null | Array<string>; + + /** + * If set, partial message deltas will be sent, like in ChatGPT. Tokens will be + * sent as data-only + * [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format) + * as they become available, with the stream terminated by a `data: [DONE]` + * message. + * [Example Python code](https://cookbook.openai.com/examples/how_to_stream_completions). + */ + stream?: boolean | null; + + /** + * What sampling temperature to use, between 0 and 2. Higher values like 0.8 will + * make the output more random, while lower values like 0.2 will make it more + * focused and deterministic. + * + * We generally recommend altering this or `top_p` but not both. + */ + temperature?: number | null; + + /** + * An alternative to sampling with temperature, called nucleus sampling, where the + * model considers the results of the tokens with top_p probability mass. So 0.1 + * means only the tokens comprising the top 10% probability mass are considered. + * + * We generally recommend altering this or `temperature` but not both. + */ + top_p?: number | null; + + /** + * A unique identifier representing your end-user, which can help OpenAI to monitor + * and detect abuse. + * [Learn more](https://platform.openai.com/docs/guides/safety-best-practices/end-user-ids). + */ + user?: string; + } +} + +export namespace OpenAIOutput { + export interface ChatCompletion { + /** + * A unique identifier for the chat completion. + */ + id: string; + + /** + * A list of chat completion choices. Can be more than one if `n` is greater + * than 1. + */ + choices: Array<Choice>; + + /** + * The Unix timestamp (in seconds) of when the chat completion was created. + */ + created: number; + + /** + * The model used for the chat completion. + */ + model: string; + + /** + * The object type, which is always `chat.completion`. + */ + object: "chat.completion"; + + /** + * This fingerprint represents the backend configuration that the model runs with. + * + * Can be used in conjunction with the `seed` request parameter to understand when + * backend changes have been made that might impact determinism. + */ + system_fingerprint?: string; + + /** + * Usage statistics for the completion request. + */ + usage?: CompletionUsage; + } + export interface Choice { + /** + * The reason the model stopped generating tokens. This will be `stop` if the model + * hit a natural stop point or a provided stop sequence, `length` if the maximum + * number of tokens specified in the request was reached, `content_filter` if + * content was omitted due to a flag from our content filters, `tool_calls` if the + * model called a tool, or `function_call` (deprecated) if the model called a + * function. + */ + finish_reason: + | "stop" + | "length" + | "tool_calls" + | "content_filter" + | "function_call"; + + /** + * The index of the choice in the list of choices. + */ + index: number; + + /** + * A chat completion message generated by the model. + */ + message: ChatCompletionMessage; + } + + interface ChatCompletionMessage { + /** + * The contents of the message. + */ + content: string | null; + + /** + * The role of the author of this message. + */ + role: "assistant"; + } + + /** + * Usage statistics for the completion request. + */ + interface CompletionUsage { + /** + * Number of tokens in the generated completion. + */ + completion_tokens: number; + + /** + * Number of tokens in the prompt. + */ + prompt_tokens: number; + + /** + * Total number of tokens used in the request (prompt + completion). + */ + total_tokens: number; + } + + export interface APIError { + readonly status: 400 | 401 | 403 | 404 | 409 | 422 | 429 | 500 | undefined; + readonly headers: Headers | undefined; + readonly error: { message: string } | undefined; + + readonly code: string | null | undefined; + readonly param: string | null | undefined; + readonly type: string | undefined; + } +} diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts new file mode 100644 index 0000000..2293f86 --- /dev/null +++ b/packages/excalidraw/data/blob.ts @@ -0,0 +1,496 @@ +import { nanoid } from "nanoid"; +import { cleanAppStateForExport } from "../appState"; +import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants"; +import { clearElementsForExport } from "../element"; +import type { ExcalidrawElement, FileId } from "../element/types"; +import { CanvasError, ImageSceneDataError } from "../errors"; +import { calculateScrollCenter } from "../scene"; +import { decodeSvgBase64Payload } from "../scene/export"; +import type { AppState, DataURL, LibraryItem } from "../types"; +import type { ValueOf } from "../utility-types"; +import { bytesToHexString, isPromiseLike } from "../utils"; +import { base64ToString, stringToBase64, toByteString } from "./encode"; +import type { FileSystemHandle } from "./filesystem"; +import { nativeFileSystemSupported } from "./filesystem"; +import { isValidExcalidrawData, isValidLibrary } from "./json"; +import { restore, restoreLibraryItems } from "./restore"; +import type { ImportedLibraryData } from "./types"; + +const parseFileContents = async (blob: Blob | File): Promise<string> => { + let contents: string; + + if (blob.type === MIME_TYPES.png) { + try { + return await (await import("./image")).decodePngMetadata(blob); + } catch (error: any) { + if (error.message === "INVALID") { + throw new ImageSceneDataError( + "Image doesn't contain scene", + "IMAGE_NOT_CONTAINS_SCENE_DATA", + ); + } else { + throw new ImageSceneDataError("Error: cannot restore image"); + } + } + } else { + if ("text" in Blob) { + contents = await blob.text(); + } else { + contents = await new Promise((resolve) => { + const reader = new FileReader(); + reader.readAsText(blob, "utf8"); + reader.onloadend = () => { + if (reader.readyState === FileReader.DONE) { + resolve(reader.result as string); + } + }; + }); + } + if (blob.type === MIME_TYPES.svg) { + try { + return decodeSvgBase64Payload({ + svg: contents, + }); + } catch (error: any) { + if (error.message === "INVALID") { + throw new ImageSceneDataError( + "Image doesn't contain scene", + "IMAGE_NOT_CONTAINS_SCENE_DATA", + ); + } else { + throw new ImageSceneDataError("Error: cannot restore image"); + } + } + } + } + return contents; +}; + +export const getMimeType = (blob: Blob | string): string => { + let name: string; + if (typeof blob === "string") { + name = blob; + } else { + if (blob.type) { + return blob.type; + } + name = blob.name || ""; + } + if (/\.(excalidraw|json)$/.test(name)) { + return MIME_TYPES.json; + } else if (/\.png$/.test(name)) { + return MIME_TYPES.png; + } else if (/\.jpe?g$/.test(name)) { + return MIME_TYPES.jpg; + } else if (/\.svg$/.test(name)) { + return MIME_TYPES.svg; + } + return ""; +}; + +export const getFileHandleType = (handle: FileSystemHandle | null) => { + if (!handle) { + return null; + } + + return handle.name.match(/\.(json|excalidraw|png|svg)$/)?.[1] || null; +}; + +export const isImageFileHandleType = ( + type: string | null, +): type is "png" | "svg" => { + return type === "png" || type === "svg"; +}; + +export const isImageFileHandle = (handle: FileSystemHandle | null) => { + const type = getFileHandleType(handle); + return type === "png" || type === "svg"; +}; + +export const isSupportedImageFileType = (type: string | null | undefined) => { + return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type); +}; + +export const isSupportedImageFile = ( + blob: Blob | null | undefined, +): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => { + const { type } = blob || {}; + return isSupportedImageFileType(type); +}; + +export const loadSceneOrLibraryFromBlob = async ( + blob: Blob | File, + /** @see restore.localAppState */ + localAppState: AppState | null, + localElements: readonly ExcalidrawElement[] | null, + /** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */ + fileHandle?: FileSystemHandle | null, +) => { + const contents = await parseFileContents(blob); + let data; + try { + try { + data = JSON.parse(contents); + } catch (error: any) { + if (isSupportedImageFile(blob)) { + throw new ImageSceneDataError( + "Image doesn't contain scene", + "IMAGE_NOT_CONTAINS_SCENE_DATA", + ); + } + throw error; + } + if (isValidExcalidrawData(data)) { + return { + type: MIME_TYPES.excalidraw, + data: restore( + { + elements: clearElementsForExport(data.elements || []), + appState: { + theme: localAppState?.theme, + fileHandle: fileHandle || blob.handle || null, + ...cleanAppStateForExport(data.appState || {}), + ...(localAppState + ? calculateScrollCenter(data.elements || [], localAppState) + : {}), + }, + files: data.files, + }, + localAppState, + localElements, + { repairBindings: true, refreshDimensions: false }, + ), + }; + } else if (isValidLibrary(data)) { + return { + type: MIME_TYPES.excalidrawlib, + data, + }; + } + throw new Error("Error: invalid file"); + } catch (error: any) { + if (error instanceof ImageSceneDataError) { + throw error; + } + throw new Error("Error: invalid file"); + } +}; + +export const loadFromBlob = async ( + blob: Blob, + /** @see restore.localAppState */ + localAppState: AppState | null, + localElements: readonly ExcalidrawElement[] | null, + /** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */ + fileHandle?: FileSystemHandle | null, +) => { + const ret = await loadSceneOrLibraryFromBlob( + blob, + localAppState, + localElements, + fileHandle, + ); + if (ret.type !== MIME_TYPES.excalidraw) { + throw new Error("Error: invalid file"); + } + return ret.data; +}; + +export const parseLibraryJSON = ( + json: string, + defaultStatus: LibraryItem["status"] = "unpublished", +) => { + const data: ImportedLibraryData | undefined = JSON.parse(json); + if (!isValidLibrary(data)) { + throw new Error("Invalid library"); + } + const libraryItems = data.libraryItems || data.library; + return restoreLibraryItems(libraryItems, defaultStatus); +}; + +export const loadLibraryFromBlob = async ( + blob: Blob, + defaultStatus: LibraryItem["status"] = "unpublished", +) => { + return parseLibraryJSON(await parseFileContents(blob), defaultStatus); +}; + +export const canvasToBlob = async ( + canvas: HTMLCanvasElement | Promise<HTMLCanvasElement>, +): Promise<Blob> => { + return new Promise(async (resolve, reject) => { + try { + if (isPromiseLike(canvas)) { + canvas = await canvas; + } + canvas.toBlob((blob) => { + if (!blob) { + return reject( + new CanvasError("Error: Canvas too big", "CANVAS_POSSIBLY_TOO_BIG"), + ); + } + resolve(blob); + }); + } catch (error: any) { + reject(error); + } + }); +}; + +/** generates SHA-1 digest from supplied file (if not supported, falls back + to a 40-char base64 random id) */ +export const generateIdFromFile = async (file: File): Promise<FileId> => { + try { + const hashBuffer = await window.crypto.subtle.digest( + "SHA-1", + await blobToArrayBuffer(file), + ); + return bytesToHexString(new Uint8Array(hashBuffer)) as FileId; + } catch (error: any) { + console.error(error); + // length 40 to align with the HEX length of SHA-1 (which is 160 bit) + return nanoid(40) as FileId; + } +}; + +/** async. For sync variant, use getDataURL_sync */ +export const getDataURL = async (file: Blob | File): Promise<DataURL> => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const dataURL = reader.result as DataURL; + resolve(dataURL); + }; + reader.onerror = (error) => reject(error); + reader.readAsDataURL(file); + }); +}; + +export const getDataURL_sync = ( + data: string | Uint8Array | ArrayBuffer, + mimeType: ValueOf<typeof MIME_TYPES>, +): DataURL => { + return `data:${mimeType};base64,${stringToBase64( + toByteString(data), + true, + )}` as DataURL; +}; + +export const dataURLToFile = (dataURL: DataURL, filename = "") => { + const dataIndexStart = dataURL.indexOf(","); + const byteString = atob(dataURL.slice(dataIndexStart + 1)); + const mimeType = dataURL.slice(0, dataIndexStart).split(":")[1].split(";")[0]; + + const ab = new ArrayBuffer(byteString.length); + const ia = new Uint8Array(ab); + for (let i = 0; i < byteString.length; i++) { + ia[i] = byteString.charCodeAt(i); + } + return new File([ab], filename, { type: mimeType }); +}; + +export const dataURLToString = (dataURL: DataURL) => { + return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1)); +}; + +export const resizeImageFile = async ( + file: File, + opts: { + /** undefined indicates auto */ + outputType?: typeof MIME_TYPES["jpg"]; + maxWidthOrHeight: number; + }, +): Promise<File> => { + // SVG files shouldn't a can't be resized + if (file.type === MIME_TYPES.svg) { + return file; + } + + const [pica, imageBlobReduce] = await Promise.all([ + import("pica").then((res) => res.default), + // a wrapper for pica for better API + import("image-blob-reduce").then((res) => res.default), + ]); + + // CRA's minification settings break pica in WebWorkers, so let's disable + // them for now + // https://github.com/nodeca/image-blob-reduce/issues/21#issuecomment-757365513 + const reduce = imageBlobReduce({ + pica: pica({ features: ["js", "wasm"] }), + }); + + if (opts.outputType) { + const { outputType } = opts; + reduce._create_blob = function (env) { + return this.pica.toBlob(env.out_canvas, outputType, 0.8).then((blob) => { + env.out_blob = blob; + return env; + }); + }; + } + + if (!isSupportedImageFile(file)) { + throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" }); + } + + return new File( + [await reduce.toBlob(file, { max: opts.maxWidthOrHeight, alpha: true })], + file.name, + { + type: opts.outputType || file.type, + }, + ); +}; + +export const SVGStringToFile = (SVGString: string, filename: string = "") => { + return new File([new TextEncoder().encode(SVGString)], filename, { + type: MIME_TYPES.svg, + }) as File & { type: typeof MIME_TYPES.svg }; +}; + +export const ImageURLToFile = async ( + imageUrl: string, + filename: string = "", +): Promise<File | undefined> => { + let response; + try { + response = await fetch(imageUrl); + } catch (error: any) { + throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" }); + } + + if (!response.ok) { + throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" }); + } + + const blob = await response.blob(); + + if (blob.type && isSupportedImageFile(blob)) { + const name = filename || blob.name || ""; + return new File([blob], name, { type: blob.type }); + } + + throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" }); +}; + +export const getFileFromEvent = async ( + event: React.DragEvent<HTMLDivElement>, +) => { + const file = event.dataTransfer.files.item(0); + const fileHandle = await getFileHandle(event); + + return { file: file ? await normalizeFile(file) : null, fileHandle }; +}; + +export const getFileHandle = async ( + event: React.DragEvent<HTMLDivElement>, +): Promise<FileSystemHandle | null> => { + if (nativeFileSystemSupported) { + try { + const item = event.dataTransfer.items[0]; + const handle: FileSystemHandle | null = + (await (item as any).getAsFileSystemHandle()) || null; + + return handle; + } catch (error: any) { + console.warn(error.name, error.message); + return null; + } + } + return null; +}; + +/** + * attempts to detect if a buffer is a valid image by checking its leading bytes + */ +const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => { + let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null = + null; + + const first8Bytes = `${[...new Uint8Array(buffer).slice(0, 8)].join(" ")} `; + + // uint8 leading bytes + const headerBytes = { + // https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header + png: "137 80 78 71 13 10 26 10 ", + // https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure + // jpg is a bit wonky. Checking the first three bytes should be enough, + // but may yield false positives. (https://stackoverflow.com/a/23360709/927631) + jpg: "255 216 255 ", + // https://en.wikipedia.org/wiki/GIF#Example_GIF_file + gif: "71 73 70 56 57 97 ", + }; + + if (first8Bytes === headerBytes.png) { + mimeType = MIME_TYPES.png; + } else if (first8Bytes.startsWith(headerBytes.jpg)) { + mimeType = MIME_TYPES.jpg; + } else if (first8Bytes.startsWith(headerBytes.gif)) { + mimeType = MIME_TYPES.gif; + } + return mimeType; +}; + +export const createFile = ( + blob: File | Blob | ArrayBuffer, + mimeType: ValueOf<typeof MIME_TYPES>, + name: string | undefined, +) => { + return new File([blob], name || "", { + type: mimeType, + }); +}; + +/** attempts to detect correct mimeType if none is set, or if an image + * has an incorrect extension. + * Note: doesn't handle missing .excalidraw/.excalidrawlib extension */ +export const normalizeFile = async (file: File) => { + if (!file.type) { + if (file?.name?.endsWith(".excalidrawlib")) { + file = createFile( + await blobToArrayBuffer(file), + MIME_TYPES.excalidrawlib, + file.name, + ); + } else if (file?.name?.endsWith(".excalidraw")) { + file = createFile( + await blobToArrayBuffer(file), + MIME_TYPES.excalidraw, + file.name, + ); + } else { + const buffer = await blobToArrayBuffer(file); + const mimeType = getActualMimeTypeFromImage(buffer); + if (mimeType) { + file = createFile(buffer, mimeType, file.name); + } + } + // when the file is an image, make sure the extension corresponds to the + // actual mimeType (this is an edge case, but happens sometime) + } else if (isSupportedImageFile(file)) { + const buffer = await blobToArrayBuffer(file); + const mimeType = getActualMimeTypeFromImage(buffer); + if (mimeType && mimeType !== file.type) { + file = createFile(buffer, mimeType, file.name); + } + } + + return file; +}; + +export const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => { + if ("arrayBuffer" in blob) { + return blob.arrayBuffer(); + } + // Safari + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + if (!event.target?.result) { + return reject(new Error("Couldn't convert blob to ArrayBuffer")); + } + resolve(event.target.result as ArrayBuffer); + }; + reader.readAsArrayBuffer(blob); + }); +}; diff --git a/packages/excalidraw/data/encode.ts b/packages/excalidraw/data/encode.ts new file mode 100644 index 0000000..15dfdb2 --- /dev/null +++ b/packages/excalidraw/data/encode.ts @@ -0,0 +1,413 @@ +import { deflate, inflate } from "pako"; +import { encryptData, decryptData } from "./encryption"; + +// ----------------------------------------------------------------------------- +// byte (binary) strings +// ----------------------------------------------------------------------------- + +// Buffer-compatible implem. +// +// Note that in V8, spreading the uint8array (by chunks) into +// `String.fromCharCode(...uint8array)` tends to be faster for large +// strings/buffers, in case perf is needed in the future. +export const toByteString = (data: string | Uint8Array | ArrayBuffer) => { + const bytes = + typeof data === "string" + ? new TextEncoder().encode(data) + : data instanceof Uint8Array + ? data + : new Uint8Array(data); + let bstring = ""; + for (const byte of bytes) { + bstring += String.fromCharCode(byte); + } + return bstring; +}; + +const byteStringToArrayBuffer = (byteString: string) => { + const buffer = new ArrayBuffer(byteString.length); + const bufferView = new Uint8Array(buffer); + for (let i = 0, len = byteString.length; i < len; i++) { + bufferView[i] = byteString.charCodeAt(i); + } + return buffer; +}; + +const byteStringToString = (byteString: string) => { + return new TextDecoder("utf-8").decode(byteStringToArrayBuffer(byteString)); +}; + +// ----------------------------------------------------------------------------- +// base64 +// ----------------------------------------------------------------------------- + +/** + * @param isByteString set to true if already byte string to prevent bloat + * due to reencoding + */ +export const stringToBase64 = (str: string, isByteString = false) => { + return isByteString ? window.btoa(str) : window.btoa(toByteString(str)); +}; + +// async to align with stringToBase64 +export const base64ToString = (base64: string, isByteString = false) => { + return isByteString + ? window.atob(base64) + : byteStringToString(window.atob(base64)); +}; + +export const base64ToArrayBuffer = (base64: string): ArrayBuffer => { + if (typeof Buffer !== "undefined") { + // Node.js environment + return Buffer.from(base64, "base64").buffer; + } + // Browser environment + return byteStringToArrayBuffer(atob(base64)); +}; + +// ----------------------------------------------------------------------------- +// base64url +// ----------------------------------------------------------------------------- + +export const base64urlToString = (str: string) => { + return window.atob( + // normalize base64URL to base64 + str + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(str.length + ((4 - (str.length % 4)) % 4), "="), + ); +}; + +// ----------------------------------------------------------------------------- +// text encoding +// ----------------------------------------------------------------------------- + +type EncodedData = { + encoded: string; + encoding: "bstring"; + /** whether text is compressed (zlib) */ + compressed: boolean; + /** version for potential migration purposes */ + version?: string; +}; + +/** + * Encodes (and potentially compresses via zlib) text to byte string + */ +export const encode = ({ + text, + compress, +}: { + text: string; + /** defaults to `true`. If compression fails, falls back to bstring alone. */ + compress?: boolean; +}): EncodedData => { + let deflated!: string; + if (compress !== false) { + try { + deflated = toByteString(deflate(text)); + } catch (error: any) { + console.error("encode: cannot deflate", error); + } + } + return { + version: "1", + encoding: "bstring", + compressed: !!deflated, + encoded: deflated || toByteString(text), + }; +}; + +export const decode = (data: EncodedData): string => { + let decoded: string; + + switch (data.encoding) { + case "bstring": + // if compressed, do not double decode the bstring + decoded = data.compressed + ? data.encoded + : byteStringToString(data.encoded); + break; + default: + throw new Error(`decode: unknown encoding "${data.encoding}"`); + } + + if (data.compressed) { + return inflate(new Uint8Array(byteStringToArrayBuffer(decoded)), { + to: "string", + }); + } + + return decoded; +}; + +// ----------------------------------------------------------------------------- +// binary encoding +// ----------------------------------------------------------------------------- + +type FileEncodingInfo = { + /* version 2 is the version we're shipping the initial image support with. + version 1 was a PR version that a lot of people were using anyway. + Thus, if there are issues we can check whether they're not using the + unoffic version */ + version: 1 | 2; + compression: "pako@1" | null; + encryption: "AES-GCM" | null; +}; + +// ----------------------------------------------------------------------------- +const CONCAT_BUFFERS_VERSION = 1; +/** how many bytes we use to encode how many bytes the next chunk has. + * Corresponds to DataView setter methods (setUint32, setUint16, etc). + * + * NOTE ! values must not be changed, which would be backwards incompatible ! + */ +const VERSION_DATAVIEW_BYTES = 4; +const NEXT_CHUNK_SIZE_DATAVIEW_BYTES = 4; +// ----------------------------------------------------------------------------- + +const DATA_VIEW_BITS_MAP = { 1: 8, 2: 16, 4: 32 } as const; + +// getter +function dataView(buffer: Uint8Array, bytes: 1 | 2 | 4, offset: number): number; +// setter +function dataView( + buffer: Uint8Array, + bytes: 1 | 2 | 4, + offset: number, + value: number, +): Uint8Array; +/** + * abstraction over DataView that serves as a typed getter/setter in case + * you're using constants for the byte size and want to ensure there's no + * discrepenancy in the encoding across refactors. + * + * DataView serves for an endian-agnostic handling of numbers in ArrayBuffers. + */ +function dataView( + buffer: Uint8Array, + bytes: 1 | 2 | 4, + offset: number, + value?: number, +): Uint8Array | number { + if (value != null) { + if (value > Math.pow(2, DATA_VIEW_BITS_MAP[bytes]) - 1) { + throw new Error( + `attempting to set value higher than the allocated bytes (value: ${value}, bytes: ${bytes})`, + ); + } + const method = `setUint${DATA_VIEW_BITS_MAP[bytes]}` as const; + new DataView(buffer.buffer)[method](offset, value); + return buffer; + } + const method = `getUint${DATA_VIEW_BITS_MAP[bytes]}` as const; + return new DataView(buffer.buffer)[method](offset); +} + +// ----------------------------------------------------------------------------- + +/** + * Resulting concatenated buffer has this format: + * + * [ + * VERSION chunk (4 bytes) + * LENGTH chunk 1 (4 bytes) + * DATA chunk 1 (up to 2^32 bits) + * LENGTH chunk 2 (4 bytes) + * DATA chunk 2 (up to 2^32 bits) + * ... + * ] + * + * @param buffers each buffer (chunk) must be at most 2^32 bits large (~4GB) + */ +const concatBuffers = (...buffers: Uint8Array[]) => { + const bufferView = new Uint8Array( + VERSION_DATAVIEW_BYTES + + NEXT_CHUNK_SIZE_DATAVIEW_BYTES * buffers.length + + buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0), + ); + + let cursor = 0; + + // as the first chunk we'll encode the version for backwards compatibility + dataView(bufferView, VERSION_DATAVIEW_BYTES, cursor, CONCAT_BUFFERS_VERSION); + cursor += VERSION_DATAVIEW_BYTES; + + for (const buffer of buffers) { + dataView( + bufferView, + NEXT_CHUNK_SIZE_DATAVIEW_BYTES, + cursor, + buffer.byteLength, + ); + cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES; + + bufferView.set(buffer, cursor); + cursor += buffer.byteLength; + } + + return bufferView; +}; + +/** can only be used on buffers created via `concatBuffers()` */ +const splitBuffers = (concatenatedBuffer: Uint8Array) => { + const buffers = []; + + let cursor = 0; + + // first chunk is the version + const version = dataView( + concatenatedBuffer, + NEXT_CHUNK_SIZE_DATAVIEW_BYTES, + cursor, + ); + // If version is outside of the supported versions, throw an error. + // This usually means the buffer wasn't encoded using this API, so we'd only + // waste compute. + if (version > CONCAT_BUFFERS_VERSION) { + throw new Error(`invalid version ${version}`); + } + + cursor += VERSION_DATAVIEW_BYTES; + + while (true) { + const chunkSize = dataView( + concatenatedBuffer, + NEXT_CHUNK_SIZE_DATAVIEW_BYTES, + cursor, + ); + cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES; + + buffers.push(concatenatedBuffer.slice(cursor, cursor + chunkSize)); + cursor += chunkSize; + if (cursor >= concatenatedBuffer.byteLength) { + break; + } + } + + return buffers; +}; + +// helpers for (de)compressing data with JSON metadata including encryption +// ----------------------------------------------------------------------------- + +/** @private */ +const _encryptAndCompress = async ( + data: Uint8Array | string, + encryptionKey: string, +) => { + const { encryptedBuffer, iv } = await encryptData( + encryptionKey, + deflate(data), + ); + + return { iv, buffer: new Uint8Array(encryptedBuffer) }; +}; + +/** + * The returned buffer has following format: + * `[]` refers to a buffers wrapper (see `concatBuffers`) + * + * [ + * encodingMetadataBuffer, + * iv, + * [ + * contentsMetadataBuffer + * contentsBuffer + * ] + * ] + */ +export const compressData = async <T extends Record<string, any> = never>( + dataBuffer: Uint8Array, + options: { + encryptionKey: string; + } & ([T] extends [never] + ? { + metadata?: T; + } + : { + metadata: T; + }), +): Promise<Uint8Array> => { + const fileInfo: FileEncodingInfo = { + version: 2, + compression: "pako@1", + encryption: "AES-GCM", + }; + + const encodingMetadataBuffer = new TextEncoder().encode( + JSON.stringify(fileInfo), + ); + + const contentsMetadataBuffer = new TextEncoder().encode( + JSON.stringify(options.metadata || null), + ); + + const { iv, buffer } = await _encryptAndCompress( + concatBuffers(contentsMetadataBuffer, dataBuffer), + options.encryptionKey, + ); + + return concatBuffers(encodingMetadataBuffer, iv, buffer); +}; + +/** @private */ +const _decryptAndDecompress = async ( + iv: Uint8Array, + decryptedBuffer: Uint8Array, + decryptionKey: string, + isCompressed: boolean, +) => { + decryptedBuffer = new Uint8Array( + await decryptData(iv, decryptedBuffer, decryptionKey), + ); + + if (isCompressed) { + return inflate(decryptedBuffer); + } + + return decryptedBuffer; +}; + +export const decompressData = async <T extends Record<string, any>>( + bufferView: Uint8Array, + options: { decryptionKey: string }, +) => { + // first chunk is encoding metadata (ignored for now) + const [encodingMetadataBuffer, iv, buffer] = splitBuffers(bufferView); + + const encodingMetadata: FileEncodingInfo = JSON.parse( + new TextDecoder().decode(encodingMetadataBuffer), + ); + + try { + const [contentsMetadataBuffer, contentsBuffer] = splitBuffers( + await _decryptAndDecompress( + iv, + buffer, + options.decryptionKey, + !!encodingMetadata.compression, + ), + ); + + const metadata = JSON.parse( + new TextDecoder().decode(contentsMetadataBuffer), + ) as T; + + return { + /** metadata source is always JSON so we can decode it here */ + metadata, + /** data can be anything so the caller must decode it */ + data: contentsBuffer, + }; + } catch (error: any) { + console.error( + `Error during decompressing and decrypting the file.`, + encodingMetadata, + ); + throw error; + } +}; + +// ----------------------------------------------------------------------------- diff --git a/packages/excalidraw/data/encryption.ts b/packages/excalidraw/data/encryption.ts new file mode 100644 index 0000000..33e6899 --- /dev/null +++ b/packages/excalidraw/data/encryption.ts @@ -0,0 +1,93 @@ +import { ENCRYPTION_KEY_BITS } from "../constants"; +import { blobToArrayBuffer } from "./blob"; + +export const IV_LENGTH_BYTES = 12; + +export const createIV = () => { + const arr = new Uint8Array(IV_LENGTH_BYTES); + return window.crypto.getRandomValues(arr); +}; + +export const generateEncryptionKey = async < + T extends "string" | "cryptoKey" = "string", +>( + returnAs?: T, +): Promise<T extends "cryptoKey" ? CryptoKey : string> => { + const key = await window.crypto.subtle.generateKey( + { + name: "AES-GCM", + length: ENCRYPTION_KEY_BITS, + }, + true, // extractable + ["encrypt", "decrypt"], + ); + return ( + returnAs === "cryptoKey" + ? key + : (await window.crypto.subtle.exportKey("jwk", key)).k + ) as T extends "cryptoKey" ? CryptoKey : string; +}; + +export const getCryptoKey = (key: string, usage: KeyUsage) => + window.crypto.subtle.importKey( + "jwk", + { + alg: "A128GCM", + ext: true, + k: key, + key_ops: ["encrypt", "decrypt"], + kty: "oct", + }, + { + name: "AES-GCM", + length: ENCRYPTION_KEY_BITS, + }, + false, // extractable + [usage], + ); + +export const encryptData = async ( + key: string | CryptoKey, + data: Uint8Array | ArrayBuffer | Blob | File | string, +): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => { + const importedKey = + typeof key === "string" ? await getCryptoKey(key, "encrypt") : key; + const iv = createIV(); + const buffer: ArrayBuffer | Uint8Array = + typeof data === "string" + ? new TextEncoder().encode(data) + : data instanceof Uint8Array + ? data + : data instanceof Blob + ? await blobToArrayBuffer(data) + : data; + + // We use symmetric encryption. AES-GCM is the recommended algorithm and + // includes checks that the ciphertext has not been modified by an attacker. + const encryptedBuffer = await window.crypto.subtle.encrypt( + { + name: "AES-GCM", + iv, + }, + importedKey, + buffer as ArrayBuffer | Uint8Array, + ); + + return { encryptedBuffer, iv }; +}; + +export const decryptData = async ( + iv: Uint8Array, + encrypted: Uint8Array | ArrayBuffer, + privateKey: string, +): Promise<ArrayBuffer> => { + const key = await getCryptoKey(privateKey, "decrypt"); + return window.crypto.subtle.decrypt( + { + name: "AES-GCM", + iv, + }, + key, + encrypted, + ); +}; diff --git a/packages/excalidraw/data/filesystem.ts b/packages/excalidraw/data/filesystem.ts new file mode 100644 index 0000000..186d587 --- /dev/null +++ b/packages/excalidraw/data/filesystem.ts @@ -0,0 +1,104 @@ +import type { FileSystemHandle } from "browser-fs-access"; +import { + fileOpen as _fileOpen, + fileSave as _fileSave, + supported as nativeFileSystemSupported, +} from "browser-fs-access"; +import { EVENT, MIME_TYPES } from "../constants"; +import { AbortError } from "../errors"; +import { debounce } from "../utils"; + +type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">; + +const INPUT_CHANGE_INTERVAL_MS = 500; + +export const fileOpen = <M extends boolean | undefined = false>(opts: { + extensions?: FILE_EXTENSION[]; + description: string; + multiple?: M; +}): Promise<M extends false | undefined ? File : File[]> => { + // an unsafe TS hack, alas not much we can do AFAIK + type RetType = M extends false | undefined ? File : File[]; + + const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => { + mimeTypes.push(MIME_TYPES[type]); + + return mimeTypes; + }, [] as string[]); + + const extensions = opts.extensions?.reduce((acc, ext) => { + if (ext === "jpg") { + return acc.concat(".jpg", ".jpeg"); + } + return acc.concat(`.${ext}`); + }, [] as string[]); + + return _fileOpen({ + description: opts.description, + extensions, + mimeTypes, + multiple: opts.multiple ?? false, + legacySetup: (resolve, reject, input) => { + const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS); + const focusHandler = () => { + checkForFile(); + document.addEventListener(EVENT.KEYUP, scheduleRejection); + document.addEventListener(EVENT.POINTER_UP, scheduleRejection); + scheduleRejection(); + }; + const checkForFile = () => { + // this hack might not work when expecting multiple files + if (input.files?.length) { + const ret = opts.multiple ? [...input.files] : input.files[0]; + resolve(ret as RetType); + } + }; + requestAnimationFrame(() => { + window.addEventListener(EVENT.FOCUS, focusHandler); + }); + const interval = window.setInterval(() => { + checkForFile(); + }, INPUT_CHANGE_INTERVAL_MS); + return (rejectPromise) => { + clearInterval(interval); + scheduleRejection.cancel(); + window.removeEventListener(EVENT.FOCUS, focusHandler); + document.removeEventListener(EVENT.KEYUP, scheduleRejection); + document.removeEventListener(EVENT.POINTER_UP, scheduleRejection); + if (rejectPromise) { + // so that something is shown in console if we need to debug this + console.warn("Opening the file was canceled (legacy-fs)."); + rejectPromise(new AbortError()); + } + }; + }, + }) as Promise<RetType>; +}; + +export const fileSave = ( + blob: Blob | Promise<Blob>, + opts: { + /** supply without the extension */ + name: string; + /** file extension */ + extension: FILE_EXTENSION; + mimeTypes?: string[]; + description: string; + /** existing FileSystemHandle */ + fileHandle?: FileSystemHandle | null; + }, +) => { + return _fileSave( + blob, + { + fileName: `${opts.name}.${opts.extension}`, + description: opts.description, + extensions: [`.${opts.extension}`], + mimeTypes: opts.mimeTypes, + }, + opts.fileHandle, + ); +}; + +export { nativeFileSystemSupported }; +export type { FileSystemHandle }; diff --git a/packages/excalidraw/data/image.ts b/packages/excalidraw/data/image.ts new file mode 100644 index 0000000..0359a9c --- /dev/null +++ b/packages/excalidraw/data/image.ts @@ -0,0 +1,69 @@ +import decodePng from "png-chunks-extract"; +import tEXt from "png-chunk-text"; +import encodePng from "png-chunks-encode"; +import { encode, decode } from "./encode"; +import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants"; +import { blobToArrayBuffer } from "./blob"; + +// ----------------------------------------------------------------------------- +// PNG +// ----------------------------------------------------------------------------- + +export const getTEXtChunk = async ( + blob: Blob, +): Promise<{ keyword: string; text: string } | null> => { + const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); + const metadataChunk = chunks.find((chunk) => chunk.name === "tEXt"); + if (metadataChunk) { + return tEXt.decode(metadataChunk.data); + } + return null; +}; + +export const encodePngMetadata = async ({ + blob, + metadata, +}: { + blob: Blob; + metadata: string; +}) => { + const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); + + const metadataChunk = tEXt.encode( + MIME_TYPES.excalidraw, + JSON.stringify( + encode({ + text: metadata, + compress: true, + }), + ), + ); + // insert metadata before last chunk (iEND) + chunks.splice(-1, 0, metadataChunk); + + return new Blob([encodePng(chunks)], { type: MIME_TYPES.png }); +}; + +export const decodePngMetadata = async (blob: Blob) => { + const metadata = await getTEXtChunk(blob); + if (metadata?.keyword === MIME_TYPES.excalidraw) { + try { + const encodedData = JSON.parse(metadata.text); + if (!("encoded" in encodedData)) { + // legacy, un-encoded scene JSON + if ( + "type" in encodedData && + encodedData.type === EXPORT_DATA_TYPES.excalidraw + ) { + return metadata.text; + } + throw new Error("FAILED"); + } + return decode(encodedData); + } catch (error: any) { + console.error(error); + throw new Error("FAILED"); + } + } + throw new Error("INVALID"); +}; diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts new file mode 100644 index 0000000..04cdcaf --- /dev/null +++ b/packages/excalidraw/data/index.ts @@ -0,0 +1,202 @@ +import { + copyBlobToClipboardAsPng, + copyTextToSystemClipboard, +} from "../clipboard"; +import { + DEFAULT_EXPORT_PADDING, + DEFAULT_FILENAME, + IMAGE_MIME_TYPES, + isFirefox, + MIME_TYPES, +} from "../constants"; +import { getNonDeletedElements } from "../element"; +import { isFrameLikeElement } from "../element/typeChecks"; +import type { + ExcalidrawElement, + ExcalidrawFrameLikeElement, + NonDeletedExcalidrawElement, +} from "../element/types"; +import { getElementsOverlappingFrame } from "../frame"; +import { t } from "../i18n"; +import { getSelectedElements, isSomeElementSelected } from "../scene"; +import { exportToCanvas, exportToSvg } from "../scene/export"; +import type { ExportType } from "../scene/types"; +import type { AppState, BinaryFiles } from "../types"; +import { cloneJSON } from "../utils"; +import { canvasToBlob } from "./blob"; +import type { FileSystemHandle } from "./filesystem"; +import { fileSave } from "./filesystem"; +import { serializeAsJSON } from "./json"; + +export { loadFromBlob } from "./blob"; +export { loadFromJSON, saveAsJSON } from "./json"; + +export type ExportedElements = readonly NonDeletedExcalidrawElement[] & { + _brand: "exportedElements"; +}; + +export const prepareElementsForExport = ( + elements: readonly ExcalidrawElement[], + { selectedElementIds }: Pick<AppState, "selectedElementIds">, + exportSelectionOnly: boolean, +) => { + elements = getNonDeletedElements(elements); + + const isExportingSelection = + exportSelectionOnly && + isSomeElementSelected(elements, { selectedElementIds }); + + let exportingFrame: ExcalidrawFrameLikeElement | null = null; + let exportedElements = isExportingSelection + ? getSelectedElements( + elements, + { selectedElementIds }, + { + includeBoundTextElement: true, + }, + ) + : elements; + + if (isExportingSelection) { + if ( + exportedElements.length === 1 && + isFrameLikeElement(exportedElements[0]) + ) { + exportingFrame = exportedElements[0]; + exportedElements = getElementsOverlappingFrame(elements, exportingFrame); + } else if (exportedElements.length > 1) { + exportedElements = getSelectedElements( + elements, + { selectedElementIds }, + { + includeBoundTextElement: true, + includeElementsInFrames: true, + }, + ); + } + } + + return { + exportingFrame, + exportedElements: cloneJSON(exportedElements) as ExportedElements, + }; +}; + +export const exportCanvas = async ( + type: Omit<ExportType, "backend">, + elements: ExportedElements, + appState: AppState, + files: BinaryFiles, + { + exportBackground, + exportPadding = DEFAULT_EXPORT_PADDING, + viewBackgroundColor, + name = appState.name || DEFAULT_FILENAME, + fileHandle = null, + exportingFrame = null, + }: { + exportBackground: boolean; + exportPadding?: number; + viewBackgroundColor: string; + /** filename, if applicable */ + name?: string; + fileHandle?: FileSystemHandle | null; + exportingFrame: ExcalidrawFrameLikeElement | null; + }, +) => { + if (elements.length === 0) { + throw new Error(t("alerts.cannotExportEmptyCanvas")); + } + if (type === "svg" || type === "clipboard-svg") { + const svgPromise = exportToSvg( + elements, + { + exportBackground, + exportWithDarkMode: appState.exportWithDarkMode, + viewBackgroundColor, + exportPadding, + exportScale: appState.exportScale, + exportEmbedScene: appState.exportEmbedScene && type === "svg", + }, + files, + { exportingFrame }, + ); + + if (type === "svg") { + return fileSave( + svgPromise.then((svg) => { + return new Blob([svg.outerHTML], { type: MIME_TYPES.svg }); + }), + { + description: "Export to SVG", + name, + extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg", + mimeTypes: [IMAGE_MIME_TYPES.svg], + fileHandle, + }, + ); + } else if (type === "clipboard-svg") { + const svg = await svgPromise.then((svg) => svg.outerHTML); + try { + await copyTextToSystemClipboard(svg); + } catch (e) { + throw new Error(t("errors.copyToSystemClipboardFailed")); + } + return; + } + } + + const tempCanvas = exportToCanvas(elements, appState, files, { + exportBackground, + viewBackgroundColor, + exportPadding, + exportingFrame, + }); + + if (type === "png") { + let blob = canvasToBlob(tempCanvas); + + if (appState.exportEmbedScene) { + blob = blob.then((blob) => + import("./image").then(({ encodePngMetadata }) => + encodePngMetadata({ + blob, + metadata: serializeAsJSON(elements, appState, files, "local"), + }), + ), + ); + } + + return fileSave(blob, { + description: "Export to PNG", + name, + extension: appState.exportEmbedScene ? "excalidraw.png" : "png", + mimeTypes: [IMAGE_MIME_TYPES.png], + fileHandle, + }); + } else if (type === "clipboard") { + try { + const blob = canvasToBlob(tempCanvas); + await copyBlobToClipboardAsPng(blob); + } catch (error: any) { + console.warn(error); + if (error.name === "CANVAS_POSSIBLY_TOO_BIG") { + throw new Error(t("canvasError.canvasTooBig")); + } + // TypeError *probably* suggests ClipboardItem not defined, which + // people on Firefox can enable through a flag, so let's tell them. + if (isFirefox && error.name === "TypeError") { + throw new Error( + `${t("alerts.couldNotCopyToClipboard")}\n\n${t( + "hints.firefox_clipboard_write", + )}`, + ); + } else { + throw new Error(t("alerts.couldNotCopyToClipboard")); + } + } + } else { + // shouldn't happen + throw new Error("Unsupported export type"); + } +}; diff --git a/packages/excalidraw/data/json.ts b/packages/excalidraw/data/json.ts new file mode 100644 index 0000000..1270fd1 --- /dev/null +++ b/packages/excalidraw/data/json.ts @@ -0,0 +1,156 @@ +import { fileOpen, fileSave } from "./filesystem"; +import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; +import { + DEFAULT_FILENAME, + EXPORT_DATA_TYPES, + EXPORT_SOURCE, + MIME_TYPES, + VERSIONS, +} from "../constants"; +import { clearElementsForDatabase, clearElementsForExport } from "../element"; +import type { ExcalidrawElement } from "../element/types"; +import type { AppState, BinaryFiles, LibraryItems } from "../types"; +import { isImageFileHandle, loadFromBlob, normalizeFile } from "./blob"; + +import type { + ExportedDataState, + ImportedDataState, + ExportedLibraryData, + ImportedLibraryData, +} from "./types"; + +/** + * Strips out files which are only referenced by deleted elements + */ +const filterOutDeletedFiles = ( + elements: readonly ExcalidrawElement[], + files: BinaryFiles, +) => { + const nextFiles: BinaryFiles = {}; + for (const element of elements) { + if ( + !element.isDeleted && + "fileId" in element && + element.fileId && + files[element.fileId] + ) { + nextFiles[element.fileId] = files[element.fileId]; + } + } + return nextFiles; +}; + +export const serializeAsJSON = ( + elements: readonly ExcalidrawElement[], + appState: Partial<AppState>, + files: BinaryFiles, + type: "local" | "database", +): string => { + const data: ExportedDataState = { + type: EXPORT_DATA_TYPES.excalidraw, + version: VERSIONS.excalidraw, + source: EXPORT_SOURCE, + elements: + type === "local" + ? clearElementsForExport(elements) + : clearElementsForDatabase(elements), + appState: + type === "local" + ? cleanAppStateForExport(appState) + : clearAppStateForDatabase(appState), + files: + type === "local" + ? filterOutDeletedFiles(elements, files) + : // will be stripped from JSON + undefined, + }; + + return JSON.stringify(data, null, 2); +}; + +export const saveAsJSON = async ( + elements: readonly ExcalidrawElement[], + appState: AppState, + files: BinaryFiles, + /** filename */ + name: string = appState.name || DEFAULT_FILENAME, +) => { + const serialized = serializeAsJSON(elements, appState, files, "local"); + const blob = new Blob([serialized], { + type: MIME_TYPES.excalidraw, + }); + + const fileHandle = await fileSave(blob, { + name, + extension: "excalidraw", + description: "Excalidraw file", + fileHandle: isImageFileHandle(appState.fileHandle) + ? null + : appState.fileHandle, + }); + return { fileHandle }; +}; + +export const loadFromJSON = async ( + localAppState: AppState, + localElements: readonly ExcalidrawElement[] | null, +) => { + const file = await fileOpen({ + description: "Excalidraw files", + // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442 + // gets resolved. Else, iOS users cannot open `.excalidraw` files. + // extensions: ["json", "excalidraw", "png", "svg"], + }); + return loadFromBlob( + await normalizeFile(file), + localAppState, + localElements, + file.handle, + ); +}; + +export const isValidExcalidrawData = (data?: { + type?: any; + elements?: any; + appState?: any; +}): data is ImportedDataState => { + return ( + data?.type === EXPORT_DATA_TYPES.excalidraw && + (!data.elements || + (Array.isArray(data.elements) && + (!data.appState || typeof data.appState === "object"))) + ); +}; + +export const isValidLibrary = (json: any): json is ImportedLibraryData => { + return ( + typeof json === "object" && + json && + json.type === EXPORT_DATA_TYPES.excalidrawLibrary && + (json.version === 1 || json.version === 2) + ); +}; + +export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => { + const data: ExportedLibraryData = { + type: EXPORT_DATA_TYPES.excalidrawLibrary, + version: VERSIONS.excalidrawLibrary, + source: EXPORT_SOURCE, + libraryItems, + }; + return JSON.stringify(data, null, 2); +}; + +export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => { + const serialized = serializeLibraryAsJSON(libraryItems); + await fileSave( + new Blob([serialized], { + type: MIME_TYPES.excalidrawlib, + }), + { + name: "library", + extension: "excalidrawlib", + description: "Excalidraw library file", + }, + ); +}; diff --git a/packages/excalidraw/data/library.test.ts b/packages/excalidraw/data/library.test.ts new file mode 100644 index 0000000..bbbfd5f --- /dev/null +++ b/packages/excalidraw/data/library.test.ts @@ -0,0 +1,105 @@ +import { validateLibraryUrl } from "./library"; + +describe("validateLibraryUrl", () => { + it("should validate hostname & pathname", () => { + // valid hostnames + // ------------------------------------------------------------------------- + expect( + validateLibraryUrl("https://www.excalidraw.com", ["excalidraw.com"]), + ).toBe(true); + expect( + validateLibraryUrl("https://excalidraw.com", ["excalidraw.com"]), + ).toBe(true); + expect( + validateLibraryUrl("https://library.excalidraw.com", ["excalidraw.com"]), + ).toBe(true); + expect( + validateLibraryUrl("https://library.excalidraw.com", [ + "library.excalidraw.com", + ]), + ).toBe(true); + expect( + validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com/"]), + ).toBe(true); + expect( + validateLibraryUrl("https://excalidraw.com", ["excalidraw.com/"]), + ).toBe(true); + expect( + validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com"]), + ).toBe(true); + + // valid pathnames + // ------------------------------------------------------------------------- + expect( + validateLibraryUrl("https://excalidraw.com/path", ["excalidraw.com"]), + ).toBe(true); + expect( + validateLibraryUrl("https://excalidraw.com/path/", ["excalidraw.com"]), + ).toBe(true); + expect( + validateLibraryUrl("https://excalidraw.com/specific/path", [ + "excalidraw.com/specific/path", + ]), + ).toBe(true); + expect( + validateLibraryUrl("https://excalidraw.com/specific/path/", [ + "excalidraw.com/specific/path", + ]), + ).toBe(true); + expect( + validateLibraryUrl("https://excalidraw.com/specific/path", [ + "excalidraw.com/specific/path/", + ]), + ).toBe(true); + expect( + validateLibraryUrl("https://excalidraw.com/specific/path/other", [ + "excalidraw.com/specific/path", + ]), + ).toBe(true); + + // invalid hostnames + // ------------------------------------------------------------------------- + expect(() => + validateLibraryUrl("https://xexcalidraw.com", ["excalidraw.com"]), + ).toThrow(); + expect(() => + validateLibraryUrl("https://x-excalidraw.com", ["excalidraw.com"]), + ).toThrow(); + expect(() => + validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]), + ).toThrow(); + expect(() => + validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]), + ).toThrow(); + expect(() => + validateLibraryUrl("https://excalidraw.com.mx", ["excalidraw.com"]), + ).toThrow(); + // protocol must be https + expect(() => + validateLibraryUrl("http://excalidraw.com.mx", ["excalidraw.com"]), + ).toThrow(); + + // invalid pathnames + // ------------------------------------------------------------------------- + expect(() => + validateLibraryUrl("https://excalidraw.com/specific/other/path", [ + "excalidraw.com/specific/path", + ]), + ).toThrow(); + expect(() => + validateLibraryUrl("https://excalidraw.com/specific/paths", [ + "excalidraw.com/specific/path", + ]), + ).toThrow(); + expect(() => + validateLibraryUrl("https://excalidraw.com/specific/path-s", [ + "excalidraw.com/specific/path", + ]), + ).toThrow(); + expect(() => + validateLibraryUrl("https://excalidraw.com/some/specific/path", [ + "excalidraw.com/specific/path", + ]), + ).toThrow(); + }); +}); diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts new file mode 100644 index 0000000..1c23edc --- /dev/null +++ b/packages/excalidraw/data/library.ts @@ -0,0 +1,978 @@ +import { loadLibraryFromBlob } from "./blob"; +import type { + LibraryItems, + LibraryItem, + ExcalidrawImperativeAPI, + LibraryItemsSource, + LibraryItems_anyVersion, +} from "../types"; +import { restoreLibraryItems } from "./restore"; +import type App from "../components/App"; +import { atom, editorJotaiStore } from "../editor-jotai"; +import type { ExcalidrawElement } from "../element/types"; +import { getCommonBoundingBox } from "../element/bounds"; +import { AbortError } from "../errors"; +import { t } from "../i18n"; +import { useEffect, useRef } from "react"; +import { + URL_HASH_KEYS, + URL_QUERY_KEYS, + APP_NAME, + EVENT, + DEFAULT_SIDEBAR, + LIBRARY_SIDEBAR_TAB, +} from "../constants"; +import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg"; +import { + arrayToMap, + cloneJSON, + preventUnload, + promiseTry, + resolvablePromise, +} from "../utils"; +import type { MaybePromise } from "../utility-types"; +import { Emitter } from "../emitter"; +import { Queue } from "../queue"; +import { hashElementsVersion, hashString } from "../element"; +import { toValidURL } from "./url"; + +/** + * format: hostname or hostname/pathname + * + * Both hostname and pathname are matched partially, + * hostname from the end, pathname from the start, with subdomain/path + * boundaries + **/ +const ALLOWED_LIBRARY_URLS = [ + "excalidraw.com", + // when installing from github PRs + "raw.githubusercontent.com/excalidraw/excalidraw-libraries", +]; + +type LibraryUpdate = { + /** deleted library items since last onLibraryChange event */ + deletedItems: Map<LibraryItem["id"], LibraryItem>; + /** newly added items in the library */ + addedItems: Map<LibraryItem["id"], LibraryItem>; +}; + +// an object so that we can later add more properties to it without breaking, +// such as schema version +export type LibraryPersistedData = { libraryItems: LibraryItems }; + +const onLibraryUpdateEmitter = new Emitter< + [update: LibraryUpdate, libraryItems: LibraryItems] +>(); + +export type LibraryAdatapterSource = "load" | "save"; + +export interface LibraryPersistenceAdapter { + /** + * Should load data that were previously saved into the database using the + * `save` method. Should throw if saving fails. + * + * Will be used internally in multiple places, such as during save to + * in order to reconcile changes with latest store data. + */ + load(metadata: { + /** + * Indicates whether we're loading data for save purposes, or reading + * purposes, in which case host app can implement more aggressive caching. + */ + source: LibraryAdatapterSource; + }): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>; + /** Should persist to the database as is (do no change the data structure). */ + save(libraryData: LibraryPersistedData): MaybePromise<void>; +} + +export interface LibraryMigrationAdapter { + /** + * loads data from legacy data source. Returns `null` if no data is + * to be migrated. + */ + load(): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>; + + /** clears entire storage afterwards */ + clear(): MaybePromise<void>; +} + +export const libraryItemsAtom = atom<{ + status: "loading" | "loaded"; + /** indicates whether library is initialized with library items (has gone + * through at least one update). Used in UI. Specific to this atom only. */ + isInitialized: boolean; + libraryItems: LibraryItems; +}>({ status: "loaded", isInitialized: false, libraryItems: [] }); + +const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems => + cloneJSON(libraryItems); + +/** + * checks if library item does not exist already in current library + */ +const isUniqueItem = ( + existingLibraryItems: LibraryItems, + targetLibraryItem: LibraryItem, +) => { + return !existingLibraryItems.find((libraryItem) => { + if (libraryItem.elements.length !== targetLibraryItem.elements.length) { + return false; + } + + // detect z-index difference by checking the excalidraw elements + // are in order + return libraryItem.elements.every((libItemExcalidrawItem, idx) => { + return ( + libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id && + libItemExcalidrawItem.versionNonce === + targetLibraryItem.elements[idx].versionNonce + ); + }); + }); +}; + +/** Merges otherItems into localItems. Unique items in otherItems array are + sorted first. */ +export const mergeLibraryItems = ( + localItems: LibraryItems, + otherItems: LibraryItems, +): LibraryItems => { + const newItems = []; + for (const item of otherItems) { + if (isUniqueItem(localItems, item)) { + newItems.push(item); + } + } + + return [...newItems, ...localItems]; +}; + +/** + * Returns { deletedItems, addedItems } maps of all added and deleted items + * since last onLibraryChange event. + * + * Host apps are recommended to diff with the latest state they have. + */ +const createLibraryUpdate = ( + prevLibraryItems: LibraryItems, + nextLibraryItems: LibraryItems, +): LibraryUpdate => { + const nextItemsMap = arrayToMap(nextLibraryItems); + + const update: LibraryUpdate = { + deletedItems: new Map<LibraryItem["id"], LibraryItem>(), + addedItems: new Map<LibraryItem["id"], LibraryItem>(), + }; + + for (const item of prevLibraryItems) { + if (!nextItemsMap.has(item.id)) { + update.deletedItems.set(item.id, item); + } + } + + const prevItemsMap = arrayToMap(prevLibraryItems); + + for (const item of nextLibraryItems) { + if (!prevItemsMap.has(item.id)) { + update.addedItems.set(item.id, item); + } + } + + return update; +}; + +class Library { + /** latest libraryItems */ + private currLibraryItems: LibraryItems = []; + /** snapshot of library items since last onLibraryChange call */ + private prevLibraryItems = cloneLibraryItems(this.currLibraryItems); + + private app: App; + + constructor(app: App) { + this.app = app; + } + + private updateQueue: Promise<LibraryItems>[] = []; + + private getLastUpdateTask = (): Promise<LibraryItems> | undefined => { + return this.updateQueue[this.updateQueue.length - 1]; + }; + + private notifyListeners = () => { + if (this.updateQueue.length > 0) { + editorJotaiStore.set(libraryItemsAtom, (s) => ({ + status: "loading", + libraryItems: this.currLibraryItems, + isInitialized: s.isInitialized, + })); + } else { + editorJotaiStore.set(libraryItemsAtom, { + status: "loaded", + libraryItems: this.currLibraryItems, + isInitialized: true, + }); + try { + const prevLibraryItems = this.prevLibraryItems; + this.prevLibraryItems = cloneLibraryItems(this.currLibraryItems); + + const nextLibraryItems = cloneLibraryItems(this.currLibraryItems); + + this.app.props.onLibraryChange?.(nextLibraryItems); + + // for internal use in `useHandleLibrary` hook + onLibraryUpdateEmitter.trigger( + createLibraryUpdate(prevLibraryItems, nextLibraryItems), + nextLibraryItems, + ); + } catch (error) { + console.error(error); + } + } + }; + + /** call on excalidraw instance unmount */ + destroy = () => { + this.updateQueue = []; + this.currLibraryItems = []; + editorJotaiStore.set(libraryItemSvgsCache, new Map()); + // TODO uncomment after/if we make jotai store scoped to each excal instance + // jotaiStore.set(libraryItemsAtom, { + // status: "loading", + // isInitialized: false, + // libraryItems: [], + // }); + }; + + resetLibrary = () => { + return this.setLibrary([]); + }; + + /** + * @returns latest cloned libraryItems. Awaits all in-progress updates first. + */ + getLatestLibrary = (): Promise<LibraryItems> => { + return new Promise(async (resolve) => { + try { + const libraryItems = await (this.getLastUpdateTask() || + this.currLibraryItems); + if (this.updateQueue.length > 0) { + resolve(this.getLatestLibrary()); + } else { + resolve(cloneLibraryItems(libraryItems)); + } + } catch (error) { + return resolve(this.currLibraryItems); + } + }); + }; + + // NOTE this is a high-level public API (exposed on ExcalidrawAPI) with + // a slight overhead (always restoring library items). For internal use + // where merging isn't needed, use `library.setLibrary()` directly. + updateLibrary = async ({ + libraryItems, + prompt = false, + merge = false, + openLibraryMenu = false, + defaultStatus = "unpublished", + }: { + libraryItems: LibraryItemsSource; + merge?: boolean; + prompt?: boolean; + openLibraryMenu?: boolean; + defaultStatus?: "unpublished" | "published"; + }): Promise<LibraryItems> => { + if (openLibraryMenu) { + this.app.setState({ + openSidebar: { name: DEFAULT_SIDEBAR.name, tab: LIBRARY_SIDEBAR_TAB }, + }); + } + + return this.setLibrary(() => { + return new Promise<LibraryItems>(async (resolve, reject) => { + try { + const source = await (typeof libraryItems === "function" && + !(libraryItems instanceof Blob) + ? libraryItems(this.currLibraryItems) + : libraryItems); + + let nextItems; + + if (source instanceof Blob) { + nextItems = await loadLibraryFromBlob(source, defaultStatus); + } else { + nextItems = restoreLibraryItems(source, defaultStatus); + } + if ( + !prompt || + window.confirm( + t("alerts.confirmAddLibrary", { + numShapes: nextItems.length, + }), + ) + ) { + if (prompt) { + // focus container if we've prompted. We focus conditionally + // lest `props.autoFocus` is disabled (in which case we should + // focus only on user action such as prompt confirm) + this.app.focusContainer(); + } + + if (merge) { + resolve(mergeLibraryItems(this.currLibraryItems, nextItems)); + } else { + resolve(nextItems); + } + } else { + reject(new AbortError()); + } + } catch (error: any) { + reject(error); + } + }); + }); + }; + + setLibrary = ( + /** + * LibraryItems that will replace current items. Can be a function which + * will be invoked after all previous tasks are resolved + * (this is the prefered way to update the library to avoid race conditions, + * but you'll want to manually merge the library items in the callback + * - which is what we're doing in Library.importLibrary()). + * + * If supplied promise is rejected with AbortError, we swallow it and + * do not update the library. + */ + libraryItems: + | LibraryItems + | Promise<LibraryItems> + | (( + latestLibraryItems: LibraryItems, + ) => LibraryItems | Promise<LibraryItems>), + ): Promise<LibraryItems> => { + const task = new Promise<LibraryItems>(async (resolve, reject) => { + try { + await this.getLastUpdateTask(); + + if (typeof libraryItems === "function") { + libraryItems = libraryItems(this.currLibraryItems); + } + + this.currLibraryItems = cloneLibraryItems(await libraryItems); + + resolve(this.currLibraryItems); + } catch (error: any) { + reject(error); + } + }) + .catch((error) => { + if (error.name === "AbortError") { + console.warn("Library update aborted by user"); + return this.currLibraryItems; + } + throw error; + }) + .finally(() => { + this.updateQueue = this.updateQueue.filter((_task) => _task !== task); + this.notifyListeners(); + }); + + this.updateQueue.push(task); + this.notifyListeners(); + + return task; + }; +} + +export default Library; + +export const distributeLibraryItemsOnSquareGrid = ( + libraryItems: LibraryItems, +) => { + const PADDING = 50; + const ITEMS_PER_ROW = Math.ceil(Math.sqrt(libraryItems.length)); + + const resElements: ExcalidrawElement[] = []; + + const getMaxHeightPerRow = (row: number) => { + const maxHeight = libraryItems + .slice(row * ITEMS_PER_ROW, row * ITEMS_PER_ROW + ITEMS_PER_ROW) + .reduce((acc, item) => { + const { height } = getCommonBoundingBox(item.elements); + return Math.max(acc, height); + }, 0); + return maxHeight; + }; + + const getMaxWidthPerCol = (targetCol: number) => { + let index = 0; + let currCol = 0; + let maxWidth = 0; + for (const item of libraryItems) { + if (index % ITEMS_PER_ROW === 0) { + currCol = 0; + } + if (currCol === targetCol) { + const { width } = getCommonBoundingBox(item.elements); + maxWidth = Math.max(maxWidth, width); + } + index++; + currCol++; + } + return maxWidth; + }; + + let colOffsetX = 0; + let rowOffsetY = 0; + + let maxHeightCurrRow = 0; + let maxWidthCurrCol = 0; + + let index = 0; + let col = 0; + let row = 0; + + for (const item of libraryItems) { + if (index && index % ITEMS_PER_ROW === 0) { + rowOffsetY += maxHeightCurrRow + PADDING; + colOffsetX = 0; + col = 0; + row++; + } + + if (col === 0) { + maxHeightCurrRow = getMaxHeightPerRow(row); + } + maxWidthCurrCol = getMaxWidthPerCol(col); + + const { minX, minY, width, height } = getCommonBoundingBox(item.elements); + const offsetCenterX = (maxWidthCurrCol - width) / 2; + const offsetCenterY = (maxHeightCurrRow - height) / 2; + resElements.push( + // eslint-disable-next-line no-loop-func + ...item.elements.map((element) => ({ + ...element, + x: + element.x + + // offset for column + colOffsetX + + // offset to center in given square grid + offsetCenterX - + // subtract minX so that given item starts at 0 coord + minX, + y: + element.y + + // offset for row + rowOffsetY + + // offset to center in given square grid + offsetCenterY - + // subtract minY so that given item starts at 0 coord + minY, + })), + ); + colOffsetX += maxWidthCurrCol + PADDING; + index++; + col++; + } + + return resElements; +}; + +export const validateLibraryUrl = ( + libraryUrl: string, + /** + * @returns `true` if the URL is valid, throws otherwise. + */ + validator: + | ((libraryUrl: string) => boolean) + | string[] = ALLOWED_LIBRARY_URLS, +): true => { + if ( + typeof validator === "function" + ? validator(libraryUrl) + : validator.some((allowedUrlDef) => { + const allowedUrl = new URL( + `https://${allowedUrlDef.replace(/^https?:\/\//, "")}`, + ); + + const { hostname, pathname } = new URL(libraryUrl); + + return ( + new RegExp(`(^|\\.)${allowedUrl.hostname}$`).test(hostname) && + new RegExp( + `^${allowedUrl.pathname.replace(/\/+$/, "")}(/+|$)`, + ).test(pathname) + ); + }) + ) { + return true; + } + + throw new Error(`Invalid or disallowed library URL: "${libraryUrl}"`); +}; + +export const parseLibraryTokensFromUrl = () => { + const libraryUrl = + // current + new URLSearchParams(window.location.hash.slice(1)).get( + URL_HASH_KEYS.addLibrary, + ) || + // legacy, kept for compat reasons + new URLSearchParams(window.location.search).get(URL_QUERY_KEYS.addLibrary); + const idToken = libraryUrl + ? new URLSearchParams(window.location.hash.slice(1)).get("token") + : null; + + return libraryUrl ? { libraryUrl, idToken } : null; +}; + +class AdapterTransaction { + static queue = new Queue(); + + static async getLibraryItems( + adapter: LibraryPersistenceAdapter, + source: LibraryAdatapterSource, + _queue = true, + ): Promise<LibraryItems> { + const task = () => + new Promise<LibraryItems>(async (resolve, reject) => { + try { + const data = await adapter.load({ source }); + resolve(restoreLibraryItems(data?.libraryItems || [], "published")); + } catch (error: any) { + reject(error); + } + }); + + if (_queue) { + return AdapterTransaction.queue.push(task); + } + + return task(); + } + + static run = async <T>( + adapter: LibraryPersistenceAdapter, + fn: (transaction: AdapterTransaction) => Promise<T>, + ) => { + const transaction = new AdapterTransaction(adapter); + return AdapterTransaction.queue.push(() => fn(transaction)); + }; + + // ------------------ + + private adapter: LibraryPersistenceAdapter; + + constructor(adapter: LibraryPersistenceAdapter) { + this.adapter = adapter; + } + + getLibraryItems(source: LibraryAdatapterSource) { + return AdapterTransaction.getLibraryItems(this.adapter, source, false); + } +} + +let lastSavedLibraryItemsHash = 0; +let librarySaveCounter = 0; + +export const getLibraryItemsHash = (items: LibraryItems) => { + return hashString( + items + .map((item) => { + return `${item.id}:${hashElementsVersion(item.elements)}`; + }) + .sort() + .join(), + ); +}; + +const persistLibraryUpdate = async ( + adapter: LibraryPersistenceAdapter, + update: LibraryUpdate, +): Promise<LibraryItems> => { + try { + librarySaveCounter++; + + return await AdapterTransaction.run(adapter, async (transaction) => { + const nextLibraryItemsMap = arrayToMap( + await transaction.getLibraryItems("save"), + ); + + for (const [id] of update.deletedItems) { + nextLibraryItemsMap.delete(id); + } + + const addedItems: LibraryItem[] = []; + + // we want to merge current library items with the ones stored in the + // DB so that we don't lose any elements that for some reason aren't + // in the current editor library, which could happen when: + // + // 1. we haven't received an update deleting some elements + // (in which case it's still better to keep them in the DB lest + // it was due to a different reason) + // 2. we keep a single DB for all active editors, but the editors' + // libraries aren't synced or there's a race conditions during + // syncing + // 3. some other race condition, e.g. during init where emit updates + // for partial updates (e.g. you install a 3rd party library and + // init from DB only after — we emit events for both updates) + for (const [id, item] of update.addedItems) { + if (nextLibraryItemsMap.has(id)) { + // replace item with latest version + // TODO we could prefer the newer item instead + nextLibraryItemsMap.set(id, item); + } else { + // we want to prepend the new items with the ones that are already + // in DB to preserve the ordering we do in editor (newly added + // items are added to the beginning) + addedItems.push(item); + } + } + + const nextLibraryItems = addedItems.concat( + Array.from(nextLibraryItemsMap.values()), + ); + + const version = getLibraryItemsHash(nextLibraryItems); + + if (version !== lastSavedLibraryItemsHash) { + await adapter.save({ libraryItems: nextLibraryItems }); + } + + lastSavedLibraryItemsHash = version; + + return nextLibraryItems; + }); + } finally { + librarySaveCounter--; + } +}; + +export const useHandleLibrary = ( + opts: { + excalidrawAPI: ExcalidrawImperativeAPI | null; + /** + * Return `true` if the library install url should be allowed. + * If not supplied, only the excalidraw.com base domain is allowed. + */ + validateLibraryUrl?: (libraryUrl: string) => boolean; + } & ( + | { + /** @deprecated we recommend using `opts.adapter` instead */ + getInitialLibraryItems?: () => MaybePromise<LibraryItemsSource>; + } + | { + adapter: LibraryPersistenceAdapter; + /** + * Adapter that takes care of loading data from legacy data store. + * Supply this if you want to migrate data on initial load from legacy + * data store. + * + * Can be a different LibraryPersistenceAdapter. + */ + migrationAdapter?: LibraryMigrationAdapter; + } + ), +) => { + const { excalidrawAPI } = opts; + + const optsRef = useRef(opts); + optsRef.current = opts; + + const isLibraryLoadedRef = useRef(false); + + useEffect(() => { + if (!excalidrawAPI) { + return; + } + + // reset on editor remount (excalidrawAPI changed) + isLibraryLoadedRef.current = false; + + const importLibraryFromURL = async ({ + libraryUrl, + idToken, + }: { + libraryUrl: string; + idToken: string | null; + }) => { + const libraryPromise = new Promise<Blob>(async (resolve, reject) => { + try { + libraryUrl = decodeURIComponent(libraryUrl); + + libraryUrl = toValidURL(libraryUrl); + + validateLibraryUrl(libraryUrl, optsRef.current.validateLibraryUrl); + + const request = await fetch(libraryUrl); + const blob = await request.blob(); + resolve(blob); + } catch (error: any) { + reject(error); + } + }); + + const shouldPrompt = idToken !== excalidrawAPI.id; + + // wait for the tab to be focused before continuing in case we'll prompt + // for confirmation + await (shouldPrompt && document.hidden + ? new Promise<void>((resolve) => { + window.addEventListener("focus", () => resolve(), { + once: true, + }); + }) + : null); + + try { + await excalidrawAPI.updateLibrary({ + libraryItems: libraryPromise, + prompt: shouldPrompt, + merge: true, + defaultStatus: "published", + openLibraryMenu: true, + }); + } catch (error: any) { + excalidrawAPI.updateScene({ + appState: { + errorMessage: error.message, + }, + }); + throw error; + } finally { + if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) { + const hash = new URLSearchParams(window.location.hash.slice(1)); + hash.delete(URL_HASH_KEYS.addLibrary); + window.history.replaceState({}, APP_NAME, `#${hash.toString()}`); + } else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) { + const query = new URLSearchParams(window.location.search); + query.delete(URL_QUERY_KEYS.addLibrary); + window.history.replaceState({}, APP_NAME, `?${query.toString()}`); + } + } + }; + const onHashChange = (event: HashChangeEvent) => { + event.preventDefault(); + const libraryUrlTokens = parseLibraryTokensFromUrl(); + if (libraryUrlTokens) { + event.stopImmediatePropagation(); + // If hash changed and it contains library url, import it and replace + // the url to its previous state (important in case of collaboration + // and similar). + // Using history API won't trigger another hashchange. + window.history.replaceState({}, "", event.oldURL); + + importLibraryFromURL(libraryUrlTokens); + } + }; + + // ------------------------------------------------------------------------- + // ---------------------------------- init --------------------------------- + // ------------------------------------------------------------------------- + + const libraryUrlTokens = parseLibraryTokensFromUrl(); + + if (libraryUrlTokens) { + importLibraryFromURL(libraryUrlTokens); + } + + // ------ (A) init load (legacy) ------------------------------------------- + if ( + "getInitialLibraryItems" in optsRef.current && + optsRef.current.getInitialLibraryItems + ) { + console.warn( + "useHandleLibrar `opts.getInitialLibraryItems` is deprecated. Use `opts.adapter` instead.", + ); + + Promise.resolve(optsRef.current.getInitialLibraryItems()) + .then((libraryItems) => { + excalidrawAPI.updateLibrary({ + libraryItems, + // merge with current library items because we may have already + // populated it (e.g. by installing 3rd party library which can + // happen before the DB data is loaded) + merge: true, + }); + }) + .catch((error: any) => { + console.error( + `UseHandeLibrary getInitialLibraryItems failed: ${error?.message}`, + ); + }); + } + + // ------------------------------------------------------------------------- + // --------------------------------------------------------- init load ----- + // ------------------------------------------------------------------------- + + // ------ (B) data source adapter ------------------------------------------ + + if ("adapter" in optsRef.current && optsRef.current.adapter) { + const adapter = optsRef.current.adapter; + const migrationAdapter = optsRef.current.migrationAdapter; + + const initDataPromise = resolvablePromise<LibraryItems | null>(); + + // migrate from old data source if needed + // (note, if `migrate` function is defined, we always migrate even + // if the data has already been migrated. In that case it'll be a no-op, + // though with several unnecessary steps — we will still load latest + // DB data during the `persistLibraryChange()` step) + // ----------------------------------------------------------------------- + if (migrationAdapter) { + initDataPromise.resolve( + promiseTry(migrationAdapter.load) + .then(async (libraryData) => { + let restoredData: LibraryItems | null = null; + try { + // if no library data to migrate, assume no migration needed + // and skip persisting to new data store, as well as well + // clearing the old store via `migrationAdapter.clear()` + if (!libraryData) { + return AdapterTransaction.getLibraryItems(adapter, "load"); + } + + restoredData = restoreLibraryItems( + libraryData.libraryItems || [], + "published", + ); + + // we don't queue this operation because it's running inside + // a promise that's running inside Library update queue itself + const nextItems = await persistLibraryUpdate( + adapter, + createLibraryUpdate([], restoredData), + ); + try { + await migrationAdapter.clear(); + } catch (error: any) { + console.error( + `couldn't delete legacy library data: ${error.message}`, + ); + } + // migration suceeded, load migrated data + return nextItems; + } catch (error: any) { + console.error( + `couldn't migrate legacy library data: ${error.message}`, + ); + // migration failed, load data from previous store, if any + return restoredData; + } + }) + // errors caught during `migrationAdapter.load()` + .catch((error: any) => { + console.error(`error during library migration: ${error.message}`); + // as a default, load latest library from current data source + return AdapterTransaction.getLibraryItems(adapter, "load"); + }), + ); + } else { + initDataPromise.resolve( + promiseTry(AdapterTransaction.getLibraryItems, adapter, "load"), + ); + } + + // load initial (or migrated) library + excalidrawAPI + .updateLibrary({ + libraryItems: initDataPromise.then((libraryItems) => { + const _libraryItems = libraryItems || []; + lastSavedLibraryItemsHash = getLibraryItemsHash(_libraryItems); + return _libraryItems; + }), + // merge with current library items because we may have already + // populated it (e.g. by installing 3rd party library which can + // happen before the DB data is loaded) + merge: true, + }) + .finally(() => { + isLibraryLoadedRef.current = true; + }); + } + // ---------------------------------------------- data source datapter ----- + + window.addEventListener(EVENT.HASHCHANGE, onHashChange); + return () => { + window.removeEventListener(EVENT.HASHCHANGE, onHashChange); + }; + }, [ + // important this useEffect only depends on excalidrawAPI so it only reruns + // on editor remounts (the excalidrawAPI changes) + excalidrawAPI, + ]); + + // This effect is run without excalidrawAPI dependency so that host apps + // can run this hook outside of an active editor instance and the library + // update queue/loop survives editor remounts + // + // This effect is still only meant to be run if host apps supply an persitence + // adapter. If we don't have access to it, it the update listener doesn't + // do anything. + useEffect( + () => { + // on update, merge with current library items and persist + // ----------------------------------------------------------------------- + const unsubOnLibraryUpdate = onLibraryUpdateEmitter.on( + async (update, nextLibraryItems) => { + const isLoaded = isLibraryLoadedRef.current; + // we want to operate with the latest adapter, but we don't want this + // effect to rerun on every adapter change in case host apps' adapter + // isn't stable + const adapter = + ("adapter" in optsRef.current && optsRef.current.adapter) || null; + try { + if (adapter) { + if ( + // if nextLibraryItems hash identical to previously saved hash, + // exit early, even if actual upstream state ends up being + // different (e.g. has more data than we have locally), as it'd + // be low-impact scenario. + lastSavedLibraryItemsHash !== + getLibraryItemsHash(nextLibraryItems) + ) { + await persistLibraryUpdate(adapter, update); + } + } + } catch (error: any) { + console.error( + `couldn't persist library update: ${error.message}`, + update, + ); + + // currently we only show error if an editor is loaded + if (isLoaded && optsRef.current.excalidrawAPI) { + optsRef.current.excalidrawAPI.updateScene({ + appState: { + errorMessage: t("errors.saveLibraryError"), + }, + }); + } + } + }, + ); + + const onUnload = (event: Event) => { + if (librarySaveCounter) { + preventUnload(event); + } + }; + + window.addEventListener(EVENT.BEFORE_UNLOAD, onUnload); + + return () => { + window.removeEventListener(EVENT.BEFORE_UNLOAD, onUnload); + unsubOnLibraryUpdate(); + lastSavedLibraryItemsHash = 0; + librarySaveCounter = 0; + }; + }, + [ + // this effect must not have any deps so it doesn't rerun + ], + ); +}; diff --git a/packages/excalidraw/data/reconcile.ts b/packages/excalidraw/data/reconcile.ts new file mode 100644 index 0000000..fa4cff8 --- /dev/null +++ b/packages/excalidraw/data/reconcile.ts @@ -0,0 +1,118 @@ +import throttle from "lodash.throttle"; +import { ENV } from "../constants"; +import type { OrderedExcalidrawElement } from "../element/types"; +import { + orderByFractionalIndex, + syncInvalidIndices, + validateFractionalIndices, +} from "../fractionalIndex"; +import type { AppState } from "../types"; +import type { MakeBrand } from "../utility-types"; +import { arrayToMap } from "../utils"; + +export type ReconciledExcalidrawElement = OrderedExcalidrawElement & + MakeBrand<"ReconciledElement">; + +export type RemoteExcalidrawElement = OrderedExcalidrawElement & + MakeBrand<"RemoteExcalidrawElement">; + +const shouldDiscardRemoteElement = ( + localAppState: AppState, + local: OrderedExcalidrawElement | undefined, + remote: RemoteExcalidrawElement, +): boolean => { + if ( + local && + // local element is being edited + (local.id === localAppState.editingTextElement?.id || + local.id === localAppState.resizingElement?.id || + local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array + // local element is newer + local.version > remote.version || + // resolve conflicting edits deterministically by taking the one with + // the lowest versionNonce + (local.version === remote.version && + local.versionNonce < remote.versionNonce)) + ) { + return true; + } + return false; +}; + +const validateIndicesThrottled = throttle( + ( + orderedElements: readonly OrderedExcalidrawElement[], + localElements: readonly OrderedExcalidrawElement[], + remoteElements: readonly RemoteExcalidrawElement[], + ) => { + if ( + import.meta.env.DEV || + import.meta.env.MODE === ENV.TEST || + window?.DEBUG_FRACTIONAL_INDICES + ) { + // create new instances due to the mutation + const elements = syncInvalidIndices( + orderedElements.map((x) => ({ ...x })), + ); + + validateFractionalIndices(elements, { + // throw in dev & test only, to remain functional on `DEBUG_FRACTIONAL_INDICES` + shouldThrow: import.meta.env.DEV || import.meta.env.MODE === ENV.TEST, + includeBoundTextValidation: true, + reconciliationContext: { + localElements, + remoteElements, + }, + }); + } + }, + 1000 * 60, + { leading: true, trailing: false }, +); + +export const reconcileElements = ( + localElements: readonly OrderedExcalidrawElement[], + remoteElements: readonly RemoteExcalidrawElement[], + localAppState: AppState, +): ReconciledExcalidrawElement[] => { + const localElementsMap = arrayToMap(localElements); + const reconciledElements: OrderedExcalidrawElement[] = []; + const added = new Set<string>(); + + // process remote elements + for (const remoteElement of remoteElements) { + if (!added.has(remoteElement.id)) { + const localElement = localElementsMap.get(remoteElement.id); + const discardRemoteElement = shouldDiscardRemoteElement( + localAppState, + localElement, + remoteElement, + ); + + if (localElement && discardRemoteElement) { + reconciledElements.push(localElement); + added.add(localElement.id); + } else { + reconciledElements.push(remoteElement); + added.add(remoteElement.id); + } + } + } + + // process remaining local elements + for (const localElement of localElements) { + if (!added.has(localElement.id)) { + reconciledElements.push(localElement); + added.add(localElement.id); + } + } + + const orderedElements = orderByFractionalIndex(reconciledElements); + + validateIndicesThrottled(orderedElements, localElements, remoteElements); + + // de-duplicate indices + syncInvalidIndices(orderedElements); + + return orderedElements as ReconciledExcalidrawElement[]; +}; diff --git a/packages/excalidraw/data/resave.ts b/packages/excalidraw/data/resave.ts new file mode 100644 index 0000000..6249184 --- /dev/null +++ b/packages/excalidraw/data/resave.ts @@ -0,0 +1,41 @@ +import type { ExcalidrawElement } from "../element/types"; +import type { AppState, BinaryFiles } from "../types"; +import { exportCanvas, prepareElementsForExport } from "."; +import { getFileHandleType, isImageFileHandleType } from "./blob"; + +export const resaveAsImageWithScene = async ( + elements: readonly ExcalidrawElement[], + appState: AppState, + files: BinaryFiles, + name: string, +) => { + const { exportBackground, viewBackgroundColor, fileHandle } = appState; + + const fileHandleType = getFileHandleType(fileHandle); + + if (!fileHandle || !isImageFileHandleType(fileHandleType)) { + throw new Error( + "fileHandle should exist and should be of type svg or png when resaving", + ); + } + appState = { + ...appState, + exportEmbedScene: true, + }; + + const { exportedElements, exportingFrame } = prepareElementsForExport( + elements, + appState, + false, + ); + + await exportCanvas(fileHandleType, exportedElements, appState, files, { + exportBackground, + viewBackgroundColor, + name, + fileHandle, + exportingFrame, + }); + + return { fileHandle }; +}; diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts new file mode 100644 index 0000000..c4e45b0 --- /dev/null +++ b/packages/excalidraw/data/restore.ts @@ -0,0 +1,813 @@ +import type { + ExcalidrawArrowElement, + ExcalidrawElbowArrowElement, + ExcalidrawElement, + ExcalidrawElementType, + ExcalidrawLinearElement, + ExcalidrawSelectionElement, + ExcalidrawTextElement, + FixedPointBinding, + FontFamilyValues, + NonDeletedSceneElementsMap, + OrderedExcalidrawElement, + PointBinding, + StrokeRoundness, +} from "../element/types"; +import type { AppState, BinaryFiles, LibraryItem } from "../types"; +import type { ImportedDataState, LegacyAppState } from "./types"; +import { + getNonDeletedElements, + getNormalizedDimensions, + isInvisiblySmallElement, + refreshTextDimensions, +} from "../element"; +import { + isArrowElement, + isElbowArrow, + isFixedPointBinding, + isLinearElement, + isTextElement, + isUsingAdaptiveRadius, +} from "../element/typeChecks"; +import { randomId } from "../random"; +import { + DEFAULT_FONT_FAMILY, + DEFAULT_TEXT_ALIGN, + DEFAULT_VERTICAL_ALIGN, + FONT_FAMILY, + ROUNDNESS, + DEFAULT_SIDEBAR, + DEFAULT_ELEMENT_PROPS, + DEFAULT_GRID_SIZE, + DEFAULT_GRID_STEP, +} from "../constants"; +import { getDefaultAppState } from "../appState"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import { bumpVersion } from "../element/mutateElement"; +import { getUpdatedTimestamp, updateActiveTool } from "../utils"; +import { arrayToMap } from "../utils"; +import type { MarkOptional, Mutable } from "../utility-types"; +import { getContainerElement } from "../element/textElement"; +import { normalizeLink } from "./url"; +import { syncInvalidIndices } from "../fractionalIndex"; +import { getSizeFromPoints } from "../points"; +import { getLineHeight } from "../fonts"; +import { normalizeFixedPoint } from "../element/binding"; +import { + getNormalizedGridSize, + getNormalizedGridStep, + getNormalizedZoom, +} from "../scene"; +import type { LocalPoint, Radians } from "@excalidraw/math"; +import { isFiniteNumber, pointFrom } from "@excalidraw/math"; +import { detectLineHeight } from "../element/textMeasurements"; +import { + updateElbowArrowPoints, + validateElbowPoints, +} from "../element/elbowArrow"; + +type RestoredAppState = Omit< + AppState, + "offsetTop" | "offsetLeft" | "width" | "height" +>; + +export const AllowedExcalidrawActiveTools: Record< + AppState["activeTool"]["type"], + boolean +> = { + selection: true, + text: true, + rectangle: true, + diamond: true, + ellipse: true, + line: true, + image: true, + arrow: true, + freedraw: true, + eraser: false, + custom: true, + frame: true, + embeddable: true, + hand: true, + laser: false, + magicframe: false, +}; + +export type RestoredDataState = { + elements: OrderedExcalidrawElement[]; + appState: RestoredAppState; + files: BinaryFiles; +}; + +const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => { + if (Object.keys(FONT_FAMILY).includes(fontFamilyName)) { + return FONT_FAMILY[ + fontFamilyName as keyof typeof FONT_FAMILY + ] as FontFamilyValues; + } + return DEFAULT_FONT_FAMILY; +}; + +const repairBinding = <T extends ExcalidrawLinearElement>( + element: T, + binding: PointBinding | FixedPointBinding | null, +): T extends ExcalidrawElbowArrowElement + ? FixedPointBinding | null + : PointBinding | FixedPointBinding | null => { + if (!binding) { + return null; + } + + const focus = binding.focus || 0; + + if (isElbowArrow(element)) { + const fixedPointBinding: + | ExcalidrawElbowArrowElement["startBinding"] + | ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding) + ? { + ...binding, + focus, + fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]), + } + : null; + + return fixedPointBinding; + } + + return { + ...binding, + focus, + } as T extends ExcalidrawElbowArrowElement + ? FixedPointBinding | null + : PointBinding | FixedPointBinding | null; +}; + +const restoreElementWithProperties = < + T extends Required<Omit<ExcalidrawElement, "customData">> & { + customData?: ExcalidrawElement["customData"]; + /** @deprecated */ + boundElementIds?: readonly ExcalidrawElement["id"][]; + /** @deprecated */ + strokeSharpness?: StrokeRoundness; + }, + K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>, +>( + element: T, + extra: Pick< + T, + // This extra Pick<T, keyof K> ensure no excess properties are passed. + // @ts-ignore TS complains here but type checks the call sites fine. + keyof K + > & + Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>, +): T => { + const base: Pick<T, keyof ExcalidrawElement> = { + type: extra.type || element.type, + // all elements must have version > 0 so getSceneVersion() will pick up + // newly added elements + version: element.version || 1, + versionNonce: element.versionNonce ?? 0, + index: element.index ?? null, + isDeleted: element.isDeleted ?? false, + id: element.id || randomId(), + fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle, + strokeWidth: element.strokeWidth || DEFAULT_ELEMENT_PROPS.strokeWidth, + strokeStyle: element.strokeStyle ?? DEFAULT_ELEMENT_PROPS.strokeStyle, + roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness, + opacity: + element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity, + angle: element.angle || (0 as Radians), + x: extra.x ?? element.x ?? 0, + y: extra.y ?? element.y ?? 0, + strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor, + backgroundColor: + element.backgroundColor || DEFAULT_ELEMENT_PROPS.backgroundColor, + width: element.width || 0, + height: element.height || 0, + seed: element.seed ?? 1, + groupIds: element.groupIds ?? [], + frameId: element.frameId ?? null, + roundness: element.roundness + ? element.roundness + : element.strokeSharpness === "round" + ? { + // for old elements that would now use adaptive radius algo, + // use legacy algo instead + type: isUsingAdaptiveRadius(element.type) + ? ROUNDNESS.LEGACY + : ROUNDNESS.PROPORTIONAL_RADIUS, + } + : null, + boundElements: element.boundElementIds + ? element.boundElementIds.map((id) => ({ type: "arrow", id })) + : element.boundElements ?? [], + updated: element.updated ?? getUpdatedTimestamp(), + link: element.link ? normalizeLink(element.link) : null, + locked: element.locked ?? false, + }; + + if ("customData" in element || "customData" in extra) { + base.customData = + "customData" in extra ? extra.customData : element.customData; + } + + return { + // spread the original element properties to not lose unknown ones + // for forward-compatibility + ...element, + // normalized properties + ...base, + ...getNormalizedDimensions(base), + ...extra, + } as unknown as T; +}; + +const restoreElement = ( + element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>, +): typeof element | null => { + element = { ...element }; + + switch (element.type) { + case "text": + // temp fix: cleanup legacy obsidian-excalidraw attribute else it'll + // conflict when porting between the apps + delete (element as any).rawText; + + let fontSize = element.fontSize; + let fontFamily = element.fontFamily; + if ("font" in element) { + const [fontPx, _fontFamily]: [string, string] = ( + element as any + ).font.split(" "); + fontSize = parseFloat(fontPx); + fontFamily = getFontFamilyByName(_fontFamily); + } + const text = (typeof element.text === "string" && element.text) || ""; + + // line-height might not be specified either when creating elements + // programmatically, or when importing old diagrams. + // For the latter we want to detect the original line height which + // will likely differ from our per-font fixed line height we now use, + // to maintain backward compatibility. + const lineHeight = + element.lineHeight || + (element.height + ? // detect line-height from current element height and font-size + detectLineHeight(element) + : // no element height likely means programmatic use, so default + // to a fixed line height + getLineHeight(element.fontFamily)); + element = restoreElementWithProperties(element, { + fontSize, + fontFamily, + text, + textAlign: element.textAlign || DEFAULT_TEXT_ALIGN, + verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, + containerId: element.containerId ?? null, + originalText: element.originalText || text, + autoResize: element.autoResize ?? true, + lineHeight, + }); + + // if empty text, mark as deleted. We keep in array + // for data integrity purposes (collab etc.) + if (!text && !element.isDeleted) { + element = { ...element, originalText: text, isDeleted: true }; + element = bumpVersion(element); + } + + return element; + case "freedraw": { + return restoreElementWithProperties(element, { + points: element.points, + lastCommittedPoint: null, + simulatePressure: element.simulatePressure, + pressures: element.pressures, + }); + } + case "image": + return restoreElementWithProperties(element, { + status: element.status || "pending", + fileId: element.fileId, + scale: element.scale || [1, 1], + crop: element.crop ?? null, + }); + case "line": + // @ts-ignore LEGACY type + // eslint-disable-next-line no-fallthrough + case "draw": + const { startArrowhead = null, endArrowhead = null } = element; + let x = element.x; + let y = element.y; + let points = // migrate old arrow model to new one + !Array.isArray(element.points) || element.points.length < 2 + ? [pointFrom(0, 0), pointFrom(element.width, element.height)] + : element.points; + + if (points[0][0] !== 0 || points[0][1] !== 0) { + ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element)); + } + + return restoreElementWithProperties(element, { + type: + (element.type as ExcalidrawElementType | "draw") === "draw" + ? "line" + : element.type, + startBinding: repairBinding(element, element.startBinding), + endBinding: repairBinding(element, element.endBinding), + lastCommittedPoint: null, + startArrowhead, + endArrowhead, + points, + x, + y, + ...getSizeFromPoints(points), + }); + case "arrow": { + const { startArrowhead = null, endArrowhead = "arrow" } = element; + let x: number | undefined = element.x; + let y: number | undefined = element.y; + let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one + !Array.isArray(element.points) || element.points.length < 2 + ? [pointFrom(0, 0), pointFrom(element.width, element.height)] + : element.points; + + if (points[0][0] !== 0 || points[0][1] !== 0) { + ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element)); + } + + const base = { + type: element.type, + startBinding: repairBinding(element, element.startBinding), + endBinding: repairBinding(element, element.endBinding), + lastCommittedPoint: null, + startArrowhead, + endArrowhead, + points, + x, + y, + elbowed: (element as ExcalidrawArrowElement).elbowed, + ...getSizeFromPoints(points), + } as const; + + // TODO: Separate arrow from linear element + return isElbowArrow(element) + ? restoreElementWithProperties(element as ExcalidrawElbowArrowElement, { + ...base, + elbowed: true, + startBinding: repairBinding(element, element.startBinding), + endBinding: repairBinding(element, element.endBinding), + fixedSegments: element.fixedSegments, + startIsSpecial: element.startIsSpecial, + endIsSpecial: element.endIsSpecial, + }) + : restoreElementWithProperties(element as ExcalidrawArrowElement, base); + } + + // generic elements + case "ellipse": + case "rectangle": + case "diamond": + case "iframe": + case "embeddable": + return restoreElementWithProperties(element, {}); + case "magicframe": + case "frame": + return restoreElementWithProperties(element, { + name: element.name ?? null, + }); + + // Don't use default case so as to catch a missing an element type case. + // We also don't want to throw, but instead return void so we filter + // out these unsupported elements from the restored array. + } + return null; +}; + +/** + * Repairs container element's boundElements array by removing duplicates and + * fixing containerId of bound elements if not present. Also removes any + * bound elements that do not exist in the elements array. + * + * NOTE mutates elements. + */ +const repairContainerElement = ( + container: Mutable<ExcalidrawElement>, + elementsMap: Map<string, Mutable<ExcalidrawElement>>, +) => { + if (container.boundElements) { + // copy because we're not cloning on restore, and we don't want to mutate upstream + const boundElements = container.boundElements.slice(); + + // dedupe bindings & fix boundElement.containerId if not set already + const boundIds = new Set<ExcalidrawElement["id"]>(); + container.boundElements = boundElements.reduce( + ( + acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>, + binding, + ) => { + const boundElement = elementsMap.get(binding.id); + if (boundElement && !boundIds.has(binding.id)) { + boundIds.add(binding.id); + + if (boundElement.isDeleted) { + return acc; + } + + acc.push(binding); + + if ( + isTextElement(boundElement) && + // being slightly conservative here, preserving existing containerId + // if defined, lest boundElements is stale + !boundElement.containerId + ) { + (boundElement as Mutable<ExcalidrawTextElement>).containerId = + container.id; + } + } + return acc; + }, + [], + ); + } +}; + +/** + * Repairs target bound element's container's boundElements array, + * or removes contaienrId if container does not exist. + * + * NOTE mutates elements. + */ +const repairBoundElement = ( + boundElement: Mutable<ExcalidrawTextElement>, + elementsMap: Map<string, Mutable<ExcalidrawElement>>, +) => { + const container = boundElement.containerId + ? elementsMap.get(boundElement.containerId) + : null; + + if (!container) { + boundElement.containerId = null; + return; + } + + if (boundElement.isDeleted) { + return; + } + + if ( + container.boundElements && + !container.boundElements.find((binding) => binding.id === boundElement.id) + ) { + // copy because we're not cloning on restore, and we don't want to mutate upstream + const boundElements = ( + container.boundElements || (container.boundElements = []) + ).slice(); + boundElements.push({ type: "text", id: boundElement.id }); + container.boundElements = boundElements; + } +}; + +/** + * Remove an element's frameId if its containing frame is non-existent + * + * NOTE mutates elements. + */ +const repairFrameMembership = ( + element: Mutable<ExcalidrawElement>, + elementsMap: Map<string, Mutable<ExcalidrawElement>>, +) => { + if (element.frameId) { + const containingFrame = elementsMap.get(element.frameId); + + if (!containingFrame) { + element.frameId = null; + } + } +}; + +export const restoreElements = ( + elements: ImportedDataState["elements"], + /** NOTE doesn't serve for reconciliation */ + localElements: readonly ExcalidrawElement[] | null | undefined, + opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined, +): OrderedExcalidrawElement[] => { + // used to detect duplicate top-level element ids + const existingIds = new Set<string>(); + const localElementsMap = localElements ? arrayToMap(localElements) : null; + const restoredElements = syncInvalidIndices( + (elements || []).reduce((elements, element) => { + // filtering out selection, which is legacy, no longer kept in elements, + // and causing issues if retained + if (element.type !== "selection" && !isInvisiblySmallElement(element)) { + let migratedElement: ExcalidrawElement | null = restoreElement(element); + if (migratedElement) { + const localElement = localElementsMap?.get(element.id); + if (localElement && localElement.version > migratedElement.version) { + migratedElement = bumpVersion( + migratedElement, + localElement.version, + ); + } + if (existingIds.has(migratedElement.id)) { + migratedElement = { ...migratedElement, id: randomId() }; + } + existingIds.add(migratedElement.id); + + elements.push(migratedElement); + } + } + return elements; + }, [] as ExcalidrawElement[]), + ); + + if (!opts?.repairBindings) { + return restoredElements; + } + + // repair binding. Mutates elements. + const restoredElementsMap = arrayToMap(restoredElements); + for (const element of restoredElements) { + if (element.frameId) { + repairFrameMembership(element, restoredElementsMap); + } + + if (isTextElement(element) && element.containerId) { + repairBoundElement(element, restoredElementsMap); + } else if (element.boundElements) { + repairContainerElement(element, restoredElementsMap); + } + + if (opts.refreshDimensions && isTextElement(element)) { + Object.assign( + element, + refreshTextDimensions( + element, + getContainerElement(element, restoredElementsMap), + restoredElementsMap, + ), + ); + } + + if (isLinearElement(element)) { + if ( + element.startBinding && + (!restoredElementsMap.has(element.startBinding.elementId) || + !isArrowElement(element)) + ) { + (element as Mutable<ExcalidrawLinearElement>).startBinding = null; + } + if ( + element.endBinding && + (!restoredElementsMap.has(element.endBinding.elementId) || + !isArrowElement(element)) + ) { + (element as Mutable<ExcalidrawLinearElement>).endBinding = null; + } + } + } + + // NOTE (mtolmacs): Temporary fix for extremely large arrows + // Need to iterate again so we have attached text nodes in elementsMap + return restoredElements.map((element) => { + if ( + isElbowArrow(element) && + element.startBinding == null && + element.endBinding == null && + !validateElbowPoints(element.points) + ) { + return { + ...element, + ...updateElbowArrowPoints( + element, + restoredElementsMap as NonDeletedSceneElementsMap, + { + points: [ + pointFrom<LocalPoint>(0, 0), + element.points[element.points.length - 1], + ], + }, + ), + index: element.index, + }; + } + + if ( + isElbowArrow(element) && + element.startBinding && + element.endBinding && + element.startBinding.elementId === element.endBinding.elementId && + element.points.length > 1 && + element.points.some( + ([rx, ry]) => Math.abs(rx) > 1e6 || Math.abs(ry) > 1e6, + ) + ) { + console.error("Fixing self-bound elbow arrow", element.id); + const boundElement = restoredElementsMap.get( + element.startBinding.elementId, + ); + if (!boundElement) { + console.error( + "Bound element not found", + element.startBinding.elementId, + ); + return element; + } + + return { + ...element, + x: boundElement.x + boundElement.width / 2, + y: boundElement.y - 5, + width: boundElement.width, + height: boundElement.height, + points: [ + pointFrom<LocalPoint>(0, 0), + pointFrom<LocalPoint>(0, -10), + pointFrom<LocalPoint>(boundElement.width / 2 + 5, -10), + pointFrom<LocalPoint>( + boundElement.width / 2 + 5, + boundElement.height / 2 + 5, + ), + ], + }; + } + + return element; + }); +}; + +const coalesceAppStateValue = < + T extends keyof ReturnType<typeof getDefaultAppState>, +>( + key: T, + appState: Exclude<ImportedDataState["appState"], null | undefined>, + defaultAppState: ReturnType<typeof getDefaultAppState>, +) => { + const value = appState[key]; + // NOTE the value! assertion is needed in TS 4.5.5 (fixed in newer versions) + return value !== undefined ? value! : defaultAppState[key]; +}; + +const LegacyAppStateMigrations: { + [K in keyof LegacyAppState]: ( + ImportedDataState: Exclude<ImportedDataState["appState"], null | undefined>, + defaultAppState: ReturnType<typeof getDefaultAppState>, + ) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]]; +} = { + isSidebarDocked: (appState, defaultAppState) => { + return [ + "defaultSidebarDockedPreference", + appState.isSidebarDocked ?? + coalesceAppStateValue( + "defaultSidebarDockedPreference", + appState, + defaultAppState, + ), + ]; + }, +}; + +export const restoreAppState = ( + appState: ImportedDataState["appState"], + localAppState: Partial<AppState> | null | undefined, +): RestoredAppState => { + appState = appState || {}; + const defaultAppState = getDefaultAppState(); + const nextAppState = {} as typeof defaultAppState; + + // first, migrate all legacy AppState properties to new ones. We do it + // in one go before migrate the rest of the properties in case the new ones + // depend on checking any other key (i.e. they are coupled) + for (const legacyKey of Object.keys( + LegacyAppStateMigrations, + ) as (keyof typeof LegacyAppStateMigrations)[]) { + if (legacyKey in appState) { + const [nextKey, nextValue] = LegacyAppStateMigrations[legacyKey]( + appState, + defaultAppState, + ); + (nextAppState as any)[nextKey] = nextValue; + } + } + + for (const [key, defaultValue] of Object.entries(defaultAppState) as [ + keyof typeof defaultAppState, + any, + ][]) { + // if AppState contains a legacy key, prefer that one and migrate its + // value to the new one + const suppliedValue = appState[key]; + + const localValue = localAppState ? localAppState[key] : undefined; + (nextAppState as any)[key] = + suppliedValue !== undefined + ? suppliedValue + : localValue !== undefined + ? localValue + : defaultValue; + } + + return { + ...nextAppState, + cursorButton: localAppState?.cursorButton || "up", + // reset on fresh restore so as to hide the UI button if penMode not active + penDetected: + localAppState?.penDetected ?? + (appState.penMode ? appState.penDetected ?? false : false), + activeTool: { + ...updateActiveTool( + defaultAppState, + nextAppState.activeTool.type && + AllowedExcalidrawActiveTools[nextAppState.activeTool.type] + ? nextAppState.activeTool + : { type: "selection" }, + ), + lastActiveTool: null, + locked: nextAppState.activeTool.locked ?? false, + }, + // Migrates from previous version where appState.zoom was a number + zoom: { + value: getNormalizedZoom( + isFiniteNumber(appState.zoom) + ? appState.zoom + : appState.zoom?.value ?? defaultAppState.zoom.value, + ), + }, + openSidebar: + // string (legacy) + typeof (appState.openSidebar as any as string) === "string" + ? { name: DEFAULT_SIDEBAR.name } + : nextAppState.openSidebar, + gridSize: getNormalizedGridSize( + isFiniteNumber(appState.gridSize) ? appState.gridSize : DEFAULT_GRID_SIZE, + ), + gridStep: getNormalizedGridStep( + isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP, + ), + editingFrame: null, + }; +}; + +export const restore = ( + data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null, + /** + * Local AppState (`this.state` or initial state from localStorage) so that we + * don't overwrite local state with default values (when values not + * explicitly specified). + * Supply `null` if you can't get access to it. + */ + localAppState: Partial<AppState> | null | undefined, + localElements: readonly ExcalidrawElement[] | null | undefined, + elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean }, +): RestoredDataState => { + return { + elements: restoreElements(data?.elements, localElements, elementsConfig), + appState: restoreAppState(data?.appState, localAppState || null), + files: data?.files || {}, + }; +}; + +const restoreLibraryItem = (libraryItem: LibraryItem) => { + const elements = restoreElements( + getNonDeletedElements(libraryItem.elements), + null, + ); + return elements.length ? { ...libraryItem, elements } : null; +}; + +export const restoreLibraryItems = ( + libraryItems: ImportedDataState["libraryItems"] = [], + defaultStatus: LibraryItem["status"], +) => { + const restoredItems: LibraryItem[] = []; + for (const item of libraryItems) { + // migrate older libraries + if (Array.isArray(item)) { + const restoredItem = restoreLibraryItem({ + status: defaultStatus, + elements: item, + id: randomId(), + created: Date.now(), + }); + if (restoredItem) { + restoredItems.push(restoredItem); + } + } else { + const _item = item as MarkOptional< + LibraryItem, + "id" | "status" | "created" + >; + const restoredItem = restoreLibraryItem({ + ..._item, + id: _item.id || randomId(), + status: _item.status || defaultStatus, + created: _item.created || Date.now(), + }); + if (restoredItem) { + restoredItems.push(restoredItem); + } + } + } + return restoredItems; +}; diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts new file mode 100644 index 0000000..a36af0c --- /dev/null +++ b/packages/excalidraw/data/transform.test.ts @@ -0,0 +1,970 @@ +import { vi } from "vitest"; +import type { ExcalidrawElementSkeleton } from "./transform"; +import { convertToExcalidrawElements } from "./transform"; +import type { ExcalidrawArrowElement } from "../element/types"; +import { pointFrom } from "@excalidraw/math"; + +const opts = { regenerateIds: false }; + +describe("Test Transform", () => { + it("should generate id unless opts.regenerateIds is set to false explicitly", () => { + const elements = [ + { + type: "rectangle", + x: 100, + y: 100, + id: "rect-1", + }, + ]; + let data = convertToExcalidrawElements( + elements as ExcalidrawElementSkeleton[], + ); + expect(data.length).toBe(1); + expect(data[0].id).toBe("id0"); + + data = convertToExcalidrawElements( + elements as ExcalidrawElementSkeleton[], + opts, + ); + expect(data[0].id).toBe("rect-1"); + }); + + it("should transform regular shapes", () => { + const elements = [ + { + type: "rectangle", + x: 100, + y: 100, + }, + { + type: "ellipse", + x: 100, + y: 250, + }, + { + type: "diamond", + x: 100, + y: 400, + }, + { + type: "rectangle", + x: 300, + y: 100, + width: 200, + height: 100, + backgroundColor: "#c0eb75", + strokeWidth: 2, + }, + { + type: "ellipse", + x: 300, + y: 250, + width: 200, + height: 100, + backgroundColor: "#ffc9c9", + strokeStyle: "dotted", + fillStyle: "solid", + strokeWidth: 2, + }, + { + type: "diamond", + x: 300, + y: 400, + width: 200, + height: 100, + backgroundColor: "#a5d8ff", + strokeColor: "#1971c2", + strokeStyle: "dashed", + fillStyle: "cross-hatch", + strokeWidth: 2, + }, + ]; + + convertToExcalidrawElements( + elements as ExcalidrawElementSkeleton[], + opts, + ).forEach((ele) => { + expect(ele).toMatchSnapshot({ + seed: expect.any(Number), + versionNonce: expect.any(Number), + id: expect.any(String), + }); + }); + }); + + it("should transform text element", () => { + const elements = [ + { + type: "text", + x: 100, + y: 100, + text: "HELLO WORLD!", + }, + { + type: "text", + x: 100, + y: 150, + text: "STYLED HELLO WORLD!", + fontSize: 20, + strokeColor: "#5f3dc4", + }, + ]; + convertToExcalidrawElements( + elements as ExcalidrawElementSkeleton[], + opts, + ).forEach((ele) => { + expect(ele).toMatchSnapshot({ + seed: expect.any(Number), + versionNonce: expect.any(Number), + id: expect.any(String), + }); + }); + }); + + it("should transform linear elements", () => { + const elements = [ + { + type: "arrow", + x: 100, + y: 20, + }, + { + type: "arrow", + x: 450, + y: 20, + startArrowhead: "dot", + endArrowhead: "triangle", + strokeColor: "#1971c2", + strokeWidth: 2, + }, + { + type: "line", + x: 100, + y: 60, + }, + { + type: "line", + x: 450, + y: 60, + strokeColor: "#2f9e44", + strokeWidth: 2, + strokeStyle: "dotted", + }, + ]; + const excalidrawElements = convertToExcalidrawElements( + elements as ExcalidrawElementSkeleton[], + opts, + ); + + expect(excalidrawElements.length).toBe(4); + + excalidrawElements.forEach((ele) => { + expect(ele).toMatchSnapshot({ + seed: expect.any(Number), + versionNonce: expect.any(Number), + id: expect.any(String), + }); + }); + }); + + it("should transform to text containers when label provided", () => { + const elements = [ + { + type: "rectangle", + x: 100, + y: 100, + label: { + text: "RECTANGLE TEXT CONTAINER", + }, + }, + { + type: "ellipse", + x: 500, + y: 100, + width: 200, + label: { + text: "ELLIPSE TEXT CONTAINER", + }, + }, + { + type: "diamond", + x: 100, + y: 150, + width: 280, + label: { + text: "DIAMOND\nTEXT CONTAINER", + }, + }, + { + type: "diamond", + x: 100, + y: 400, + width: 300, + backgroundColor: "#fff3bf", + strokeWidth: 2, + label: { + text: "STYLED DIAMOND TEXT CONTAINER", + strokeColor: "#099268", + fontSize: 20, + }, + }, + { + type: "rectangle", + x: 500, + y: 300, + width: 200, + strokeColor: "#c2255c", + label: { + text: "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER", + textAlign: "left", + verticalAlign: "top", + fontSize: 20, + }, + }, + { + type: "ellipse", + x: 500, + y: 500, + strokeColor: "#f08c00", + backgroundColor: "#ffec99", + width: 200, + label: { + text: "STYLED ELLIPSE TEXT CONTAINER", + strokeColor: "#c2255c", + }, + }, + ]; + const excalidrawElements = convertToExcalidrawElements( + elements as ExcalidrawElementSkeleton[], + opts, + ); + + expect(excalidrawElements.length).toBe(12); + + excalidrawElements.forEach((ele) => { + expect(ele).toMatchSnapshot({ + seed: expect.any(Number), + versionNonce: expect.any(Number), + id: expect.any(String), + }); + }); + }); + + it("should transform to labelled arrows when label provided for arrows", () => { + const elements = [ + { + type: "arrow", + x: 100, + y: 100, + label: { + text: "LABELED ARROW", + }, + }, + { + type: "arrow", + x: 100, + y: 200, + label: { + text: "STYLED LABELED ARROW", + strokeColor: "#099268", + fontSize: 20, + }, + }, + { + type: "arrow", + x: 100, + y: 300, + strokeColor: "#1098ad", + strokeWidth: 2, + label: { + text: "ANOTHER STYLED LABELLED ARROW", + }, + }, + { + type: "arrow", + x: 100, + y: 400, + strokeColor: "#1098ad", + strokeWidth: 2, + label: { + text: "ANOTHER STYLED LABELLED ARROW", + strokeColor: "#099268", + }, + }, + ]; + const excalidrawElements = convertToExcalidrawElements( + elements as ExcalidrawElementSkeleton[], + opts, + ); + + expect(excalidrawElements.length).toBe(8); + + excalidrawElements.forEach((ele) => { + expect(ele).toMatchSnapshot({ + seed: expect.any(Number), + versionNonce: expect.any(Number), + id: expect.any(String), + }); + }); + }); + + describe("Test Frames", () => { + const elements: ExcalidrawElementSkeleton[] = [ + { + type: "rectangle", + x: 10, + y: 10, + strokeWidth: 2, + id: "1", + }, + { + type: "diamond", + x: 120, + y: 20, + backgroundColor: "#fff3bf", + strokeWidth: 2, + label: { + text: "HELLO EXCALIDRAW", + strokeColor: "#099268", + fontSize: 30, + }, + id: "2", + }, + ]; + + it("should transform frames and update frame ids when regenerated", () => { + const elementsSkeleton: ExcalidrawElementSkeleton[] = [ + ...elements, + { + type: "frame", + children: ["1", "2"], + name: "My frame", + }, + ]; + const excalidrawElements = convertToExcalidrawElements( + elementsSkeleton, + opts, + ); + expect(excalidrawElements.length).toBe(4); + + excalidrawElements.forEach((ele) => { + expect(ele).toMatchObject({ + seed: expect.any(Number), + versionNonce: expect.any(Number), + id: expect.any(String), + }); + }); + }); + + it("should consider user defined frame dimensions over calculated when provided", () => { + const elementsSkeleton: ExcalidrawElementSkeleton[] = [ + ...elements, + { + type: "frame", + children: ["1", "2"], + name: "My frame", + width: 800, + height: 100, + }, + ]; + const excalidrawElements = convertToExcalidrawElements( + elementsSkeleton, + opts, + ); + const frame = excalidrawElements.find((ele) => ele.type === "frame")!; + expect(frame.width).toBe(800); + expect(frame.height).toBe(100); + }); + + it("should consider user defined frame coordinates calculated when provided", () => { + const elementsSkeleton: ExcalidrawElementSkeleton[] = [ + ...elements, + { + type: "frame", + children: ["1", "2"], + name: "My frame", + x: 100, + y: 300, + }, + ]; + const excalidrawElements = convertToExcalidrawElements( + elementsSkeleton, + opts, + ); + const frame = excalidrawElements.find((ele) => ele.type === "frame")!; + expect(frame.x).toBe(100); + expect(frame.y).toBe(300); + }); + }); + + describe("Test arrow bindings", () => { + it("should bind arrows to shapes when start / end provided without ids", () => { + const elements = [ + { + type: "arrow", + x: 255, + y: 239, + label: { + text: "HELLO WORLD!!", + }, + start: { + type: "rectangle", + }, + end: { + type: "ellipse", + }, + }, + ]; + const excalidrawElements = convertToExcalidrawElements( + elements as ExcalidrawElementSkeleton[], + opts, + ); + + expect(excalidrawElements.length).toBe(4); + const [arrow, text, rectangle, ellipse] = excalidrawElements; + expect(arrow).toMatchObject({ + type: "arrow", + x: 255, + y: 239, + boundElements: [{ id: text.id, type: "text" }], + startBinding: { + elementId: rectangle.id, + focus: 0, + gap: 1, + }, + endBinding: { + elementId: ellipse.id, + focus: -0, + }, + }); + + expect(text).toMatchObject({ + x: 240, + y: 226.5, + type: "text", + text: "HELLO WORLD!!", + containerId: arrow.id, + }); + + expect(rectangle).toMatchObject({ + x: 155, + y: 189, + type: "rectangle", + boundElements: [ + { + id: arrow.id, + type: "arrow", + }, + ], + }); + + expect(ellipse).toMatchObject({ + x: 355, + y: 189, + type: "ellipse", + boundElements: [ + { + id: arrow.id, + type: "arrow", + }, + ], + }); + + excalidrawElements.forEach((ele) => { + expect(ele).toMatchSnapshot({ + seed: expect.any(Number), + versionNonce: expect.any(Number), + id: expect.any(String), + }); + }); + }); + + it("should bind arrows to text when start / end provided without ids", () => { + const elements = [ + { + type: "arrow", + x: 255, + y: 239, + label: { + text: "HELLO WORLD!!", + }, + start: { + type: "text", + text: "HEYYYYY", + }, + end: { + type: "text", + text: "WHATS UP ?", + }, + }, + ]; + + const excalidrawElements = convertToExcalidrawElements( + elements as ExcalidrawElementSkeleton[], + opts, + ); + + expect(excalidrawElements.length).toBe(4); + const [arrow, text1, text2, text3] = excalidrawElements; + + expect(arrow).toMatchObject({ + type: "arrow", + x: 255, + y: 239, + boundElements: [{ id: text1.id, type: "text" }], + startBinding: { + elementId: text2.id, + focus: 0, + gap: 1, + }, + endBinding: { + elementId: text3.id, + focus: -0, + }, + }); + + expect(text1).toMatchObject({ + x: 240, + y: 226.5, + type: "text", + text: "HELLO WORLD!!", + containerId: arrow.id, + }); + + expect(text2).toMatchObject({ + x: 185, + y: 226.5, + type: "text", + boundElements: [ + { + id: arrow.id, + type: "arrow", + }, + ], + }); + + expect(text3).toMatchObject({ + x: 355, + y: 226.5, + type: "text", + boundElements: [ + { + id: arrow.id, + type: "arrow", + }, + ], + }); + + excalidrawElements.forEach((ele) => { + expect(ele).toMatchSnapshot({ + seed: expect.any(Number), + versionNonce: expect.any(Number), + id: expect.any(String), + }); + }); + }); + + it("should bind arrows to existing shapes when start / end provided with ids", () => { + const elements = [ + { + type: "ellipse", + id: "ellipse-1", + strokeColor: "#66a80f", + x: 630, + y: 316, + width: 300, + height: 300, + backgroundColor: "#d8f5a2", + }, + { + type: "diamond", + id: "diamond-1", + strokeColor: "#9c36b5", + width: 140, + x: 96, + y: 400, + }, + { + type: "arrow", + x: 247, + y: 420, + width: 395, + height: 35, + strokeColor: "#1864ab", + start: { + type: "rectangle", + width: 300, + height: 300, + }, + end: { + id: "ellipse-1", + }, + }, + { + type: "arrow", + x: 227, + y: 450, + width: 400, + strokeColor: "#e67700", + start: { + id: "diamond-1", + }, + end: { + id: "ellipse-1", + }, + }, + ]; + + const excalidrawElements = convertToExcalidrawElements( + elements as ExcalidrawElementSkeleton[], + opts, + ); + + expect(excalidrawElements.length).toBe(5); + + excalidrawElements.forEach((ele) => { + expect(ele).toMatchSnapshot({ + seed: expect.any(Number), + versionNonce: expect.any(Number), + id: expect.any(String), + }); + }); + }); + + it("should bind arrows to existing text elements when start / end provided with ids", () => { + const elements = [ + { + x: 100, + y: 239, + type: "text", + text: "HEYYYYY", + id: "text-1", + strokeColor: "#c2255c", + }, + { + type: "text", + id: "text-2", + x: 560, + y: 239, + text: "Whats up ?", + }, + { + type: "arrow", + x: 255, + y: 239, + label: { + text: "HELLO WORLD!!", + }, + start: { + id: "text-1", + }, + end: { + id: "text-2", + }, + }, + ]; + + const excalidrawElements = convertToExcalidrawElements( + elements as ExcalidrawElementSkeleton[], + opts, + ); + + expect(excalidrawElements.length).toBe(4); + + excalidrawElements.forEach((ele) => { + expect(ele).toMatchSnapshot({ + seed: expect.any(Number), + versionNonce: expect.any(Number), + id: expect.any(String), + }); + }); + }); + + it("should bind arrows to existing elements if ids are correct", () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementationOnce(() => void 0); + const elements = [ + { + x: 100, + y: 239, + type: "text", + text: "HEYYYYY", + id: "text-1", + strokeColor: "#c2255c", + }, + { + type: "rectangle", + x: 560, + y: 139, + id: "rect-1", + width: 100, + height: 200, + backgroundColor: "#bac8ff", + }, + { + type: "arrow", + x: 255, + y: 239, + label: { + text: "HELLO WORLD!!", + }, + start: { + id: "text-13", + }, + end: { + id: "rect-11", + }, + }, + ]; + + const excalidrawElements = convertToExcalidrawElements( + elements as ExcalidrawElementSkeleton[], + opts, + ); + + expect(excalidrawElements.length).toBe(4); + const [, , arrow, text] = excalidrawElements; + expect(arrow).toMatchObject({ + type: "arrow", + x: 255, + y: 239, + boundElements: [ + { + id: text.id, + type: "text", + }, + ], + startBinding: null, + endBinding: null, + }); + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + expect(consoleErrorSpy).toHaveBeenNthCalledWith( + 1, + "No element for start binding with id text-13 found", + ); + expect(consoleErrorSpy).toHaveBeenNthCalledWith( + 2, + "No element for end binding with id rect-11 found", + ); + }); + + it("should bind when ids referenced before the element data", () => { + const elements = [ + { + type: "arrow", + x: 255, + y: 239, + end: { + id: "rect-1", + }, + }, + { + type: "rectangle", + x: 560, + y: 139, + id: "rect-1", + width: 100, + height: 200, + backgroundColor: "#bac8ff", + }, + ]; + const excalidrawElements = convertToExcalidrawElements( + elements as ExcalidrawElementSkeleton[], + opts, + ); + expect(excalidrawElements.length).toBe(2); + const [arrow, rect] = excalidrawElements; + expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ + elementId: "rect-1", + focus: -0, + gap: 14, + }); + expect(rect.boundElements).toStrictEqual([ + { + id: arrow.id, + type: "arrow", + }, + ]); + }); + }); + + it("should not allow duplicate ids", () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementationOnce(() => void 0); + const elements = [ + { + type: "rectangle", + x: 300, + y: 100, + id: "rect-1", + width: 100, + height: 200, + }, + + { + type: "rectangle", + x: 100, + y: 200, + id: "rect-1", + width: 100, + height: 200, + }, + ]; + const excalidrawElements = convertToExcalidrawElements( + elements as ExcalidrawElementSkeleton[], + opts, + ); + + expect(excalidrawElements.length).toBe(1); + expect(excalidrawElements[0]).toMatchSnapshot({ + seed: expect.any(Number), + versionNonce: expect.any(Number), + }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Duplicate id found for rect-1", + ); + }); + + it("should contains customData if provided", () => { + const rawData = [ + { + type: "rectangle", + x: 100, + y: 100, + customData: { createdBy: "user01" }, + }, + ]; + const convertedElements = convertToExcalidrawElements( + rawData as ExcalidrawElementSkeleton[], + opts, + ); + expect(convertedElements[0].customData).toStrictEqual({ + createdBy: "user01", + }); + }); + + it("should transform the elements correctly when linear elements have single point", () => { + const elements: ExcalidrawElementSkeleton[] = [ + { + id: "B", + type: "rectangle", + groupIds: ["subgraph_group_B"], + x: 0, + y: 0, + width: 166.03125, + height: 163, + label: { + groupIds: ["subgraph_group_B"], + text: "B", + fontSize: 20, + verticalAlign: "top", + }, + }, + { + id: "A", + type: "rectangle", + groupIds: ["subgraph_group_A"], + x: 364.546875, + y: 0, + width: 120.265625, + height: 114, + label: { + groupIds: ["subgraph_group_A"], + text: "A", + fontSize: 20, + verticalAlign: "top", + }, + }, + { + id: "Alice", + type: "rectangle", + groupIds: ["subgraph_group_A"], + x: 389.546875, + y: 35, + width: 70.265625, + height: 44, + strokeWidth: 2, + label: { + groupIds: ["subgraph_group_A"], + text: "Alice", + fontSize: 20, + }, + link: null, + }, + { + id: "Bob", + type: "rectangle", + groupIds: ["subgraph_group_B"], + x: 54.76953125, + y: 35, + width: 56.4921875, + height: 44, + strokeWidth: 2, + label: { + groupIds: ["subgraph_group_B"], + text: "Bob", + fontSize: 20, + }, + link: null, + }, + { + id: "Bob_Alice", + type: "arrow", + groupIds: [], + x: 111.262, + y: 57, + strokeWidth: 2, + points: [pointFrom(0, 0), pointFrom(272.985, 0)], + label: { + text: "How are you?", + fontSize: 20, + groupIds: [], + }, + roundness: { + type: 2, + }, + start: { + id: "Bob", + }, + end: { + id: "Alice", + }, + }, + { + id: "Bob_B", + type: "arrow", + groupIds: [], + x: 77.017, + y: 79, + strokeWidth: 2, + points: [pointFrom(0, 0)], + label: { + text: "Friendship", + fontSize: 20, + groupIds: [], + }, + roundness: { + type: 2, + }, + start: { + id: "Bob", + }, + end: { + id: "B", + }, + }, + ]; + + const excalidrawElements = convertToExcalidrawElements(elements, opts); + expect(excalidrawElements.length).toBe(12); + excalidrawElements.forEach((ele) => { + expect(ele).toMatchSnapshot({ + seed: expect.any(Number), + versionNonce: expect.any(Number), + id: expect.any(String), + }); + }); + }); +}); diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts new file mode 100644 index 0000000..023740c --- /dev/null +++ b/packages/excalidraw/data/transform.ts @@ -0,0 +1,791 @@ +import { + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, + TEXT_ALIGN, + VERTICAL_ALIGN, +} from "../constants"; +import { + getCommonBounds, + newElement, + newLinearElement, + redrawTextBoundingBox, +} from "../element"; +import { bindLinearElement } from "../element/binding"; +import type { ElementConstructorOpts } from "../element/newElement"; +import { + newArrowElement, + newFrameElement, + newImageElement, + newMagicFrameElement, + newTextElement, +} from "../element/newElement"; +import type { + ElementsMap, + ExcalidrawArrowElement, + ExcalidrawBindableElement, + ExcalidrawElement, + ExcalidrawFrameElement, + ExcalidrawFreeDrawElement, + ExcalidrawGenericElement, + ExcalidrawIframeLikeElement, + ExcalidrawImageElement, + ExcalidrawLinearElement, + ExcalidrawMagicFrameElement, + ExcalidrawSelectionElement, + ExcalidrawTextElement, + FileId, + FontFamilyValues, + NonDeletedSceneElementsMap, + TextAlign, + VerticalAlign, +} from "../element/types"; +import type { MarkOptional } from "../utility-types"; +import { + arrayToMap, + assertNever, + cloneJSON, + getFontString, + isDevEnv, + toBrandedType, +} from "../utils"; +import { getSizeFromPoints } from "../points"; +import { randomId } from "../random"; +import { syncInvalidIndices } from "../fractionalIndex"; +import { getLineHeight } from "../fonts"; +import { isArrowElement } from "../element/typeChecks"; +import { pointFrom, type LocalPoint } from "@excalidraw/math"; +import { measureText, normalizeText } from "../element/textMeasurements"; + +export type ValidLinearElement = { + type: "arrow" | "line"; + x: number; + y: number; + label?: { + text: string; + fontSize?: number; + fontFamily?: FontFamilyValues; + textAlign?: TextAlign; + verticalAlign?: VerticalAlign; + } & MarkOptional<ElementConstructorOpts, "x" | "y">; + end?: + | ( + | ( + | { + type: Exclude< + ExcalidrawBindableElement["type"], + | "image" + | "text" + | "frame" + | "magicframe" + | "embeddable" + | "iframe" + >; + id?: ExcalidrawGenericElement["id"]; + } + | { + id: ExcalidrawGenericElement["id"]; + type?: Exclude< + ExcalidrawBindableElement["type"], + | "image" + | "text" + | "frame" + | "magicframe" + | "embeddable" + | "iframe" + >; + } + ) + | (( + | { + type: "text"; + text: string; + } + | { + type?: "text"; + id: ExcalidrawTextElement["id"]; + text: string; + } + ) & + Partial<ExcalidrawTextElement>) + ) & + MarkOptional<ElementConstructorOpts, "x" | "y">; + start?: + | ( + | ( + | { + type: Exclude< + ExcalidrawBindableElement["type"], + | "image" + | "text" + | "frame" + | "magicframe" + | "embeddable" + | "iframe" + >; + id?: ExcalidrawGenericElement["id"]; + } + | { + id: ExcalidrawGenericElement["id"]; + type?: Exclude< + ExcalidrawBindableElement["type"], + | "image" + | "text" + | "frame" + | "magicframe" + | "embeddable" + | "iframe" + >; + } + ) + | (( + | { + type: "text"; + text: string; + } + | { + type?: "text"; + id: ExcalidrawTextElement["id"]; + text: string; + } + ) & + Partial<ExcalidrawTextElement>) + ) & + MarkOptional<ElementConstructorOpts, "x" | "y">; +} & Partial<ExcalidrawLinearElement>; + +export type ValidContainer = + | { + type: Exclude<ExcalidrawGenericElement["type"], "selection">; + id?: ExcalidrawGenericElement["id"]; + label?: { + text: string; + fontSize?: number; + fontFamily?: FontFamilyValues; + textAlign?: TextAlign; + verticalAlign?: VerticalAlign; + } & MarkOptional<ElementConstructorOpts, "x" | "y">; + } & ElementConstructorOpts; + +export type ExcalidrawElementSkeleton = + | Extract< + Exclude<ExcalidrawElement, ExcalidrawSelectionElement>, + ExcalidrawIframeLikeElement | ExcalidrawFreeDrawElement + > + | ({ + type: Extract<ExcalidrawLinearElement["type"], "line">; + x: number; + y: number; + } & Partial<ExcalidrawLinearElement>) + | ValidContainer + | ValidLinearElement + | ({ + type: "text"; + text: string; + x: number; + y: number; + id?: ExcalidrawTextElement["id"]; + } & Partial<ExcalidrawTextElement>) + | ({ + type: Extract<ExcalidrawImageElement["type"], "image">; + x: number; + y: number; + fileId: FileId; + } & Partial<ExcalidrawImageElement>) + | ({ + type: "frame"; + children: readonly ExcalidrawElement["id"][]; + name?: string; + } & Partial<ExcalidrawFrameElement>) + | ({ + type: "magicframe"; + children: readonly ExcalidrawElement["id"][]; + name?: string; + } & Partial<ExcalidrawMagicFrameElement>); + +const DEFAULT_LINEAR_ELEMENT_PROPS = { + width: 100, + height: 0, +}; + +const DEFAULT_DIMENSION = 100; + +const bindTextToContainer = ( + container: ExcalidrawElement, + textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">, + elementsMap: ElementsMap, +) => { + const textElement: ExcalidrawTextElement = newTextElement({ + x: 0, + y: 0, + textAlign: TEXT_ALIGN.CENTER, + verticalAlign: VERTICAL_ALIGN.MIDDLE, + ...textProps, + containerId: container.id, + strokeColor: textProps.strokeColor || container.strokeColor, + }); + + Object.assign(container, { + boundElements: (container.boundElements || []).concat({ + type: "text", + id: textElement.id, + }), + }); + + redrawTextBoundingBox(textElement, container, elementsMap); + return [container, textElement] as const; +}; + +const bindLinearElementToElement = ( + linearElement: ExcalidrawArrowElement, + start: ValidLinearElement["start"], + end: ValidLinearElement["end"], + elementStore: ElementStore, + elementsMap: NonDeletedSceneElementsMap, +): { + linearElement: ExcalidrawLinearElement; + startBoundElement?: ExcalidrawElement; + endBoundElement?: ExcalidrawElement; +} => { + let startBoundElement; + let endBoundElement; + + Object.assign(linearElement, { + startBinding: linearElement?.startBinding || null, + endBinding: linearElement.endBinding || null, + }); + + if (start) { + const width = start?.width ?? DEFAULT_DIMENSION; + const height = start?.height ?? DEFAULT_DIMENSION; + + let existingElement; + if (start.id) { + existingElement = elementStore.getElement(start.id); + if (!existingElement) { + console.error(`No element for start binding with id ${start.id} found`); + } + } + + const startX = start.x || linearElement.x - width; + const startY = start.y || linearElement.y - height / 2; + const startType = existingElement ? existingElement.type : start.type; + + if (startType) { + if (startType === "text") { + let text = ""; + if (existingElement && existingElement.type === "text") { + text = existingElement.text; + } else if (start.type === "text") { + text = start.text; + } + if (!text) { + console.error( + `No text found for start binding text element for ${linearElement.id}`, + ); + } + startBoundElement = newTextElement({ + x: startX, + y: startY, + type: "text", + ...existingElement, + ...start, + text, + }); + // to position the text correctly when coordinates not provided + Object.assign(startBoundElement, { + x: start.x || linearElement.x - startBoundElement.width, + y: start.y || linearElement.y - startBoundElement.height / 2, + }); + } else { + switch (startType) { + case "rectangle": + case "ellipse": + case "diamond": { + startBoundElement = newElement({ + x: startX, + y: startY, + width, + height, + ...existingElement, + ...start, + type: startType, + }); + break; + } + default: { + assertNever( + linearElement as never, + `Unhandled element start type "${start.type}"`, + true, + ); + } + } + } + + bindLinearElement( + linearElement, + startBoundElement as ExcalidrawBindableElement, + "start", + elementsMap, + ); + } + } + if (end) { + const height = end?.height ?? DEFAULT_DIMENSION; + const width = end?.width ?? DEFAULT_DIMENSION; + + let existingElement; + if (end.id) { + existingElement = elementStore.getElement(end.id); + if (!existingElement) { + console.error(`No element for end binding with id ${end.id} found`); + } + } + const endX = end.x || linearElement.x + linearElement.width; + const endY = end.y || linearElement.y - height / 2; + const endType = existingElement ? existingElement.type : end.type; + + if (endType) { + if (endType === "text") { + let text = ""; + if (existingElement && existingElement.type === "text") { + text = existingElement.text; + } else if (end.type === "text") { + text = end.text; + } + + if (!text) { + console.error( + `No text found for end binding text element for ${linearElement.id}`, + ); + } + endBoundElement = newTextElement({ + x: endX, + y: endY, + type: "text", + ...existingElement, + ...end, + text, + }); + // to position the text correctly when coordinates not provided + Object.assign(endBoundElement, { + y: end.y || linearElement.y - endBoundElement.height / 2, + }); + } else { + switch (endType) { + case "rectangle": + case "ellipse": + case "diamond": { + endBoundElement = newElement({ + x: endX, + y: endY, + width, + height, + ...existingElement, + ...end, + type: endType, + }); + break; + } + default: { + assertNever( + linearElement as never, + `Unhandled element end type "${endType}"`, + true, + ); + } + } + } + + bindLinearElement( + linearElement, + endBoundElement as ExcalidrawBindableElement, + "end", + elementsMap, + ); + } + } + + // Safe check to early return for single point + if (linearElement.points.length < 2) { + return { + linearElement, + startBoundElement, + endBoundElement, + }; + } + + // Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates. + const endPointIndex = linearElement.points.length - 1; + const delta = 0.5; + + const newPoints = cloneJSON<readonly LocalPoint[]>(linearElement.points); + + // left to right so shift the arrow towards right + if ( + linearElement.points[endPointIndex][0] > + linearElement.points[endPointIndex - 1][0] + ) { + newPoints[0][0] = delta; + newPoints[endPointIndex][0] -= delta; + } + + // right to left so shift the arrow towards left + if ( + linearElement.points[endPointIndex][0] < + linearElement.points[endPointIndex - 1][0] + ) { + newPoints[0][0] = -delta; + newPoints[endPointIndex][0] += delta; + } + // top to bottom so shift the arrow towards top + if ( + linearElement.points[endPointIndex][1] > + linearElement.points[endPointIndex - 1][1] + ) { + newPoints[0][1] = delta; + newPoints[endPointIndex][1] -= delta; + } + + // bottom to top so shift the arrow towards bottom + if ( + linearElement.points[endPointIndex][1] < + linearElement.points[endPointIndex - 1][1] + ) { + newPoints[0][1] = -delta; + newPoints[endPointIndex][1] += delta; + } + + Object.assign(linearElement, { points: newPoints }); + + return { + linearElement, + startBoundElement, + endBoundElement, + }; +}; + +class ElementStore { + excalidrawElements = new Map<string, ExcalidrawElement>(); + + add = (ele?: ExcalidrawElement) => { + if (!ele) { + return; + } + + this.excalidrawElements.set(ele.id, ele); + }; + + getElements = () => { + return syncInvalidIndices(Array.from(this.excalidrawElements.values())); + }; + + getElementsMap = () => { + return toBrandedType<NonDeletedSceneElementsMap>( + arrayToMap(this.getElements()), + ); + }; + + getElement = (id: string) => { + return this.excalidrawElements.get(id); + }; +} + +export const convertToExcalidrawElements = ( + elementsSkeleton: ExcalidrawElementSkeleton[] | null, + opts?: { regenerateIds: boolean }, +) => { + if (!elementsSkeleton) { + return []; + } + const elements = cloneJSON(elementsSkeleton); + const elementStore = new ElementStore(); + const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>(); + const oldToNewElementIdMap = new Map<string, string>(); + + // Create individual elements + for (const element of elements) { + let excalidrawElement: ExcalidrawElement; + const originalId = element.id; + if (opts?.regenerateIds !== false) { + Object.assign(element, { id: randomId() }); + } + + switch (element.type) { + case "rectangle": + case "ellipse": + case "diamond": { + const width = + element?.label?.text && element.width === undefined + ? 0 + : element?.width || DEFAULT_DIMENSION; + const height = + element?.label?.text && element.height === undefined + ? 0 + : element?.height || DEFAULT_DIMENSION; + excalidrawElement = newElement({ + ...element, + width, + height, + }); + + break; + } + case "line": { + const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width; + const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height; + excalidrawElement = newLinearElement({ + width, + height, + points: [pointFrom(0, 0), pointFrom(width, height)], + ...element, + }); + + break; + } + case "arrow": { + const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width; + const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height; + excalidrawElement = newArrowElement({ + width, + height, + endArrowhead: "arrow", + points: [pointFrom(0, 0), pointFrom(width, height)], + ...element, + type: "arrow", + }); + + Object.assign( + excalidrawElement, + getSizeFromPoints(excalidrawElement.points), + ); + break; + } + case "text": { + const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY; + const fontSize = element?.fontSize || DEFAULT_FONT_SIZE; + const lineHeight = element?.lineHeight || getLineHeight(fontFamily); + const text = element.text ?? ""; + const normalizedText = normalizeText(text); + const metrics = measureText( + normalizedText, + getFontString({ fontFamily, fontSize }), + lineHeight, + ); + + excalidrawElement = newTextElement({ + width: metrics.width, + height: metrics.height, + fontFamily, + fontSize, + ...element, + }); + break; + } + case "image": { + excalidrawElement = newImageElement({ + width: element?.width || DEFAULT_DIMENSION, + height: element?.height || DEFAULT_DIMENSION, + ...element, + }); + + break; + } + case "frame": { + excalidrawElement = newFrameElement({ + x: 0, + y: 0, + ...element, + }); + break; + } + case "magicframe": { + excalidrawElement = newMagicFrameElement({ + x: 0, + y: 0, + ...element, + }); + break; + } + case "freedraw": + case "iframe": + case "embeddable": { + excalidrawElement = element; + break; + } + + default: { + excalidrawElement = element; + assertNever( + element, + `Unhandled element type "${(element as any).type}"`, + true, + ); + } + } + const existingElement = elementStore.getElement(excalidrawElement.id); + if (existingElement) { + console.error(`Duplicate id found for ${excalidrawElement.id}`); + } else { + elementStore.add(excalidrawElement); + elementsWithIds.set(excalidrawElement.id, element); + if (originalId) { + oldToNewElementIdMap.set(originalId, excalidrawElement.id); + } + } + } + + const elementsMap = elementStore.getElementsMap(); + // Add labels and arrow bindings + for (const [id, element] of elementsWithIds) { + const excalidrawElement = elementStore.getElement(id)!; + + switch (element.type) { + case "rectangle": + case "ellipse": + case "diamond": + case "arrow": { + if (element.label?.text) { + let [container, text] = bindTextToContainer( + excalidrawElement, + element?.label, + elementsMap, + ); + elementStore.add(container); + elementStore.add(text); + + if (isArrowElement(container)) { + const originalStart = + element.type === "arrow" ? element?.start : undefined; + const originalEnd = + element.type === "arrow" ? element?.end : undefined; + if (originalStart && originalStart.id) { + const newStartId = oldToNewElementIdMap.get(originalStart.id); + if (newStartId) { + Object.assign(originalStart, { id: newStartId }); + } + } + if (originalEnd && originalEnd.id) { + const newEndId = oldToNewElementIdMap.get(originalEnd.id); + if (newEndId) { + Object.assign(originalEnd, { id: newEndId }); + } + } + const { linearElement, startBoundElement, endBoundElement } = + bindLinearElementToElement( + container, + originalStart, + originalEnd, + elementStore, + elementsMap, + ); + container = linearElement; + elementStore.add(linearElement); + elementStore.add(startBoundElement); + elementStore.add(endBoundElement); + } + } else { + switch (element.type) { + case "arrow": { + const { start, end } = element; + if (start && start.id) { + const newStartId = oldToNewElementIdMap.get(start.id); + Object.assign(start, { id: newStartId }); + } + if (end && end.id) { + const newEndId = oldToNewElementIdMap.get(end.id); + Object.assign(end, { id: newEndId }); + } + const { linearElement, startBoundElement, endBoundElement } = + bindLinearElementToElement( + excalidrawElement as ExcalidrawArrowElement, + start, + end, + elementStore, + elementsMap, + ); + + elementStore.add(linearElement); + elementStore.add(startBoundElement); + elementStore.add(endBoundElement); + break; + } + } + } + break; + } + } + } + + // Once all the excalidraw elements are created, we can add frames since we + // need to calculate coordinates and dimensions of frame which is possible after all + // frame children are processed. + for (const [id, element] of elementsWithIds) { + if (element.type !== "frame" && element.type !== "magicframe") { + continue; + } + const frame = elementStore.getElement(id); + + if (!frame) { + throw new Error(`Excalidraw element with id ${id} doesn't exist`); + } + const childrenElements: ExcalidrawElement[] = []; + + element.children.forEach((id) => { + const newElementId = oldToNewElementIdMap.get(id); + if (!newElementId) { + throw new Error(`Element with ${id} wasn't mapped correctly`); + } + + const elementInFrame = elementStore.getElement(newElementId); + if (!elementInFrame) { + throw new Error(`Frame element with id ${newElementId} doesn't exist`); + } + Object.assign(elementInFrame, { frameId: frame.id }); + + elementInFrame?.boundElements?.forEach((boundElement) => { + const ele = elementStore.getElement(boundElement.id); + if (!ele) { + throw new Error( + `Bound element with id ${boundElement.id} doesn't exist`, + ); + } + Object.assign(ele, { frameId: frame.id }); + childrenElements.push(ele); + }); + + childrenElements.push(elementInFrame); + }); + + let [minX, minY, maxX, maxY] = getCommonBounds(childrenElements); + + const PADDING = 10; + minX = minX - PADDING; + minY = minY - PADDING; + maxX = maxX + PADDING; + maxY = maxY + PADDING; + + const frameX = frame?.x || minX; + const frameY = frame?.y || minY; + const frameWidth = frame?.width || maxX - minX; + const frameHeight = frame?.height || maxY - minY; + + Object.assign(frame, { + x: frameX, + y: frameY, + width: frameWidth, + height: frameHeight, + }); + if ( + isDevEnv() && + element.children.length && + (frame?.x || frame?.y || frame?.width || frame?.height) + ) { + console.info( + "User provided frame attributes are being considered, if you find this inaccurate, please remove any of the attributes - x, y, width and height so frame coordinates and dimensions are calculated automatically", + ); + } + } + + return elementStore.getElements(); +}; diff --git a/packages/excalidraw/data/types.ts b/packages/excalidraw/data/types.ts new file mode 100644 index 0000000..f5f5535 --- /dev/null +++ b/packages/excalidraw/data/types.ts @@ -0,0 +1,59 @@ +import type { ExcalidrawElement } from "../element/types"; +import type { + AppState, + BinaryFiles, + LibraryItems, + LibraryItems_anyVersion, +} from "../types"; +import type { cleanAppStateForExport } from "../appState"; +import type { VERSIONS } from "../constants"; + +export interface ExportedDataState { + type: string; + version: number; + source: string; + elements: readonly ExcalidrawElement[]; + appState: ReturnType<typeof cleanAppStateForExport>; + files: BinaryFiles | undefined; +} + +/** + * Map of legacy AppState keys, with values of: + * [<legacy type>, <new AppState proeprty>] + * + * This is a helper type used in downstream abstractions. + * Don't consume on its own. + */ +export type LegacyAppState = { + /** @deprecated #6213 TODO remove 23-06-01 */ + isSidebarDocked: [boolean, "defaultSidebarDockedPreference"]; +}; + +export interface ImportedDataState { + type?: string; + version?: number; + source?: string; + elements?: readonly ExcalidrawElement[] | null; + appState?: Readonly< + Partial< + AppState & { + [T in keyof LegacyAppState]: LegacyAppState[T][0]; + } + > + > | null; + scrollToContent?: boolean; + libraryItems?: LibraryItems_anyVersion; + files?: BinaryFiles; +} + +export interface ExportedLibraryData { + type: string; + version: typeof VERSIONS.excalidrawLibrary; + source: string; + libraryItems: LibraryItems; +} + +export interface ImportedLibraryData extends Partial<ExportedLibraryData> { + /** @deprecated v1 */ + library?: LibraryItems; +} diff --git a/packages/excalidraw/data/url.test.tsx b/packages/excalidraw/data/url.test.tsx new file mode 100644 index 0000000..9a40aad --- /dev/null +++ b/packages/excalidraw/data/url.test.tsx @@ -0,0 +1,31 @@ +import { normalizeLink } from "./url"; + +describe("normalizeLink", () => { + // NOTE not an extensive XSS test suite, just to check if we're not + // regressing in sanitization + it("should sanitize links", () => { + expect( + // eslint-disable-next-line no-script-url + normalizeLink(`javascript://%0aalert(document.domain)`).startsWith( + // eslint-disable-next-line no-script-url + `javascript:`, + ), + ).toBe(false); + expect(normalizeLink("ola")).toBe("ola"); + expect(normalizeLink(" ola")).toBe("ola"); + + expect(normalizeLink("https://www.excalidraw.com")).toBe( + "https://www.excalidraw.com", + ); + expect(normalizeLink("www.excalidraw.com")).toBe("www.excalidraw.com"); + expect(normalizeLink("/ola")).toBe("/ola"); + expect(normalizeLink("http://test")).toBe("http://test"); + expect(normalizeLink("ftp://test")).toBe("ftp://test"); + expect(normalizeLink("file://")).toBe("file://"); + expect(normalizeLink("file://")).toBe("file://"); + expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)"); + expect(normalizeLink("[[test]]")).toBe("[[test]]"); + expect(normalizeLink("<test>")).toBe("<test>"); + expect(normalizeLink("test&")).toBe("test&"); + }); +}); diff --git a/packages/excalidraw/data/url.ts b/packages/excalidraw/data/url.ts new file mode 100644 index 0000000..2ab553b --- /dev/null +++ b/packages/excalidraw/data/url.ts @@ -0,0 +1,36 @@ +import { sanitizeUrl } from "@braintree/sanitize-url"; +import { escapeDoubleQuotes } from "../utils"; + +export const normalizeLink = (link: string) => { + link = link.trim(); + if (!link) { + return link; + } + return sanitizeUrl(escapeDoubleQuotes(link)); +}; + +export const isLocalLink = (link: string | null) => { + return !!(link?.includes(location.origin) || link?.startsWith("/")); +}; + +/** + * Returns URL sanitized and safe for usage in places such as + * iframe's src attribute or <a> href attributes. + */ +export const toValidURL = (link: string) => { + link = normalizeLink(link); + + // make relative links into fully-qualified urls + if (link.startsWith("/")) { + return `${location.origin}${link}`; + } + + try { + new URL(link); + } catch { + // if link does not parse as URL, assume invalid and return blank page + return "about:blank"; + } + + return link; +}; |
