diff --git a/apps/docs/content/getting-started/quick-start.mdx b/apps/docs/content/getting-started/quick-start.mdx index fbfa2e481..7cc374c82 100644 --- a/apps/docs/content/getting-started/quick-start.mdx +++ b/apps/docs/content/getting-started/quick-start.mdx @@ -31,7 +31,7 @@ To import fonts and CSS for tldraw: - Copy and paste this into the file: ```CSS -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@500;700;&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap"); @import url("tldraw/tldraw.css"); body { diff --git a/apps/dotcom/src/utils/context-menu/CursorChatMenuItem.tsx b/apps/dotcom/src/utils/context-menu/CursorChatMenuItem.tsx index db0e25d19..5a6872d56 100644 --- a/apps/dotcom/src/utils/context-menu/CursorChatMenuItem.tsx +++ b/apps/dotcom/src/utils/context-menu/CursorChatMenuItem.tsx @@ -7,7 +7,7 @@ export function CursorChatMenuItem() { const shouldShow = useValue( 'show cursor chat', () => { - return editor.getInstanceState().isCoarsePointer && !editor.getSelectedShapes().length + return !editor.getInstanceState().isCoarsePointer }, [editor] ) diff --git a/apps/dotcom/src/utils/useFileSystem.tsx b/apps/dotcom/src/utils/useFileSystem.tsx index c76e1934d..c328bb138 100644 --- a/apps/dotcom/src/utils/useFileSystem.tsx +++ b/apps/dotcom/src/utils/useFileSystem.tsx @@ -108,34 +108,33 @@ export function getSaveFileCopyAction( readonlyOk: true, kbd: '$s', async onSelect(source) { + handleUiEvent('save-project-to-file', { source }) + const documentName = + editor.getDocumentSettings().name === '' + ? defaultDocumentName + : editor.getDocumentSettings().name + const defaultName = + saveFileNames.get(editor.store) || `${documentName}${TLDRAW_FILE_EXTENSION}` + + const blobToSave = serializeTldrawJsonBlob(editor.store) + let handle try { - handleUiEvent('save-project-to-file', { source }) - const documentName = - editor.getDocumentSettings().name === '' - ? defaultDocumentName - : editor.getDocumentSettings().name - const defaultName = - saveFileNames.get(editor.store) || `${documentName}${TLDRAW_FILE_EXTENSION}` - - const blobToSave = serializeTldrawJsonBlob(editor.store) - - const handle = await fileSave(blobToSave, { + handle = await fileSave(blobToSave, { fileName: defaultName, extensions: [TLDRAW_FILE_EXTENSION], description: 'tldraw project', }) - - if (handle) { - // we deliberately don't store the handle for re-use - // next time. we always want to save a copy, but to - // help the user out we'll remember the last name - // they used - saveFileNames.set(editor.store, handle.name) - } else { - throw Error('Could not save file.') - } } catch (e) { - console.error(e) + // user cancelled + return + } + + if (handle) { + // we deliberately don't store the handle for re-use + // next time. we always want to save a copy, but to + // help the user out we'll remember the last name + // they used + saveFileNames.set(editor.store, handle.name) } }, } diff --git a/apps/examples/src/examples/action-overrides/ActionOverridesExample.tsx b/apps/examples/src/examples/action-overrides/ActionOverridesExample.tsx new file mode 100644 index 000000000..90d21d1ee --- /dev/null +++ b/apps/examples/src/examples/action-overrides/ActionOverridesExample.tsx @@ -0,0 +1,27 @@ +import { Tldraw } from 'tldraw' +import 'tldraw/tldraw.css' + +export default function BasicExample() { + return ( +
+ { + const newActions = { + ...actions, + delete: { ...actions['delete'], kbd: 'x' }, + } + return newActions + }, + }} + /> +
+ ) +} + +/* +This example shows how you can override tldraw's actions object to change the keyboard shortcuts. +In this case we're changing the delete action's shortcut to 'x'. To customize the actions menu +please see the custom actions menu example. For more information on keyboard shortcuts see the +keyboard shortcuts example. +*/ diff --git a/apps/examples/src/examples/action-overrides/README.md b/apps/examples/src/examples/action-overrides/README.md new file mode 100644 index 000000000..87b182bab --- /dev/null +++ b/apps/examples/src/examples/action-overrides/README.md @@ -0,0 +1,12 @@ +--- +title: Action overrides +component: ./ActionOverridesExample.tsx +category: ui +priority: 2 +--- + +Override tldraw's actions + +--- + +This example shows how you can override tldraw's actions object to change the keyboard shortcuts. In this case we're changing the delete action's shortcut to 'x'. To customize the actions menu please see the custom actions menu example. For more information on keyboard shortcuts see the keyboard shortcuts example. diff --git a/apps/examples/src/examples/custom-renderer/CustomRenderer.tsx b/apps/examples/src/examples/custom-renderer/CustomRenderer.tsx index 7e20457ea..66cbe70a1 100644 --- a/apps/examples/src/examples/custom-renderer/CustomRenderer.tsx +++ b/apps/examples/src/examples/custom-renderer/CustomRenderer.tsx @@ -36,7 +36,8 @@ export function CustomRenderer() { const theme = getDefaultColorTheme({ isDarkMode: editor.user.getIsDarkMode() }) const currentPageId = editor.getCurrentPageId() - for (const { shape, maskedPageBounds, opacity } of renderingShapes) { + for (const { shape, opacity } of renderingShapes) { + const maskedPageBounds = editor.getShapeMaskedPageBounds(shape) if (!maskedPageBounds) continue ctx.save() diff --git a/apps/examples/src/examples/keyboard-shortcuts/KeyboardShortcuts.tsx b/apps/examples/src/examples/keyboard-shortcuts/KeyboardShortcuts.tsx index b7c01e4eb..c0b7c1436 100644 --- a/apps/examples/src/examples/keyboard-shortcuts/KeyboardShortcuts.tsx +++ b/apps/examples/src/examples/keyboard-shortcuts/KeyboardShortcuts.tsx @@ -1,6 +1,5 @@ import { TLUiActionsContextType, TLUiOverrides, TLUiToolsContextType, Tldraw } from 'tldraw' import 'tldraw/tldraw.css' -import jsonSnapshot from './snapshot.json' // There's a guide at the bottom of this file! @@ -8,13 +7,18 @@ import jsonSnapshot from './snapshot.json' const overrides: TLUiOverrides = { //[a] actions(_editor, actions): TLUiActionsContextType { - actions['toggle-grid'].kbd = 'x' - return actions + const newActions = { + ...actions, + 'toggle-grid': { ...actions['toggle-grid'], kbd: 'x' }, + 'copy-as-png': { ...actions['copy-as-png'], kbd: '$1' }, + } + + return newActions }, //[b] tools(_editor, tools): TLUiToolsContextType { - tools['draw'].kbd = 'p' - return tools + const newTools = { ...tools, draw: { ...tools.draw, kbd: 'p' } } + return newTools }, } @@ -22,7 +26,7 @@ const overrides: TLUiOverrides = { export default function KeyboardShortcuts() { return (
- +
) } diff --git a/apps/examples/src/examples/keyboard-shortcuts/README.md b/apps/examples/src/examples/keyboard-shortcuts/README.md index f726e110c..bd050528e 100644 --- a/apps/examples/src/examples/keyboard-shortcuts/README.md +++ b/apps/examples/src/examples/keyboard-shortcuts/README.md @@ -5,8 +5,14 @@ category: ui priority: 2 --- -Override default keyboard shortcuts. +How to replace tldraw's default keyboard shortcuts with your own. --- -How to replace tldraw's default keyboard shortcuts with your own. +This example shows how you can replace tldraw's default keyboard shortcuts with your own, +or add a shortcut for an action that doesn't have one. An example of how to add shortcuts +for custom tools can be found in the custom-config example. + +- Toggle show grid by pressing 'x' +- Select the Draw tool by pressing 'p' +- Copy as png by pressing 'ctrl/cmd + 1' diff --git a/apps/examples/src/examples/keyboard-shortcuts/snapshot.json b/apps/examples/src/examples/keyboard-shortcuts/snapshot.json deleted file mode 100644 index 65718fd4b..000000000 --- a/apps/examples/src/examples/keyboard-shortcuts/snapshot.json +++ /dev/null @@ -1,156 +0,0 @@ -{ - "store": { - "asset:2051922215": { - "meta": {}, - "type": "image", - "props": { - "name": "tldrawFile", - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+wAAAJWCAYAAADC/w0vAAAAAXNSR0IArs4c6QAAIABJREFUeF7s3Qe4VMX9//FvghrBqNiNAUzU2LtgwUZRAQvYCyjYRbqoqCCgooINGyhFwQ4oRQSNIiBNBFTsgAIW0MQOJoqaqPn/Pye/NXv3nplzdu/uvQO853nyJHk4e86c18zdPd8zM9/5zY//+vd/jIIAAggggAACCCCAAAIIIIAAAkEJ/IaAPaj2oDIIIIAAAggggAACCCCAAAIIRAIE7HQEBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBFZTgenTp9mIx0bY5MnP284772xtL77Yjj32uNX0bsKr9qOPPGL33XefLV++zP785z9bu/bt7YQTTgyvotQIgQSBf/zjH/af//zHNt5440qz+n7VKhs+fLg9+OAD0TX3P+AAa9u2re222+4lr8M777wdXfv9pUttp513tlatzrTddy/9dUt+Y1wAgRIK/Pzzz/bVV1/ZZpttZtWqVSvhlTg1AgjkK0DAnq8YxyMQgMBbb71lJxzfolxNRowcZfXq1Qughqt3FZYuXWpNjjqy3E3c1v92a9GivPvqfbfUfk0UUJA+dOgQe+jBB+27776LblEv9g4/vIF16NDBqteoUdLbHjDgbrvj9tvLXWPck+Ntjz32KNm1V61aZXvuUT44HzBgoDVt1qxk1+XECKyuAnPmvGS39+9vr776anQLG2ywgR166KF2+uln2CGHHrq63hb1RmCNEiBgX6Oak5tZWwR69expjz32aLnbPeWUU6xvv5vWFoaS3aceXgYOHFDu/Bp5mP3SHEYfSibPiYsh8MMPP9iZZ7ay1197LfZ0CtoHDR5s6667bjEuF3uO00495dcAIPsAzQK64847S3bdWTNn2tlnt4n92502fYZVr169ZNfmxAisbgKTJj1n7S6+2Fnthx951A466KDV7bZWi/p++OGHdtedd5gGYP683XbWrFkza9q0WZV+Rz3//CQbOWKkLVv2kR10UH076qijeGkTSG8iYA+kIagGAvkIHLB/vWjqWm7Ze599bPToMfmcimNjBK679lp76KEHY20ef2K07bvvvrghEKzAxIkTrEvnzt76XXvtddbqzDNLdg977bnHryP7uRd5+50Ftv7665fk2o88/LBdc03v2HOXenS/JDfESREokYCWyTRq2DBa9uUqGm2f/9rrvKQucht8/tlnVr9++Rch55x7rvXocXWRr5budM/+9a/WoUP7cgcPHjzEGh9xRLqTcFTJBAjYS0Zb9SfWl/CUKVPsg/fft2rV1rFq61Sz9dZdz35b7bf2u9/9zjaosYHVqFHD1q9e3TbYoIZVr17D1lt3Xau2zjq2TvSfavbLL/+x77//3lat+s5Wrlhpf//0U/vi88+j///td9/Zzz/9bAcffLAd17y5bbTRRlV/02tBDVasWGH16u4Xe6daa/385ClrgUJpb9EXsN9088120kknl7YCnB2BCghce+019vBDD3nPUMqRbq2F3WnHvzivP3fey9E62VIU11R8XWvIkKHWqHHjUlyWcyKw2gm4gsbcG3n2uedshx3cf8+r3Y0HUOF7Bg60/v1vi63J9Bkz7Y9//GOl1/KM00+zl19+udx1a9euYy9Mm1bp9eGCZQUI2NfQHuGaFliq22129NF2993lpxCX6npr83nnzZ1rLVueEUuw9dZb26wXZ6/NPEW59z7XXfdrsqzcE3bu0sU6duxUlOtwEgRKIXDLzTfb4MGDvKfWevaJTz9Tisvb119/bfvXq+s89+zZL9mWW21VkmvfcMP1NnzYsNhzDxh4jzVt2rQk1+WkCKxuAspzse8+eydWe8jQ+6xRo0aJx3FAegHXskadQbMkNVuyskvDBg2csy0WL1lqv/nNbyq7SlwvS4CAfQ3tDlqTpLVJlVkWLFxk6623XmVecq28lm/KpxLOKfEcpWICN990kw0ZMjj2JB06dLQul1xSsQvwaQRKKDD+ySft0ku7eq/Q4vjj7bbb+pekFq6kjZmL6aWiXi6WonRo386effbZ2FOPGTPW9to7OUApRb04JwIhChxycH379NNPvVWb9Pxk22677UKs/mpbJ9+gwP3DhkXJQSu7NG3SxJYsWRx72dffeNN+//vfV3aVuB4B+5rfB1q1bGlz586p1Bt96+13qjRZRqXebBVe7KqrrrQnHn88tgaaqq0p25SKCdx66y026N57Y09y6aWX2cXt2lXsAnwagRIK/PTTT3b66ac5k87p0qUcxVG2aSWdc5VS/lb4HjqVMHLLLbcsoTynRmD1EnjhhRfsgvPPc1a6YcOGNvS++1evm1oNanvrLTfboEHxs6Buv+NOO+64yt+i98QTjrc333wzVm/GzFm2zTbbrAaya24VGWFfQ9vWN92mFLe855572thxT5bi1JwzR6BF8+amfYbjyiVdu1r79h0wq6CA7+23EsIoMQwFgZAFVq5caXfdeWe55IlKItWv302mZUylKpMnP29tL7oo9vRau6417KUoSWvn31u8xH7729+W4tKcE4EqE9ALui+++MK0Jl2J5GrXqZNXjojp06fZ9X362AcffFDmHo46qoldf8MNtummm1bZva2pF/YNvNw7aJAdeeRRlX7rvtkWpcw7Uuk3uppekIB9NW24pGovW7bMGjWsnCk1egB85NHHSrq3btL9ri3//u9//9t22Xkn5+1W1ZvZ0PyVKPHzKDniKtts001tiy23zGv9VbfLL7exY+Oz7fft289OOfXU0G6Z+iAQK6C/heXLl0e7Smy11VZWu3btkm7npko8PmqUde9+VWx9tEWUtooqRfn73/9uhx5ycOypSchZCnHOmSTwyy+/RDkd9PdXrdpv7Y9/rFXhmYgKzJ8c/6S9MHVq9LcdN6VdS060E0Ta7N4K9JVQeNny5bZOtWpWZ9tt14hA/dtvv40SJ/9+gw2seo0aSc1Vaf9+3rnnml6UxJWq2IlG/XTHv+zgvP9331vMTgGV1jviL0TAXsUNUMrL60t96H1D7a/PPJO4RqnQemikUSO6NWvWLPQUa9znvvnmG1uwYIF99dWX9uWXX9p/ftEb79pWt269Cju99957dnQzd9KkpyZMsF133W2NM/XdkEYXZs6cadrK6t1Fi6IHmO+++67cR1q1amWXXnZ5qt0MTj75JOd0Yt+PqUY53n77bdugRg37+ZdfbPvttitZcq21qpGr8GY//vhje//9pdED94qvV5heUG6++ea24047mrLnUsoL3HbbrXbvPffE0rRs2cqu69OnJGzad15/u3FFo4X3OJa5VLQy2rv4oQcftJdeeinqE5pGrDwXaXZOeeWVVyKrV1552XbddddoZO3sc85hJkBFG6WKPv/JJ5/YhKeein6TPvrow9hnL7086t37mrz2t9bskRkzZkTL4fLJT3TjjX3t1NNOqyKNyr2sBjRmz37Rpk+bbjNnzig3Y0CJ3A477DBr3Lix7bbb7pVbuayrKTjeZ++9nNteTp4y1f70pz9Vav2WLlliTZrEj+qXclZUpd7kan4xAvbVvAHTVl9vGBVMKJj8ZuVKi946/vC9Pffsc6bpi3FF09wPO+zwMv+0zrrrWO1ataMpV/rR2WSTTdJWoVKP04+mtrP74ssvou3o1l13Xdtl111N96T/XeyiL+Bx48ba2DFjvbkDdP0LL7zImjZrVlAVpkyebBdddKHzs8Xe3/jHH3/871S7zz83TbHVaLXaPoQpckqOMm7sOBs9+onYPenjkNRnJ0x8OnEPaN8e0q/Of8023njjMqfX/qU33XRTbIZV5RXocfXVqR7eC+oUfKjoAnrhM3z4MJs4YaIzCY8uqiSPva+51pRxvSqKRsX+9a9/Rdt0hlR8SU+vuPJKu+AC93dYRe7jueees/btLo49RduLL7bLLru8IqeP/axrvX7amQRxa+7Pat06Cugo/xOoaF9XwPvVl1/a5198Ef2m1ahRPXq5olkn1apVqxC1nq+mTJlso594wmbNmpX6XEr6qOSPSUW/veeff543J4XvHNpHPc3Lo6R6FOPf9Qz61ptvRmulf/nPL7bLzrvYTjvvbLVq1arQ6efMecl6Xn11uSDdddLLLu9mbdu2rdA1C/3wokWL7Nhj4pckVdW+948+8oj17t0r9pa0s4Z22KBUrQABe9X6V/nVfYkvOnXqbJ06d67yOqatgB5ctfewAmd9IbqK1m5eccWVFf6ByJxf1+rdq6fpwS1t6XbFFdFD6+LFi61G9er2x1q1Uk3Z9mWIL9ZemXr4mD5tmo0dN9amTonf010/Kvvvv791vfQy22WXXdLedtGOe+ihB017pRdSkgIGvZyof9CBsafOfdOsB7/rrrs2msXiK9rDdsLEiQW/LFKbTJo0ySZOeCp60NFor14+6C28RhU23Ggj26TmJrbV1ltZ3bp1bc8990o97VJ/N/Pnv2qL31scjRrn89nce9asno8++si22nrr6O+rIuuFdY+aUbLhhhtG2Wkra8RBLzB7dO+e+iWQDB57bES0Dc/ixe/Z1lv/Ia/1o/n2YfXPUaNG2vz5823+q69GozT62+/Vu3c0sptbtDxq4cKF9sP330cPxvm+XNDLyNdeey3Km/HZp5/ZZ59/Zt/+89vopZVmGtSqXcvq1z/Ytt12218vfeQRjZ0PzgPvudeaNGmS722nOn7UyJHWo0f32GNdI43//Oc/oxdtP//8SzQjaosttoiWDeS+lHNVoGPHDs6//6Ts2itWrLB6dfeLPXVlrmPVUgK171dffmXy0HfCJptuEllssfkWttnmm0f7Qlf2tk759vVcSPXdV195xcaPH28TJjzlHNHUaGvHTh3tiCOOTNXPsg/67LNPTdObfc8crpPqd3TO3Hne72qdv03rNt4Xh0mVHjFipNXbf3/vYd+vWmULFy2yDz54P/q73nff/aLv3mKVpN/Kc84516686qq8X56oj9x880325LhxeVdV35eff/5F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Lr/cxo4dU5Qb2W233W3kqFEFbwOWphI333STDRky2Hko+RbSKHJMCAKXXtrVNHW8mGWzzTaz5yY9bzVr1izmaTkXAhUWIGCvMCEnqEoBBetnnH66dw1p2vppneq4J8fbdtu5k5lkn+urr76yNq3PKlrQ3qhxYxs0aHCq7KZp74njEEAgnYCydrdp07poQfuVV15l519wQbqLc9RqK/D555+btvn05TFIc3PaTm30mLGm/y5lGTx4kN1y882xl2BksZTynLvYAsoLpKD9maefLtqpS7mdYtEqyYnWSgEC9rWy2desm1YCuttv72/Dhw0r+MZatWplXS+9LEomlU/RD4ZGK27vX/ie6Xqj273H1aaHJfZXzUefYxEoroBewmmpy5PjxhV8Yo2SXnvttVW2f2/BFeeDBQt89NFHdnabNgXP9tJWVN179Mj796eQCs+YMd3OPeec2I8+89dnbccddyzktHwGgSoTePrpiaatCvX9XWjR89fl3a4oWe6IQuvF5xDICBCw0xfWGAHthfzMM0/blMlT7JVXXk4cKVOgvPvue1jHjh0r/HCtlwbPT5pkTz/ztM2dMyfx2kKvXbuOKZO8ElIpgz0FAQTCEFAm7QlPTbApUybbyy+/nKpSWq/e5uw2duqppyUuqUl1Qg5arQR++uknmzhhgj355DibNWtWqroff8IJpmBdiUYrs1zRrVu5LNkkm6vMFuBaxRb4+eef7aXZs23ChAk2b968VC/PNKtSu3h06tzF6tWrV+wqcT4EiipAwF5UTk4WkoAeuj/55G+mTJvfffdttK1ajRobmAJ1bc+U72h6Pvf2+WefmUZd/vntt7Zq1Xf23bffRdffZpttomvXql3bfve73+VzSo5FAIEqENCD4PLly6MtuTJ/y9+t+i5auqKXbtvWqWN/2GYbgvQqaJtQL6nfnL///e+ml8grV6ywr1essL998omt97vfWe3atWzrrf9gu+6yi1WvUaPKbuHlefNsxswZVqd2Hduvbt3US8GqrMJcGIE8BL799ltbumSJrfxmpa36bpV9+9139uMPP0Tbctaps63VqVPbatbcJI8zcigCVStAwF61/lwdAQQQQAABBBBAAAEEEEAAgVgBAnY6BgIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIId8fKeQAAIABJREFUIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQACB/9feWcBdUaV//FGxXQmDXQWDEEGRUEBCQUC6OwQJCWmkSwRpFKRUQOmSDmmQUBFUuiRXBddccVWw3f//N7uXve+8J2bm3nm5L/zO5+NHPu+dOfE9Z2bO85wnKLBzDZAACZAACZAACZAACZAACZAACZBAAhKgwJ6Ak8IukQAJkAAJkAAJkAAJkAAJkAAJkAAFdq4BEiABEiABEiABEiABEiABEiABEkhAAhTYE3BS2CUSIAESIAESIAESIAESIAESIAESoMDONUACJEACJEACJEACJEACJEACJEACCUiAAnsCTgq7RAIkQAIkQAIkQAIkQAIkQAIkQAIU2LkGSIAESIAESIAESIAESIAESIAESCABCVBgT8BJYZdIgARIgARIgARIgARIgARIgARIgAI71wAJkAAJkAAJkAAJkAAJkAAJkAAJJCABCuwJOCnsEgmQAAmQAAmQAAmQAAmQAAmQAAlQYOcaIAESIAESIAESIAESIAESIAESIIEEJECBPQEnhV0iARIgARIgARIgARIgARIgARIgAQrsXAMkQAIkQAIkQAIkQAIkQAIkQAIkkIAEKLAn4KSwSyRAAiRAAiRAAiRAAiRAAiRAAiRAgZ1rgARIgARIgARIgARIgARIgARIgAQSkAAF9gScFHaJBEiABEiABEiABEiABEiABEiABCiwcw2QAAmQAAmQAAmQAAmQAAmQAAmQQAISoMCegJPCLpEACZAACZAACZAACZAACZAACZAABXauARIgARIgARIgARIgARIgARIgARJIQAIU2BNwUtglEiABEiABEiABEiABEiABEiABEqDAzjVAAiRAAiRAAiRAAiRAAiRAAiRAAglIgAJ7Ak4Ku0QCJEACJEACJEACJEACJEACJEACFNi5BkiABEiABEiABEiABEiABEiABEggAQlQYE/ASWGXSIAESIAESIAESIAESIAESIAESIACO9cACZAACZAACZAACZAACZAACZAACSQgAQrsCTgp7BIJkAAJkAAJkAAJkAAJkAAJkAAJUGDnGiABEiABEiABEiABEiABEiABEiCBBCRAgT0BJ4VdIgESIAESIAESIAESIAESIAESIAEK7FwDJEACJEACJEACJEACJEACJEACJJCABCiwJ+CksEskQAIkQAIkQAIkQAIkQAIkQAIkQIGda4AESIAESIAESIAESIAESIAESIAEEpAABfYEnBR2iQRIgARIgARIgARIgARIgARIgAQosHMNkAAJkAAJkAAJkAAJkAAJkAAJkEACEqDAnoCTwi6RAAmQAAmQAAmQAAmQAAmQAAmQAAV2rgESIAESIAESIAESIAESIAESIAESSEACFNgTcFLYJRIgARIgARIgARIgARIgARIgARKgwM41QAIkQAIkQAIkQAIkQAIkQAIkQAIJSIACewJOCrtEAiRAAiRAAiRAAiRAAiRAAiRAAhTYuQZIgARIgARIgARIgARIgARIgARIIAEJUGBPwElhl0iABEiABEiABEiABEiABEiABEiAAjvXAAmQAAmQAAmQAAmQAAmQAAmQAAkkIAEK7Ak4KewSCZAACZAACZAACZAACZAACZAACVBg5xogARIgARIgARIgARIgARIgARIggQQkQIE9ASeFXSIBEiABEiABEiABEiABEiABEiABCuxcAyRAAiRAAiRAAiRAAiRAAiRAAiSQgAQosCfgpLBLJEACJEACJEACJEACJEACJEACJECBnWuABEiABEiABEiABEiABEiABEiABBKQAAX2BJwUdokESIAESIAESIAESIAESIAESIAEKLBzDZAACZAACZAACZAACZAACZAACZBAAhKgwJ6Ak8IukQAJkAAJkAAJkAAJkAAJkAAJkAAFdq4BEiABEiABEiABEiABEiABEiABEkhAAhTYE3BS2CUS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", - "w": 1000, - "h": 595.6175298804781, - "mimeType": "image/png", - "isAnimated": false - }, - "id": "asset:2051922215", - "typeName": "asset" - }, - "document:document": { - "gridSize": 10, - "name": "", - "meta": {}, - "id": "document:document", - "typeName": "document" - }, - "page:NXBP7PKuITv3tMvoIRxFz": { - "meta": {}, - "id": "page:NXBP7PKuITv3tMvoIRxFz", - "name": "Page 1", - "index": "a1", - "typeName": "page" - }, - "shape:0O-D3-H12T9edVMgA0cDh": { - "x": 224.28665129528747, - "y": 474.44055394139673, - "rotation": 0, - "isLocked": false, - "opacity": 1, - "meta": {}, - "type": "text", - "props": { - "color": "black", - "size": "m", - "w": 438.1640625, - "text": "Select the Draw tool by pressing 'p'", - "font": "draw", - "align": "middle", - "autoSize": true, - "scale": 1 - }, - "parentId": "page:NXBP7PKuITv3tMvoIRxFz", - "index": "a2", - "id": "shape:0O-D3-H12T9edVMgA0cDh", - "typeName": "shape" - }, - "shape:tAsz1L8N4kLDwIvpCcjqN": { - "x": 241.59357019335232, - "y": 367.6707932953341, - "rotation": 0, - "isLocked": false, - "opacity": 1, - "meta": {}, - "type": "text", - "props": { - "color": "black", - "size": "m", - "w": 397.3125, - "text": "Toggle show grid by pressing 'x'", - "font": "draw", - "align": "middle", - "autoSize": true, - "scale": 1 - }, - "parentId": "page:NXBP7PKuITv3tMvoIRxFz", - "index": "a1", - "id": "shape:tAsz1L8N4kLDwIvpCcjqN", - "typeName": "shape" - }, - "shape:Jv8vs9T77_Ojum2OGZd7D": { - "x": 204.13270568300845, - "y": 579.9171242617172, - "rotation": 0, - "isLocked": false, - "opacity": 1, - "meta": {}, - "id": "shape:Jv8vs9T77_Ojum2OGZd7D", - "type": "text", - "props": { - "color": "black", - "size": "m", - "w": 462.7421875, - "text": "Copy as png by pressing 'ctrl/cmd + 1'", - "font": "draw", - "align": "middle", - "autoSize": true, - "scale": 1 - }, - "parentId": "page:NXBP7PKuITv3tMvoIRxFz", - "index": "a3", - "typeName": "shape" - } - }, - "schema": { - "schemaVersion": 1, - "storeVersion": 4, - "recordVersions": { - "asset": { - "version": 1, - "subTypeKey": "type", - "subTypeVersions": { - "image": 3, - "video": 3, - "bookmark": 1 - } - }, - "camera": { - "version": 1 - }, - "document": { - "version": 2 - }, - "instance": { - "version": 23 - }, - "instance_page_state": { - "version": 5 - }, - "page": { - "version": 1 - }, - "shape": { - "version": 3, - "subTypeKey": "type", - "subTypeVersions": { - "group": 0, - "text": 1, - "bookmark": 2, - "draw": 1, - "geo": 8, - "note": 5, - "line": 1, - "frame": 0, - "arrow": 2, - "highlight": 0, - "embed": 4, - "image": 3, - "video": 2 - } - }, - "instance_presence": { - "version": 5 - }, - "pointer": { - "version": 1 - } - } - } -} diff --git a/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts b/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts index ae181b28d..4b4ac137f 100644 --- a/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts +++ b/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts @@ -5,26 +5,34 @@ export function useChangedShapesReactor( cb: (info: { culled: TLShape[]; restored: TLShape[] }) => void ) { const editor = useEditor() - const rPrevShapes = useRef(editor.getRenderingShapes()) + const rPrevShapes = useRef({ + renderingShapes: editor.getRenderingShapes(), + culledShapes: editor.getCulledShapes(), + }) useEffect(() => { return react('when rendering shapes change', () => { - const after = editor.getRenderingShapes() + const after = { + culledShapes: editor.getCulledShapes(), + renderingShapes: editor.getRenderingShapes(), + } const before = rPrevShapes.current const culled: TLShape[] = [] const restored: TLShape[] = [] - const beforeToVisit = new Set(before) + const beforeToVisit = new Set(before.renderingShapes) - for (const afterInfo of after) { - const beforeInfo = before.find((s) => s.id === afterInfo.id) + for (const afterInfo of after.renderingShapes) { + const beforeInfo = before.renderingShapes.find((s) => s.id === afterInfo.id) if (!beforeInfo) { continue } else { - if (afterInfo.isCulled && !beforeInfo.isCulled) { + const isAfterCulled = after.culledShapes.has(afterInfo.id) + const isBeforeCulled = before.culledShapes.has(beforeInfo.id) + if (isAfterCulled && !isBeforeCulled) { culled.push(afterInfo.shape) - } else if (!afterInfo.isCulled && beforeInfo.isCulled) { + } else if (!isAfterCulled && isBeforeCulled) { restored.push(afterInfo.shape) } beforeToVisit.delete(beforeInfo) diff --git a/apps/vscode/extension/CHANGELOG.md b/apps/vscode/extension/CHANGELOG.md index 2713f418c..c40252b56 100644 --- a/apps/vscode/extension/CHANGELOG.md +++ b/apps/vscode/extension/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.27 + +- Bug fixes and performance improvements. + ## 2.0.26 - Bug fixes and performance improvements. diff --git a/apps/vscode/extension/package.json b/apps/vscode/extension/package.json index 35c3332bb..9558bbf95 100644 --- a/apps/vscode/extension/package.json +++ b/apps/vscode/extension/package.json @@ -1,7 +1,7 @@ { "name": "tldraw-vscode", "description": "The tldraw extension for VS Code.", - "version": "2.0.26", + "version": "2.0.27", "private": true, "author": { "name": "tldraw Inc.", diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index a95820545..01be034e1 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -516,7 +516,7 @@ export function degreesToRadians(d: number): number; export const DOUBLE_CLICK_DURATION = 450; // @internal (undocumented) -export const DRAG_DISTANCE = 4; +export const DRAG_DISTANCE = 16; // @public (undocumented) export const EASINGS: { @@ -677,6 +677,7 @@ export class Editor extends EventEmitter { // @internal getCrashingError(): unknown; getCroppingShapeId(): null | TLShapeId; + getCulledShapes(): Set; getCurrentPage(): TLPage; getCurrentPageBounds(): Box | undefined; getCurrentPageId(): TLPageId; @@ -714,7 +715,6 @@ export class Editor extends EventEmitter { getPointInParentSpace(shape: TLShape | TLShapeId, point: VecLike): Vec; getPointInShapeSpace(shape: TLShape | TLShapeId, point: VecLike): Vec; getRenderingBounds(): Box; - getRenderingBoundsExpanded(): Box; getRenderingShapes(): { id: TLShapeId; shape: TLShape; @@ -722,8 +722,6 @@ export class Editor extends EventEmitter { index: number; backgroundIndex: number; opacity: number; - isCulled: boolean; - maskedPageBounds: Box | undefined; }[]; getSelectedShapeAtPoint(point: VecLike): TLShape | undefined; getSelectedShapeIds(): TLShapeId[]; @@ -784,8 +782,6 @@ export class Editor extends EventEmitter { index: number; backgroundIndex: number; opacity: number; - isCulled: boolean; - maskedPageBounds: Box | undefined; }[]; getViewportPageBounds(): Box; getViewportPageCenter(): Vec; @@ -1676,7 +1672,6 @@ export abstract class ShapeUtil { canResize: TLShapeUtilFlag; canScroll: TLShapeUtilFlag; canSnap: TLShapeUtilFlag; - canUnmount: TLShapeUtilFlag; abstract component(shape: Shape): any; // (undocumented) editor: Editor; @@ -1888,6 +1883,8 @@ export abstract class StateNode implements Partial { // (undocumented) onKeyUp?: TLEventHandlers['onKeyUp']; // (undocumented) + onLongPress?: TLEventHandlers['onLongPress']; + // (undocumented) onMiddleClick?: TLEventHandlers['onMiddleClick']; // (undocumented) onPointerDown?: TLEventHandlers['onPointerDown']; @@ -2185,6 +2182,8 @@ export interface TLEventHandlers { // (undocumented) onKeyUp: TLKeyboardEvent; // (undocumented) + onLongPress: TLPointerEvent; + // (undocumented) onMiddleClick: TLPointerEvent; // (undocumented) onPointerDown: TLPointerEvent; @@ -2437,7 +2436,7 @@ export type TLPointerEventInfo = TLBaseEventInfo & { } & TLPointerEventTarget; // @public (undocumented) -export type TLPointerEventName = 'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click'; +export type TLPointerEventName = 'long_press' | 'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click'; // @public (undocumented) export type TLPointerEventTarget = { diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index 9faa05b3d..ac7faa3f9 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -10367,6 +10367,51 @@ "isAbstract": false, "name": "getCroppingShapeId" }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!Editor#getCulledShapes:member(1)", + "docComment": "/**\n * Get culled shapes.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "getCulledShapes(): " + }, + { + "kind": "Reference", + "text": "Set", + "canonicalReference": "!Set:interface" + }, + { + "kind": "Content", + "text": "<" + }, + { + "kind": "Reference", + "text": "TLShapeId", + "canonicalReference": "@tldraw/tlschema!TLShapeId:type" + }, + { + "kind": "Content", + "text": ">" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 1, + "endIndex": 5 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [], + "isOptional": false, + "isAbstract": false, + "name": "getCulledShapes" + }, { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#getCurrentPage:member(1)", @@ -11959,38 +12004,6 @@ "isAbstract": false, "name": "getRenderingBounds" }, - { - "kind": "Method", - "canonicalReference": "@tldraw/editor!Editor#getRenderingBoundsExpanded:member(1)", - "docComment": "/**\n * The current rendering bounds in the current page space, expanded slightly. Used for determining which shapes to render and which to \"cull\".\n *\n * @public\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "getRenderingBoundsExpanded(): " - }, - { - "kind": "Reference", - "text": "Box", - "canonicalReference": "@tldraw/editor!Box:class" - }, - { - "kind": "Content", - "text": ";" - } - ], - "isStatic": false, - "returnTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 - }, - "releaseTag": "Public", - "isProtected": false, - "overloadIndex": 1, - "parameters": [], - "isOptional": false, - "isAbstract": false, - "name": "getRenderingBoundsExpanded" - }, { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#getRenderingShapes:member(1)", @@ -12038,16 +12051,7 @@ }, { "kind": "Content", - "text": ">;\n index: number;\n backgroundIndex: number;\n opacity: number;\n isCulled: boolean;\n maskedPageBounds: " - }, - { - "kind": "Reference", - "text": "Box", - "canonicalReference": "@tldraw/editor!Box:class" - }, - { - "kind": "Content", - "text": " | undefined;\n }[]" + "text": ">;\n index: number;\n backgroundIndex: number;\n opacity: number;\n }[]" }, { "kind": "Content", @@ -12057,7 +12061,7 @@ "isStatic": false, "returnTypeTokenRange": { "startIndex": 1, - "endIndex": 12 + "endIndex": 10 }, "releaseTag": "Public", "isProtected": false, @@ -31139,41 +31143,6 @@ "isProtected": false, "isAbstract": false }, - { - "kind": "Property", - "canonicalReference": "@tldraw/editor!ShapeUtil#canUnmount:member", - "docComment": "/**\n * Whether the shape should unmount when not visible in the editor. Consider keeping this to false if the shape's `component` has local state.\n *\n * @public\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "canUnmount: " - }, - { - "kind": "Reference", - "text": "TLShapeUtilFlag", - "canonicalReference": "@tldraw/editor!TLShapeUtilFlag:type" - }, - { - "kind": "Content", - "text": "" - }, - { - "kind": "Content", - "text": ";" - } - ], - "isReadonly": false, - "isOptional": false, - "releaseTag": "Public", - "name": "canUnmount", - "propertyTypeTokenRange": { - "startIndex": 1, - "endIndex": 3 - }, - "isStatic": false, - "isProtected": false, - "isAbstract": false - }, { "kind": "Method", "canonicalReference": "@tldraw/editor!ShapeUtil#component:member(1)", @@ -35319,6 +35288,41 @@ "isProtected": false, "isAbstract": false }, + { + "kind": "Property", + "canonicalReference": "@tldraw/editor!StateNode#onLongPress:member", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "onLongPress?: " + }, + { + "kind": "Reference", + "text": "TLEventHandlers", + "canonicalReference": "@tldraw/editor!TLEventHandlers:interface" + }, + { + "kind": "Content", + "text": "['onLongPress']" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": true, + "releaseTag": "Public", + "name": "onLongPress", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 3 + }, + "isStatic": false, + "isProtected": false, + "isAbstract": false + }, { "kind": "Property", "canonicalReference": "@tldraw/editor!StateNode#onMiddleClick:member", @@ -38590,6 +38594,34 @@ "endIndex": 2 } }, + { + "kind": "PropertySignature", + "canonicalReference": "@tldraw/editor!TLEventHandlers#onLongPress:member", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "onLongPress: " + }, + { + "kind": "Reference", + "text": "TLPointerEvent", + "canonicalReference": "@tldraw/editor!TLPointerEvent:type" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": false, + "releaseTag": "Public", + "name": "onLongPress", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + } + }, { "kind": "PropertySignature", "canonicalReference": "@tldraw/editor!TLEventHandlers#onMiddleClick:member", @@ -41123,7 +41155,7 @@ }, { "kind": "Content", - "text": "'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click'" + "text": "'long_press' | 'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click'" }, { "kind": "Content", diff --git a/packages/editor/editor.css b/packages/editor/editor.css index 7ed918505..03bbb64f4 100644 --- a/packages/editor/editor.css +++ b/packages/editor/editor.css @@ -24,10 +24,12 @@ /* Z Index */ --layer-background: 100; --layer-grid: 150; + --layer-culled-shapes: 175; --layer-canvas: 200; --layer-shapes: 300; --layer-overlays: 400; --layer-following-indicator: 1000; + --layer-blocker: 10000; /* Misc */ --tl-zoom: 1; @@ -236,6 +238,20 @@ input, contain: strict; } +.tl-culled-shapes { + width: 100%; + height: 100%; + z-index: var(--layer-culled-shapes); + position: absolute; + pointer-events: none; + contain: size layout; +} + +.tl-culled-shapes__canvas { + width: 100%; + height: 100%; +} + .tl-shapes { position: relative; z-index: var(--layer-shapes); @@ -264,18 +280,25 @@ input, position: absolute; top: 0px; left: 0px; + width: 100%; + height: 100%; pointer-events: none; } /* ------------------- Background ------------------- */ -.tl-background { +.tl-background__wrapper { + z-index: var(--layer-background); position: absolute; - background-color: var(--color-background); inset: 0px; height: 100%; width: 100%; - z-index: var(--layer-background); +} + +.tl-background { + background-color: var(--color-background); + width: 100%; + height: 100%; } /* --------------------- Grid Layer --------------------- */ @@ -338,10 +361,13 @@ input, } .tl-shape__culled { - position: relative; + position: absolute; + pointer-events: none; + overflow: visible; + transform-origin: top left; + contain: size layout; background-color: var(--color-culled); - width: 100%; - height: 100%; + z-index: 0; } /* ---------------- Shape Containers ---------------- */ @@ -1480,3 +1506,18 @@ it from receiving any pointer events or affecting the cursor. */ font-size: 12px; font-family: monospace; } + +/* ---------------- Hit test blocker ---------------- */ + +.tl-hit-test-blocker { + position: absolute; + z-index: var(--layer-blocker); + inset: 0px; + width: 100%; + height: 100%; + pointer-events: all; +} + +.tl-hit-test-blocker__hidden { + display: none; +} diff --git a/packages/editor/src/lib/components/Shape.tsx b/packages/editor/src/lib/components/Shape.tsx index 3762290b3..909d51885 100644 --- a/packages/editor/src/lib/components/Shape.tsx +++ b/packages/editor/src/lib/components/Shape.tsx @@ -1,12 +1,10 @@ import { useQuickReactor, useStateTracking } from '@tldraw/state' -import { IdOf } from '@tldraw/store' import { TLShape, TLShapeId } from '@tldraw/tlschema' import { memo, useCallback, useRef } from 'react' import { ShapeUtil } from '../editor/shapes/ShapeUtil' import { useEditor } from '../hooks/useEditor' import { useEditorComponents } from '../hooks/useEditorComponents' import { Mat } from '../primitives/Mat' -import { toDomPrecision } from '../primitives/utils' import { setStyleProperty } from '../utils/dom' import { OptionalErrorBoundary } from './ErrorBoundary' @@ -28,7 +26,6 @@ export const Shape = memo(function Shape({ index, backgroundIndex, opacity, - isCulled, dprMultiple, }: { id: TLShapeId @@ -37,7 +34,6 @@ export const Shape = memo(function Shape({ index: number backgroundIndex: number opacity: number - isCulled: boolean dprMultiple: number }) { const editor = useEditor() @@ -52,6 +48,9 @@ export const Shape = memo(function Shape({ clipPath: 'none', width: 0, height: 0, + x: 0, + y: 0, + isCulled: false, }) useQuickReactor( @@ -71,7 +70,11 @@ export const Shape = memo(function Shape({ } // Page transform - const transform = Mat.toCssString(editor.getShapePageTransform(id)) + const pageTransform = editor.getShapePageTransform(id) + const transform = Mat.toCssString(pageTransform) + const bounds = editor.getShapeGeometry(shape).bounds + + // Update if the tranform has changed if (transform !== prev.transform) { setStyleProperty(containerRef.current, 'transform', transform) setStyleProperty(bgContainerRef.current, 'transform', transform) @@ -81,7 +84,6 @@ export const Shape = memo(function Shape({ // Width / Height // We round the shape width and height up to the nearest multiple of dprMultiple // to avoid the browser making miscalculations when applying the transform. - const bounds = editor.getShapeGeometry(shape).bounds const widthRemainder = bounds.w % dprMultiple const heightRemainder = bounds.h % dprMultiple const width = widthRemainder === 0 ? bounds.w : bounds.w + (dprMultiple - widthRemainder) @@ -117,6 +119,22 @@ export const Shape = memo(function Shape({ [opacity, index, backgroundIndex] ) + useQuickReactor( + 'set display', + () => { + const shape = editor.getShape(id) + if (!shape) return // probably the shape was just deleted + + const culledShapes = editor.getCulledShapes() + const isCulled = culledShapes.has(id) + if (isCulled !== memoizedStuffRef.current.isCulled) { + setStyleProperty(containerRef.current, 'display', isCulled ? 'none' : 'block') + setStyleProperty(bgContainerRef.current, 'display', isCulled ? 'none' : 'block') + memoizedStuffRef.current.isCulled = isCulled + } + }, + [editor] + ) const annotateError = useCallback( (error: any) => editor.annotateError(error, { origin: 'shape', willCrashApp: false }), [editor] @@ -133,21 +151,15 @@ export const Shape = memo(function Shape({ data-shape-type={shape.type} draggable={false} > - {isCulled ? null : ( - - - - )} + + + )}
- {isCulled ? ( - - ) : ( - - - - )} + + +
) @@ -172,23 +184,3 @@ const InnerShapeBackground = memo( }, (prev, next) => prev.shape.props === next.shape.props && prev.shape.meta === next.shape.meta ) - -const CulledShape = function CulledShape({ shapeId }: { shapeId: IdOf }) { - const editor = useEditor() - const culledRef = useRef(null) - - useQuickReactor( - 'set shape stuff', - () => { - const bounds = editor.getShapeGeometry(shapeId).bounds - setStyleProperty( - culledRef.current, - 'transform', - `translate(${toDomPrecision(bounds.minX)}px, ${toDomPrecision(bounds.minY)}px)` - ) - }, - [editor] - ) - - return
-} diff --git a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx index 16a18da6a..a752edb54 100644 --- a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx +++ b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx @@ -3,9 +3,10 @@ import { TLHandle, TLShapeId } from '@tldraw/tlschema' import { dedupe, modulate, objectMapValues } from '@tldraw/utils' import classNames from 'classnames' import { Fragment, JSX, useEffect, useRef, useState } from 'react' -import { COARSE_HANDLE_RADIUS, HANDLE_RADIUS } from '../../constants' +import { COARSE_HANDLE_RADIUS, HANDLE_RADIUS, TEXT_SHADOW_LOD } from '../../constants' import { useCanvasEvents } from '../../hooks/useCanvasEvents' import { useCoarsePointer } from '../../hooks/useCoarsePointer' +import { useContainer } from '../../hooks/useContainer' import { useDocumentEvents } from '../../hooks/useDocumentEvents' import { useEditor } from '../../hooks/useEditor' import { useEditorComponents } from '../../hooks/useEditorComponents' @@ -36,6 +37,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) { const rCanvas = useRef(null) const rHtmlLayer = useRef(null) const rHtmlLayer2 = useRef(null) + const container = useContainer() useScreenBounds(rCanvas) useDocumentEvents() @@ -44,11 +46,37 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) { useGestureEvents(rCanvas) useFixSafariDoubleTapZoomPencilEvents(rCanvas) + const rMemoizedStuff = useRef({ lodDisableTextOutline: false, allowTextOutline: true }) + useQuickReactor( 'position layers', - () => { + function positionLayersWhenCameraMoves() { const { x, y, z } = editor.getCamera() + // This should only run once on first load + if (rMemoizedStuff.current.allowTextOutline && editor.environment.isSafari) { + container.style.setProperty('--tl-text-outline', 'none') + rMemoizedStuff.current.allowTextOutline = false + } + + // And this should only run if we're not in Safari; + // If we're below the lod distance for text shadows, turn them off + if ( + rMemoizedStuff.current.allowTextOutline && + z < TEXT_SHADOW_LOD !== rMemoizedStuff.current.lodDisableTextOutline + ) { + const lodDisableTextOutline = z < TEXT_SHADOW_LOD + container.style.setProperty( + '--tl-text-outline', + lodDisableTextOutline + ? 'none' + : `0 var(--b) 0 var(--color-background), 0 var(--a) 0 var(--color-background), + var(--b) var(--b) 0 var(--color-background), var(--a) var(--b) 0 var(--color-background), + var(--a) var(--a) 0 var(--color-background), var(--b) var(--a) 0 var(--color-background)` + ) + rMemoizedStuff.current.lodDisableTextOutline = lodDisableTextOutline + } + // Because the html container has a width/height of 1px, we // need to create a small offset when zoomed to ensure that // the html container and svg container are lined up exactly. @@ -61,7 +89,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) { setStyleProperty(rHtmlLayer.current, 'transform', transform) setStyleProperty(rHtmlLayer2.current, 'transform', transform) }, - [editor] + [editor, container] ) const events = useCanvasEvents() @@ -105,9 +133,12 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) { {SvgDefs && } - {Background && } + {Background && ( +
+ +
+ )} -
@@ -129,6 +160,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
+ ) } @@ -350,6 +382,30 @@ function ShapesWithSVGs() { ) } +function ReflowIfNeeded() { + const editor = useEditor() + const culledShapesRef = useRef>(new Set()) + useQuickReactor( + 'reflow for culled shapes', + () => { + const culledShapes = editor.getCulledShapes() + if ( + culledShapesRef.current.size === culledShapes.size && + [...culledShapes].every((id) => culledShapesRef.current.has(id)) + ) + return + + culledShapesRef.current = culledShapes + const canvas = document.getElementsByClassName('tl-canvas') + if (canvas.length === 0) return + // This causes a reflow + // https://gist.github.com/paulirish/5d52fb081b3570c81e3a + const _height = (canvas[0] as HTMLDivElement).offsetHeight + }, + [editor] + ) + return null +} function ShapesToDisplay() { const editor = useEditor() @@ -370,6 +426,7 @@ function ShapesToDisplay() { {renderingShapes.map((result) => ( ))} + {editor.environment.isSafari && } ) } @@ -581,3 +638,16 @@ function InFrontOfTheCanvasWrapper() { if (!InFrontOfTheCanvas) return null return } + +function MovingCameraHitTestBlocker() { + const editor = useEditor() + const cameraState = useValue('camera state', () => editor.getCameraState(), [editor]) + + return ( +
+ ) +} diff --git a/packages/editor/src/lib/constants.ts b/packages/editor/src/lib/constants.ts index fbdb79814..418491770 100644 --- a/packages/editor/src/lib/constants.ts +++ b/packages/editor/src/lib/constants.ts @@ -34,10 +34,10 @@ export const DOUBLE_CLICK_DURATION = 450 export const MULTI_CLICK_DURATION = 200 /** @internal */ -export const COARSE_DRAG_DISTANCE = 6 +export const COARSE_DRAG_DISTANCE = 36 // 6 squared /** @internal */ -export const DRAG_DISTANCE = 4 +export const DRAG_DISTANCE = 16 // 4 squared /** @internal */ export const SVG_PADDING = 32 @@ -104,3 +104,9 @@ export const COARSE_HANDLE_RADIUS = 20 /** @internal */ export const HANDLE_RADIUS = 12 + +/** @internal */ +export const LONG_PRESS_DURATION = 500 + +/** @internal */ +export const TEXT_SHADOW_LOD = 0.35 diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 8729d3fa3..db09c3a1a 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -78,6 +78,7 @@ import { FOLLOW_CHASE_ZOOM_UNSNAP, HIT_TEST_MARGIN, INTERNAL_POINTER_IDS, + LONG_PRESS_DURATION, MAX_PAGES, MAX_SHAPES_PER_PAGE, MAX_ZOOM, @@ -100,6 +101,7 @@ import { getReorderingShapesChanges } from '../utils/reorderShapes' import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation' import { uniqueId } from '../utils/uniqueId' import { arrowBindingsIndex } from './derivations/arrowBindingsIndex' +import { notVisibleShapes } from './derivations/notVisibleShapes' import { parentsToChildren } from './derivations/parentsToChildren' import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage' import { getSvgJsx } from './getSvgJsx' @@ -3071,50 +3073,26 @@ export class Editor extends EventEmitter { index: number backgroundIndex: number opacity: number - isCulled: boolean - maskedPageBounds: Box | undefined }[] = [] let nextIndex = MAX_SHAPES_PER_PAGE * 2 let nextBackgroundIndex = MAX_SHAPES_PER_PAGE - // We only really need these if we're using editor state, but that's ok - const editingShapeId = this.getEditingShapeId() - const selectedShapeIds = this.getSelectedShapeIds() const erasingShapeIds = this.getErasingShapeIds() - const renderingBoundsExpanded = this.getRenderingBoundsExpanded() - - // If renderingBoundsMargin is set to Infinity, then we won't cull offscreen shapes - const isCullingOffScreenShapes = Number.isFinite(this.renderingBoundsMargin) const addShapeById = (id: TLShapeId, opacity: number, isAncestorErasing: boolean) => { const shape = this.getShape(id) if (!shape) return opacity *= shape.opacity - let isCulled = false let isShapeErasing = false const util = this.getShapeUtil(shape) - const maskedPageBounds = this.getShapeMaskedPageBounds(id) if (useEditorState) { isShapeErasing = !isAncestorErasing && erasingShapeIds.includes(id) if (isShapeErasing) { opacity *= 0.32 } - - isCulled = - isCullingOffScreenShapes && - // only cull shapes that allow unmounting, i.e. not stateful components - util.canUnmount(shape) && - // never cull editingg shapes - editingShapeId !== id && - // if the shape is fully outside of its parent's clipping bounds... - (maskedPageBounds === undefined || - // ...or if the shape is outside of the expanded viewport bounds... - (!renderingBoundsExpanded.includes(maskedPageBounds) && - // ...and if it's not selected... then cull it - !selectedShapeIds.includes(id))) } renderingShapes.push({ @@ -3124,8 +3102,6 @@ export class Editor extends EventEmitter { index: nextIndex, backgroundIndex: nextBackgroundIndex, opacity, - isCulled, - maskedPageBounds, }) nextIndex += 1 @@ -3195,19 +3171,6 @@ export class Editor extends EventEmitter { /** @internal */ private readonly _renderingBounds = atom('rendering viewport', new Box()) - /** - * The current rendering bounds in the current page space, expanded slightly. Used for determining which shapes - * to render and which to "cull". - * - * @public - */ - getRenderingBoundsExpanded() { - return this._renderingBoundsExpanded.get() - } - - /** @internal */ - private readonly _renderingBoundsExpanded = atom('rendering viewport expanded', new Box()) - /** * Update the rendering bounds. This should be called when the viewport has stopped changing, such * as at the end of a pan, zoom, or animation. @@ -3225,13 +3188,6 @@ export class Editor extends EventEmitter { if (viewportPageBounds.equals(this._renderingBounds.__unsafe__getWithoutCapture())) return this this._renderingBounds.set(viewportPageBounds.clone()) - if (Number.isFinite(this.renderingBoundsMargin)) { - this._renderingBoundsExpanded.set( - viewportPageBounds.clone().expandBy(this.renderingBoundsMargin / this.getZoomLevel()) - ) - } else { - this._renderingBoundsExpanded.set(viewportPageBounds) - } return this } @@ -3272,7 +3228,7 @@ export class Editor extends EventEmitter { * * @public */ - getCurrentPageId(): TLPageId { + @computed getCurrentPageId(): TLPageId { return this.getInstanceState().currentPageId } @@ -4029,6 +3985,33 @@ export class Editor extends EventEmitter { return this.isShapeOrAncestorLocked(this.getShapeParent(shape)) } + @computed + private _notVisibleShapes() { + return notVisibleShapes(this) + } + + /** + * Get culled shapes. + * + * @public + */ + @computed + getCulledShapes() { + const notVisibleShapes = this._notVisibleShapes().get() + const selectedShapeIds = this.getSelectedShapeIds() + const editingId = this.getEditingShapeId() + const culledShapes = new Set(notVisibleShapes) + // we don't cull the shape we are editing + if (editingId) { + culledShapes.delete(editingId) + } + // we also don't cull selected shapes + selectedShapeIds.forEach((id) => { + culledShapes.delete(id) + }) + return culledShapes + } + /** * The bounds of the current page (the common bounds of all of the shapes on the page). * @@ -4111,7 +4094,6 @@ export class Editor extends EventEmitter { if (filter) return filter(shape) return true }) - for (let i = shapesToCheck.length - 1; i >= 0; i--) { const shape = shapesToCheck[i] const geometry = this.getShapeGeometry(shape) @@ -4400,10 +4382,8 @@ export class Editor extends EventEmitter { * @public */ @computed getCurrentPageRenderingShapesSorted(): TLShape[] { - return this.getRenderingShapes() - .filter(({ isCulled }) => !isCulled) - .sort((a, b) => a.index - b.index) - .map(({ shape }) => shape) + const culledShapes = this.getCulledShapes() + return this.getCurrentPageShapesSorted().filter(({ id }) => !culledShapes.has(id)) } /** @@ -7987,6 +7967,9 @@ export class Editor extends EventEmitter { /** @internal */ private _selectedShapeIdsAtPointerDown: TLShapeId[] = [] + /** @internal */ + private _longPressTimeout = -1 as any + /** @internal */ capturedPointerId: number | null = null @@ -8004,7 +7987,13 @@ export class Editor extends EventEmitter { */ dispatch = (info: TLEventInfo): this => { this._pendingEventsForNextTick.push(info) - if (!(info.type === 'pointer' || info.type === 'wheel' || info.type === 'pinch')) { + if ( + !( + (info.type === 'pointer' && info.name === 'pointer_move') || + info.type === 'wheel' || + info.type === 'pinch' + ) + ) { this._flushEventsForTick(0) } return this @@ -8023,8 +8012,8 @@ export class Editor extends EventEmitter { } if (elapsed > 0) { this.root.handleEvent({ type: 'misc', name: 'tick', elapsed }) - this.scribbles.tick(elapsed) } + this.scribbles.tick(elapsed) }) } @@ -8084,6 +8073,7 @@ export class Editor extends EventEmitter { switch (type) { case 'pinch': { if (!this.getInstanceState().canMoveCamera) return + clearTimeout(this._longPressTimeout) this._updateInputsFromEvent(info) switch (info.name) { @@ -8204,10 +8194,11 @@ export class Editor extends EventEmitter { if ( !inputs.isDragging && inputs.isPointing && - originPagePoint.dist(currentPagePoint) > + Vec.Dist2(originPagePoint, currentPagePoint) > (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.getZoomLevel() ) { + clearTimeout(this._longPressTimeout) inputs.isDragging = true } } @@ -8225,6 +8216,10 @@ export class Editor extends EventEmitter { case 'pointer_down': { this.clearOpenMenus() + this._longPressTimeout = setTimeout(() => { + this.dispatch({ ...info, name: 'long_press' }) + }, LONG_PRESS_DURATION) + this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds() // Firefox bug fix... @@ -8289,10 +8284,11 @@ export class Editor extends EventEmitter { if ( !inputs.isDragging && inputs.isPointing && - originPagePoint.dist(currentPagePoint) > + Vec.Dist2(originPagePoint, currentPagePoint) > (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.getZoomLevel() ) { + clearTimeout(this._longPressTimeout) inputs.isDragging = true } break @@ -8435,6 +8431,8 @@ export class Editor extends EventEmitter { break } case 'pointer_up': { + clearTimeout(this._longPressTimeout) + const otherEvent = this._clickManager.transformPointerUpEvent(info) if (info.name !== otherEvent.name) { this.root.handleEvent(info) diff --git a/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts b/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts new file mode 100644 index 000000000..461835500 --- /dev/null +++ b/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts @@ -0,0 +1,105 @@ +import { RESET_VALUE, computed, isUninitialized } from '@tldraw/state' +import { TLPageId, TLShapeId, isShape, isShapeId } from '@tldraw/tlschema' +import { Box } from '../../primitives/Box' +import { Editor } from '../Editor' + +function isShapeNotVisible(editor: Editor, id: TLShapeId, viewportPageBounds: Box): boolean { + const maskedPageBounds = editor.getShapeMaskedPageBounds(id) + // if the shape is fully outside of its parent's clipping bounds... + if (maskedPageBounds === undefined) return true + + // if the shape is fully outside of the viewport page bounds... + return !viewportPageBounds.includes(maskedPageBounds) +} + +/** + * Incremental derivation of not visible shapes. + * Non visible shapes are shapes outside of the viewport page bounds and shapes outside of parent's clipping bounds. + * + * @param editor - Instance of the tldraw Editor. + * @returns Incremental derivation of non visible shapes. + */ +export const notVisibleShapes = (editor: Editor) => { + const isCullingOffScreenShapes = Number.isFinite(editor.renderingBoundsMargin) + const shapeHistory = editor.store.query.filterHistory('shape') + let lastPageId: TLPageId | null = null + let prevViewportPageBounds: Box + + function fromScratch(editor: Editor): Set { + const shapes = editor.getCurrentPageShapeIds() + lastPageId = editor.getCurrentPageId() + const viewportPageBounds = editor.getViewportPageBounds() + prevViewportPageBounds = viewportPageBounds.clone() + const notVisibleShapes = new Set() + shapes.forEach((id) => { + if (isShapeNotVisible(editor, id, viewportPageBounds)) { + notVisibleShapes.add(id) + } + }) + return notVisibleShapes + } + return computed>('getCulledShapes', (prevValue, lastComputedEpoch) => { + if (!isCullingOffScreenShapes) return new Set() + + if (isUninitialized(prevValue)) { + return fromScratch(editor) + } + const diff = shapeHistory.getDiffSince(lastComputedEpoch) + + if (diff === RESET_VALUE) { + return fromScratch(editor) + } + + const currentPageId = editor.getCurrentPageId() + if (lastPageId !== currentPageId) { + return fromScratch(editor) + } + const viewportPageBounds = editor.getViewportPageBounds() + if (!prevViewportPageBounds || !viewportPageBounds.equals(prevViewportPageBounds)) { + return fromScratch(editor) + } + + let nextValue = null as null | Set + const addId = (id: TLShapeId) => { + // Already added + if (prevValue.has(id)) return + if (!nextValue) nextValue = new Set(prevValue) + nextValue.add(id) + } + const deleteId = (id: TLShapeId) => { + // No need to delete since it's not there + if (!prevValue.has(id)) return + if (!nextValue) nextValue = new Set(prevValue) + nextValue.delete(id) + } + + for (const changes of diff) { + for (const record of Object.values(changes.added)) { + if (isShape(record)) { + const isCulled = isShapeNotVisible(editor, record.id, viewportPageBounds) + if (isCulled) { + addId(record.id) + } + } + } + + for (const [_from, to] of Object.values(changes.updated)) { + if (isShape(to)) { + const isCulled = isShapeNotVisible(editor, to.id, viewportPageBounds) + if (isCulled) { + addId(to.id) + } else { + deleteId(to.id) + } + } + } + for (const id of Object.keys(changes.removed)) { + if (isShapeId(id)) { + deleteId(id) + } + } + } + + return nextValue ?? prevValue + }) +} diff --git a/packages/editor/src/lib/editor/getSvgJsx.tsx b/packages/editor/src/lib/editor/getSvgJsx.tsx index 34965135c..1f16b0ce1 100644 --- a/packages/editor/src/lib/editor/getSvgJsx.tsx +++ b/packages/editor/src/lib/editor/getSvgJsx.tsx @@ -38,7 +38,8 @@ export async function getSvgJsx( if (opts.bounds) { bbox = opts.bounds } else { - for (const { maskedPageBounds } of renderingShapes) { + for (const { id } of renderingShapes) { + const maskedPageBounds = editor.getShapeMaskedPageBounds(id) if (!maskedPageBounds) continue if (bbox) { bbox.union(maskedPageBounds) diff --git a/packages/editor/src/lib/editor/managers/ClickManager.ts b/packages/editor/src/lib/editor/managers/ClickManager.ts index ef2c11111..ef5e53b10 100644 --- a/packages/editor/src/lib/editor/managers/ClickManager.ts +++ b/packages/editor/src/lib/editor/managers/ClickManager.ts @@ -227,7 +227,7 @@ export class ClickManager { if ( this._clickState !== 'idle' && this._clickScreenPoint && - this._clickScreenPoint.dist(this.editor.inputs.currentScreenPoint) > + Vec.Dist2(this._clickScreenPoint, this.editor.inputs.currentScreenPoint) > (this.editor.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) ) { this.cancelDoubleClickTimeout() diff --git a/packages/editor/src/lib/editor/shapes/ShapeUtil.ts b/packages/editor/src/lib/editor/shapes/ShapeUtil.ts index d2c3c9553..fcdd38ce4 100644 --- a/packages/editor/src/lib/editor/shapes/ShapeUtil.ts +++ b/packages/editor/src/lib/editor/shapes/ShapeUtil.ts @@ -89,13 +89,6 @@ export abstract class ShapeUtil { */ canScroll: TLShapeUtilFlag = () => false - /** - * Whether the shape should unmount when not visible in the editor. Consider keeping this to false if the shape's `component` has local state. - * - * @public - */ - canUnmount: TLShapeUtilFlag = () => true - /** * Whether the shape can be bound to by an arrow. * diff --git a/packages/editor/src/lib/editor/tools/StateNode.ts b/packages/editor/src/lib/editor/tools/StateNode.ts index 5c5378b63..f170fe90e 100644 --- a/packages/editor/src/lib/editor/tools/StateNode.ts +++ b/packages/editor/src/lib/editor/tools/StateNode.ts @@ -198,6 +198,7 @@ export abstract class StateNode implements Partial { onWheel?: TLEventHandlers['onWheel'] onPointerDown?: TLEventHandlers['onPointerDown'] onPointerMove?: TLEventHandlers['onPointerMove'] + onLongPress?: TLEventHandlers['onLongPress'] onPointerUp?: TLEventHandlers['onPointerUp'] onDoubleClick?: TLEventHandlers['onDoubleClick'] onTripleClick?: TLEventHandlers['onTripleClick'] diff --git a/packages/editor/src/lib/editor/types/event-types.ts b/packages/editor/src/lib/editor/types/event-types.ts index 5fa41deb8..89ab5725e 100644 --- a/packages/editor/src/lib/editor/types/event-types.ts +++ b/packages/editor/src/lib/editor/types/event-types.ts @@ -16,6 +16,7 @@ export type TLPointerEventTarget = export type TLPointerEventName = | 'pointer_down' | 'pointer_move' + | 'long_press' | 'pointer_up' | 'right_click' | 'middle_click' @@ -152,6 +153,7 @@ export type TLExitEventHandler = (info: any, to: string) => void export interface TLEventHandlers { onPointerDown: TLPointerEvent onPointerMove: TLPointerEvent + onLongPress: TLPointerEvent onRightClick: TLPointerEvent onDoubleClick: TLClickEvent onTripleClick: TLClickEvent @@ -176,6 +178,7 @@ export const EVENT_NAME_MAP: Record< wheel: 'onWheel', pointer_down: 'onPointerDown', pointer_move: 'onPointerMove', + long_press: 'onLongPress', pointer_up: 'onPointerUp', right_click: 'onRightClick', middle_click: 'onMiddleClick', diff --git a/packages/editor/src/lib/primitives/Vec.ts b/packages/editor/src/lib/primitives/Vec.ts index 82bf47395..e0ee4b717 100644 --- a/packages/editor/src/lib/primitives/Vec.ts +++ b/packages/editor/src/lib/primitives/Vec.ts @@ -308,19 +308,20 @@ export class Vec { static Per(A: VecLike): Vec { return new Vec(A.y, -A.x) } - - static Dist2(A: VecLike, B: VecLike): number { - return Vec.Sub(A, B).len2() - } - static Abs(A: VecLike): Vec { return new Vec(Math.abs(A.x), Math.abs(A.y)) } + // Get the distance between two points. static Dist(A: VecLike, B: VecLike): number { return Math.hypot(A.y - B.y, A.x - B.x) } + // Get the squared distance between two points. This is faster to calculate (no square root) so useful for "minimum distance" checks where the actual measurement does not matter. + static Dist2(A: VecLike, B: VecLike): number { + return (A.x - B.x) * (A.x - B.x) + (A.y - B.y) * (A.y - B.y) + } + /** * Dot product of two vectors which is used to calculate the angle between them. */ diff --git a/packages/editor/src/lib/primitives/geometry/Arc2d.ts b/packages/editor/src/lib/primitives/geometry/Arc2d.ts index 5feee17ef..3ca170a47 100644 --- a/packages/editor/src/lib/primitives/geometry/Arc2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Arc2d.ts @@ -52,15 +52,16 @@ export class Arc2d extends Geometry2d { // Get the point (P) on the arc, then pick the nearest of A, B, and P const P = _center.clone().add(point.clone().sub(_center).uni().mul(radius)) - let distance = Infinity let nearest: Vec | undefined - for (const pt of [A, B, P]) { - if (point.dist(pt) < distance) { - nearest = pt - distance = point.dist(pt) + let dist = Infinity + let d: number + for (const p of [A, B, P]) { + d = Vec.Dist2(point, p) + if (d < dist) { + nearest = p + dist = d } } - if (!nearest) throw Error('nearest point not found') return nearest } diff --git a/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts b/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts index 9923891b7..912144378 100644 --- a/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts +++ b/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts @@ -55,9 +55,11 @@ export class CubicBezier2d extends Polyline2d { nearestPoint(A: Vec): Vec { let nearest: Vec | undefined let dist = Infinity + let d: number + let p: Vec for (const edge of this.segments) { - const p = edge.nearestPoint(A) - const d = p.dist(A) + p = edge.nearestPoint(A) + d = Vec.Dist2(p, A) if (d < dist) { nearest = p dist = d diff --git a/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts b/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts index 3c362b91b..8ff99bb0a 100644 --- a/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts +++ b/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts @@ -67,15 +67,16 @@ export class CubicSpline2d extends Geometry2d { nearestPoint(A: Vec): Vec { let nearest: Vec | undefined let dist = Infinity + let d: number + let p: Vec for (const segment of this.segments) { - const p = segment.nearestPoint(A) - const d = p.dist(A) + p = segment.nearestPoint(A) + d = Vec.Dist2(p, A) if (d < dist) { nearest = p dist = d } } - if (!nearest) throw Error('nearest point not found') return nearest } diff --git a/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts b/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts index d15b864af..a2340c298 100644 --- a/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts @@ -76,15 +76,16 @@ export class Ellipse2d extends Geometry2d { nearestPoint(A: Vec): Vec { let nearest: Vec | undefined let dist = Infinity + let d: number + let p: Vec for (const edge of this.edges) { - const p = edge.nearestPoint(A) - const d = p.dist(A) + p = edge.nearestPoint(A) + d = Vec.Dist2(p, A) if (d < dist) { nearest = p dist = d } } - if (!nearest) throw Error('nearest point not found') return nearest } diff --git a/packages/editor/src/lib/primitives/geometry/Geometry2d.ts b/packages/editor/src/lib/primitives/geometry/Geometry2d.ts index e028de31e..b7df7a84d 100644 --- a/packages/editor/src/lib/primitives/geometry/Geometry2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Geometry2d.ts @@ -55,14 +55,17 @@ export abstract class Geometry2d { } nearestPointOnLineSegment(A: Vec, B: Vec): Vec { - let distance = Infinity + const { vertices } = this let nearest: Vec | undefined - for (let i = 0; i < this.vertices.length; i++) { - const point = this.vertices[i] - const d = Vec.DistanceToLineSegment(A, B, point) - if (d < distance) { - distance = d - nearest = point + let dist = Infinity + let d: number + let p: Vec + for (let i = 0; i < vertices.length; i++) { + p = vertices[i] + d = Vec.DistanceToLineSegment(A, B, p) + if (d < dist) { + dist = d + nearest = p } } if (!nearest) throw Error('nearest point not found') diff --git a/packages/editor/src/lib/primitives/geometry/Group2d.ts b/packages/editor/src/lib/primitives/geometry/Group2d.ts index 3caeabfc8..b98f1ea99 100644 --- a/packages/editor/src/lib/primitives/geometry/Group2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Group2d.ts @@ -30,8 +30,8 @@ export class Group2d extends Geometry2d { } override nearestPoint(point: Vec): Vec { - let d = Infinity - let p: Vec | undefined + let dist = Infinity + let nearest: Vec | undefined const { children } = this @@ -39,16 +39,18 @@ export class Group2d extends Geometry2d { throw Error('no children') } + let p: Vec + let d: number for (const child of children) { - const nearest = child.nearestPoint(point) - const dist = nearest.dist(point) - if (dist < d) { - d = dist - p = nearest + p = child.nearestPoint(point) + d = Vec.Dist2(p, point) + if (d < dist) { + dist = d + nearest = p } } - if (!p) throw Error('nearest point not found') - return p + if (!nearest) throw Error('nearest point not found') + return nearest } override distanceToPoint(point: Vec, hitInside = false) { diff --git a/packages/editor/src/lib/primitives/geometry/Polyline2d.ts b/packages/editor/src/lib/primitives/geometry/Polyline2d.ts index ffa541cb2..84c8e7471 100644 --- a/packages/editor/src/lib/primitives/geometry/Polyline2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Polyline2d.ts @@ -51,18 +51,17 @@ export class Polyline2d extends Geometry2d { const { segments } = this let nearest = this.points[0] let dist = Infinity - let p: Vec // current point on segment let d: number // distance from A to p for (let i = 0; i < segments.length; i++) { p = segments[i].nearestPoint(A) - d = p.dist(A) + d = Vec.Dist2(p, A) if (d < dist) { nearest = p dist = d } } - + if (!nearest) throw Error('nearest point not found') return nearest } diff --git a/packages/store/api/api.json b/packages/store/api/api.json index 4b35dd4ba..668ba8f04 100644 --- a/packages/store/api/api.json +++ b/packages/store/api/api.json @@ -4201,7 +4201,7 @@ { "kind": "Property", "canonicalReference": "@tldraw/store!Store#onBeforeChange:member", - "docComment": "/**\n * A callback before after each record's change.\n *\n * @param prev - The previous value, if any.\n *\n * @param next - The next value.\n */\n", + "docComment": "/**\n * A callback fired before each record's change.\n *\n * @param prev - The previous value, if any.\n *\n * @param next - The next value.\n */\n", "excerptTokens": [ { "kind": "Content", diff --git a/packages/store/src/lib/Store.ts b/packages/store/src/lib/Store.ts index 968996247..ceb24785c 100644 --- a/packages/store/src/lib/Store.ts +++ b/packages/store/src/lib/Store.ts @@ -311,7 +311,7 @@ export class Store { onAfterCreate?: (record: R, source: 'remote' | 'user') => void /** - * A callback before after each record's change. + * A callback fired before each record's change. * * @param prev - The previous value, if any. * @param next - The next value. diff --git a/packages/store/src/lib/devFreeze.ts b/packages/store/src/lib/devFreeze.ts index d1c9622d4..d481ad37e 100644 --- a/packages/store/src/lib/devFreeze.ts +++ b/packages/store/src/lib/devFreeze.ts @@ -15,6 +15,8 @@ import { STRUCTURED_CLONE_OBJECT_PROTOTYPE } from '@tldraw/utils' * @public */ export function devFreeze(object: T): T { + return object + if (process.env.NODE_ENV === 'production') { return object } diff --git a/packages/tldraw/api-report.md b/packages/tldraw/api-report.md index 608ceb74d..2a861e466 100644 --- a/packages/tldraw/api-report.md +++ b/packages/tldraw/api-report.md @@ -536,8 +536,6 @@ export class EmbedShapeUtil extends BaseBoxShapeUtil { // (undocumented) canResize: (shape: TLEmbedShape) => boolean; // (undocumented) - canUnmount: TLShapeUtilFlag; - // (undocumented) component(shape: TLEmbedShape): JSX_2.Element; // (undocumented) getDefaultProps(): TLEmbedShape['props']; diff --git a/packages/tldraw/api/api.json b/packages/tldraw/api/api.json index 24a04a7c8..4d0d710d6 100644 --- a/packages/tldraw/api/api.json +++ b/packages/tldraw/api/api.json @@ -5705,50 +5705,6 @@ "isProtected": false, "isAbstract": false }, - { - "kind": "Property", - "canonicalReference": "tldraw!EmbedShapeUtil#canUnmount:member", - "docComment": "", - "excerptTokens": [ - { - "kind": "Content", - "text": "canUnmount: " - }, - { - "kind": "Reference", - "text": "TLShapeUtilFlag", - "canonicalReference": "@tldraw/editor!TLShapeUtilFlag:type" - }, - { - "kind": "Content", - "text": "<" - }, - { - "kind": "Reference", - "text": "TLEmbedShape", - "canonicalReference": "@tldraw/tlschema!TLEmbedShape:type" - }, - { - "kind": "Content", - "text": ">" - }, - { - "kind": "Content", - "text": ";" - } - ], - "isReadonly": false, - "isOptional": false, - "releaseTag": "Public", - "name": "canUnmount", - "propertyTypeTokenRange": { - "startIndex": 1, - "endIndex": 5 - }, - "isStatic": false, - "isProtected": false, - "isAbstract": false - }, { "kind": "Method", "canonicalReference": "tldraw!EmbedShapeUtil#component:member(1)", diff --git a/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts b/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts index 049223951..804144248 100644 --- a/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts +++ b/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts @@ -309,7 +309,7 @@ export class Drawing extends StateNode { } const hasMovedFarEnough = - Vec.Dist(pagePointWhereNextSegmentChanged, inputs.currentPagePoint) > DRAG_DISTANCE + Vec.Dist2(pagePointWhereNextSegmentChanged, inputs.currentPagePoint) > DRAG_DISTANCE // Find the distance from where the pointer was when shift was released and // where it is now; if it's far enough away, then update the page point where @@ -380,7 +380,7 @@ export class Drawing extends StateNode { } const hasMovedFarEnough = - Vec.Dist(pagePointWhereNextSegmentChanged, inputs.currentPagePoint) > DRAG_DISTANCE + Vec.Dist2(pagePointWhereNextSegmentChanged, inputs.currentPagePoint) > DRAG_DISTANCE // Find the distance from where the pointer was when shift was released and // where it is now; if it's far enough away, then update the page point where diff --git a/packages/tldraw/src/lib/shapes/embed/EmbedShapeUtil.tsx b/packages/tldraw/src/lib/shapes/embed/EmbedShapeUtil.tsx index 80f11a83e..470fb3e0f 100644 --- a/packages/tldraw/src/lib/shapes/embed/EmbedShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/embed/EmbedShapeUtil.tsx @@ -34,9 +34,6 @@ export class EmbedShapeUtil extends BaseBoxShapeUtil { override hideSelectionBoundsFg: TLShapeUtilFlag = (shape) => !this.canResize(shape) override canEdit: TLShapeUtilFlag = () => true - override canUnmount: TLShapeUtilFlag = (shape: TLEmbedShape) => { - return !!getEmbedInfo(shape.props.url)?.definition?.canUnmount - } override canResize = (shape: TLEmbedShape) => { return !!getEmbedInfo(shape.props.url)?.definition?.doesResize } diff --git a/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx b/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx index 8a6ec0787..16e4495be 100644 --- a/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx +++ b/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx @@ -179,6 +179,11 @@ function usePattern() { const [backgroundUrls, setBackgroundUrls] = useState(defaultPatterns) useEffect(() => { + if (process.env.NODE_ENV === 'test') { + setIsReady(true) + return + } + const promises: Promise<{ zoom: number; url: string; darkMode: boolean }>[] = [] for (let i = 1; i <= Math.ceil(MAX_ZOOM); i++) { diff --git a/packages/tldraw/src/lib/tools/EraserTool/childStates/Pointing.ts b/packages/tldraw/src/lib/tools/EraserTool/childStates/Pointing.ts index 8d3da33aa..bf46a8c01 100644 --- a/packages/tldraw/src/lib/tools/EraserTool/childStates/Pointing.ts +++ b/packages/tldraw/src/lib/tools/EraserTool/childStates/Pointing.ts @@ -4,6 +4,7 @@ import { TLEventHandlers, TLFrameShape, TLGroupShape, + TLPointerEventInfo, TLShapeId, } from '@tldraw/editor' @@ -52,9 +53,13 @@ export class Pointing extends StateNode { this.editor.setErasingShapes([...erasing]) } + override onLongPress: TLEventHandlers['onLongPress'] = (info) => { + this.startErasing(info) + } + override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { if (this.editor.inputs.isDragging) { - this.parent.transition('erasing', info) + this.startErasing(info) } } @@ -74,6 +79,10 @@ export class Pointing extends StateNode { this.cancel() } + private startErasing(info: TLPointerEventInfo) { + this.parent.transition('erasing', info) + } + complete() { const erasingShapeIds = this.editor.getErasingShapeIds() diff --git a/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts b/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts index 8d6e60553..a0138ef0e 100644 --- a/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts +++ b/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts @@ -3,7 +3,10 @@ import { CAMERA_SLIDE_FRICTION, StateNode, TLEventHandlers, Vec } from '@tldraw/ export class Dragging extends StateNode { static override id = 'dragging' + initialCamera = new Vec() + override onEnter = () => { + this.initialCamera = Vec.From(this.editor.getCamera()) this.update() } @@ -16,7 +19,7 @@ export class Dragging extends StateNode { } override onCancel: TLEventHandlers['onCancel'] = () => { - this.complete() + this.parent.transition('idle') } override onComplete = () => { @@ -24,21 +27,27 @@ export class Dragging extends StateNode { } private update() { - const { currentScreenPoint, previousScreenPoint } = this.editor.inputs + const { initialCamera, editor } = this + const { currentScreenPoint, originScreenPoint } = editor.inputs - const delta = Vec.Sub(currentScreenPoint, previousScreenPoint) - - if (Math.abs(delta.x) > 0 || Math.abs(delta.y) > 0) { - this.editor.pan(delta) - } + const delta = Vec.Sub(currentScreenPoint, originScreenPoint).div(editor.getZoomLevel()) + if (delta.len2() === 0) return + editor.setCamera(initialCamera.clone().add(delta)) } private complete() { - this.editor.slideCamera({ - speed: Math.min(2, this.editor.inputs.pointerVelocity.len()), - direction: this.editor.inputs.pointerVelocity, - friction: CAMERA_SLIDE_FRICTION, - }) + const { editor } = this + const { pointerVelocity } = editor.inputs + + const velocityAtPointerUp = Math.min(pointerVelocity.len(), 2) + + if (velocityAtPointerUp > 0.1) { + this.editor.slideCamera({ + speed: velocityAtPointerUp, + direction: pointerVelocity, + friction: CAMERA_SLIDE_FRICTION, + }) + } this.parent.transition('idle') } diff --git a/packages/tldraw/src/lib/tools/HandTool/childStates/Pointing.ts b/packages/tldraw/src/lib/tools/HandTool/childStates/Pointing.ts index e28f74462..e5746c578 100644 --- a/packages/tldraw/src/lib/tools/HandTool/childStates/Pointing.ts +++ b/packages/tldraw/src/lib/tools/HandTool/childStates/Pointing.ts @@ -8,12 +8,20 @@ export class Pointing extends StateNode { this.editor.setCursor({ type: 'grabbing', rotation: 0 }) } - override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { + override onLongPress: TLEventHandlers['onLongPress'] = () => { + this.startDragging() + } + + override onPointerMove: TLEventHandlers['onPointerMove'] = () => { if (this.editor.inputs.isDragging) { - this.parent.transition('dragging', info) + this.startDragging() } } + private startDragging() { + this.parent.transition('dragging') + } + override onPointerUp: TLEventHandlers['onPointerUp'] = () => { this.complete() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCropHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCropHandle.ts index 5e8414ebc..603aaa471 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCropHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCropHandle.ts @@ -29,16 +29,23 @@ export class PointingCropHandle extends StateNode { } override onPointerMove: TLEventHandlers['onPointerMove'] = () => { - const isDragging = this.editor.inputs.isDragging - - if (isDragging) { - this.parent.transition('cropping', { - ...this.info, - onInteractionEnd: this.info.onInteractionEnd, - }) + if (this.editor.inputs.isDragging) { + this.startCropping() } } + override onLongPress: TLEventHandlers['onLongPress'] = () => { + this.startCropping() + } + + private startCropping() { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('cropping', { + ...this.info, + onInteractionEnd: this.info.onInteractionEnd, + }) + } + override onPointerUp: TLEventHandlers['onPointerUp'] = () => { if (this.info.onInteractionEnd) { this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts index 910280191..5ba8b0579 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts @@ -31,10 +31,19 @@ export class PointingHandle extends StateNode { override onPointerMove: TLEventHandlers['onPointerMove'] = () => { if (this.editor.inputs.isDragging) { - this.parent.transition('dragging_handle', this.info) + this.startDraggingHandle() } } + override onLongPress: TLEventHandlers['onLongPress'] = () => { + this.startDraggingHandle() + } + + private startDraggingHandle() { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('dragging_handle', this.info) + } + override onCancel: TLEventHandlers['onCancel'] = () => { this.cancel() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts index 8633629c6..28e5ee041 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts @@ -46,13 +46,20 @@ export class PointingResizeHandle extends StateNode { } override onPointerMove: TLEventHandlers['onPointerMove'] = () => { - const isDragging = this.editor.inputs.isDragging - - if (isDragging) { - this.parent.transition('resizing', this.info) + if (this.editor.inputs.isDragging) { + this.startResizing() } } + override onLongPress: TLEventHandlers['onLongPress'] = () => { + this.startResizing() + } + + private startResizing() { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('resizing', this.info) + } + override onPointerUp: TLEventHandlers['onPointerUp'] = () => { this.complete() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts index fbd95219d..d5d9388c1 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts @@ -28,14 +28,21 @@ export class PointingRotateHandle extends StateNode { this.editor.setCursor({ type: 'default', rotation: 0 }) } - override onPointerMove = () => { - const { isDragging } = this.editor.inputs - - if (isDragging) { - this.parent.transition('rotating', this.info) + override onPointerMove: TLEventHandlers['onPointerMove'] = () => { + if (this.editor.inputs.isDragging) { + this.startRotating() } } + override onLongPress: TLEventHandlers['onLongPress'] = () => { + this.startRotating() + } + + private startRotating() { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('rotating', this.info) + } + override onPointerUp = () => { this.complete() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts index 14ce18f57..8ac57a1e1 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts @@ -25,11 +25,19 @@ export class PointingSelection extends StateNode { override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { if (this.editor.inputs.isDragging) { - if (this.editor.getInstanceState().isReadonly) return - this.parent.transition('translating', info) + this.startTranslating(info) } } + override onLongPress: TLEventHandlers['onLongPress'] = (info) => { + this.startTranslating(info) + } + + private startTranslating(info: TLPointerEventInfo) { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('translating', info) + } + override onDoubleClick?: TLClickEvent | undefined = (info) => { const hoveredShape = this.editor.getHoveredShape() const hitShape = diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts index 4dad8d5e4..7a08ee826 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts @@ -195,11 +195,19 @@ export class PointingShape extends StateNode { override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { if (this.editor.inputs.isDragging) { - if (this.editor.getInstanceState().isReadonly) return - this.parent.transition('translating', info) + this.startTranslating(info) } } + override onLongPress: TLEventHandlers['onLongPress'] = (info) => { + this.startTranslating(info) + } + + private startTranslating(info: TLPointerEventInfo) { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('translating', info) + } + override onCancel: TLEventHandlers['onCancel'] = () => { this.cancel() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/ScribbleBrushing.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/ScribbleBrushing.ts index 477dfe59a..34b9c585e 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/ScribbleBrushing.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/ScribbleBrushing.ts @@ -42,9 +42,7 @@ export class ScribbleBrushing extends StateNode { this.updateScribbleSelection(true) - requestAnimationFrame(() => { - this.editor.updateInstanceState({ brush: null }) - }) + this.editor.updateInstanceState({ brush: null }) } override onExit = () => { diff --git a/packages/tldraw/src/lib/tools/selection-logic/updateHoveredId.ts b/packages/tldraw/src/lib/tools/selection-logic/updateHoveredId.ts index 9f0d44202..f03d333c3 100644 --- a/packages/tldraw/src/lib/tools/selection-logic/updateHoveredId.ts +++ b/packages/tldraw/src/lib/tools/selection-logic/updateHoveredId.ts @@ -1,6 +1,6 @@ -import { Editor, HIT_TEST_MARGIN, TLShape } from '@tldraw/editor' +import { Editor, HIT_TEST_MARGIN, TLShape, throttle } from '@tldraw/editor' -export function updateHoveredId(editor: Editor) { +function _updateHoveredId(editor: Editor) { // todo: consider replacing `get hoveredShapeId` with this; it would mean keeping hoveredShapeId in memory rather than in the store and possibly re-computing it more often than necessary const hitShape = editor.getShapeAtPoint(editor.inputs.currentPagePoint, { hitInside: false, @@ -30,3 +30,6 @@ export function updateHoveredId(editor: Editor) { return editor.setHoveredShape(shapeToHover.id) } + +export const updateHoveredId = + process.env.NODE_ENV === 'test' ? _updateHoveredId : throttle(_updateHoveredId, 32) diff --git a/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx b/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx index f2968a86f..004dedfdf 100644 --- a/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx +++ b/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx @@ -17,13 +17,12 @@ export function BackToContent() { const renderingShapes = editor.getRenderingShapes() const renderingBounds = editor.getRenderingBounds() - // renderingShapes will also include shapes that have the canUnmount flag - // set to true. These shapes will be on the canvas but may not be in the - // viewport... so we also need to narrow down the list to only shapes that - // are ALSO in the viewport. - const visibleShapes = renderingShapes.filter( - (s) => s.maskedPageBounds && renderingBounds.includes(s.maskedPageBounds) - ) + // Rendering shapes includes all the shapes in the current page. + // We have to filter them down to just the shapes that are inside the renderingBounds. + const visibleShapes = renderingShapes.filter((s) => { + const maskedPageBounds = editor.getShapeMaskedPageBounds(s.id) + return maskedPageBounds && renderingBounds.includes(maskedPageBounds) + }) const showBackToContentNow = visibleShapes.length === 0 && editor.getCurrentPageShapes().length > 0 diff --git a/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts b/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts index 993030791..eeef0fd7f 100644 --- a/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts +++ b/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts @@ -189,7 +189,7 @@ export class MinimapManager { this const { width: cw, height: ch } = canvasScreenBounds - const selectedShapeIds = editor.getSelectedShapeIds() + const selectedShapeIds = new Set(editor.getSelectedShapeIds()) const viewportPageBounds = editor.getViewportPageBounds() if (!cvs || !pageBounds) { @@ -215,16 +215,6 @@ export class MinimapManager { ctx.scale(sx, sy) ctx.translate(-contentPageBounds.minX, -contentPageBounds.minY) - // Default radius for rounded rects - const rx = 8 / sx - const ry = 8 / sx - // Min radius - const ax = 1 / sx - const ay = 1 / sx - // Max radius factor - const bx = rx / 4 - const by = ry / 4 - // shapes const shapesPath = new Path2D() const selectedPath = new Path2D() @@ -237,14 +227,11 @@ export class MinimapManager { let pb: Box & { id: TLShapeId } for (let i = 0, n = pageBounds.length; i < n; i++) { pb = pageBounds[i] - MinimapManager.roundedRect( - selectedShapeIds.includes(pb.id) ? selectedPath : shapesPath, + ;(selectedShapeIds.has(pb.id) ? selectedPath : shapesPath).rect( pb.minX, pb.minY, pb.width, - pb.height, - clamp(rx, ax, pb.width / bx), - clamp(ry, ay, pb.height / by) + pb.height ) } diff --git a/packages/tldraw/src/test/getCulledShapes.test.tsx b/packages/tldraw/src/test/getCulledShapes.test.tsx new file mode 100644 index 000000000..53c99bd35 --- /dev/null +++ b/packages/tldraw/src/test/getCulledShapes.test.tsx @@ -0,0 +1,138 @@ +import { Box, TLShapeId, createShapeId } from '@tldraw/editor' +import { TestEditor } from './TestEditor' +import { TL } from './test-jsx' + +let editor: TestEditor + +beforeEach(() => { + editor = new TestEditor() + editor.setScreenBounds({ x: 0, y: 0, w: 1800, h: 900 }) + editor.renderingBoundsMargin = 100 +}) + +function createShapes() { + return editor.createShapesFromJsx([ + , + + + {/* this is outside of the frames clipping bounds, so it should never be rendered */} + + , + ]) +} + +it('lists shapes in viewport', () => { + const ids = createShapes() + editor.selectNone() + // D is clipped and so should always be culled / outside of viewport + expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.D])) + + // Move the camera 201 pixels to the right and 201 pixels down + editor.pan({ x: -201, y: -201 }) + jest.advanceTimersByTime(500) + + // A is now outside of the viewport + expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.D])) + + editor.pan({ x: -900, y: -900 }) + jest.advanceTimersByTime(500) + // Now all shapes are outside of the viewport + expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.B, ids.C, ids.D])) + + editor.select(ids.B) + // We don't cull selected shapes + expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.C, ids.D])) + + editor.setEditingShape(ids.C) + // or shapes being edited + expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.D])) +}) + +const shapeSize = 100 +const numberOfShapes = 100 + +function getChangeOutsideBounds(viewportSize: number) { + const changeDirection = Math.random() > 0.5 ? 1 : -1 + const maxChange = 1000 + const changeAmount = 1 + Math.random() * maxChange + if (changeDirection === 1) { + // We need to get past the viewport size and then add a bit more + return viewportSize + changeAmount + } else { + // We also need to take the shape size into account + return -changeAmount - shapeSize + } +} + +function getChangeInsideBounds(viewportSize: number) { + // We can go from -shapeSize to viewportSize + return -shapeSize + Math.random() * (viewportSize + shapeSize) +} + +function createFuzzShape(viewport: Box) { + const id = createShapeId() + if (Math.random() > 0.5) { + const positionChange = Math.random() + // Should x, or y, or both go outside the bounds? + const dimensionChange = positionChange < 0.33 ? 'x' : positionChange < 0.66 ? 'y' : 'both' + const xOutsideBounds = dimensionChange === 'x' || dimensionChange === 'both' + const yOutsideBounds = dimensionChange === 'y' || dimensionChange === 'both' + + // Create a shape outside the viewport + editor.createShape({ + id, + type: 'geo', + x: + viewport.x + + (xOutsideBounds ? getChangeOutsideBounds(viewport.w) : getChangeInsideBounds(viewport.w)), + y: + viewport.y + + (yOutsideBounds ? getChangeOutsideBounds(viewport.h) : getChangeInsideBounds(viewport.h)), + props: { w: shapeSize, h: shapeSize }, + }) + return { isCulled: true, id } + } else { + // Create a shape inside the viewport + editor.createShape({ + id, + type: 'geo', + x: viewport.x + getChangeInsideBounds(viewport.w), + y: viewport.y + getChangeInsideBounds(viewport.h), + props: { w: shapeSize, h: shapeSize }, + }) + return { isCulled: false, id } + } +} + +it('correctly calculates the culled shapes when adding and deleting shapes', () => { + const viewport = editor.getViewportPageBounds() + const shapes: Array = [] + for (let i = 0; i < numberOfShapes; i++) { + const { isCulled, id } = createFuzzShape(viewport) + shapes.push(id) + if (isCulled) { + expect(editor.getCulledShapes()).toContain(id) + } else { + expect(editor.getCulledShapes()).not.toContain(id) + } + } + const numberOfShapesToDelete = Math.floor((Math.random() * numberOfShapes) / 2) + for (let i = 0; i < numberOfShapesToDelete; i++) { + const index = Math.floor(Math.random() * (shapes.length - 1)) + const id = shapes[index] + if (id) { + editor.deleteShape(id) + shapes[index] = undefined + expect(editor.getCulledShapes()).not.toContain(id) + } + } + + const culledShapesIncremental = editor.getCulledShapes() + + // force full refresh + editor.pan({ x: -1, y: 0 }) + editor.pan({ x: 1, y: 0 }) + + const culledShapeFromScratch = editor.getCulledShapes() + expect(culledShapesIncremental).toEqual(culledShapeFromScratch) +}) diff --git a/packages/tldraw/src/test/renderingShapes.test.tsx b/packages/tldraw/src/test/renderingShapes.test.tsx index 322dfc34f..8b774da5b 100644 --- a/packages/tldraw/src/test/renderingShapes.test.tsx +++ b/packages/tldraw/src/test/renderingShapes.test.tsx @@ -60,47 +60,6 @@ it('updates the rendering viewport when the camera stops moving', () => { expect(editor.getShapePageBounds(ids.A)).toMatchObject({ x: 100, y: 100, w: 100, h: 100 }) }) -it('lists shapes in viewport', () => { - const ids = createShapes() - editor.selectNone() - expect(editor.getRenderingShapes().map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([ - [ids.A, false], // A is within the expanded rendering bounds, so should not be culled; and it's in the regular viewport too, so it's on screen. - [ids.B, false], - [ids.C, false], - [ids.D, true], // D is clipped and so should always be culled / outside of viewport - ]) - - // Move the camera 201 pixels to the right and 201 pixels down - editor.pan({ x: -201, y: -201 }) - jest.advanceTimersByTime(500) - - expect(editor.getRenderingShapes().map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([ - [ids.A, false], // A should not be culled, even though it's no longer in the viewport (because it's still in the EXPANDED viewport) - [ids.B, false], - [ids.C, false], - [ids.D, true], // D is clipped and so should always be culled / outside of viewport - ]) - - editor.pan({ x: -100, y: -100 }) - jest.advanceTimersByTime(500) - - expect(editor.getRenderingShapes().map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([ - [ids.A, true], // A should be culled now that it's outside of the expanded viewport too - [ids.B, false], - [ids.C, false], - [ids.D, true], // D is clipped and so should always be culled, even if it's in the viewport - ]) - - editor.pan({ x: -900, y: -900 }) - jest.advanceTimersByTime(500) - expect(editor.getRenderingShapes().map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([ - [ids.A, true], - [ids.B, true], - [ids.C, true], - [ids.D, true], - ]) -}) - it('lists shapes in viewport sorted by id with correct indexes & background indexes', () => { const ids = createShapes() // Expect the results to be sorted correctly by id diff --git a/packages/tlschema/api-report.md b/packages/tlschema/api-report.md index c272c8d22..d3e74a98a 100644 --- a/packages/tlschema/api-report.md +++ b/packages/tlschema/api-report.md @@ -218,7 +218,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: true; readonly overridePermissions: { readonly 'allow-top-navigation': true; }; @@ -231,7 +230,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: true; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -241,7 +239,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -253,7 +250,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -265,7 +261,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -277,7 +272,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 520; readonly height: 400; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -287,7 +281,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 520; readonly height: 400; readonly doesResize: false; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -297,7 +290,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 800; readonly height: 450; readonly doesResize: true; - readonly canUnmount: false; readonly overridePermissions: { readonly 'allow-presentation': true; }; @@ -313,7 +305,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly minWidth: 460; readonly minHeight: 360; readonly doesResize: true; - readonly canUnmount: false; readonly instructionLink: "https://support.google.com/calendar/answer/41207?hl=en"; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; @@ -326,7 +317,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly minWidth: 460; readonly minHeight: 360; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -336,7 +326,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: true; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -346,7 +335,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -356,7 +344,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -368,7 +355,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly minHeight: 500; readonly overrideOutlineRadius: 12; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -378,7 +364,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 640; readonly height: 360; readonly doesResize: true; - readonly canUnmount: false; readonly isAspectRatioLocked: true; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; @@ -389,7 +374,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: false; readonly isAspectRatioLocked: true; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; @@ -400,7 +384,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: false; readonly isAspectRatioLocked: false; readonly backgroundColor: "#fff"; readonly toEmbedUrl: (url: string) => string | undefined; @@ -417,7 +400,6 @@ export type EmbedDefinition = { readonly width: number; readonly height: number; readonly doesResize: boolean; - readonly canUnmount: boolean; readonly isAspectRatioLocked?: boolean; readonly overridePermissions?: TLEmbedShapePermissions; readonly instructionLink?: string; diff --git a/packages/tlschema/api/api.json b/packages/tlschema/api/api.json index 3ecd1144d..30ee2f5f4 100644 --- a/packages/tlschema/api/api.json +++ b/packages/tlschema/api/api.json @@ -1801,7 +1801,7 @@ }, { "kind": "Content", - "text": "readonly [{\n readonly type: \"tldraw\";\n readonly title: \"tldraw\";\n readonly hostnames: readonly [\"beta.tldraw.com\", \"tldraw.com\", \"localhost:3000\"];\n readonly minWidth: 300;\n readonly minHeight: 300;\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: true;\n readonly overridePermissions: {\n readonly 'allow-top-navigation': true;\n };\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"figma\";\n readonly title: \"Figma\";\n readonly hostnames: readonly [\"figma.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"google_maps\";\n readonly title: \"Google Maps\";\n readonly hostnames: readonly [\"google.*\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"val_town\";\n readonly title: \"Val Town\";\n readonly hostnames: readonly [\"val.town\"];\n readonly minWidth: 260;\n readonly minHeight: 100;\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"codesandbox\";\n readonly title: \"CodeSandbox\";\n readonly hostnames: readonly [\"codesandbox.io\"];\n readonly minWidth: 300;\n readonly minHeight: 300;\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"codepen\";\n readonly title: \"Codepen\";\n readonly hostnames: readonly [\"codepen.io\"];\n readonly minWidth: 300;\n readonly minHeight: 300;\n readonly width: 520;\n readonly height: 400;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"scratch\";\n readonly title: \"Scratch\";\n readonly hostnames: readonly [\"scratch.mit.edu\"];\n readonly width: 520;\n readonly height: 400;\n readonly doesResize: false;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"youtube\";\n readonly title: \"YouTube\";\n readonly hostnames: readonly [\"*.youtube.com\", \"youtube.com\", \"youtu.be\"];\n readonly width: 800;\n readonly height: 450;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly overridePermissions: {\n readonly 'allow-presentation': true;\n };\n readonly isAspectRatioLocked: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"google_calendar\";\n readonly title: \"Google Calendar\";\n readonly hostnames: readonly [\"calendar.google.*\"];\n readonly width: 720;\n readonly height: 500;\n readonly minWidth: 460;\n readonly minHeight: 360;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly instructionLink: \"https://support.google.com/calendar/answer/41207?hl=en\";\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"google_slides\";\n readonly title: \"Google Slides\";\n readonly hostnames: readonly [\"docs.google.*\"];\n readonly width: 720;\n readonly height: 500;\n readonly minWidth: 460;\n readonly minHeight: 360;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"github_gist\";\n readonly title: \"GitHub Gist\";\n readonly hostnames: readonly [\"gist.github.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"replit\";\n readonly title: \"Replit\";\n readonly hostnames: readonly [\"replit.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"felt\";\n readonly title: \"Felt\";\n readonly hostnames: readonly [\"felt.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"spotify\";\n readonly title: \"Spotify\";\n readonly hostnames: readonly [\"open.spotify.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly minHeight: 500;\n readonly overrideOutlineRadius: 12;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"vimeo\";\n readonly title: \"Vimeo\";\n readonly hostnames: readonly [\"vimeo.com\", \"player.vimeo.com\"];\n readonly width: 640;\n readonly height: 360;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly isAspectRatioLocked: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"excalidraw\";\n readonly title: \"Excalidraw\";\n readonly hostnames: readonly [\"excalidraw.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly isAspectRatioLocked: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"observable\";\n readonly title: \"Observable\";\n readonly hostnames: readonly [\"observablehq.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly isAspectRatioLocked: false;\n readonly backgroundColor: \"#fff\";\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}]" + "text": "readonly [{\n readonly type: \"tldraw\";\n readonly title: \"tldraw\";\n readonly hostnames: readonly [\"beta.tldraw.com\", \"tldraw.com\", \"localhost:3000\"];\n readonly minWidth: 300;\n readonly minHeight: 300;\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly overridePermissions: {\n readonly 'allow-top-navigation': true;\n };\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"figma\";\n readonly title: \"Figma\";\n readonly hostnames: readonly [\"figma.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"google_maps\";\n readonly title: \"Google Maps\";\n readonly hostnames: readonly [\"google.*\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"val_town\";\n readonly title: \"Val Town\";\n readonly hostnames: readonly [\"val.town\"];\n readonly minWidth: 260;\n readonly minHeight: 100;\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"codesandbox\";\n readonly title: \"CodeSandbox\";\n readonly hostnames: readonly [\"codesandbox.io\"];\n readonly minWidth: 300;\n readonly minHeight: 300;\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"codepen\";\n readonly title: \"Codepen\";\n readonly hostnames: readonly [\"codepen.io\"];\n readonly minWidth: 300;\n readonly minHeight: 300;\n readonly width: 520;\n readonly height: 400;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"scratch\";\n readonly title: \"Scratch\";\n readonly hostnames: readonly [\"scratch.mit.edu\"];\n readonly width: 520;\n readonly height: 400;\n readonly doesResize: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"youtube\";\n readonly title: \"YouTube\";\n readonly hostnames: readonly [\"*.youtube.com\", \"youtube.com\", \"youtu.be\"];\n readonly width: 800;\n readonly height: 450;\n readonly doesResize: true;\n readonly overridePermissions: {\n readonly 'allow-presentation': true;\n };\n readonly isAspectRatioLocked: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"google_calendar\";\n readonly title: \"Google Calendar\";\n readonly hostnames: readonly [\"calendar.google.*\"];\n readonly width: 720;\n readonly height: 500;\n readonly minWidth: 460;\n readonly minHeight: 360;\n readonly doesResize: true;\n readonly instructionLink: \"https://support.google.com/calendar/answer/41207?hl=en\";\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"google_slides\";\n readonly title: \"Google Slides\";\n readonly hostnames: readonly [\"docs.google.*\"];\n readonly width: 720;\n readonly height: 500;\n readonly minWidth: 460;\n readonly minHeight: 360;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"github_gist\";\n readonly title: \"GitHub Gist\";\n readonly hostnames: readonly [\"gist.github.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"replit\";\n readonly title: \"Replit\";\n readonly hostnames: readonly [\"replit.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"felt\";\n readonly title: \"Felt\";\n readonly hostnames: readonly [\"felt.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"spotify\";\n readonly title: \"Spotify\";\n readonly hostnames: readonly [\"open.spotify.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly minHeight: 500;\n readonly overrideOutlineRadius: 12;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"vimeo\";\n readonly title: \"Vimeo\";\n readonly hostnames: readonly [\"vimeo.com\", \"player.vimeo.com\"];\n readonly width: 640;\n readonly height: 360;\n readonly doesResize: true;\n readonly isAspectRatioLocked: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"excalidraw\";\n readonly title: \"Excalidraw\";\n readonly hostnames: readonly [\"excalidraw.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly isAspectRatioLocked: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"observable\";\n readonly title: \"Observable\";\n readonly hostnames: readonly [\"observablehq.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly isAspectRatioLocked: false;\n readonly backgroundColor: \"#fff\";\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}]" } ], "fileUrlPath": "packages/tlschema/src/shapes/TLEmbedShape.ts", @@ -1824,7 +1824,7 @@ }, { "kind": "Content", - "text": "{\n readonly type: string;\n readonly title: string;\n readonly hostnames: readonly string[];\n readonly minWidth?: number;\n readonly minHeight?: number;\n readonly width: number;\n readonly height: number;\n readonly doesResize: boolean;\n readonly canUnmount: boolean;\n readonly isAspectRatioLocked?: boolean;\n readonly overridePermissions?: " + "text": "{\n readonly type: string;\n readonly title: string;\n readonly hostnames: readonly string[];\n readonly minWidth?: number;\n readonly minHeight?: number;\n readonly width: number;\n readonly height: number;\n readonly doesResize: boolean;\n readonly isAspectRatioLocked?: boolean;\n readonly overridePermissions?: " }, { "kind": "Reference", diff --git a/packages/tlschema/src/migrations.test.ts b/packages/tlschema/src/migrations.test.ts index 377920f2d..f9a6d9548 100644 --- a/packages/tlschema/src/migrations.test.ts +++ b/packages/tlschema/src/migrations.test.ts @@ -2030,6 +2030,34 @@ describe('Fractional indexing for line points', () => { }) }) +describe('add white', () => { + const { up, down } = rootShapeMigrations.migrators[rootShapeVersions.AddWhite] + + test('up works as expected', () => { + expect( + up({ + props: {}, + }) + ).toEqual({ + props: {}, + }) + }) + + test('down works as expected', () => { + expect( + down({ + props: { + color: 'white', + }, + }) + ).toEqual({ + props: { + color: 'black', + }, + }) + }) +}) + /* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */ for (const migrator of allMigrators) { diff --git a/packages/tlschema/src/records/TLShape.ts b/packages/tlschema/src/records/TLShape.ts index 5821ae09e..300c5fad4 100644 --- a/packages/tlschema/src/records/TLShape.ts +++ b/packages/tlschema/src/records/TLShape.ts @@ -87,11 +87,12 @@ export const rootShapeVersions = { AddIsLocked: 1, HoistOpacity: 2, AddMeta: 3, + AddWhite: 4, } as const /** @internal */ export const rootShapeMigrations = defineMigrations({ - currentVersion: rootShapeVersions.AddMeta, + currentVersion: rootShapeVersions.AddWhite, migrators: { [rootShapeVersions.AddIsLocked]: { up: (record) => { @@ -147,6 +148,22 @@ export const rootShapeMigrations = defineMigrations({ } }, }, + [rootShapeVersions.AddWhite]: { + up: (record) => { + return { + ...record, + } + }, + down: (record) => { + return { + ...record, + props: { + ...record.props, + color: record.props.color === 'white' ? 'black' : record.props.color, + }, + } + }, + }, }, }) diff --git a/packages/tlschema/src/shapes/TLEmbedShape.ts b/packages/tlschema/src/shapes/TLEmbedShape.ts index 6037f2aad..416ab83a3 100644 --- a/packages/tlschema/src/shapes/TLEmbedShape.ts +++ b/packages/tlschema/src/shapes/TLEmbedShape.ts @@ -24,7 +24,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: true, overridePermissions: { 'allow-top-navigation': true, }, @@ -50,7 +49,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: true, toEmbedUrl: (url) => { if ( !!url.match( @@ -81,7 +79,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { if (url.includes('/maps/')) { const match = url.match(/@(.*),(.*),(.*)z/) @@ -120,7 +117,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) // e.g. extract "steveruizok.mathFact" from https://www.val.town/v/steveruizok.mathFact @@ -149,7 +145,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) const matches = urlObj && urlObj.pathname.match(/\/s\/([^/]+)\/?/) @@ -176,7 +171,6 @@ export const EMBED_DEFINITIONS = [ width: 520, height: 400, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { const CODEPEN_URL_REGEXP = /https:\/\/codepen.io\/([^/]+)\/pen\/([^/]+)/ const matches = url.match(CODEPEN_URL_REGEXP) @@ -203,7 +197,6 @@ export const EMBED_DEFINITIONS = [ width: 520, height: 400, doesResize: false, - canUnmount: false, toEmbedUrl: (url) => { const SCRATCH_URL_REGEXP = /https?:\/\/scratch.mit.edu\/projects\/([^/]+)/ const matches = url.match(SCRATCH_URL_REGEXP) @@ -230,7 +223,6 @@ export const EMBED_DEFINITIONS = [ width: 800, height: 450, doesResize: true, - canUnmount: false, overridePermissions: { 'allow-presentation': true, }, @@ -275,7 +267,6 @@ export const EMBED_DEFINITIONS = [ minWidth: 460, minHeight: 360, doesResize: true, - canUnmount: false, instructionLink: 'https://support.google.com/calendar/answer/41207?hl=en', toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) @@ -318,7 +309,6 @@ export const EMBED_DEFINITIONS = [ minWidth: 460, minHeight: 360, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) @@ -353,7 +343,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: true, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.pathname.match(/\/([^/]+)\/([^/]+)/)) { @@ -378,7 +367,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.pathname.match(/\/@([^/]+)\/([^/]+)/)) { @@ -406,7 +394,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.pathname.match(/^\/map\//)) { @@ -432,7 +419,6 @@ export const EMBED_DEFINITIONS = [ minHeight: 500, overrideOutlineRadius: 12, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.pathname.match(/^\/(artist|album)\//)) { @@ -455,7 +441,6 @@ export const EMBED_DEFINITIONS = [ width: 640, height: 360, doesResize: true, - canUnmount: false, isAspectRatioLocked: true, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) @@ -486,7 +471,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: false, isAspectRatioLocked: true, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) @@ -510,7 +494,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: false, isAspectRatioLocked: false, backgroundColor: '#fff', toEmbedUrl: (url) => { @@ -619,7 +602,6 @@ export type EmbedDefinition = { readonly width: number readonly height: number readonly doesResize: boolean - readonly canUnmount: boolean readonly isAspectRatioLocked?: boolean readonly overridePermissions?: TLEmbedShapePermissions readonly instructionLink?: string diff --git a/packages/utils/api-report.md b/packages/utils/api-report.md index e1379999c..022651e9a 100644 --- a/packages/utils/api-report.md +++ b/packages/utils/api-report.md @@ -176,6 +176,15 @@ export function mapObjectMapValues( [K in Key]: ValueAfter; }; +// @internal (undocumented) +export function measureAverageDuration(_target: any, propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor; + +// @internal (undocumented) +export function measureCbDuration(name: string, cb: () => any): any; + +// @internal (undocumented) +export function measureDuration(_target: any, propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor; + // @public export class MediaHelpers { static getImageSize(blob: Blob): Promise<{ diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index b008b9753..90273933b 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -36,6 +36,7 @@ export { objectMapKeys, objectMapValues, } from './lib/object' +export { measureAverageDuration, measureCbDuration, measureDuration } from './lib/perf' export { PngHelpers } from './lib/png' export { type IndexKey } from './lib/reordering/IndexKey' export { diff --git a/packages/utils/src/lib/perf.ts b/packages/utils/src/lib/perf.ts new file mode 100644 index 000000000..e6ac86450 --- /dev/null +++ b/packages/utils/src/lib/perf.ts @@ -0,0 +1,50 @@ +/** @internal */ +export function measureCbDuration(name: string, cb: () => any) { + const now = performance.now() + const result = cb() + // eslint-disable-next-line no-console + console.log(`${name} took`, performance.now() - now, 'ms') + return result +} + +/** @internal */ +export function measureDuration(_target: any, propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value + descriptor.value = function (...args: any[]) { + const start = performance.now() + const result = originalMethod.apply(this, args) + const end = performance.now() + // eslint-disable-next-line no-console + console.log(`${propertyKey} took ${end - start}ms `) + return result + } + return descriptor +} + +const averages = new Map() + +/** @internal */ +export function measureAverageDuration( + _target: any, + propertyKey: string, + descriptor: PropertyDescriptor +) { + const originalMethod = descriptor.value + descriptor.value = function (...args: any[]) { + const start = performance.now() + const result = originalMethod.apply(this, args) + const end = performance.now() + const value = averages.get(descriptor.value)! + const length = end - start + const total = value.total + length + const count = value.count + 1 + averages.set(descriptor.value, { total, count }) + // eslint-disable-next-line no-console + console.log( + `${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms` + ) + return result + } + averages.set(descriptor.value, { total: 0, count: 0 }) + return descriptor +}