# Learning from Excalidraw ###### 2023/12/ #### Lynda Lin <style> code { font-family: courier; color: salmon; } .marker { text-decoration: underline; font-weight: bold; color: #191919; background-color: #FFD972; } .alarm { color: #FFD972; } </style> --- # Overview * 3rd-party packages * socket messages * canvas rendering <!-- * text component --> --- <iframe width="100%" height="600px" src="https://excalidraw.com/"> </iframe> --- # 3rd-Party Packages --- ```json "idb-keyval": "6.0.3", indexDB "jotai": "1.13.1", State Management "pepjs": "0.5.3", Pointer Event Polyfill "rewire": "6.0.0", Unit Test ``` --- ## State Management ![image](https://hackmd.io/_uploads/H1DkAQRYT.png) --- ## State Management History ![image](https://hackmd.io/_uploads/rJ9UokkwT.png) --- ### redux ![image](https://hackmd.io/_uploads/HymmMNAY6.png) <!-- ![image](https://hackmd.io/_uploads/BJp6-ERFp.png) --> <!-- ![image](https://hackmd.io/_uploads/Bylkhkywp.png) --> --- ### jotai ![image](https://hackmd.io/_uploads/r1eoj1JDT.png) --- ### jotai ![image](https://hackmd.io/_uploads/B1qIGVCYa.png) <!-- ![image](https://hackmd.io/_uploads/Hk-pj1ywp.png) --> --- # Socket Messages --- ```json "elements": [ { "id": "t59rXC8fW8OGnnmdOn7J4", "type": "freedraw", "x": 90.83203125, "y": 325.71875, "width": 149.390625, "height": 154.1171875, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, "seed": 116686265, "version": 72, "versionNonce": 1691364761, "isDeleted": false, "boundElements": null, "updated": 1702361068841, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 0, -0.4609375 ] ], "pressures": [], "simulatePressure": true, "lastCommittedPoint": [ 141.328125, -46.5 ], "__precedingElement__": "^" } ] ``` --- ### Special Keys ```json "seed": rough.js的變數種子 確保手繪風格圖案一致 "version": 每次change就會 +1 "versionNonce": 每次change都會亂數產生 "boundedElements": 紀錄綁訂的箭頭或是文字 "points": 在畫的過程會一直push新的點進去 ex. [[0, 0], [1, 2], [2, 4]...] "lastCommittedPoint": 會存最後提筆的點,在筆畫完成前都是 null "__precedingElement__": 會存前一個elementId,第一個element會存 "^",應該類似LinkedList的Pointer ``` --- ### Encryption #### AES-GCM ![image](https://hackmd.io/_uploads/SkGDes6IT.png) #### use `roomKey` as the shared key --- #### Url contains `roomId` & `roomKey` ```javascript! export const getCollaborationLinkData = (link: string) => { const hash = new URL(link).hash; const match = hash.match(RE_COLLAB_LINK); if (match && match[2].length !== 22) { window.alert(t("alerts.invalidEncryptionKey")); return null; } return match ? { roomId: match[1], roomKey: match[2] } : null; }; ``` --- ### AES-GCM #### key ![image](https://hackmd.io/_uploads/SJXKkiTLp.png) --- ### AES-GCM #### iv: initial vector ![image](https://hackmd.io/_uploads/rk-qyjTL6.png) [Reference](https://ithelp.ithome.com.tw/articles/10249953) --- ### Encryption I ```typescript! 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], // encrypt || decrypt ); ``` --- ### Encryption II ```typescript! 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, ); ``` --- ### Decryption I ```typescript! window.crypto.subtle.decrypt( { name: "AES-GCM", iv, }, key, encrypted, ); ``` --- # Canvas Rendering --- ### Every element has its own canvas Element canvases colored in <span style="color: cyan">cyan</span> ![image](https://hackmd.io/_uploads/H1oTqOp8a.png) --- ### Element Canvas ```javascript renderScene = (...) => { visibleElements.forEach((element) => { renderElement(...) }) } // 把元素畫布用 drawImage() 畫到大畫布上 renderElement = (...) => { ShapeCache.generateElementShape(...) generateElementWithCanvas(...) drawElementFromCanvas(...) // drawImage(elementCanvas) } // 得到每個元素的canvas generateElementWithCanvas = (...) => { generateElementCanvas(...) } ``` --- ### Cache Mechanism ```javascript! generateElementCanvas = (...) => { drawElementOnCanvas(...) } drawElementOnCanvas = (...) => { // get shape from shapeCache // get image from imageCache } ``` --- ### Data Driven Rendering InteractiveCanvas, StaticCanvas: ```javascript /** * when deps is 'undefined', * the code will run on every render. */ useEffect(() => { renderScene() }) ``` ```javascript /** * memo lets you skip re-rendering a component * when its props are unchanged. */ export default React.memo( SomeComponent, arePropsEqual? // false triggers re-render ) ``` --- ### Data Driven Rendering Interactive Canvas Props: ```javascript! { zoom: appState.zoom, scrollX: appState.scrollX, scrollY: appState.scrollY, width: appState.width, height: appState.height, viewModeEnabled: appState.viewModeEnabled, editingGroupId: appState.editingGroupId, editingLinearElement: appState.editingLinearElement, selectedElementIds: appState.selectedElementIds, frameToHighlight: appState.frameToHighlight, offsetLeft: appState.offsetLeft, offsetTop: appState.offsetTop, theme: appState.theme, pendingImageElementId: appState.pendingImageElementId, selectionElement: appState.selectionElement, selectedGroupIds: appState.selectedGroupIds, selectedLinearElement: appState.selectedLinearElement, multiElement: appState.multiElement, isBindingEnabled: appState.isBindingEnabled, suggestedBindings: appState.suggestedBindings, isRotating: appState.isRotating, elementsToHighlight: appState.elementsToHighlight, collaborators: appState.collaborators, // Necessary for collab. sessions activeEmbeddable: appState.activeEmbeddable, snapLines: appState.snapLines, zenModeEnabled: appState.zenModeEnabled, } ``` --- ### Data Driven Rendering Static Canvas Props: ```javascript! { zoom: appState.zoom, scrollX: appState.scrollX, scrollY: appState.scrollY, width: appState.width, height: appState.height, viewModeEnabled: appState.viewModeEnabled, offsetLeft: appState.offsetLeft, offsetTop: appState.offsetTop, theme: appState.theme, pendingImageElementId: appState.pendingImageElementId, shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom, viewBackgroundColor: appState.viewBackgroundColor, exportScale: appState.exportScale, selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged, gridSize: appState.gridSize, frameRendering: appState.frameRendering, selectedElementIds: appState.selectedElementIds, frameToHighlight: appState.frameToHighlight, editingGroupId: appState.editingGroupId, } ``` --- ### Compare to ours * canvaslib: renders every frame `requestAnimationFrame(loop)` * excalidraw: renders when props change <img src ="https://hackmd.io/_uploads/SJtjhOTLa.png" style="height: 300px" /> --- ### Trials <!-- .slide: data-visibility="hidden" --> * `requestAnimationFrame` -> `setInterval` <img src ="https://hackmd.io/_uploads/Hy1VC_TIT.png" style="height: 300px" /> --- <!-- .slide: data-visibility="hidden" --> # Text Component --- <!-- .slide: data-visibility="hidden" --> ### Adaptive Line Height Based on Font #### Unitless line height In previous versions we used normal line height, __which browsers interpret differently, and based on font-family and font-size.__ To make line heights consistent across browsers we hardcode the values for each of our fonts based on most common average line-heights. --- <!-- .slide: data-visibility="hidden" --> ### Line Height ```typescript= /** * See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971 * where the values come from. */ const DEFAULT_LINE_HEIGHT = { // ~1.25 is the average for Virgil in WebKit and Blink. // Gecko (FF) uses ~1.28. [FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"], // ~1.15 is the average for Virgil in WebKit and Blink. // Gecko if all over the place. [FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"], // ~1.2 is the average for Virgil in WebKit and Blink, and kinda Gecko too [FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"], }; ``` --- <!-- .slide: data-visibility="hidden" --> ### Text Socket Message ```json! { "id": "-G0-vbaygn0jSD-jRRyhX", "type": "text", "x": 377.84765625, "y": 318.83984375, "width": 57.799957275390625, "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, "seed": 958942882, "version": 5, "versionNonce": 14157438, "isDeleted": false, "boundElements": null, "updated": 1702611628141, "link": null, "locked": false, "text": "TEST", "fontSize": 20, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", // * "baseline": 18, // * "containerId": null, "originalText": "TEST", // * "lineHeight": 1.25, // * "__precedingElement__": "qdfuOUT3a2qmeZSuiZwBx" } ``` --- <!-- .slide: data-visibility="hidden" --> ### Text Area Styles ```css! { position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0px; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: normal; white-space: pre; overflow-wrap: break-word; box-sizing: content-box; /* */ font: 104.362px / 1.15 Helvetica, "Segoe UI Emoji"; width: 220.287px; height: 120.016px; left: 431.86px; top: 407.01px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: left; vertical-align: top; /* */ color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 308.99px; } ```
{"description":"Unitless line height","slideOptions":"{\"theme\":\"dark\"}","title":"Learning from Excalidraw","contributors":"[{\"id\":\"792a630b-c597-466b-ae21-1c0e7fd7a47e\",\"add\":12584,\"del\":1290}]"}
    316 views