# 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

---
## State Management History

---
### redux

<!--  -->
<!--  -->
---
### jotai

---
### jotai

<!--  -->
---
# 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

#### 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

---
### AES-GCM
#### iv: initial vector

[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>

---
### 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}]"}