tldraw/state/state.ts
2021-07-12 12:17:47 +01:00

2418 lines
69 KiB
TypeScript

import { createSelectorHook, createState } from '@state-designer/react'
import { updateFromCode } from './code/generate'
import { createShape, getShapeUtils } from './shape-utils'
import * as Sessions from './sessions'
import inputs from './inputs'
import history from './history'
import storage from './storage'
import session from './session'
import clipboard from './clipboard'
import commands from './commands'
import { dark, light } from 'styles'
import {
vec,
getCommonBounds,
rotateBounds,
getBoundsCenter,
deepClone,
pointInBounds,
uniqueId,
boundsContain,
boundsCollide,
} from 'utils'
import tld from '../utils/tld'
import {
Data,
PointerInfo,
Shape,
ShapeType,
Corner,
Edge,
CodeControl,
MoveType,
ShapeStyles,
DistributeType,
AlignType,
StretchType,
DashStyle,
SizeStyle,
ColorStyle,
ShapeTreeNode,
} from 'types'
import { getFontSize } from './shape-styles'
import logger from './logger'
const initialData: Data = {
isReadOnly: false,
settings: {
fontSize: 13,
isTestMode: false,
isDarkMode: true,
isCodeOpen: false,
isDebugMode: false,
isDebugOpen: false,
isStyleOpen: false,
isToolLocked: false,
isPenLocked: false,
nudgeDistanceLarge: 10,
nudgeDistanceSmall: 1,
},
currentStyle: {
size: SizeStyle.Medium,
color: ColorStyle.Black,
dash: DashStyle.Draw,
isFilled: false,
},
brush: undefined,
activeTool: 'select',
pointedId: null,
hoveredId: null,
editingId: null,
boundsRotation: 0,
currentPageId: 'page1',
currentParentId: 'page1',
currentCodeFileId: 'file0',
codeControls: {},
document: {
id: '0001',
name: 'My Document',
pages: {
page1: {
id: 'page1',
type: 'page',
name: 'Page 1',
childIndex: 0,
shapes: {},
},
},
code: {
file0: {
id: 'file0',
name: 'index.ts',
code: `
const draw = new Draw({
points: [
...Utils.getPointsBetween([0, 0], [20, 50]),
...Utils.getPointsBetween([20, 50], [100, 20], 3),
...Utils.getPointsBetween([100, 20], [100, 100], 10),
[100, 100],
],
})
const rectangle = new Rectangle({
point: [200, 0],
style: {
color: ColorStyle.Blue,
},
})
const ellipse = new Ellipse({
point: [400, 0],
})
const arrow = new Arrow({
start: [600, 0],
end: [700, 100],
})
const radius = 1000
const count = 100
const center = [350, 50]
for (let i = 0; i < count; i++) {
const point = Vec.rotWith(
Vec.add(center, [radius, 0]),
center,
(Math.PI * 2 * i) / count
)
const dot = new Dot({
point,
})
}
`,
},
},
},
pageStates: {
page1: {
id: 'page1',
selectedIds: [],
camera: {
point: [0, 0],
zoom: 1,
},
},
},
}
const state = createState({
data: initialData,
onEnter: 'applyTheme',
on: {
TOGGLED_DEBUG_PANEL: 'toggleDebugPanel',
TOGGLED_DEBUG_MODE: 'toggleDebugMode',
TOGGLED_TEST_MODE: 'toggleTestMode',
TOGGLED_LOGGER: 'toggleLogger',
COPIED_DEBUG_LOG: 'copyDebugLog',
LOADED_FROM_SNAPSHOT: {
unless: 'isInSession',
do: ['loadDocumentFromJson', 'resetHistory'],
},
},
initial: 'loading',
states: {
loading: {
on: {
MOUNTED: [
'resetHistory',
'resetStorage',
'restoredPreviousDocument',
{ to: 'ready' },
],
},
},
ready: {
onEnter: [
'applyTheme',
{
wait: 0.01,
if: 'hasSelection',
do: 'zoomCameraToSelectionActual',
else: ['zoomCameraToActual'],
},
],
on: {
UNMOUNTED: {
do: ['saveDocumentState', 'resetDocumentState'],
to: 'loading',
},
RESIZED_WINDOW: 'resetPageState',
RESET_DOCUMENT_STATE: [
'resetHistory',
'resetDocumentState',
{ to: 'selecting' },
],
TOGGLED_READ_ONLY: 'toggleReadOnly',
LOADED_FONTS: 'resetShapes',
USED_PEN_DEVICE: 'enablePenLock',
DISABLED_PEN_LOCK: 'disablePenLock',
TOGGLED_CODE_PANEL_OPEN: ['toggleCodePanel', 'saveAppState'],
TOGGLED_STYLE_PANEL_OPEN: 'toggleStylePanel',
PANNED_CAMERA: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'panCamera',
},
POINTED_CANVAS: ['closeStylePanel', 'clearCurrentParentId'],
COPIED_STATE_TO_CLIPBOARD: 'copyStateToClipboard',
COPIED: { if: 'hasSelection', do: 'copyToClipboard' },
PASTED: {
unlessAny: ['isReadOnly', 'isInSession'],
do: 'pasteFromClipboard',
},
PASTED_SHAPES_FROM_CLIPBOARD: {
unlessAny: ['isReadOnly', 'isInSession'],
do: 'pasteShapesFromClipboard',
},
TOGGLED_DARK_MODE: {
do: ['toggleDarkMode', 'applyTheme'],
},
TOGGLED_SHAPE_LOCK: {
unlessAny: ['isReadOnly', 'isInSession'],
if: 'hasSelection',
do: 'lockSelection',
},
TOGGLED_SHAPE_HIDE: {
unlessAny: ['isReadOnly', 'isInSession'],
if: 'hasSelection',
do: 'hideSelection',
},
TOGGLED_SHAPE_ASPECT_LOCK: {
unlessAny: ['isReadOnly', 'isInSession'],
if: 'hasSelection',
do: 'aspectLockSelection',
},
CHANGED_STYLE: {
unlessAny: ['isReadOnly', 'isInSession'],
do: ['updateStyles', 'applyStylesToSelection'],
},
FORCE_CLEARED_PAGE: ['selectAll', 'deleteSelection'],
CLEARED_PAGE: {
unlessAny: ['isReadOnly', 'isInSession'],
if: 'hasSelection',
do: 'deleteSelection',
else: ['selectAll', 'deleteSelection'],
},
CREATED_PAGE: {
unless: ['isReadOnly', 'isInSession'],
do: ['clearSelectedIds', 'createPage'],
},
DELETED_PAGE: {
unlessAny: ['isReadOnly', 'isInSession', 'hasOnlyOnePage'],
do: 'deletePage',
},
SELECTED_SELECT_TOOL: {
unless: 'isInSession',
to: 'selecting',
},
SELECTED_DRAW_TOOL: {
unlessAny: ['isReadOnly', 'isInSession'],
to: 'draw',
},
SELECTED_ARROW_TOOL: {
unless: ['isReadOnly', 'isInSession'],
to: 'arrow',
},
SELECTED_DOT_TOOL: {
unless: ['isReadOnly', 'isInSession'],
to: 'dot',
},
SELECTED_ELLIPSE_TOOL: {
unless: ['isReadOnly', 'isInSession'],
to: 'ellipse',
},
SELECTED_RAY_TOOL: {
unless: ['isReadOnly', 'isInSession'],
to: 'ray',
},
SELECTED_LINE_TOOL: {
unless: ['isReadOnly', 'isInSession'],
to: 'line',
},
SELECTED_POLYLINE_TOOL: {
unless: ['isReadOnly', 'isInSession'],
to: 'polyline',
},
SELECTED_RECTANGLE_TOOL: {
unless: ['isReadOnly', 'isInSession'],
to: 'rectangle',
},
SELECTED_TEXT_TOOL: {
unless: ['isReadOnly', 'isInSession'],
to: 'text',
},
GENERATED_FROM_CODE: {
unless: ['isReadOnly', 'isInSession'],
do: ['setCodeControls', 'setGeneratedShapes'],
},
UNDO: {
unless: ['isReadOnly', 'isInSession'],
do: 'undo',
},
REDO: {
unless: ['isReadOnly', 'isInSession'],
do: 'redo',
},
SAVED: {
unlessAny: ['isInSession', 'isReadOnly'],
do: ['saveDocumentState', 'saveToFileSystem'],
},
LOADED_FROM_FILE: {
unless: 'isInSession',
do: ['loadDocumentFromJson', 'resetHistory'],
},
DESELECTED_ALL: {
unless: 'isInSession',
do: 'deselectAll',
to: 'selecting',
},
SELECTED_ALL: {
unless: 'isInSession',
do: 'selectAll',
to: 'selecting',
},
CHANGED_PAGE: {
unless: 'isInSession',
do: 'changePage',
},
ZOOMED_TO_ACTUAL: {
if: 'hasSelection',
do: 'zoomCameraToSelectionActual',
else: 'zoomCameraToActual',
},
ZOOMED_CAMERA: 'zoomCamera',
INCREASED_CODE_FONT_SIZE: 'increaseCodeFontSize',
DECREASED_CODE_FONT_SIZE: 'decreaseCodeFontSize',
CHANGED_CODE_CONTROL: { to: 'updatingControls' },
TOGGLED_TOOL_LOCK: 'toggleToolLock',
ZOOMED_TO_SELECTION: {
if: 'hasSelection',
do: 'zoomCameraToSelection',
else: 'zoomCameraToFit',
},
ZOOMED_TO_CONTENT: 'zoomCameraToContent',
ZOOMED_TO_FIT: 'zoomCameraToFit',
ZOOMED_IN: 'zoomIn',
ZOOMED_OUT: 'zoomOut',
RESET_CAMERA: 'resetCamera',
COPIED_TO_SVG: 'copyToSvg',
LOADED_FROM_FILE_STSTEM: 'loadFromFileSystem',
SAVED_AS_TO_FILESYSTEM: ['saveDocumentState', 'saveAsToFileSystem'],
SAVED_TO_FILESYSTEM: [
'saveDocumentState',
{
unless: 'isReadOnly',
then: {
if: 'isReadOnly',
do: 'saveAsToFileSystem',
else: 'saveToFileSystem',
},
},
],
},
initial: 'selecting',
states: {
selecting: {
onEnter: ['setActiveToolSelect', 'clearInputs'],
on: {
KEYBOARD_PANNED_CAMERA: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'panCamera',
},
STARTED_PINCHING: {
unless: 'isInSession',
to: 'pinching.selectPinching',
},
DELETED: {
unless: 'isReadOnly',
do: 'deleteSelection',
},
SAVED_CODE: {
unless: 'isReadOnly',
do: 'saveCode',
},
MOVED_TO_PAGE: {
unless: 'isReadOnly',
if: 'hasSelection',
do: ['moveSelectionToPage', 'zoomCameraToSelectionActual'],
},
MOVED: {
unless: 'isReadOnly',
if: 'hasSelection',
do: 'moveSelection',
},
DUPLICATED: {
unless: 'isReadOnly',
if: 'hasSelection',
do: 'duplicateSelection',
},
ROTATED_CCW: {
unless: 'isReadOnly',
if: 'hasSelection',
do: 'rotateSelectionCcw',
},
ALIGNED: {
unless: 'isReadOnly',
if: 'hasMultipleSelection',
do: 'alignSelection',
},
STRETCHED: {
unless: 'isReadOnly',
if: 'hasMultipleSelection',
do: 'stretchSelection',
},
DISTRIBUTED: {
unless: 'isReadOnly',
if: 'hasMultipleSelection',
do: 'distributeSelection',
},
GROUPED: {
unless: 'isReadOnly',
if: 'hasMultipleSelection',
do: 'groupSelection',
},
UNGROUPED: {
unless: 'isReadOnly',
if: ['hasSelection', 'selectionIncludesGroups'],
do: 'ungroupSelection',
},
MOVED_OVER_SHAPE: {
if: 'pointHitsShape',
then: {
unless: 'shapeIsHovered',
do: 'setHoveredId',
},
else: {
if: 'shapeIsHovered',
do: 'clearHoveredId',
},
},
UNHOVERED_SHAPE: 'clearHoveredId',
NUDGED: 'nudgeSelection',
},
initial: 'notPointing',
states: {
notPointing: {
onEnter: 'clearPointedId',
on: {
CANCELLED: {
if: 'hasCurrentParentShape',
do: ['selectCurrentParentId', 'raiseCurrentParentId'],
else: 'clearSelectedIds',
},
POINTED_CANVAS: {
to: 'brushSelecting',
do: 'setCurrentParentIdToPage',
},
DOUBLE_POINTED_CANVAS: [
{
get: 'newText',
do: 'createShape',
},
{
get: 'firstSelectedShape',
if: 'canEditSelectedShape',
do: 'setEditingId',
to: 'editingShape',
},
],
POINTED_BOUNDS: [
{
if: 'isPressingMetaKey',
to: 'brushSelecting',
},
{ to: 'pointingBounds' },
],
POINTED_BOUNDS_HANDLE: {
unless: 'isReadOnly',
if: 'isPointingRotationHandle',
to: 'rotatingSelection',
else: { to: 'transformingSelection' },
},
STARTED_EDITING_SHAPE: {
unless: 'isReadOnly',
get: 'firstSelectedShape',
if: ['hasSingleSelection', 'canEditSelectedShape'],
do: 'setEditingId',
to: 'editingShape',
},
DOUBLE_POINTED_BOUNDS_HANDLE: {
unless: 'isReadOnly',
if: 'hasSingleSelection',
do: 'resetShapeBounds',
},
POINTED_HANDLE: {
unless: 'isReadOnly',
to: 'translatingHandles',
},
POINTED_SHAPE: [
{
if: 'isPressingMetaKey',
to: 'brushSelecting',
},
'setPointedId',
{
if: 'pointInSelectionBounds',
to: 'pointingBounds',
},
{
unless: 'isPointedShapeSelected',
then: {
if: 'isPressingShiftKey',
do: ['pushPointedIdToSelectedIds', 'clearPointedId'],
else: ['clearSelectedIds', 'pushPointedIdToSelectedIds'],
},
},
{
to: 'pointingBounds',
},
],
DOUBLE_POINTED_SHAPE: [
'setPointedId',
{
if: 'isPointedShapeSelected',
then: {
get: 'firstSelectedShape',
if: 'canEditSelectedShape',
do: 'setEditingId',
to: 'editingShape',
},
},
{
unless: 'isPressingShiftKey',
do: [
'setCurrentParentId',
'clearSelectedIds',
'pushPointedIdToSelectedIds',
],
to: 'pointingBounds',
},
],
RIGHT_POINTED: [
{
if: 'isPointingCanvas',
do: 'clearSelectedIds',
else: {
if: 'isPointingShape',
then: [
'setPointedId',
{
unless: 'isPointedShapeSelected',
do: [
'clearSelectedIds',
'pushPointedIdToSelectedIds',
],
},
],
},
},
],
},
},
pointingBounds: {
on: {
CANCELLED: { to: 'notPointing' },
STOPPED_POINTING_BOUNDS: [],
STOPPED_POINTING: [
{
if: 'isPointingBounds',
do: 'clearSelectedIds',
},
{
if: 'isPressingShiftKey',
then: {
if: 'isPointedShapeSelected',
do: 'pullPointedIdFromSelectedIds',
},
else: {
if: 'isPointingShape',
do: [
'clearSelectedIds',
'setPointedId',
'pushPointedIdToSelectedIds',
],
},
},
{ to: 'notPointing' },
],
MOVED_POINTER: {
unless: ['isReadOnly', 'isInSession'],
if: 'distanceImpliesDrag',
to: 'translatingSelection',
},
},
},
rotatingSelection: {
onEnter: 'startRotateSession',
onExit: ['completeSession', 'clearBoundsRotation'],
on: {
MOVED_POINTER: 'updateRotateSession',
PANNED_CAMERA: 'updateRotateSession',
PRESSED_SHIFT_KEY: 'keyUpdateRotateSession',
RELEASED_SHIFT_KEY: 'keyUpdateRotateSession',
STOPPED_POINTING: { to: 'selecting' },
CANCELLED: { do: 'cancelSession', to: 'selecting' },
},
},
transformingSelection: {
onEnter: 'startTransformSession',
onExit: 'completeSession',
on: {
MOVED_POINTER: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateTransformSession',
},
PANNED_CAMERA: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateTransformSession',
},
PRESSED_SHIFT_KEY: 'keyUpdateTransformSession',
RELEASED_SHIFT_KEY: 'keyUpdateTransformSession',
STOPPED_POINTING: { to: 'selecting' },
CANCELLED: { do: 'cancelSession', to: 'selecting' },
},
},
translatingSelection: {
onEnter: 'startTranslateSession',
onExit: 'completeSession',
on: {
STARTED_PINCHING: { to: 'pinching' },
MOVED_POINTER: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateTranslateSession',
},
PANNED_CAMERA: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateTranslateSession',
},
PRESSED_SHIFT_KEY: 'keyUpdateTranslateSession',
RELEASED_SHIFT_KEY: 'keyUpdateTranslateSession',
PRESSED_ALT_KEY: 'keyUpdateTranslateSession',
RELEASED_ALT_KEY: 'keyUpdateTranslateSession',
STOPPED_POINTING: { to: 'selecting' },
CANCELLED: { do: 'cancelSession', to: 'selecting' },
},
},
translatingHandles: {
onEnter: 'startHandleSession',
onExit: 'completeSession',
on: {
MOVED_POINTER: 'updateHandleSession',
PANNED_CAMERA: 'updateHandleSession',
PRESSED_SHIFT_KEY: 'keyUpdateHandleSession',
RELEASED_SHIFT_KEY: 'keyUpdateHandleSession',
STOPPED_POINTING: { to: 'selecting' },
DOUBLE_POINTED_HANDLE: {
do: ['cancelSession', 'doublePointHandle'],
to: 'selecting',
},
CANCELLED: { do: 'cancelSession', to: 'selecting' },
},
},
brushSelecting: {
onExit: 'completeSession',
onEnter: [
{
unless: ['isPressingMetaKey', 'isPressingShiftKey'],
do: 'clearSelectedIds',
},
'clearBoundsRotation',
'startBrushSession',
],
on: {
MOVED_POINTER: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateBrushSession',
},
PANNED_CAMERA: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateBrushSession',
},
STOPPED_POINTING: { to: 'selecting' },
STARTED_PINCHING: { to: 'pinching' },
CANCELLED: { do: 'cancelSession', to: 'selecting' },
},
},
updatingControls: {
onEnter: 'updateControls',
async: {
await: 'getUpdatedShapes',
onResolve: { do: 'updateGeneratedShapes', to: 'selecting' },
},
},
},
},
editingShape: {
onEnter: ['startEditSession', 'clearHoveredId'],
onExit: ['completeSession', 'clearEditingId'],
on: {
EDITED_SHAPE: { do: 'updateEditSession' },
POINTED_SHAPE: [
{
unless: 'isPointingEditingShape',
if: 'isPointingTextShape',
do: [
'completeSession',
'clearEditingId',
'setPointedId',
'clearSelectedIds',
'pushPointedIdToSelectedIds',
'setEditingId',
'startEditSession',
],
},
],
BLURRED_EDITING_SHAPE: [
{
unless: 'isEditingShape',
get: 'editingShape',
if: 'shouldDeleteShape',
do: ['cancelSession', 'deleteSelection'],
},
{ to: 'selecting' },
],
POINTED_CANVAS: [
{
unless: 'isEditingShape',
get: 'editingShape',
if: 'shouldDeleteShape',
do: ['cancelSession', 'deleteSelection'],
},
{ to: 'selecting' },
],
CANCELLED: [
{
unless: 'isEditingShape',
get: 'editingShape',
if: 'shouldDeleteShape',
do: ['cancelSession', 'deleteSelection'],
},
{ to: 'selecting' },
],
},
},
pinching: {
on: {
PINCHED: { if: 'isTestMode', do: 'pinchCamera' },
},
initial: 'selectPinching',
onExit: { secretlyDo: 'updateZoomCSS' },
states: {
selectPinching: {
on: {
STOPPED_PINCHING: { to: 'selecting' },
},
},
toolPinching: {
on: {
STOPPED_PINCHING: { to: 'usingTool.previous' },
},
},
},
},
usingTool: {
initial: 'draw',
onEnter: 'clearSelectedIds',
on: {
STARTED_PINCHING: {
do: 'breakSession',
to: 'pinching.toolPinching',
},
},
states: {
draw: {
onEnter: 'setActiveToolDraw',
initial: 'creating',
states: {
creating: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_SHAPE: {
get: 'newDraw',
do: 'createShape',
to: 'draw.editing',
},
POINTED_CANVAS: {
get: 'newDraw',
do: 'createShape',
to: 'draw.editing',
},
STOPPED_POINTING: {
to: 'draw.creating',
},
},
},
editing: {
onEnter: 'startDrawSession',
onExit: 'completeSession',
on: {
CANCELLED: {
do: 'breakSession',
to: 'selecting',
},
STOPPED_POINTING: {
do: 'completeSession',
to: 'draw.creating',
},
PRESSED_SHIFT: 'keyUpdateDrawSession',
RELEASED_SHIFT: 'keyUpdateDrawSession',
PANNED_CAMERA: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateDrawSession',
},
MOVED_POINTER: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateDrawSession',
},
},
},
},
},
dot: {
onEnter: 'setActiveToolDot',
initial: 'creating',
states: {
creating: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_SHAPE: {
get: 'newDot',
do: 'createShape',
to: 'dot.editing',
},
POINTED_CANVAS: {
get: 'newDot',
do: 'createShape',
to: 'dot.editing',
},
},
},
editing: {
on: {
STOPPED_POINTING: [
'completeSession',
{
if: 'isToolLocked',
to: 'dot.creating',
else: {
to: 'selecting',
},
},
],
CANCELLED: {
do: 'breakSession',
to: 'selecting',
},
},
initial: 'inactive',
states: {
inactive: {
on: {
MOVED_POINTER: {
if: 'distanceImpliesDrag',
to: 'dot.editing.active',
},
},
},
active: {
onExit: 'completeSession',
onEnter: 'startTranslateSession',
on: {
MOVED_POINTER: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateTranslateSession',
},
PANNED_CAMERA: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateTranslateSession',
},
},
},
},
},
},
},
arrow: {
onEnter: 'setActiveToolArrow',
initial: 'idle',
states: {
idle: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_SHAPE: {
get: 'newArrow',
do: 'createShape',
to: 'arrow.editing',
},
POINTED_CANVAS: {
get: 'newArrow',
do: 'createShape',
to: 'arrow.editing',
},
},
},
editing: {
onExit: 'completeSession',
onEnter: 'startArrowHandleSession',
on: {
STOPPED_POINTING: [
'completeSession',
{
if: 'isToolLocked',
to: 'arrow.creating',
else: { to: 'selecting' },
},
],
CANCELLED: {
do: 'breakSession',
if: 'isToolLocked',
to: 'arrow.creating',
else: { to: 'selecting' },
},
PRESSED_SHIFT: 'keyUpdateHandleSession',
RELEASED_SHIFT: 'keyUpdateHandleSession',
MOVED_POINTER: 'updateHandleSession',
PANNED_CAMERA: 'updateHandleSession',
},
},
},
},
ellipse: {
onEnter: 'setActiveToolEllipse',
initial: 'creating',
states: {
creating: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_CANVAS: {
to: 'ellipse.editing',
},
},
},
editing: {
on: {
STOPPED_POINTING: { to: 'selecting' },
CANCELLED: { to: 'selecting' },
MOVED_POINTER: {
if: 'distanceImpliesDrag',
then: {
get: 'newEllipse',
do: 'createShape',
to: 'drawingShape.bounds',
},
},
},
},
},
},
rectangle: {
onEnter: 'setActiveToolRectangle',
initial: 'creating',
states: {
creating: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_SHAPE: {
to: 'rectangle.editing',
},
POINTED_CANVAS: {
to: 'rectangle.editing',
},
},
},
editing: {
on: {
STOPPED_POINTING: { to: 'selecting' },
CANCELLED: { to: 'selecting' },
MOVED_POINTER: {
if: 'distanceImpliesDrag',
then: {
get: 'newRectangle',
do: 'createShape',
to: 'drawingShape.bounds',
},
},
},
},
},
},
text: {
onEnter: 'setActiveToolText',
initial: 'creating',
states: {
creating: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_SHAPE: [
{
if: 'isPointingTextShape',
unless: 'isPressingShiftKey',
do: [
'clearSelectedIds',
'pushPointedIdToSelectedIds',
'setEditingId',
],
to: 'editingShape',
},
{
get: 'newText',
do: 'createShape',
},
{
get: 'firstSelectedShape',
if: 'canEditSelectedShape',
do: 'setEditingId',
to: 'editingShape',
},
],
POINTED_CANVAS: [
{
get: 'newText',
do: 'createShape',
},
{
get: 'firstSelectedShape',
if: 'canEditSelectedShape',
do: 'setEditingId',
to: 'editingShape',
},
],
},
},
},
},
ray: {
onEnter: 'setActiveToolRay',
initial: 'creating',
states: {
creating: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_SHAPE: {
get: 'newRay',
do: 'createShape',
to: 'ray.editing',
},
POINTED_CANVAS: {
get: 'newRay',
do: 'createShape',
to: 'ray.editing',
},
},
},
editing: {
on: {
STOPPED_POINTING: { to: 'selecting' },
CANCELLED: { to: 'selecting' },
MOVED_POINTER: {
if: 'distanceImpliesDrag',
to: 'drawingShape.direction',
},
},
},
},
},
line: {
onEnter: 'setActiveToolLine',
initial: 'creating',
states: {
creating: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_SHAPE: {
get: 'newLine',
do: 'createShape',
to: 'line.editing',
},
POINTED_CANVAS: {
get: 'newLine',
do: 'createShape',
to: 'line.editing',
},
},
},
editing: {
on: {
STOPPED_POINTING: { to: 'selecting' },
CANCELLED: { to: 'selecting' },
MOVED_POINTER: {
if: 'distanceImpliesDrag',
to: 'drawingShape.direction',
},
},
},
},
},
polyline: {
onEnter: 'setActiveToolPolyline',
},
},
},
drawingShape: {
onExit: 'completeSession',
on: {
STOPPED_POINTING: [
'completeSession',
{
if: 'isToolLocked',
to: 'usingTool.previous',
else: { to: 'selecting' },
},
],
CANCELLED: {
do: 'breakSession',
to: 'selecting',
},
},
initial: 'bounds',
states: {
bounds: {
onEnter: 'startDrawTransformSession',
on: {
MOVED_POINTER: {
do: 'updateTransformSession',
},
PANNED_CAMERA: {
do: 'updateTransformSession',
},
},
},
direction: {
onEnter: 'startDirectionSession',
onExit: 'completeSession',
on: {
MOVED_POINTER: {
do: 'updateDirectionSession',
},
PANNED_CAMERA: {
do: 'updateDirectionSession',
},
},
},
},
},
},
},
},
results: {
newDot() {
return ShapeType.Dot
},
newRay() {
return ShapeType.Ray
},
newLine() {
return ShapeType.Line
},
newText() {
return ShapeType.Text
},
newDraw() {
return ShapeType.Draw
},
newArrow() {
return ShapeType.Arrow
},
newEllipse() {
return ShapeType.Ellipse
},
newRectangle() {
return ShapeType.Rectangle
},
firstSelectedShape(data) {
return tld.getSelectedShapes(data)[0]
},
editingShape(data) {
return tld.getShape(data, data.editingId)
},
},
conditions: {
isSimulating() {
return logger.isSimulating
},
isTestMode(data) {
return data.settings.isTestMode
},
isEditingShape(data, payload: { id: string }) {
return payload.id === data.editingId
},
shouldDeleteShape(data, payload, shape: Shape) {
return getShapeUtils(shape).shouldDelete(shape)
},
isPointingCanvas(data, payload: PointerInfo) {
return payload.target === 'canvas'
},
isPointingEditingShape(data, payload: { target: string }) {
return payload.target === data.editingId
},
isPointingTextShape(data, payload: { target: string }) {
return tld.getShape(data, payload.target)?.type === ShapeType.Text
},
isPointingBounds(data, payload: PointerInfo) {
return tld.getSelectedIds(data).length > 0 && payload.target === 'bounds'
},
isPointingShape(data, payload: PointerInfo) {
return (
payload.target &&
payload.target !== 'canvas' &&
payload.target !== 'bounds'
)
},
isReadOnly(data) {
return data.isReadOnly
},
isInSession() {
return session.isInSession
},
canEditSelectedShape(data, payload, result: Shape) {
return getShapeUtils(result).canEdit && !result.isLocked
},
distanceImpliesDrag(data, payload: PointerInfo) {
return vec.dist2(payload.origin, payload.point) > 8
},
hasPointedTarget(data, payload: PointerInfo) {
return payload.target !== undefined
},
isPointedShapeSelected(data) {
return tld.getSelectedIds(data).includes(data.pointedId)
},
isPressingShiftKey(data, payload: PointerInfo) {
return payload.shiftKey
},
isPressingMetaKey(data, payload: PointerInfo) {
return payload.metaKey
},
shapeIsHovered(data, payload: { target: string }) {
return data.hoveredId === payload.target
},
pointInSelectionBounds(data, payload: PointerInfo) {
const bounds = getSelectionBounds(data)
if (!bounds) return false
return pointInBounds(tld.screenToWorld(payload.point, data), bounds)
},
pointHitsShape(data, payload: PointerInfo) {
const shape = tld.getShape(data, payload.target)
return getShapeUtils(shape).hitTest(
shape,
tld.screenToWorld(payload.point, data)
)
},
hasPointedId(data, payload: PointerInfo) {
return tld.getShape(data, payload.target) !== undefined
},
isPointingRotationHandle(
_data,
payload: { target: Edge | Corner | 'rotate' }
) {
return payload.target === 'rotate'
},
hasSelection(data) {
return tld.getSelectedIds(data).length > 0
},
hasSingleSelection(data) {
return tld.getSelectedIds(data).length === 1
},
hasMultipleSelection(data) {
return tld.getSelectedIds(data).length > 1
},
hasCurrentParentShape(data) {
return data.currentParentId !== data.currentPageId
},
isToolLocked(data) {
return data.settings.isToolLocked
},
isPenLocked(data) {
return data.settings.isPenLocked
},
hasOnlyOnePage(data) {
return Object.keys(data.document.pages).length === 1
},
selectionIncludesGroups(data) {
return tld
.getSelectedShapes(data)
.some((shape) => shape.type === ShapeType.Group)
},
},
actions: {
/* ---------------------- Debug --------------------- */
closeDebugPanel(data) {
data.settings.isDebugOpen = false
},
openDebugPanel(data) {
data.settings.isDebugOpen = true
},
toggleDebugMode(data) {
data.settings.isDebugMode = !data.settings.isDebugMode
},
toggleTestMode(data) {
data.settings.isTestMode = !data.settings.isTestMode
},
toggleDebugPanel(data) {
data.settings.isDebugOpen = !data.settings.isDebugOpen
},
toggleLogger(data) {
if (logger.isRunning) {
logger.stop(data)
} else {
logger.start(data)
}
},
copyDebugLog() {
logger.copyToJson()
},
// Networked Room
addRtShape(data, payload: { pageId: string; shape: Shape }) {
const { pageId, shape } = payload
// What if the page is in storage?
data.document.pages[pageId].shapes[shape.id] = shape
},
deleteRtShape(data, payload: { pageId: string; shapeId: string }) {
const { pageId, shapeId } = payload
// What if the page is in storage?
delete data.document[pageId].shapes[shapeId]
},
editRtShape(data, payload: { pageId: string; shape: Shape }) {
const { pageId, shape } = payload
// What if the page is in storage?
Object.assign(data.document[pageId].shapes[shape.id], shape)
},
clearRoom(data) {
data.room = undefined
},
resetStorage() {
storage.reset()
},
resetDocumentState(data, payload: { roomId?: string }) {
// Save the current document and app state.
storage.savePage(data)
storage.savePageState(data)
storage.saveAppStateToLocalStorage(data)
storage.saveDocumentToLocalStorage(data)
// Cancel all current sessions, reset history, etc..
session.cancel(data)
inputs.reset()
history.reset()
storage.reset()
// Populate a new app state.
const newDocumentId = payload?.roomId ? payload.roomId : uniqueId()
const newPageId = 'page1'
data.document.id = newDocumentId
data.pointedId = null
data.hoveredId = null
data.editingId = null
data.currentPageId = newPageId
data.currentParentId = newPageId
data.currentCodeFileId = 'file0'
data.codeControls = {}
data.document.pages = {
[newPageId]: {
id: newPageId,
name: 'Page 1',
type: 'page',
shapes: {},
childIndex: 1,
},
}
data.pageStates = {
[newPageId]: {
id: newPageId,
selectedIds: [],
camera: {
point: [0, 0],
zoom: 1,
},
},
}
// Save the new app state.
storage.savePage(data)
storage.savePageState(data)
storage.saveAppStateToLocalStorage(data)
storage.saveDocumentToLocalStorage(data)
},
resetPageState(data) {
const pageState = data.pageStates[data.currentPageId]
data.pageStates[data.currentPageId] = { ...pageState }
},
toggleReadOnly(data) {
data.isReadOnly = !data.isReadOnly
},
/* ---------------------- Pages --------------------- */
changePage(data, payload: { id: string }) {
commands.changePage(data, payload.id)
},
createPage(data) {
commands.createPage(data, true)
},
deletePage(data, payload: { id: string }) {
commands.deletePage(data, payload.id)
},
/* --------------------- Shapes --------------------- */
resetShapes(data) {
const page = tld.getPage(data)
Object.values(page.shapes).forEach((shape) => {
page.shapes[shape.id] = { ...shape }
})
},
createShape(data, payload, type: ShapeType) {
const style = deepClone(data.currentStyle)
let point = tld.screenToWorld(payload.point, data)
if (type === ShapeType.Text) {
point = vec.sub(point, vec.mul([0, 1], getFontSize(style.size) * 0.8))
}
const shape = createShape(type, {
id: uniqueId(),
parentId: data.currentPageId,
point: vec.round(point),
style: deepClone(data.currentStyle),
})
const siblings = tld.getChildren(data, shape.parentId)
const childIndex = siblings.length
? siblings[siblings.length - 1].childIndex + 1
: 1
data.editingId = shape.id
getShapeUtils(shape).setProperty(shape, 'childIndex', childIndex)
tld.getPage(data).shapes[shape.id] = shape
tld.setSelectedIds(data, [shape.id])
},
/* -------------------- Sessions -------------------- */
// Shared
breakSession(data) {
session.cancel(data)
history.disable()
commands.deleteShapes(data, tld.getSelectedShapes(data))
history.enable()
},
cancelSession(data) {
session.cancel(data)
},
completeSession(data) {
session.complete(data)
},
// Editing
startEditSession(data) {
session.begin(new Sessions.EditSession(data))
},
updateEditSession(data, payload: { change: Partial<Shape> }) {
session.update<Sessions.EditSession>(data, payload.change)
},
// Brushing
startBrushSession(data, payload: PointerInfo) {
session.begin(
new Sessions.BrushSession(data, tld.screenToWorld(payload.point, data))
)
},
updateBrushSession(data, payload: PointerInfo) {
session.update<Sessions.BrushSession>(
data,
tld.screenToWorld(payload.point, data)
)
},
// Rotating
startRotateSession(data, payload: PointerInfo) {
session.begin(
new Sessions.RotateSession(data, tld.screenToWorld(payload.point, data))
)
},
keyUpdateRotateSession(data, payload: PointerInfo) {
session.update<Sessions.RotateSession>(
data,
tld.screenToWorld(inputs.pointer.point, data),
payload.shiftKey
)
},
updateRotateSession(data, payload: PointerInfo) {
session.update<Sessions.RotateSession>(
data,
tld.screenToWorld(payload.point, data),
payload.shiftKey
)
},
// Dragging / Translating
startTranslateSession(data) {
session.begin(
new Sessions.TranslateSession(
data,
tld.screenToWorld(inputs.pointer.origin, data)
)
)
},
keyUpdateTranslateSession(
data,
payload: { shiftKey: boolean; altKey: boolean }
) {
session.update<Sessions.TranslateSession>(
data,
tld.screenToWorld(inputs.pointer.point, data),
payload.shiftKey,
payload.altKey
)
},
updateTranslateSession(data, payload: PointerInfo) {
session.update<Sessions.TranslateSession>(
data,
tld.screenToWorld(payload.point, data),
payload.shiftKey,
payload.altKey
)
},
// Handles
doublePointHandle(data, payload: PointerInfo) {
const id = tld.getSelectedIds(data)[0]
commands.doublePointHandle(data, id, payload)
},
// Dragging Handle
startHandleSession(data, payload: PointerInfo) {
const shapeId = Array.from(tld.getSelectedIds(data).values())[0]
const handleId = payload.target
session.begin(
new Sessions.HandleSession(
data,
shapeId,
handleId,
tld.screenToWorld(inputs.pointer.origin, data),
false
)
)
},
keyUpdateHandleSession(
data,
payload: { shiftKey: boolean; altKey: boolean; metaKey: boolean }
) {
session.update<Sessions.HandleSession>(
data,
tld.screenToWorld(inputs.pointer.point, data),
payload.shiftKey,
payload.altKey,
payload.metaKey
)
},
updateHandleSession(data, payload: PointerInfo) {
session.update<Sessions.HandleSession>(
data,
tld.screenToWorld(payload.point, data),
payload.shiftKey,
payload.altKey,
payload.metaKey
)
},
// Transforming
startTransformSession(
data,
payload: PointerInfo & { target: Corner | Edge }
) {
const point = tld.screenToWorld(inputs.pointer.origin, data)
session.begin(
tld.getSelectedIds(data).length === 1
? new Sessions.TransformSingleSession(data, payload.target, point)
: new Sessions.TransformSession(data, payload.target, point)
)
},
startDrawTransformSession(data, payload: PointerInfo) {
session.begin(
new Sessions.TransformSingleSession(
data,
Corner.BottomRight,
tld.screenToWorld(payload.point, data),
true
)
)
},
keyUpdateTransformSession(data, payload: PointerInfo) {
session.update<Sessions.TransformSession>(
data,
tld.screenToWorld(inputs.pointer.point, data),
payload.shiftKey
)
},
updateTransformSession(data, payload: PointerInfo) {
session.update<Sessions.TransformSession>(
data,
tld.screenToWorld(payload.point, data),
payload.shiftKey
)
},
// Direction
startDirectionSession(data) {
session.begin(
new Sessions.DirectionSession(
data,
tld.screenToWorld(inputs.pointer.origin, data)
)
)
},
updateDirectionSession(data, payload: PointerInfo) {
session.update<Sessions.DirectionSession>(
data,
tld.screenToWorld(payload.point, data)
)
},
// Drawing
startDrawSession(data) {
const id = Array.from(tld.getSelectedIds(data).values())[0]
session.begin(
new Sessions.DrawSession(
data,
id,
tld.screenToWorld(inputs.pointer.origin, data)
)
)
},
keyUpdateDrawSession(data, payload: PointerInfo) {
session.update<Sessions.DrawSession>(
data,
tld.screenToWorld(inputs.pointer.point, data),
payload.pressure,
payload.shiftKey
)
},
updateDrawSession(data, payload: PointerInfo) {
session.update<Sessions.DrawSession>(
data,
tld.screenToWorld(payload.point, data),
payload.pressure,
payload.shiftKey
)
},
// Arrow
startArrowHandleSession(data) {
const shapeId = Array.from(tld.getSelectedIds(data).values())[0]
const handleId = 'end'
session.begin(
new Sessions.HandleSession(
data,
shapeId,
handleId,
tld.screenToWorld(inputs.pointer.origin, data),
true
)
)
},
/* -------------------- Selection ------------------- */
// Nudges
nudgeSelection(data, payload: { delta: number[]; shiftKey: boolean }) {
commands.nudge(
data,
vec.mul(
payload.delta,
payload.shiftKey
? data.settings.nudgeDistanceLarge
: data.settings.nudgeDistanceSmall
)
)
},
clearInputs() {
inputs.clear()
},
deselectAll(data) {
tld.setSelectedIds(data, [])
},
selectAll(data) {
tld.setSelectedIds(
data,
tld
.getShapes(data)
.filter((shape) => shape.parentId === data.currentPageId)
.map((shape) => shape.id)
)
},
setHoveredId(data, payload: PointerInfo) {
data.hoveredId = payload.target
},
clearHoveredId(data) {
data.hoveredId = undefined
},
setPointedId(data, payload: PointerInfo) {
data.pointedId = getPointedId(data, payload.target)
data.currentParentId = getParentId(data, data.pointedId)
},
setCurrentParentId(data, payload: PointerInfo) {
data.pointedId = getDrilledPointedId(data, payload.target)
data.currentParentId = getParentId(data, data.pointedId)
},
raiseCurrentParentId(data) {
const currentParent = tld.getShape(data, data.currentParentId)
data.currentParentId =
currentParent.parentId === data.currentPageId
? data.currentPageId
: currentParent.parentId
},
setCurrentParentIdToPage(data) {
data.currentParentId = data.currentPageId
},
selectCurrentParentId(data) {
tld.setSelectedIds(data, [data.currentParentId])
},
clearCurrentParentId(data) {
data.currentParentId = data.currentPageId
data.pointedId = undefined
},
clearPointedId(data) {
data.pointedId = undefined
},
clearSelectedIds(data) {
tld.setSelectedIds(data, [])
},
selectId(data, payload: PointerInfo) {
tld.setSelectedIds(data, [payload.target])
},
pullPointedIdFromSelectedIds(data) {
const { pointedId } = data
const selectedIds = tld.getSelectedIds(data)
selectedIds.splice(selectedIds.indexOf(pointedId), 1)
},
pushPointedIdToSelectedIds(data) {
tld.getSelectedIds(data).push(data.pointedId)
},
moveSelection(data, payload: { type: MoveType }) {
commands.move(data, payload.type)
},
moveSelectionToPage(data, payload: { id: string }) {
commands.moveToPage(data, payload.id)
},
alignSelection(data, payload: { type: AlignType }) {
commands.align(data, payload.type)
},
stretchSelection(data, payload: { type: StretchType }) {
commands.stretch(data, payload.type)
},
distributeSelection(data, payload: { type: DistributeType }) {
commands.distribute(data, payload.type)
},
duplicateSelection(data) {
commands.duplicate(data)
},
lockSelection(data) {
commands.toggle(data, 'isLocked')
},
hideSelection(data) {
commands.toggle(data, 'isHidden')
},
aspectLockSelection(data) {
commands.toggle(data, 'isAspectRatioLocked')
},
deleteSelection(data) {
commands.deleteShapes(data, tld.getSelectedShapes(data))
},
rotateSelectionCcw(data) {
commands.rotateCcw(data)
},
groupSelection(data) {
commands.group(data)
},
ungroupSelection(data) {
commands.ungroup(data)
},
resetShapeBounds(data) {
commands.resetBounds(data)
},
resetPage(data) {
data.document.pages[data.currentPageId].shapes = {}
},
/* --------------------- Editing -------------------- */
setEditingId(data) {
const selectedShape = tld.getSelectedShapes(data)[0]
if (getShapeUtils(selectedShape).canEdit) {
data.editingId = selectedShape.id
}
tld.getPageState(data).selectedIds = [selectedShape.id]
},
clearEditingId(data) {
data.editingId = null
},
/* ---------------------- Tool ---------------------- */
setActiveTool(data, payload: { tool: ShapeType | 'select' }) {
data.activeTool = payload.tool
},
setActiveToolSelect(data) {
data.activeTool = 'select'
},
setActiveToolDraw(data) {
data.activeTool = ShapeType.Draw
},
setActiveToolRectangle(data) {
data.activeTool = ShapeType.Rectangle
},
setActiveToolEllipse(data) {
data.activeTool = ShapeType.Ellipse
},
setActiveToolArrow(data) {
data.activeTool = ShapeType.Arrow
},
setActiveToolDot(data) {
data.activeTool = ShapeType.Dot
},
setActiveToolPolyline(data) {
data.activeTool = ShapeType.Polyline
},
setActiveToolRay(data) {
data.activeTool = ShapeType.Ray
},
setActiveToolLine(data) {
data.activeTool = ShapeType.Line
},
setActiveToolText(data) {
data.activeTool = ShapeType.Text
},
/* --------------------- Camera --------------------- */
zoomIn(data) {
const camera = tld.getCurrentCamera(data)
const i = Math.round((camera.zoom * 100) / 25)
const center = [window.innerWidth / 2, window.innerHeight / 2]
const p0 = tld.screenToWorld(center, data)
camera.zoom = tld.getCameraZoom((i + 1) * 0.25)
const p1 = tld.screenToWorld(center, data)
camera.point = vec.add(camera.point, vec.sub(p1, p0))
tld.setZoomCSS(camera.zoom)
},
zoomOut(data) {
const camera = tld.getCurrentCamera(data)
const i = Math.round((camera.zoom * 100) / 25)
const center = [window.innerWidth / 2, window.innerHeight / 2]
const p0 = tld.screenToWorld(center, data)
camera.zoom = tld.getCameraZoom((i - 1) * 0.25)
const p1 = tld.screenToWorld(center, data)
camera.point = vec.add(camera.point, vec.sub(p1, p0))
tld.setZoomCSS(camera.zoom)
},
zoomCameraToActual(data) {
const camera = tld.getCurrentCamera(data)
const center = [window.innerWidth / 2, window.innerHeight / 2]
const p0 = tld.screenToWorld(center, data)
camera.zoom = 1
const p1 = tld.screenToWorld(center, data)
camera.point = vec.add(camera.point, vec.sub(p1, p0))
tld.setZoomCSS(camera.zoom)
},
zoomCameraToSelectionActual(data) {
const camera = tld.getCurrentCamera(data)
const bounds = tld.getSelectedBounds(data)
const mx = (window.innerWidth - bounds.width) / 2
const my = (window.innerHeight - bounds.height) / 2
camera.zoom = 1
camera.point = vec.add([-bounds.minX, -bounds.minY], [mx, my])
tld.setZoomCSS(camera.zoom)
},
zoomCameraToSelection(data) {
const camera = tld.getCurrentCamera(data)
const bounds = tld.getSelectedBounds(data)
const zoom = tld.getCameraZoom(
bounds.width > bounds.height
? (window.innerWidth - 128) / bounds.width
: (window.innerHeight - 128) / bounds.height
)
const mx = (window.innerWidth - bounds.width * zoom) / 2 / zoom
const my = (window.innerHeight - bounds.height * zoom) / 2 / zoom
camera.zoom = zoom
camera.point = vec.add([-bounds.minX, -bounds.minY], [mx, my])
tld.setZoomCSS(camera.zoom)
},
zoomCameraToFit(data) {
const camera = tld.getCurrentCamera(data)
const page = tld.getPage(data)
const shapes = Object.values(page.shapes)
if (shapes.length === 0) {
return
}
const bounds = getCommonBounds(
...Object.values(shapes).map((shape) =>
getShapeUtils(shape).getBounds(shape)
)
)
const zoom = tld.getCameraZoom(
bounds.width > bounds.height
? (window.innerWidth - 128) / bounds.width
: (window.innerHeight - 128) / bounds.height
)
const mx = (window.innerWidth - bounds.width * zoom) / 2 / zoom
const my = (window.innerHeight - bounds.height * zoom) / 2 / zoom
camera.zoom = zoom
camera.point = vec.add([-bounds.minX, -bounds.minY], [mx, my])
tld.setZoomCSS(camera.zoom)
},
zoomCameraToContent(data) {
const camera = tld.getCurrentCamera(data)
const page = tld.getPage(data)
const shapes = Object.values(page.shapes)
if (shapes.length === 0) {
return
}
const bounds = getCommonBounds(
...Object.values(shapes).map((shape) =>
getShapeUtils(shape).getBounds(shape)
)
)
const { zoom } = camera
const mx = (window.innerWidth - bounds.width * zoom) / 2 / zoom
const my = (window.innerHeight - bounds.height * zoom) / 2 / zoom
camera.point = vec.add([-bounds.minX, -bounds.minY], [mx, my])
},
zoomCamera(data, payload: { delta: number; point: number[] }) {
const camera = tld.getCurrentCamera(data)
const next = camera.zoom - (payload.delta / 100) * camera.zoom
const p0 = tld.screenToWorld(payload.point, data)
camera.zoom = tld.getCameraZoom(next)
const p1 = tld.screenToWorld(payload.point, data)
camera.point = vec.add(camera.point, vec.sub(p1, p0))
tld.setZoomCSS(camera.zoom)
},
panCamera(data, payload: { delta: number[] }) {
const camera = tld.getCurrentCamera(data)
camera.point = vec.sub(camera.point, vec.div(payload.delta, camera.zoom))
},
updateZoomCSS(data) {
const camera = tld.getCurrentCamera(data)
tld.setZoomCSS(camera.zoom)
},
pinchCamera(
data,
payload: {
delta: number[]
distanceDelta: number
angleDelta: number
point: number[]
}
) {
// This is usually replaced with hacks.fastPinchCamera!
const camera = tld.getCurrentCamera(data)
camera.point = vec.sub(camera.point, vec.div(payload.delta, camera.zoom))
const next = camera.zoom - (payload.distanceDelta / 300) * camera.zoom
const p0 = tld.screenToWorld(payload.point, data)
camera.zoom = tld.getCameraZoom(next)
const p1 = tld.screenToWorld(payload.point, data)
camera.point = vec.add(camera.point, vec.sub(p1, p0))
tld.setZoomCSS(camera.zoom)
},
resetCamera(data) {
const camera = tld.getCurrentCamera(data)
camera.zoom = 1
camera.point = [window.innerWidth / 2, window.innerHeight / 2]
document.documentElement.style.setProperty('--camera-zoom', '1')
},
/* ---------------------- History ---------------------- */
// History
popHistory() {
history.pop()
},
enableHistory() {
history.enable()
},
disableHistory() {
history.disable()
},
undo(data) {
history.undo(data)
},
redo(data) {
history.redo(data)
},
resetHistory() {
history.reset()
},
/* ------------------- Preferences ------------------ */
toggleDarkMode(data) {
data.settings.isDarkMode = !data.settings.isDarkMode
},
applyTheme(data) {
if (data.settings.isDarkMode && typeof document !== 'undefined') {
document.body.classList.remove(light)
document.body.classList.add(dark)
} else {
document.body.classList.remove(dark)
document.body.classList.add(light)
}
},
/* --------------------- Styles --------------------- */
toggleStylePanel(data) {
data.settings.isStyleOpen = !data.settings.isStyleOpen
},
closeStylePanel(data) {
data.settings.isStyleOpen = false
},
updateStyles(data, payload: Partial<ShapeStyles>) {
Object.assign(data.currentStyle, payload)
},
applyStylesToSelection(data, payload: Partial<ShapeStyles>) {
commands.style(data, payload)
},
/* ---------------------- Code ---------------------- */
closeCodePanel(data) {
data.settings.isCodeOpen = false
},
openCodePanel(data) {
data.settings.isCodeOpen = true
},
toggleCodePanel(data) {
data.settings.isCodeOpen = !data.settings.isCodeOpen
},
setGeneratedShapes(
data,
payload: { shapes: Shape[]; controls: CodeControl[] }
) {
commands.generate(data, payload.shapes)
},
updateGeneratedShapes(data, payload, result: { shapes: Shape[] }) {
tld.setSelectedIds(data, [])
history.disable()
commands.generate(data, result.shapes)
history.enable()
},
setCodeControls(data, payload: { controls: CodeControl[] }) {
data.codeControls = Object.fromEntries(
payload.controls.map((control) => [control.id, control])
)
},
increaseCodeFontSize(data) {
data.settings.fontSize++
},
decreaseCodeFontSize(data) {
data.settings.fontSize--
},
updateControls(data, payload: { [key: string]: any }) {
for (const key in payload) {
data.codeControls[key].value = payload[key]
}
},
/* -------------------- Settings -------------------- */
enablePenLock(data) {
data.settings.isPenLocked = true
},
disablePenLock(data) {
data.settings.isPenLocked = false
},
toggleToolLock(data) {
data.settings.isToolLocked = !data.settings.isToolLocked
},
/* ------------------- Clipboard -------------------- */
copyToSvg(data) {
clipboard.copySelectionToSvg(data)
},
copyToClipboard(data) {
clipboard.copy(tld.getSelectedShapes(data))
},
copyStateToClipboard(data) {
clipboard.copyStringToClipboard(JSON.stringify(data))
},
pasteFromClipboard(data) {
clipboard.paste()
if (clipboard.fallback) {
try {
commands.paste(data, JSON.parse(clipboard.current).shapes)
} catch (e) {
console.warn('Could not paste that text.')
}
}
},
pasteShapesFromClipboard(data, payload: { shapes: Shape[] }) {
commands.paste(data, payload.shapes)
},
/* ---------------------- Data ---------------------- */
restoredPreviousDocument(data, payload: { roomId?: string }) {
storage.firstLoad(data, payload?.roomId)
},
saveToFileSystem(data) {
storage.saveToFileSystem(data)
},
saveAsToFileSystem(data) {
storage.saveAsToFileSystem(data)
},
loadFromFileSystem() {
storage.loadDocumentFromFilesystem()
},
loadDocumentFromJson(data, payload: { json: any }) {
storage.loadDocumentFromJson(data, payload.json)
},
saveAppState(data) {
storage.saveAppStateToLocalStorage(data)
},
saveDocumentState(data) {
storage.savePage(data)
storage.savePageState(data)
storage.saveAppStateToLocalStorage(data)
storage.saveDocumentToLocalStorage(data)
},
forceSave(data) {
storage.saveToFileSystem(data)
},
saveCode(data, payload: { code: string }) {
data.document.code[data.currentCodeFileId].code = payload.code
storage.saveDocumentToLocalStorage(data)
storage.saveAppStateToLocalStorage(data)
},
clearBoundsRotation(data) {
data.boundsRotation = 0
},
},
asyncs: {
async getUpdatedShapes(data) {
return updateFromCode(
data,
data.document.code[data.currentCodeFileId].code
)
},
},
values: {
selectedIds(data) {
return tld.getSelectedIds(data)
},
selectedBounds(data) {
return getSelectionBounds(data)
},
currentShapes(data) {
const page = tld.getPage(data)
return Object.values(page.shapes)
.filter((shape) => shape.parentId === page.id)
.sort((a, b) => a.childIndex - b.childIndex)
},
selectedStyle(data) {
const selectedIds = tld.getSelectedIds(data)
const { currentStyle } = data
if (selectedIds.length === 0) {
return currentStyle
}
const page = tld.getPage(data)
const shapeStyles = selectedIds.map((id) => page.shapes[id].style)
const commonStyle: ShapeStyles = {} as ShapeStyles
const overrides = new Set<string>([])
for (const shapeStyle of shapeStyles) {
for (const key in currentStyle) {
if (overrides.has(key)) continue
if (commonStyle[key] === undefined) {
commonStyle[key] = shapeStyle[key]
} else {
if (commonStyle[key] === shapeStyle[key]) continue
commonStyle[key] = currentStyle[key]
overrides.add(key)
}
}
}
return commonStyle
},
selectedRotation(data) {
const selectedIds = tld.getSelectedIds(data)
if (selectedIds.length === 1) {
const selected = selectedIds[0]
const page = tld.getPage(data)
return page.shapes[selected]?.rotation
} else {
return 0
}
},
shapesToRender(data) {
const viewport = tld.getViewport(data)
const page = tld.getPage(data)
const shapesToShow = Object.values(page.shapes).filter((shape) => {
if (shape.parentId !== page.id) return false
const shapeBounds = getShapeUtils(shape).getBounds(shape)
return (
shape.type === ShapeType.Ray ||
shape.type === ShapeType.Line ||
boundsContain(viewport, shapeBounds) ||
boundsCollide(viewport, shapeBounds)
)
})
// Populate the shape tree
const tree: ShapeTreeNode[] = []
const selectedIds = tld.getSelectedIds(data)
shapesToShow
.sort((a, b) => a.childIndex - b.childIndex)
.forEach((shape) => tld.addToShapeTree(data, selectedIds, tree, shape))
return tree
},
},
options: {
onSend(eventName, payload, didCauseUpdate) {
logger.addToLog(eventName, payload, didCauseUpdate)
},
},
})
export default state
export const useSelector = createSelectorHook(state)
function getParentId(data: Data, id: string) {
const shape = tld.getPage(data).shapes[id]
return shape.parentId
}
function getPointedId(data: Data, id: string) {
const shape = tld.getPage(data).shapes[id]
if (!shape) return id
return shape.parentId === data.currentParentId ||
shape.parentId === data.currentPageId
? id
: getPointedId(data, shape.parentId)
}
function getDrilledPointedId(data: Data, id: string) {
const shape = tld.getPage(data).shapes[id]
return shape.parentId === data.currentPageId ||
shape.parentId === data.pointedId ||
shape.parentId === data.currentParentId
? id
: getDrilledPointedId(data, shape.parentId)
}
// function hasPointedIdInChildren(data: Data, id: string, pointedId: string) {
// const shape = getPage(data).shapes[id]
// if (shape.type !== ShapeType.Group) {
// return false
// }
// if (shape.children.includes(pointedId)) {
// return true
// }
// return shape.children.some((childId) =>
// hasPointedIdInChildren(data, childId, pointedId)
// )
// }
function getSelectionBounds(data: Data) {
const selectedIds = tld.getSelectedIds(data)
const page = tld.getPage(data)
const shapes = tld.getSelectedShapes(data)
if (selectedIds.length === 0) return null
if (selectedIds.length === 1) {
if (!shapes[0]) {
console.warn('Could not find that shape! Clearing selected IDs.')
tld.setSelectedIds(data, [])
return null
}
const shape = shapes[0]
const shapeUtils = getShapeUtils(shape)
if (!shapeUtils.canTransform) return null
let bounds = shapeUtils.getBounds(shape)
let parentId = shape.parentId
while (parentId !== data.currentPageId) {
const parent = page.shapes[parentId]
bounds = rotateBounds(
bounds,
getBoundsCenter(getShapeUtils(parent).getBounds(parent)),
parent.rotation
)
bounds.rotation = parent.rotation
parentId = parent.parentId
}
return bounds
}
const uniqueSelectedShapeIds: string[] = Array.from(
new Set(
Array.from(selectedIds.values()).flatMap((id) =>
tld.getDocumentBranch(data, id)
)
).values()
)
const commonBounds = getCommonBounds(
...uniqueSelectedShapeIds
.map((id) => page.shapes[id])
.filter((shape) => shape.type !== ShapeType.Group)
.map((shape) => getShapeUtils(shape).getRotatedBounds(shape))
)
return commonBounds
}
// const skippedLogs = new Set<string>([
// 'MOVED_POINTER',
// 'MOVED_OVER_SHAPE',
// 'RESIZED_WINDOW',
// 'HOVERED_SHAPE',
// 'UNHOVERED_SHAPE',
// 'PANNED_CAMERA',
// ])
// state.enableLog(true)
// state.onUpdate((s) => console.log(s.log.filter((l) => !skippedLogs.has(l))))