You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

TldrawApp.ts 81KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045
  1. /* eslint-disable @typescript-eslint/no-explicit-any */
  2. /* eslint-disable @typescript-eslint/ban-ts-comment */
  3. /* eslint-disable @typescript-eslint/no-non-null-assertion */
  4. import { Vec } from '@tldraw/vec'
  5. import {
  6. TLBoundsEventHandler,
  7. TLBoundsHandleEventHandler,
  8. TLKeyboardEventHandler,
  9. TLShapeCloneHandler,
  10. TLCanvasEventHandler,
  11. TLPageState,
  12. TLPinchEventHandler,
  13. TLPointerEventHandler,
  14. TLWheelEventHandler,
  15. Utils,
  16. TLBounds,
  17. } from '@tldraw/core'
  18. import {
  19. FlipType,
  20. TDDocument,
  21. MoveType,
  22. AlignType,
  23. StretchType,
  24. DistributeType,
  25. ShapeStyles,
  26. TDShape,
  27. TDShapeType,
  28. TDSnapshot,
  29. TDStatus,
  30. TDPage,
  31. TDBinding,
  32. GroupShape,
  33. TldrawCommand,
  34. TDUser,
  35. SessionType,
  36. TDToolType,
  37. } from '~types'
  38. import {
  39. migrate,
  40. FileSystemHandle,
  41. loadFileHandle,
  42. openFromFileSystem,
  43. saveToFileSystem,
  44. } from './data'
  45. import { TLDR } from './TLDR'
  46. import { shapeUtils } from '~state/shapes'
  47. import { defaultStyle } from '~state/shapes/shared/shape-styles'
  48. import * as Commands from './commands'
  49. import { SessionArgsOfType, getSession, TldrawSession } from './sessions'
  50. import type { BaseTool } from './tools/BaseTool'
  51. import { USER_COLORS, FIT_TO_SCREEN_PADDING, GRID_SIZE } from '~constants'
  52. import { SelectTool } from './tools/SelectTool'
  53. import { EraseTool } from './tools/EraseTool'
  54. import { TextTool } from './tools/TextTool'
  55. import { DrawTool } from './tools/DrawTool'
  56. import { EllipseTool } from './tools/EllipseTool'
  57. import { RectangleTool } from './tools/RectangleTool'
  58. import { LineTool } from './tools/LineTool'
  59. import { ArrowTool } from './tools/ArrowTool'
  60. import { StickyTool } from './tools/StickyTool'
  61. import { StateManager } from './StateManager'
  62. const uuid = Utils.uniqueId()
  63. export interface TDCallbacks {
  64. /**
  65. * (optional) A callback to run when the component mounts.
  66. */
  67. onMount?: (state: TldrawApp) => void
  68. /**
  69. * (optional) A callback to run when the component's state changes.
  70. */
  71. onChange?: (state: TldrawApp, reason?: string) => void
  72. /**
  73. * (optional) A callback to run when the user creates a new project through the menu or through a keyboard shortcut.
  74. */
  75. onNewProject?: (state: TldrawApp, e?: KeyboardEvent) => void
  76. /**
  77. * (optional) A callback to run when the user saves a project through the menu or through a keyboard shortcut.
  78. */
  79. onSaveProject?: (state: TldrawApp, e?: KeyboardEvent) => void
  80. /**
  81. * (optional) A callback to run when the user saves a project as a new project through the menu or through a keyboard shortcut.
  82. */
  83. onSaveProjectAs?: (state: TldrawApp, e?: KeyboardEvent) => void
  84. /**
  85. * (optional) A callback to run when the user opens new project through the menu or through a keyboard shortcut.
  86. */
  87. onOpenProject?: (state: TldrawApp, e?: KeyboardEvent) => void
  88. /**
  89. * (optional) A callback to run when the user signs in via the menu.
  90. */
  91. onSignIn?: (state: TldrawApp) => void
  92. /**
  93. * (optional) A callback to run when the user signs out via the menu.
  94. */
  95. onSignOut?: (state: TldrawApp) => void
  96. /**
  97. * (optional) A callback to run when the state is patched.
  98. */
  99. onPatch?: (state: TldrawApp, reason?: string) => void
  100. /**
  101. * (optional) A callback to run when the state is changed with a command.
  102. */
  103. onCommand?: (state: TldrawApp, reason?: string) => void
  104. /**
  105. * (optional) A callback to run when the state is persisted.
  106. */
  107. onPersist?: (state: TldrawApp) => void
  108. /**
  109. * (optional) A callback to run when the user undos.
  110. */
  111. onUndo?: (state: TldrawApp) => void
  112. /**
  113. * (optional) A callback to run when the user redos.
  114. */
  115. onRedo?: (state: TldrawApp) => void
  116. /**
  117. * (optional) A callback to run when the user changes the current page's shapes.
  118. */
  119. onChangePage?: (
  120. app: TldrawApp,
  121. shapes: Record<string, TDShape | undefined>,
  122. bindings: Record<string, TDBinding | undefined>
  123. ) => void
  124. /**
  125. * (optional) A callback to run when the user creates a new project.
  126. */
  127. onChangePresence?: (state: TldrawApp, user: TDUser) => void
  128. }
  129. export class TldrawApp extends StateManager<TDSnapshot> {
  130. callbacks: TDCallbacks = {}
  131. tools = {
  132. select: new SelectTool(this),
  133. erase: new EraseTool(this),
  134. [TDShapeType.Text]: new TextTool(this),
  135. [TDShapeType.Draw]: new DrawTool(this),
  136. [TDShapeType.Ellipse]: new EllipseTool(this),
  137. [TDShapeType.Rectangle]: new RectangleTool(this),
  138. [TDShapeType.Line]: new LineTool(this),
  139. [TDShapeType.Arrow]: new ArrowTool(this),
  140. [TDShapeType.Sticky]: new StickyTool(this),
  141. }
  142. currentTool: BaseTool = this.tools.select
  143. session?: TldrawSession
  144. readOnly = false
  145. isDirty = false
  146. isCreating = false
  147. originPoint = [0, 0]
  148. currentPoint = [0, 0]
  149. previousPoint = [0, 0]
  150. shiftKey = false
  151. altKey = false
  152. metaKey = false
  153. ctrlKey = false
  154. spaceKey = false
  155. editingStartTime = -1
  156. fileSystemHandle: FileSystemHandle | null = null
  157. viewport = Utils.getBoundsFromPoints([
  158. [0, 0],
  159. [100, 100],
  160. ])
  161. rendererBounds = Utils.getBoundsFromPoints([
  162. [0, 0],
  163. [100, 100],
  164. ])
  165. selectHistory = {
  166. stack: [[]] as string[][],
  167. pointer: 0,
  168. }
  169. clipboard?: {
  170. shapes: TDShape[]
  171. bindings: TDBinding[]
  172. }
  173. rotationInfo = {
  174. selectedIds: [] as string[],
  175. center: [0, 0],
  176. }
  177. pasteInfo = {
  178. center: [0, 0],
  179. offset: [0, 0],
  180. }
  181. constructor(id?: string, callbacks = {} as TDCallbacks) {
  182. super(TldrawApp.defaultState, id, TldrawApp.version, (prev, next, prevVersion) => {
  183. return {
  184. ...next,
  185. document: migrate(
  186. { ...next.document, ...prev.document, version: prevVersion },
  187. TldrawApp.version
  188. ),
  189. }
  190. })
  191. this.callbacks = callbacks
  192. }
  193. /* -------------------- Internal -------------------- */
  194. protected onReady = () => {
  195. this.loadDocument(this.document)
  196. loadFileHandle().then((fileHandle) => {
  197. this.fileSystemHandle = fileHandle
  198. })
  199. try {
  200. this.patchState({
  201. appState: {
  202. status: TDStatus.Idle,
  203. },
  204. document: migrate(this.document, TldrawApp.version),
  205. })
  206. } catch (e) {
  207. console.error('The data appears to be corrupted. Resetting!', e)
  208. localStorage.setItem(this.document.id + '_corrupted', JSON.stringify(this.document))
  209. this.patchState({
  210. ...TldrawApp.defaultState,
  211. appState: {
  212. ...TldrawApp.defaultState.appState,
  213. status: TDStatus.Idle,
  214. },
  215. })
  216. }
  217. this.callbacks.onMount?.(this)
  218. }
  219. /**
  220. * Cleanup the state after each state change.
  221. * @param state The new state
  222. * @param prev The previous state
  223. * @protected
  224. * @returns The final state
  225. */
  226. protected cleanup = (state: TDSnapshot, prev: TDSnapshot): TDSnapshot => {
  227. const next = { ...state }
  228. // Remove deleted shapes and bindings (in Commands, these will be set to undefined)
  229. if (next.document !== prev.document) {
  230. Object.entries(next.document.pages).forEach(([pageId, page]) => {
  231. if (page === undefined) {
  232. // If page is undefined, delete the page and pagestate
  233. delete next.document.pages[pageId]
  234. delete next.document.pageStates[pageId]
  235. return
  236. }
  237. const prevPage = prev.document.pages[pageId]
  238. const changedShapes: Record<string, TDShape | undefined> = {}
  239. if (!prevPage || page.shapes !== prevPage.shapes || page.bindings !== prevPage.bindings) {
  240. page.shapes = { ...page.shapes }
  241. page.bindings = { ...page.bindings }
  242. const groupsToUpdate = new Set<GroupShape>()
  243. // If shape is undefined, delete the shape
  244. Object.entries(page.shapes).forEach(([id, shape]) => {
  245. let parentId: string
  246. if (!shape) {
  247. parentId = prevPage?.shapes[id]?.parentId
  248. delete page.shapes[id]
  249. } else {
  250. parentId = shape.parentId
  251. }
  252. if (page.id === next.appState.currentPageId) {
  253. if (prevPage?.shapes[id] !== shape) {
  254. changedShapes[id] = shape
  255. }
  256. }
  257. // If the shape is the child of a group, then update the group
  258. // (unless the group is being deleted too)
  259. if (parentId && parentId !== pageId) {
  260. const group = page.shapes[parentId]
  261. if (group !== undefined) {
  262. groupsToUpdate.add(page.shapes[parentId] as GroupShape)
  263. }
  264. }
  265. })
  266. // If binding is undefined, delete the binding
  267. Object.keys(page.bindings).forEach((id) => {
  268. if (!page.bindings[id]) {
  269. delete page.bindings[id]
  270. }
  271. })
  272. next.document.pages[pageId] = page
  273. // Find which shapes have changed
  274. // const changedShapes = Object.entries(page.shapes).filter(
  275. // ([id, shape]) => prevPage?.shapes[shape.id] !== shape
  276. // )
  277. // Get bindings related to the changed shapes
  278. const bindingsToUpdate = TLDR.getRelatedBindings(next, Object.keys(changedShapes), pageId)
  279. // Update all of the bindings we've just collected
  280. bindingsToUpdate.forEach((binding) => {
  281. if (!page.bindings[binding.id]) {
  282. return
  283. }
  284. const toShape = page.shapes[binding.toId]
  285. const fromShape = page.shapes[binding.fromId]
  286. const toUtils = TLDR.getShapeUtil(toShape)
  287. const fromUtils = TLDR.getShapeUtil(fromShape)
  288. // We only need to update the binding's "from" shape
  289. const fromDelta = fromUtils.onBindingChange?.(
  290. fromShape,
  291. binding,
  292. toShape,
  293. toUtils.getBounds(toShape),
  294. toUtils.getCenter(toShape)
  295. )
  296. if (fromDelta) {
  297. const nextShape = {
  298. ...fromShape,
  299. ...fromDelta,
  300. } as TDShape
  301. page.shapes[fromShape.id] = nextShape
  302. }
  303. })
  304. groupsToUpdate.forEach((group) => {
  305. if (!group) throw Error('no group!')
  306. const children = group.children.filter((id) => page.shapes[id] !== undefined)
  307. const commonBounds = Utils.getCommonBounds(
  308. children
  309. .map((id) => page.shapes[id])
  310. .filter(Boolean)
  311. .map((shape) => TLDR.getRotatedBounds(shape))
  312. )
  313. page.shapes[group.id] = {
  314. ...group,
  315. point: [commonBounds.minX, commonBounds.minY],
  316. size: [commonBounds.width, commonBounds.height],
  317. children,
  318. }
  319. })
  320. }
  321. // Clean up page state, preventing hovers on deleted shapes
  322. const nextPageState: TLPageState = {
  323. ...next.document.pageStates[pageId],
  324. }
  325. if (!nextPageState.brush) {
  326. delete nextPageState.brush
  327. }
  328. if (nextPageState.hoveredId && !page.shapes[nextPageState.hoveredId]) {
  329. delete nextPageState.hoveredId
  330. }
  331. if (nextPageState.bindingId && !page.bindings[nextPageState.bindingId]) {
  332. console.warn('Could not find the binding binding!', pageId)
  333. delete nextPageState.bindingId
  334. }
  335. if (nextPageState.editingId && !page.shapes[nextPageState.editingId]) {
  336. console.warn('Could not find the editing shape!')
  337. delete nextPageState.editingId
  338. }
  339. next.document.pageStates[pageId] = nextPageState
  340. })
  341. }
  342. const currentPageId = next.appState.currentPageId
  343. const currentPageState = next.document.pageStates[currentPageId]
  344. if (next.room && next.room !== prev.room) {
  345. const room = { ...next.room, users: { ...next.room.users } }
  346. // Remove any exited users
  347. if (prev.room) {
  348. Object.values(prev.room.users)
  349. .filter(Boolean)
  350. .forEach((user) => {
  351. if (room.users[user.id] === undefined) {
  352. delete room.users[user.id]
  353. }
  354. })
  355. }
  356. next.room = room
  357. }
  358. if (next.room) {
  359. next.room.users[next.room.userId] = {
  360. ...next.room.users[next.room.userId],
  361. point: this.currentPoint,
  362. selectedIds: currentPageState.selectedIds,
  363. }
  364. }
  365. // Temporary block on editing pages while in readonly mode.
  366. // This is a broad solution but not a very good one: the UX
  367. // for interacting with a readOnly document will be more nuanced.
  368. if (this.readOnly) {
  369. next.document.pages = prev.document.pages
  370. }
  371. return next
  372. }
  373. onPatch = (state: TDSnapshot, id?: string) => {
  374. this.callbacks.onPatch?.(this, id)
  375. }
  376. onCommand = (state: TDSnapshot, id?: string) => {
  377. this.clearSelectHistory()
  378. this.isDirty = true
  379. this.callbacks.onCommand?.(this, id)
  380. }
  381. onReplace = () => {
  382. this.clearSelectHistory()
  383. this.isDirty = false
  384. }
  385. onUndo = () => {
  386. this.rotationInfo.selectedIds = [...this.selectedIds]
  387. this.callbacks.onUndo?.(this)
  388. }
  389. onRedo = () => {
  390. this.rotationInfo.selectedIds = [...this.selectedIds]
  391. this.callbacks.onRedo?.(this)
  392. }
  393. onPersist = () => {
  394. this.broadcastPageChanges()
  395. }
  396. private prevSelectedIds = this.selectedIds
  397. /**
  398. * Clear the selection history after each new command, undo or redo.
  399. * @param state
  400. * @param id
  401. */
  402. protected onStateDidChange = (_state: TDSnapshot, id?: string): void => {
  403. this.callbacks.onChange?.(this, id)
  404. if (this.room && this.selectedIds !== this.prevSelectedIds) {
  405. this.callbacks.onChangePresence?.(this, {
  406. ...this.room.users[this.room.userId],
  407. selectedIds: this.selectedIds,
  408. })
  409. this.prevSelectedIds = this.selectedIds
  410. }
  411. }
  412. /* ----------- Managing Multiplayer State ----------- */
  413. private prevShapes = this.page.shapes
  414. private prevBindings = this.page.bindings
  415. private broadcastPageChanges = () => {
  416. const visited = new Set<string>()
  417. const changedShapes: Record<string, TDShape | undefined> = {}
  418. const changedBindings: Record<string, TDBinding | undefined> = {}
  419. this.shapes.forEach((shape) => {
  420. visited.add(shape.id)
  421. if (this.prevShapes[shape.id] !== shape) {
  422. changedShapes[shape.id] = shape
  423. }
  424. })
  425. Object.keys(this.prevShapes)
  426. .filter((id) => !visited.has(id))
  427. .forEach((id) => {
  428. changedShapes[id] = undefined
  429. })
  430. this.bindings.forEach((binding) => {
  431. visited.add(binding.id)
  432. if (this.prevBindings[binding.id] !== binding) {
  433. changedBindings[binding.id] = binding
  434. }
  435. })
  436. Object.keys(this.prevShapes)
  437. .filter((id) => !visited.has(id))
  438. .forEach((id) => {
  439. changedBindings[id] = undefined
  440. })
  441. this.callbacks.onChangePage?.(this, changedShapes, changedBindings)
  442. this.callbacks.onPersist?.(this)
  443. this.prevShapes = this.page.shapes
  444. this.prevBindings = this.page.bindings
  445. }
  446. /**
  447. * Manually patch a set of shapes.
  448. * @param shapes An array of shape partials, containing the changes to be made to each shape.
  449. * @command
  450. */
  451. public replacePageContent = (
  452. shapes: Record<string, TDShape>,
  453. bindings: Record<string, TDBinding>,
  454. pageId = this.currentPageId
  455. ): this => {
  456. this.useStore.setState((current) => {
  457. const { hoveredId, editingId, bindingId, selectedIds } = current.document.pageStates[pageId]
  458. const next = {
  459. ...current,
  460. document: {
  461. ...current.document,
  462. pages: {
  463. [pageId]: {
  464. ...current.document.pages[pageId],
  465. shapes,
  466. bindings,
  467. },
  468. },
  469. pageStates: {
  470. ...current.document.pageStates,
  471. [pageId]: {
  472. ...current.document.pageStates[pageId],
  473. selectedIds: selectedIds.filter((id) => shapes[id] !== undefined),
  474. hoveredId: hoveredId
  475. ? shapes[hoveredId] === undefined
  476. ? undefined
  477. : hoveredId
  478. : undefined,
  479. editingId: editingId
  480. ? shapes[editingId] === undefined
  481. ? undefined
  482. : hoveredId
  483. : undefined,
  484. bindingId: bindingId
  485. ? bindings[bindingId] === undefined
  486. ? undefined
  487. : bindingId
  488. : undefined,
  489. },
  490. },
  491. },
  492. }
  493. this.state.document = next.document
  494. this.prevShapes = next.document.pages[this.currentPageId].shapes
  495. this.prevBindings = next.document.pages[this.currentPageId].bindings
  496. return next
  497. }, true)
  498. return this
  499. }
  500. /**
  501. * Set the current status.
  502. * @param status The new status to set.
  503. * @private
  504. * @returns
  505. */
  506. setStatus(status: string) {
  507. return this.patchState(
  508. {
  509. appState: { status },
  510. },
  511. `set_status:${status}`
  512. )
  513. }
  514. /**
  515. * Update the bounding box when the renderer's bounds change.
  516. * @param bounds
  517. */
  518. updateBounds = (bounds: TLBounds) => {
  519. this.rendererBounds = bounds
  520. const { point, zoom } = this.pageState.camera
  521. this.updateViewport(point, zoom)
  522. if (!this.readOnly && this.session) {
  523. this.session.update()
  524. }
  525. }
  526. updateViewport = (point: number[], zoom: number) => {
  527. const { width, height } = this.rendererBounds
  528. const [minX, minY] = Vec.sub(Vec.div([0, 0], zoom), point)
  529. const [maxX, maxY] = Vec.sub(Vec.div([width, height], zoom), point)
  530. this.viewport = {
  531. minX,
  532. minY,
  533. maxX,
  534. maxY,
  535. height: maxX - minX,
  536. width: maxY - minY,
  537. }
  538. }
  539. /**
  540. * Set or clear the editing id
  541. * @param id [string]
  542. */
  543. setEditingId = (id?: string) => {
  544. if (this.readOnly) return
  545. this.editingStartTime = Date.now()
  546. this.patchState(
  547. {
  548. document: {
  549. pageStates: {
  550. [this.currentPageId]: {
  551. editingId: id,
  552. },
  553. },
  554. },
  555. },
  556. `set_editing_id`
  557. )
  558. }
  559. /**
  560. * Set or clear the hovered id
  561. * @param id [string]
  562. */
  563. setHoveredId = (id?: string) => {
  564. this.patchState(
  565. {
  566. document: {
  567. pageStates: {
  568. [this.currentPageId]: {
  569. hoveredId: id,
  570. },
  571. },
  572. },
  573. },
  574. `set_hovered_id`
  575. )
  576. }
  577. /* -------------------------------------------------- */
  578. /* Settings & UI */
  579. /* -------------------------------------------------- */
  580. /**
  581. * Set a setting.
  582. */
  583. setSetting = <T extends keyof TDSnapshot['settings'], V extends TDSnapshot['settings'][T]>(
  584. name: T,
  585. value: V | ((value: V) => V)
  586. ): this => {
  587. if (this.session) return this
  588. this.patchState(
  589. {
  590. settings: {
  591. [name]: typeof value === 'function' ? value(this.settings[name] as V) : value,
  592. },
  593. },
  594. `settings:${name}`
  595. )
  596. this.persist()
  597. return this
  598. }
  599. /**
  600. * Toggle pen mode.
  601. */
  602. toggleFocusMode = (): this => {
  603. if (this.session) return this
  604. this.patchState(
  605. {
  606. settings: {
  607. isFocusMode: !this.settings.isFocusMode,
  608. },
  609. },
  610. `settings:toggled_focus_mode`
  611. )
  612. this.persist()
  613. return this
  614. }
  615. /**
  616. * Toggle pen mode.
  617. */
  618. togglePenMode = (): this => {
  619. if (this.session) return this
  620. this.patchState(
  621. {
  622. settings: {
  623. isPenMode: !this.settings.isPenMode,
  624. },
  625. },
  626. `settings:toggled_pen_mode`
  627. )
  628. this.persist()
  629. return this
  630. }
  631. /**
  632. * Toggle dark mode.
  633. */
  634. toggleDarkMode = (): this => {
  635. if (this.session) return this
  636. this.patchState(
  637. { settings: { isDarkMode: !this.settings.isDarkMode } },
  638. `settings:toggled_dark_mode`
  639. )
  640. this.persist()
  641. return this
  642. }
  643. /**
  644. * Toggle zoom snap.
  645. */
  646. toggleZoomSnap = () => {
  647. if (this.session) return this
  648. this.patchState(
  649. { settings: { isZoomSnap: !this.settings.isZoomSnap } },
  650. `settings:toggled_zoom_snap`
  651. )
  652. this.persist()
  653. return this
  654. }
  655. /**
  656. * Toggle debug mode.
  657. */
  658. toggleDebugMode = () => {
  659. if (this.session) return this
  660. this.patchState(
  661. { settings: { isDebugMode: !this.settings.isDebugMode } },
  662. `settings:toggled_debug`
  663. )
  664. this.persist()
  665. return this
  666. }
  667. /**
  668. * Toggle the style panel.
  669. */
  670. toggleStylePanel = (): this => {
  671. if (this.session) return this
  672. this.patchState(
  673. { appState: { isStyleOpen: !this.appState.isStyleOpen } },
  674. 'ui:toggled_style_panel'
  675. )
  676. this.persist()
  677. return this
  678. }
  679. /**
  680. * Toggle grids.
  681. */
  682. toggleGrid = (): this => {
  683. if (this.session) return this
  684. this.patchState({ settings: { showGrid: !this.settings.showGrid } }, 'settings:toggled_grid')
  685. this.persist()
  686. return this
  687. }
  688. /**
  689. * Select a tool.
  690. * @param tool The tool to select, or "select".
  691. */
  692. selectTool = (type: TDToolType): this => {
  693. if (this.readOnly || this.session) return this
  694. const tool = this.tools[type]
  695. if (tool === this.currentTool) {
  696. this.patchState({
  697. appState: {
  698. isToolLocked: false,
  699. },
  700. })
  701. return this
  702. }
  703. this.currentTool.onExit()
  704. tool.previous = this.currentTool.type
  705. this.currentTool = tool
  706. this.currentTool.onEnter()
  707. return this.patchState(
  708. {
  709. appState: {
  710. activeTool: type,
  711. isToolLocked: false,
  712. },
  713. },
  714. `selected_tool:${type}`
  715. )
  716. }
  717. /**
  718. * Toggle the tool lock option.
  719. */
  720. toggleToolLock = (): this => {
  721. if (this.session) return this
  722. return this.patchState(
  723. {
  724. appState: {
  725. isToolLocked: !this.appState.isToolLocked,
  726. },
  727. },
  728. `toggled_tool_lock`
  729. )
  730. }
  731. /* -------------------------------------------------- */
  732. /* Document */
  733. /* -------------------------------------------------- */
  734. /**
  735. * Reset the document to a blank state.
  736. */
  737. resetDocument = (): this => {
  738. if (this.session) return this
  739. this.session = undefined
  740. this.pasteInfo.offset = [0, 0]
  741. this.currentTool = this.tools.select
  742. this.resetHistory()
  743. .clearSelectHistory()
  744. .loadDocument(migrate(TldrawApp.defaultDocument, TldrawApp.version))
  745. .persist()
  746. return this
  747. }
  748. /**
  749. *
  750. * @param document
  751. */
  752. updateUsers = (users: TDUser[], isOwnUpdate = false) => {
  753. this.patchState(
  754. {
  755. room: {
  756. users: Object.fromEntries(users.map((user) => [user.id, user])),
  757. },
  758. },
  759. isOwnUpdate ? 'room:self:update' : 'room:user:update'
  760. )
  761. }
  762. removeUser = (userId: string) => {
  763. this.patchState({
  764. room: {
  765. users: {
  766. [userId]: undefined,
  767. },
  768. },
  769. })
  770. }
  771. /**
  772. * Merge a new document patch into the current document.
  773. * @param document
  774. */
  775. mergeDocument = (document: TDDocument): this => {
  776. // If it's a new document, do a full change.
  777. if (this.document.id !== document.id) {
  778. this.replaceState({
  779. ...this.state,
  780. appState: {
  781. ...this.appState,
  782. currentPageId: Object.keys(document.pages)[0],
  783. },
  784. document: migrate(document, TldrawApp.version),
  785. })
  786. return this
  787. }
  788. // Have we deleted any pages? If so, drop everything and change
  789. // to the first page. This is an edge case.
  790. const currentPageStates = { ...this.document.pageStates }
  791. // Update the app state's current page id if needed
  792. const nextAppState = {
  793. ...this.appState,
  794. currentPageId: document.pages[this.currentPageId]
  795. ? this.currentPageId
  796. : Object.keys(document.pages)[0],
  797. pages: Object.values(document.pages).map((page, i) => ({
  798. id: page.id,
  799. name: page.name,
  800. childIndex: page.childIndex || i,
  801. })),
  802. }
  803. // Reset the history (for now)
  804. this.resetHistory()
  805. Object.keys(this.document.pages).forEach((pageId) => {
  806. if (!document.pages[pageId]) {
  807. if (pageId === this.appState.currentPageId) {
  808. this.cancelSession()
  809. this.selectNone()
  810. }
  811. currentPageStates[pageId] = undefined as unknown as TLPageState
  812. }
  813. })
  814. // Don't allow the selected ids to be deleted during a session—if
  815. // they've been removed, put them back in the client's document.
  816. if (this.session) {
  817. this.selectedIds
  818. .filter((id) => !document.pages[this.currentPageId].shapes[id])
  819. .forEach((id) => (document.pages[this.currentPageId].shapes[id] = this.page.shapes[id]))
  820. }
  821. // For other pages, remove any selected ids that were deleted.
  822. Object.entries(currentPageStates).forEach(([pageId, pageState]) => {
  823. pageState.selectedIds = pageState.selectedIds.filter(
  824. (id) => !!document.pages[pageId].shapes[id]
  825. )
  826. })
  827. // If the user is currently creating a shape (ie drawing), then put that
  828. // shape back onto the page for the client.
  829. const { editingId } = this.pageState
  830. if (editingId) {
  831. document.pages[this.currentPageId].shapes[editingId] = this.page.shapes[editingId]
  832. currentPageStates[this.currentPageId].selectedIds = [editingId]
  833. }
  834. return this.replaceState(
  835. {
  836. ...this.state,
  837. appState: nextAppState,
  838. document: {
  839. ...migrate(document, TldrawApp.version),
  840. pageStates: currentPageStates,
  841. },
  842. },
  843. 'merge'
  844. )
  845. }
  846. /**
  847. * Update the current document.
  848. * @param document
  849. */
  850. updateDocument = (document: TDDocument, reason = 'updated_document'): this => {
  851. const prevState = this.state
  852. const nextState = { ...prevState, document: { ...prevState.document } }
  853. if (!document.pages[this.currentPageId]) {
  854. nextState.appState = {
  855. ...prevState.appState,
  856. currentPageId: Object.keys(document.pages)[0],
  857. }
  858. }
  859. let i = 1
  860. for (const nextPage of Object.values(document.pages)) {
  861. if (nextPage !== prevState.document.pages[nextPage.id]) {
  862. nextState.document.pages[nextPage.id] = nextPage
  863. if (!nextPage.name) {
  864. nextState.document.pages[nextPage.id].name = `Page ${i + 1}`
  865. i++
  866. }
  867. }
  868. }
  869. for (const nextPageState of Object.values(document.pageStates)) {
  870. if (nextPageState !== prevState.document.pageStates[nextPageState.id]) {
  871. nextState.document.pageStates[nextPageState.id] = nextPageState
  872. const nextPage = document.pages[nextPageState.id]
  873. const keysToCheck = ['bindingId', 'editingId', 'hoveredId', 'pointedId'] as const
  874. for (const key of keysToCheck) {
  875. if (!nextPage.shapes[key]) {
  876. nextPageState[key] = undefined
  877. }
  878. }
  879. nextPageState.selectedIds = nextPageState.selectedIds.filter(
  880. (id) => !!document.pages[nextPage.id].shapes[id]
  881. )
  882. }
  883. }
  884. nextState.document = migrate(nextState.document, nextState.document.version || 0)
  885. return this.replaceState(nextState, `${reason}:${document.id}`)
  886. }
  887. /**
  888. * Load a fresh room into the state.
  889. * @param roomId
  890. */
  891. loadRoom = (roomId: string): this => {
  892. this.patchState({
  893. room: {
  894. id: roomId,
  895. userId: uuid,
  896. users: {
  897. [uuid]: {
  898. id: uuid,
  899. color: USER_COLORS[Math.floor(Math.random() * USER_COLORS.length)],
  900. point: [100, 100],
  901. selectedIds: [],
  902. activeShapes: [],
  903. },
  904. },
  905. },
  906. })
  907. return this
  908. }
  909. /**
  910. * Load a new document.
  911. * @param document The document to load
  912. */
  913. loadDocument = (document: TDDocument): this => {
  914. this.selectNone()
  915. this.resetHistory()
  916. this.clearSelectHistory()
  917. this.session = undefined
  918. this.replaceState(
  919. {
  920. ...TldrawApp.defaultState,
  921. document: migrate(document, TldrawApp.version),
  922. appState: {
  923. ...TldrawApp.defaultState.appState,
  924. currentPageId: Object.keys(document.pages)[0],
  925. },
  926. },
  927. 'loaded_document'
  928. )
  929. return this
  930. }
  931. // Should we move this to the app layer? onSave, onSaveAs, etc?
  932. /**
  933. * Create a new project.
  934. */
  935. newProject = () => {
  936. if (!this.isLocal) return
  937. this.fileSystemHandle = null
  938. this.resetDocument()
  939. }
  940. /**
  941. * Save the current project.
  942. */
  943. saveProject = async () => {
  944. if (this.readOnly) return
  945. try {
  946. const fileHandle = await saveToFileSystem(this.document, this.fileSystemHandle)
  947. this.fileSystemHandle = fileHandle
  948. this.persist()
  949. this.isDirty = false
  950. } catch (e: any) {
  951. // Likely cancelled
  952. console.error(e.message)
  953. }
  954. return this
  955. }
  956. /**
  957. * Save the current project as a new file.
  958. */
  959. saveProjectAs = async () => {
  960. try {
  961. const fileHandle = await saveToFileSystem(this.document, null)
  962. this.fileSystemHandle = fileHandle
  963. this.persist()
  964. this.isDirty = false
  965. } catch (e: any) {
  966. // Likely cancelled
  967. console.error(e.message)
  968. }
  969. return this
  970. }
  971. /**
  972. * Load a project from the filesystem.
  973. * @todo
  974. */
  975. openProject = async () => {
  976. if (!this.isLocal) return
  977. try {
  978. const result = await openFromFileSystem()
  979. if (!result) {
  980. throw Error()
  981. }
  982. const { fileHandle, document } = result
  983. this.loadDocument(document)
  984. this.fileSystemHandle = fileHandle
  985. this.zoomToFit()
  986. this.persist()
  987. } catch (e) {
  988. console.error(e)
  989. } finally {
  990. this.persist()
  991. }
  992. }
  993. /**
  994. * Sign out of the current account.
  995. * Should move to the www layer.
  996. * @todo
  997. */
  998. signOut = () => {
  999. // todo
  1000. }
  1001. /* -------------------- Getters --------------------- */
  1002. /**
  1003. * Get the current app state.
  1004. */
  1005. getAppState = (): TDSnapshot['appState'] => {
  1006. return this.appState
  1007. }
  1008. /**
  1009. * Get a page.
  1010. * @param pageId (optional) The page's id.
  1011. */
  1012. getPage = (pageId = this.currentPageId): TDPage => {
  1013. return TLDR.getPage(this.state, pageId || this.currentPageId)
  1014. }
  1015. /**
  1016. * Get the shapes (as an array) from a given page.
  1017. * @param pageId (optional) The page's id.
  1018. */
  1019. getShapes = (pageId = this.currentPageId): TDShape[] => {
  1020. return TLDR.getShapes(this.state, pageId || this.currentPageId)
  1021. }
  1022. /**
  1023. * Get the bindings from a given page.
  1024. * @param pageId (optional) The page's id.
  1025. */
  1026. getBindings = (pageId = this.currentPageId): TDBinding[] => {
  1027. return TLDR.getBindings(this.state, pageId || this.currentPageId)
  1028. }
  1029. /**
  1030. * Get a shape from a given page.
  1031. * @param id The shape's id.
  1032. * @param pageId (optional) The page's id.
  1033. */
  1034. getShape = <T extends TDShape = TDShape>(id: string, pageId = this.currentPageId): T => {
  1035. return TLDR.getShape<T>(this.state, id, pageId)
  1036. }
  1037. /**
  1038. * Get the bounds of a shape on a given page.
  1039. * @param id The shape's id.
  1040. * @param pageId (optional) The page's id.
  1041. */
  1042. getShapeBounds = (id: string, pageId = this.currentPageId): TLBounds => {
  1043. return TLDR.getBounds(this.getShape(id, pageId))
  1044. }
  1045. /**
  1046. * Get a binding from a given page.
  1047. * @param id The binding's id.
  1048. * @param pageId (optional) The page's id.
  1049. */
  1050. getBinding = (id: string, pageId = this.currentPageId): TDBinding => {
  1051. return TLDR.getBinding(this.state, id, pageId)
  1052. }
  1053. /**
  1054. * Get the page state for a given page.
  1055. * @param pageId (optional) The page's id.
  1056. */
  1057. getPageState = (pageId = this.currentPageId): TLPageState => {
  1058. return TLDR.getPageState(this.state, pageId || this.currentPageId)
  1059. }
  1060. /**
  1061. * Turn a screen point into a point on the page.
  1062. * @param point The screen point
  1063. * @param pageId (optional) The page to use
  1064. */
  1065. getPagePoint = (point: number[], pageId = this.currentPageId): number[] => {
  1066. const { camera } = this.getPageState(pageId)
  1067. return Vec.sub(Vec.div(point, camera.zoom), camera.point)
  1068. }
  1069. /**
  1070. * Get the current undo/redo stack.
  1071. */
  1072. get history() {
  1073. return this.stack.slice(0, this.pointer + 1)
  1074. }
  1075. /**
  1076. * Replace the current history stack.
  1077. */
  1078. set history(commands: TldrawCommand[]) {
  1079. this.replaceHistory(commands)
  1080. }
  1081. /**
  1082. * The current document.
  1083. */
  1084. get document(): TDDocument {
  1085. return this.state.document
  1086. }
  1087. /**
  1088. * The current app state.
  1089. */
  1090. get settings(): TDSnapshot['settings'] {
  1091. return this.state.settings
  1092. }
  1093. /**
  1094. * The current app state.
  1095. */
  1096. get appState(): TDSnapshot['appState'] {
  1097. return this.state.appState
  1098. }
  1099. /**
  1100. * The current page id.
  1101. */
  1102. get currentPageId(): string {
  1103. return this.state.appState.currentPageId
  1104. }
  1105. /**
  1106. * The current page.
  1107. */
  1108. get page(): TDPage {
  1109. return this.state.document.pages[this.currentPageId]
  1110. }
  1111. /**
  1112. * The current page's shapes (as an array).
  1113. */
  1114. get shapes(): TDShape[] {
  1115. return Object.values(this.page.shapes)
  1116. }
  1117. /**
  1118. * The current page's bindings.
  1119. */
  1120. get bindings(): TDBinding[] {
  1121. return Object.values(this.page.bindings)
  1122. }
  1123. /**
  1124. * The current page's state.
  1125. */
  1126. get pageState(): TLPageState {
  1127. return this.state.document.pageStates[this.currentPageId]
  1128. }
  1129. /**
  1130. * The page's current selected ids.
  1131. */
  1132. get selectedIds(): string[] {
  1133. return this.pageState.selectedIds
  1134. }
  1135. /* -------------------------------------------------- */
  1136. /* Pages */
  1137. /* -------------------------------------------------- */
  1138. /**
  1139. * Create a new page.
  1140. * @param pageId (optional) The new page's id.
  1141. */
  1142. createPage = (id?: string): this => {
  1143. if (this.readOnly) return this
  1144. const { width, height } = this.rendererBounds
  1145. return this.setState(Commands.createPage(this, [-width / 2, -height / 2], id))
  1146. }
  1147. /**
  1148. * Change the current page.
  1149. * @param pageId The new current page's id.
  1150. */
  1151. changePage = (pageId: string): this => {
  1152. return this.setState(Commands.changePage(this, pageId))
  1153. }
  1154. /**
  1155. * Rename a page.
  1156. * @param pageId The id of the page to rename.
  1157. * @param name The page's new name
  1158. */
  1159. renamePage = (pageId: string, name: string): this => {
  1160. if (this.readOnly) return this
  1161. return this.setState(Commands.renamePage(this, pageId, name))
  1162. }
  1163. /**
  1164. * Duplicate a page.
  1165. * @param pageId The id of the page to duplicate.
  1166. */
  1167. duplicatePage = (pageId: string): this => {
  1168. if (this.readOnly) return this
  1169. return this.setState(Commands.duplicatePage(this, pageId))
  1170. }
  1171. /**
  1172. * Delete a page.
  1173. * @param pageId The id of the page to delete.
  1174. */
  1175. deletePage = (pageId?: string): this => {
  1176. if (this.readOnly) return this
  1177. if (Object.values(this.document.pages).length <= 1) return this
  1178. return this.setState(Commands.deletePage(this, pageId ? pageId : this.currentPageId))
  1179. }
  1180. /* -------------------------------------------------- */
  1181. /* Clipboard */
  1182. /* -------------------------------------------------- */
  1183. /**
  1184. * Copy one or more shapes to the clipboard.
  1185. * @param ids The ids of the shapes to copy.
  1186. */
  1187. copy = (ids = this.selectedIds): this => {
  1188. const copyingShapeIds = ids.flatMap((id) =>
  1189. TLDR.getDocumentBranch(this.state, id, this.currentPageId)
  1190. )
  1191. const copyingShapes = copyingShapeIds.map((id) =>
  1192. Utils.deepClone(this.getShape(id, this.currentPageId))
  1193. )
  1194. if (copyingShapes.length === 0) return this
  1195. const copyingBindings: TDBinding[] = Object.values(this.page.bindings).filter(
  1196. (binding) =>
  1197. copyingShapeIds.includes(binding.fromId) && copyingShapeIds.includes(binding.toId)
  1198. )
  1199. this.clipboard = {
  1200. shapes: copyingShapes,
  1201. bindings: copyingBindings,
  1202. }
  1203. try {
  1204. const text = JSON.stringify({
  1205. type: 'tldr/clipboard',
  1206. shapes: copyingShapes,
  1207. bindings: copyingBindings,
  1208. })
  1209. navigator.clipboard.writeText(text).then(
  1210. () => {
  1211. // success
  1212. },
  1213. () => {
  1214. // failure
  1215. }
  1216. )
  1217. } catch (e) {
  1218. // Browser does not support copying to clipboard
  1219. }
  1220. this.pasteInfo.offset = [0, 0]
  1221. this.pasteInfo.center = [0, 0]
  1222. return this
  1223. }
  1224. /**
  1225. * Cut (copy and delete) one or more shapes to the clipboard.
  1226. * @param ids The ids of the shapes to cut.
  1227. */
  1228. cut = (ids = this.selectedIds): this => {
  1229. this.copy(ids)
  1230. this.delete(ids)
  1231. return this
  1232. }
  1233. /**
  1234. * Paste shapes (or text) from clipboard to a certain point.
  1235. * @param point
  1236. */
  1237. paste = (point?: number[]) => {
  1238. if (this.readOnly) return
  1239. const pasteInCurrentPage = (shapes: TDShape[], bindings: TDBinding[]) => {
  1240. const idsMap: Record<string, string> = {}
  1241. shapes.forEach((shape) => (idsMap[shape.id] = Utils.uniqueId()))
  1242. bindings.forEach((binding) => (idsMap[binding.id] = Utils.uniqueId()))
  1243. let startIndex = TLDR.getTopChildIndex(this.state, this.currentPageId)
  1244. const shapesToPaste = shapes
  1245. .sort((a, b) => a.childIndex - b.childIndex)
  1246. .map((shape) => {
  1247. const parentShapeId = idsMap[shape.parentId]
  1248. const copy = {
  1249. ...shape,
  1250. id: idsMap[shape.id],
  1251. parentId: parentShapeId || this.currentPageId,
  1252. }
  1253. if (shape.children) {
  1254. copy.children = shape.children.map((id) => idsMap[id])
  1255. }
  1256. if (!parentShapeId) {
  1257. copy.childIndex = startIndex
  1258. startIndex++
  1259. }
  1260. if (copy.handles) {
  1261. Object.values(copy.handles).forEach((handle) => {
  1262. if (handle.bindingId) {
  1263. handle.bindingId = idsMap[handle.bindingId]
  1264. }
  1265. })
  1266. }
  1267. return copy
  1268. })
  1269. const bindingsToPaste = bindings.map((binding) => ({
  1270. ...binding,
  1271. id: idsMap[binding.id],
  1272. toId: idsMap[binding.toId],
  1273. fromId: idsMap[binding.fromId],
  1274. }))
  1275. const commonBounds = Utils.getCommonBounds(shapesToPaste.map(TLDR.getBounds))
  1276. let center = Vec.toFixed(this.getPagePoint(point || this.centerPoint))
  1277. if (
  1278. Vec.dist(center, this.pasteInfo.center) < 2 ||
  1279. Vec.dist(center, Vec.toFixed(Utils.getBoundsCenter(commonBounds))) < 2
  1280. ) {
  1281. center = Vec.add(center, this.pasteInfo.offset)
  1282. this.pasteInfo.offset = Vec.add(this.pasteInfo.offset, [GRID_SIZE, GRID_SIZE])
  1283. } else {
  1284. this.pasteInfo.center = center
  1285. this.pasteInfo.offset = [0, 0]
  1286. }
  1287. const centeredBounds = Utils.centerBounds(commonBounds, center)
  1288. const delta = Vec.sub(
  1289. Utils.getBoundsCenter(centeredBounds),
  1290. Utils.getBoundsCenter(commonBounds)
  1291. )
  1292. this.create(
  1293. shapesToPaste.map((shape) =>
  1294. TLDR.getShapeUtil(shape.type).create({
  1295. ...shape,
  1296. point: Vec.toFixed(Vec.add(shape.point, delta)),
  1297. parentId: shape.parentId || this.currentPageId,
  1298. })
  1299. ),
  1300. bindingsToPaste
  1301. )
  1302. }
  1303. try {
  1304. if (!('clipboard' in navigator && navigator.clipboard.readText)) {
  1305. throw Error('This browser does not support the clipboard API.')
  1306. }
  1307. navigator.clipboard.readText().then((result) => {
  1308. try {
  1309. const data: { type: string; shapes: TDShape[]; bindings: TDBinding[] } =
  1310. JSON.parse(result)
  1311. if (data.type !== 'tldr/clipboard') {
  1312. throw Error('The pasted string was not from the Tldraw clipboard.')
  1313. }
  1314. pasteInCurrentPage(data.shapes, data.bindings)
  1315. } catch (e) {
  1316. console.warn(e)
  1317. const shapeId = Utils.uniqueId()
  1318. this.createShapes({
  1319. id: shapeId,
  1320. type: TDShapeType.Text,
  1321. parentId: this.appState.currentPageId,
  1322. text: TLDR.normalizeText(result),
  1323. point: this.getPagePoint(this.centerPoint, this.currentPageId),
  1324. style: { ...this.appState.currentStyle },
  1325. })
  1326. this.select(shapeId)
  1327. }
  1328. })
  1329. } catch (e) {
  1330. // Navigator does not support clipboard. Note that this fallback will
  1331. // not support pasting from one document to another.
  1332. if (this.clipboard) {
  1333. pasteInCurrentPage(this.clipboard.shapes, this.clipboard.bindings)
  1334. }
  1335. }
  1336. return this
  1337. }
  1338. /**
  1339. * Copy one or more shapes as SVG.
  1340. * @param ids The ids of the shapes to copy.
  1341. * @param pageId The page from which to copy the shapes.
  1342. * @returns A string containing the JSON.
  1343. */
  1344. copySvg = (ids = this.selectedIds, pageId = this.currentPageId) => {
  1345. if (ids.length === 0) ids = Object.keys(this.page.shapes)
  1346. if (ids.length === 0) return
  1347. const shapes = ids.map((id) => this.getShape(id, pageId))
  1348. const commonBounds = Utils.getCommonBounds(shapes.map(TLDR.getRotatedBounds))
  1349. const padding = 16
  1350. const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
  1351. const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs')
  1352. const style = document.createElementNS('http://www.w3.org/2000/svg', 'style')
  1353. style.textContent = `@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro&display=swap');`
  1354. defs.appendChild(style)
  1355. svg.appendChild(defs)
  1356. function getSvgElementForShape(shape: TDShape) {
  1357. const util = TLDR.getShapeUtil(shape)
  1358. const element = util.getSvgElement(shape)
  1359. const bounds = util.getBounds(shape)
  1360. if (!element) return
  1361. element.setAttribute(
  1362. 'transform',
  1363. `translate(${padding + shape.point[0] - commonBounds.minX}, ${
  1364. padding + shape.point[1] - commonBounds.minY
  1365. }) rotate(${((shape.rotation || 0) * 180) / Math.PI}, ${bounds.width / 2}, ${
  1366. bounds.height / 2
  1367. })`
  1368. )
  1369. return element
  1370. }
  1371. shapes.forEach((shape) => {
  1372. if (shape.children?.length) {
  1373. // Create a group <g> element for shape
  1374. const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
  1375. // Get the shape's children as elements
  1376. shape.children
  1377. .map((childId) => this.getShape(childId, pageId))
  1378. .map(getSvgElementForShape)
  1379. .filter(Boolean)
  1380. .forEach((element) => g.appendChild(element!))
  1381. // Add the group element to the SVG
  1382. svg.appendChild(g)
  1383. return
  1384. }
  1385. const element = getSvgElementForShape(shape)
  1386. if (element) {
  1387. svg.appendChild(element)
  1388. }
  1389. })
  1390. // Resize the element to the bounding box
  1391. svg.setAttribute(
  1392. 'viewBox',
  1393. [0, 0, commonBounds.width + padding * 2, commonBounds.height + padding * 2].join(' ')
  1394. )
  1395. svg.setAttribute('width', String(commonBounds.width))
  1396. svg.setAttribute('height', String(commonBounds.height))
  1397. const s = new XMLSerializer()
  1398. const svgString = s
  1399. .serializeToString(svg)
  1400. .replaceAll('&#10; ', '')
  1401. .replaceAll(/((\s|")[0-9]*\.[0-9]{2})([0-9]*)(\b|"|\))/g, '$1')
  1402. TLDR.copyStringToClipboard(svgString)
  1403. return svgString
  1404. }
  1405. /**
  1406. * Copy one or more shapes as JSON.
  1407. * @param ids The ids of the shapes to copy.
  1408. * @param pageId The page from which to copy the shapes.
  1409. * @returns A string containing the JSON.
  1410. */
  1411. copyJson = (ids = this.selectedIds, pageId = this.currentPageId) => {
  1412. if (ids.length === 0) ids = Object.keys(this.page.shapes)
  1413. if (ids.length === 0) return
  1414. const shapes = ids.map((id) => this.getShape(id, pageId))
  1415. const json = JSON.stringify(shapes, null, 2)
  1416. TLDR.copyStringToClipboard(json)
  1417. return json
  1418. }
  1419. /* -------------------------------------------------- */
  1420. /* Camera */
  1421. /* -------------------------------------------------- */
  1422. /**
  1423. * Set the camera to a specific point and zoom.
  1424. * @param point The camera point (top left of the viewport).
  1425. * @param zoom The zoom level.
  1426. * @param reason Why did the camera change?
  1427. */
  1428. setCamera = (point: number[], zoom: number, reason: string): this => {
  1429. this.updateViewport(point, zoom)
  1430. this.patchState(
  1431. {
  1432. document: {
  1433. pageStates: {
  1434. [this.currentPageId]: { camera: { point, zoom } },
  1435. },
  1436. },
  1437. },
  1438. reason
  1439. )
  1440. return this
  1441. }
  1442. /**
  1443. * Reset the camera to the default position
  1444. */
  1445. resetCamera = (): this => {
  1446. return this.setCamera(this.centerPoint, 1, `reset_camera`)
  1447. }
  1448. /**
  1449. * Pan the camera
  1450. * @param delta
  1451. */
  1452. pan = (delta: number[]): this => {
  1453. const { camera } = this.pageState
  1454. return this.setCamera(Vec.toFixed(Vec.sub(camera.point, delta)), camera.zoom, `panned`)
  1455. }
  1456. /**
  1457. * Pinch to a new zoom level, possibly together with a pan.
  1458. * @param point The current point under the cursor.
  1459. * @param delta The movement delta.
  1460. * @param zoomDelta The zoom detal
  1461. */
  1462. pinchZoom = (point: number[], delta: number[], zoom: number): this => {
  1463. const { camera } = this.pageState
  1464. const nextPoint = Vec.sub(camera.point, Vec.div(delta, camera.zoom))
  1465. const nextZoom = zoom
  1466. const p0 = Vec.sub(Vec.div(point, camera.zoom), nextPoint)
  1467. const p1 = Vec.sub(Vec.div(point, nextZoom), nextPoint)
  1468. return this.setCamera(
  1469. Vec.toFixed(Vec.add(nextPoint, Vec.sub(p1, p0))),
  1470. nextZoom,
  1471. `pinch_zoomed`
  1472. )
  1473. }
  1474. /**
  1475. * Zoom to a new zoom level, keeping the point under the cursor in the same position
  1476. * @param next The new zoom level.
  1477. * @param center The point to zoom towards (defaults to screen center).
  1478. */
  1479. zoomTo = (next: number, center = this.centerPoint): this => {
  1480. const { zoom, point } = this.pageState.camera
  1481. const p0 = Vec.sub(Vec.div(center, zoom), point)
  1482. const p1 = Vec.sub(Vec.div(center, next), point)
  1483. return this.setCamera(Vec.toFixed(Vec.add(point, Vec.sub(p1, p0))), next, `zoomed_camera`)
  1484. }
  1485. /**
  1486. * Zoom out by 25%
  1487. */
  1488. zoomIn = (): this => {
  1489. const i = Math.round((this.pageState.camera.zoom * 100) / 25)
  1490. const nextZoom = TLDR.getCameraZoom((i + 1) * 0.25)
  1491. return this.zoomTo(nextZoom)
  1492. }
  1493. /**
  1494. * Zoom in by 25%.
  1495. */
  1496. zoomOut = (): this => {
  1497. const i = Math.round((this.pageState.camera.zoom * 100) / 25)
  1498. const nextZoom = TLDR.getCameraZoom((i - 1) * 0.25)
  1499. return this.zoomTo(nextZoom)
  1500. }
  1501. /**
  1502. * Zoom to fit the page's shapes.
  1503. */
  1504. zoomToFit = (): this => {
  1505. const shapes = this.shapes
  1506. if (shapes.length === 0) return this
  1507. const { rendererBounds } = this
  1508. const commonBounds = Utils.getCommonBounds(shapes.map(TLDR.getBounds))
  1509. let zoom = TLDR.getCameraZoom(
  1510. Math.min(
  1511. (rendererBounds.width - FIT_TO_SCREEN_PADDING) / commonBounds.width,
  1512. (rendererBounds.height - FIT_TO_SCREEN_PADDING) / commonBounds.height
  1513. )
  1514. )
  1515. zoom =
  1516. this.pageState.camera.zoom === zoom || this.pageState.camera.zoom < 1
  1517. ? Math.min(1, zoom)
  1518. : zoom
  1519. const mx = (rendererBounds.width - commonBounds.width * zoom) / 2 / zoom
  1520. const my = (rendererBounds.height - commonBounds.height * zoom) / 2 / zoom
  1521. return this.setCamera(
  1522. Vec.toFixed(Vec.sub([mx, my], [commonBounds.minX, commonBounds.minY])),
  1523. zoom,
  1524. `zoomed_to_fit`
  1525. )
  1526. }
  1527. /**
  1528. * Zoom to the selected shapes.
  1529. */
  1530. zoomToSelection = (): this => {
  1531. if (this.selectedIds.length === 0) return this
  1532. const { rendererBounds } = this
  1533. const selectedBounds = TLDR.getSelectedBounds(this.state)
  1534. let zoom = TLDR.getCameraZoom(
  1535. Math.min(
  1536. (rendererBounds.width - FIT_TO_SCREEN_PADDING) / selectedBounds.width,
  1537. (rendererBounds.height - FIT_TO_SCREEN_PADDING) / selectedBounds.height
  1538. )
  1539. )
  1540. zoom =
  1541. this.pageState.camera.zoom === zoom || this.pageState.camera.zoom < 1
  1542. ? Math.min(1, zoom)
  1543. : zoom
  1544. const mx = (rendererBounds.width - selectedBounds.width * zoom) / 2 / zoom
  1545. const my = (rendererBounds.height - selectedBounds.height * zoom) / 2 / zoom
  1546. return this.setCamera(
  1547. Vec.toFixed(Vec.sub([mx, my], [selectedBounds.minX, selectedBounds.minY])),
  1548. zoom,
  1549. `zoomed_to_selection`
  1550. )
  1551. }
  1552. /**
  1553. * Zoom back to content when the canvas is empty.
  1554. */
  1555. zoomToContent = (): this => {
  1556. const shapes = this.shapes
  1557. const pageState = this.pageState
  1558. if (shapes.length === 0) return this
  1559. const { rendererBounds } = this
  1560. const { zoom } = pageState.camera
  1561. const commonBounds = Utils.getCommonBounds(shapes.map(TLDR.getBounds))
  1562. const mx = (rendererBounds.width - commonBounds.width * zoom) / 2 / zoom
  1563. const my = (rendererBounds.height - commonBounds.height * zoom) / 2 / zoom
  1564. return this.setCamera(
  1565. Vec.toFixed(Vec.sub([mx, my], [commonBounds.minX, commonBounds.minY])),
  1566. this.pageState.camera.zoom,
  1567. `zoomed_to_content`
  1568. )
  1569. }
  1570. /**
  1571. * Zoom the camera to 100%.
  1572. */
  1573. resetZoom = (): this => {
  1574. return this.zoomTo(1)
  1575. }
  1576. /**
  1577. * Zoom the camera by a certain delta.
  1578. * @param delta The zoom delta.
  1579. * @param center The point to zoom toward.
  1580. */
  1581. zoomBy = Utils.throttle((delta: number, center?: number[]): this => {
  1582. const { zoom } = this.pageState.camera
  1583. const nextZoom = TLDR.getCameraZoom(zoom - delta * zoom)
  1584. return this.zoomTo(nextZoom, center)
  1585. }, 16)
  1586. /* -------------------------------------------------- */
  1587. /* Selection */
  1588. /* -------------------------------------------------- */
  1589. /**
  1590. * Clear the selection history (undo/redo stack for selection).
  1591. */
  1592. private clearSelectHistory = (): this => {
  1593. this.selectHistory.pointer = 0
  1594. this.selectHistory.stack = [this.selectedIds]
  1595. return this
  1596. }
  1597. /**
  1598. * Adds a selection to the selection history (undo/redo stack for selection).
  1599. */
  1600. private addToSelectHistory = (ids: string[]): this => {
  1601. if (this.selectHistory.pointer < this.selectHistory.stack.length) {
  1602. this.selectHistory.stack = this.selectHistory.stack.slice(0, this.selectHistory.pointer + 1)
  1603. }
  1604. this.selectHistory.pointer++
  1605. this.selectHistory.stack.push(ids)
  1606. return this
  1607. }
  1608. /**
  1609. * Set the current selection.
  1610. * @param ids The ids to select
  1611. * @param push Whether to add the ids to the current selection instead.
  1612. */
  1613. private setSelectedIds = (ids: string[], push = false): this => {
  1614. const nextIds = push ? [...this.pageState.selectedIds, ...ids] : [...ids]
  1615. return this.patchState(
  1616. {
  1617. appState: {
  1618. activeTool: 'select',
  1619. },
  1620. document: {
  1621. pageStates: {
  1622. [this.currentPageId]: {
  1623. selectedIds: nextIds,
  1624. },
  1625. },
  1626. },
  1627. },
  1628. `selected`
  1629. )
  1630. }
  1631. /**
  1632. * Undo the most recent selection.
  1633. */
  1634. undoSelect = (): this => {
  1635. if (this.selectHistory.pointer > 0) {
  1636. this.selectHistory.pointer--
  1637. this.setSelectedIds(this.selectHistory.stack[this.selectHistory.pointer])
  1638. }
  1639. return this
  1640. }
  1641. /**
  1642. * Redo the previous selection.
  1643. */
  1644. redoSelect = (): this => {
  1645. if (this.selectHistory.pointer < this.selectHistory.stack.length - 1) {
  1646. this.selectHistory.pointer++
  1647. this.setSelectedIds(this.selectHistory.stack[this.selectHistory.pointer])
  1648. }
  1649. return this
  1650. }
  1651. /**
  1652. * Select one or more shapes.
  1653. * @param ids The shape ids to select.
  1654. */
  1655. select = (...ids: string[]): this => {
  1656. ids.forEach((id) => {
  1657. if (!this.page.shapes[id]) {
  1658. throw Error(`That shape does not exist on page ${this.currentPageId}`)
  1659. }
  1660. })
  1661. this.setSelectedIds(ids)
  1662. this.addToSelectHistory(ids)
  1663. return this
  1664. }
  1665. /**
  1666. * Select all shapes on the page.
  1667. */
  1668. selectAll = (pageId = this.currentPageId): this => {
  1669. if (this.session) return this
  1670. // Select only shapes that are the direct child of the page
  1671. this.setSelectedIds(
  1672. Object.values(this.document.pages[pageId].shapes)
  1673. .filter((shape) => shape.parentId === pageId)
  1674. .map((shape) => shape.id)
  1675. )
  1676. this.addToSelectHistory(this.selectedIds)
  1677. this.selectTool('select')
  1678. return this
  1679. }
  1680. /**
  1681. * Deselect any selected shapes.
  1682. */
  1683. selectNone = (): this => {
  1684. this.setSelectedIds([])
  1685. this.addToSelectHistory(this.selectedIds)
  1686. return this
  1687. }
  1688. /* -------------------------------------------------- */
  1689. /* Sessions p */
  1690. /* -------------------------------------------------- */
  1691. /**
  1692. * Start a new session.
  1693. * @param session The new session
  1694. * @param args arguments of the session's start method.
  1695. */
  1696. startSession = <T extends SessionType>(type: T, ...args: SessionArgsOfType<T>): this => {
  1697. if (this.readOnly && type !== SessionType.Brush) return this
  1698. if (this.session) {
  1699. console.warn(`Already in a session! (${this.session.constructor.name})`)
  1700. this.cancelSession()
  1701. }
  1702. const Session = getSession(type)
  1703. // @ts-ignore
  1704. this.session = new Session(this, ...args)
  1705. const result = this.session.start()
  1706. if (result) {
  1707. this.patchState(result, `session:start_${this.session.constructor.name}`)
  1708. }
  1709. return this
  1710. // return this.setStatus(this.session.status)
  1711. }
  1712. /**
  1713. * updateSession.
  1714. * @param args The arguments of the current session's update method.
  1715. */
  1716. updateSession = (): this => {
  1717. const { session } = this
  1718. if (!session) return this
  1719. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  1720. // @ts-ignore
  1721. const patch = session.update()
  1722. if (!patch) return this
  1723. return this.patchState(patch, `session:${session?.constructor.name}`)
  1724. }
  1725. /**
  1726. * Cancel the current session.
  1727. * @param args The arguments of the current session's cancel method.
  1728. */
  1729. cancelSession = (): this => {
  1730. const { session } = this
  1731. if (!session) return this
  1732. this.session = undefined
  1733. const result = session.cancel()
  1734. if (result) {
  1735. this.patchState(result, `session:cancel:${session.constructor.name}`)
  1736. }
  1737. return this
  1738. }
  1739. /**
  1740. * Complete the current session.
  1741. * @param args The arguments of the current session's complete method.
  1742. */
  1743. completeSession = (): this => {
  1744. const { session } = this
  1745. if (!session) return this
  1746. this.session = undefined
  1747. const result = session.complete()
  1748. if (result === undefined) {
  1749. this.isCreating = false
  1750. return this.patchState(
  1751. {
  1752. appState: {
  1753. status: TDStatus.Idle,
  1754. },
  1755. document: {
  1756. pageStates: {
  1757. [this.currentPageId]: {
  1758. editingId: undefined,
  1759. bindingId: undefined,
  1760. hoveredId: undefined,
  1761. },
  1762. },
  1763. },
  1764. },
  1765. `session:complete:${session.constructor.name}`
  1766. )
  1767. } else if ('after' in result) {
  1768. // Session ended with a command
  1769. if (this.isCreating) {
  1770. // We're currently creating a shape. Override the command's
  1771. // before state so that when we undo the command, we remove
  1772. // the shape we just created.
  1773. result.before = {
  1774. appState: {
  1775. ...result.before.appState,
  1776. status: TDStatus.Idle,
  1777. },
  1778. document: {
  1779. pages: {
  1780. [this.currentPageId]: {
  1781. shapes: Object.fromEntries(this.selectedIds.map((id) => [id, undefined])),
  1782. },
  1783. },
  1784. pageStates: {
  1785. [this.currentPageId]: {
  1786. selectedIds: [],
  1787. editingId: null,
  1788. bindingId: null,
  1789. hoveredId: null,
  1790. },
  1791. },
  1792. },
  1793. }
  1794. if (this.appState.isToolLocked) {
  1795. const pageState = result.after?.document?.pageStates?.[this.currentPageId] || {}
  1796. pageState.selectedIds = []
  1797. }
  1798. this.isCreating = false
  1799. }
  1800. result.after.appState = {
  1801. ...result.after.appState,
  1802. status: TDStatus.Idle,
  1803. }
  1804. result.after.document = {
  1805. ...result.after.document,
  1806. pageStates: {
  1807. ...result.after.document?.pageStates,
  1808. [this.currentPageId]: {
  1809. ...(result.after.document?.pageStates || {})[this.currentPageId],
  1810. editingId: null,
  1811. },
  1812. },
  1813. }
  1814. this.setState(result, `session:complete:${session.constructor.name}`)
  1815. } else {
  1816. this.patchState(
  1817. {
  1818. ...result,
  1819. appState: {
  1820. ...result.appState,
  1821. status: TDStatus.Idle,
  1822. },
  1823. document: {
  1824. ...result.document,
  1825. pageStates: {
  1826. [this.currentPageId]: {
  1827. ...result.document?.pageStates?.[this.currentPageId],
  1828. editingId: null,
  1829. },
  1830. },
  1831. },
  1832. },
  1833. `session:complete:${session.constructor.name}`
  1834. )
  1835. }
  1836. return this
  1837. }
  1838. /* -------------------------------------------------- */
  1839. /* Shape Functions */
  1840. /* -------------------------------------------------- */
  1841. /**
  1842. * Manually create shapes on the page.
  1843. * @param shapes An array of shape partials, containing the initial props for the shapes.
  1844. * @command
  1845. */
  1846. createShapes = (...shapes: ({ id: string; type: TDShapeType } & Partial<TDShape>)[]): this => {
  1847. if (shapes.length === 0) return this
  1848. return this.create(
  1849. shapes.map((shape) => {
  1850. return TLDR.getShapeUtil(shape.type).create({
  1851. parentId: this.currentPageId,
  1852. ...shape,
  1853. })
  1854. })
  1855. )
  1856. }
  1857. /**
  1858. * Manually update a set of shapes.
  1859. * @param shapes An array of shape partials, containing the changes to be made to each shape.
  1860. * @command
  1861. */
  1862. updateShapes = (...shapes: ({ id: string } & Partial<TDShape>)[]): this => {
  1863. const pageShapes = this.document.pages[this.currentPageId].shapes
  1864. const shapesToUpdate = shapes.filter((shape) => pageShapes[shape.id])
  1865. if (shapesToUpdate.length === 0) return this
  1866. return this.setState(
  1867. Commands.updateShapes(this, shapesToUpdate, this.currentPageId),
  1868. 'updated_shapes'
  1869. )
  1870. }
  1871. createTextShapeAtPoint(point: number[], id?: string): this {
  1872. const {
  1873. shapes,
  1874. appState: { currentPageId, currentStyle },
  1875. } = this
  1876. const childIndex =
  1877. shapes.length === 0
  1878. ? 1
  1879. : shapes
  1880. .filter((shape) => shape.parentId === currentPageId)
  1881. .sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
  1882. const Text = shapeUtils[TDShapeType.Text]
  1883. const newShape = Text.create({
  1884. id: id || Utils.uniqueId(),
  1885. parentId: currentPageId,
  1886. childIndex,
  1887. point,
  1888. style: { ...currentStyle },
  1889. })
  1890. const bounds = Text.getBounds(newShape)
  1891. newShape.point = Vec.sub(newShape.point, [bounds.width / 2, bounds.height / 2])
  1892. this.createShapes(newShape)
  1893. this.setEditingId(newShape.id)
  1894. return this
  1895. }
  1896. /**
  1897. * Create one or more shapes.
  1898. * @param shapes An array of shapes.
  1899. * @command
  1900. */
  1901. create = (shapes: TDShape[] = [], bindings: TDBinding[] = []): this => {
  1902. if (shapes.length === 0) return this
  1903. return this.setState(Commands.createShapes(this, shapes, bindings))
  1904. }
  1905. /**
  1906. * Patch in a new set of shapes
  1907. * @param shapes
  1908. * @param bindings
  1909. */
  1910. patchCreate = (shapes: TDShape[] = [], bindings: TDBinding[] = []): this => {
  1911. if (shapes.length === 0) return this
  1912. return this.patchState(Commands.createShapes(this, shapes, bindings).after)
  1913. }
  1914. /**
  1915. * Delete one or more shapes.
  1916. * @param ids The ids of the shapes to delete.
  1917. * @command
  1918. */
  1919. delete = (ids = this.selectedIds): this => {
  1920. if (ids.length === 0) return this
  1921. return this.setState(Commands.deleteShapes(this, ids))
  1922. }
  1923. /**
  1924. * Delete all shapes on the page.
  1925. */
  1926. deleteAll = (): this => {
  1927. this.selectAll()
  1928. this.delete()
  1929. return this
  1930. }
  1931. /**
  1932. * Change the style for one or more shapes.
  1933. * @param style A style partial to apply to the shapes.
  1934. * @param ids The ids of the shapes to change (defaults to selection).
  1935. */
  1936. style = (style: Partial<ShapeStyles>, ids = this.selectedIds): this => {
  1937. return this.setState(Commands.styleShapes(this, ids, style))
  1938. }
  1939. /**
  1940. * Align one or more shapes.
  1941. * @param direction Whether to align horizontally or vertically.
  1942. * @param ids The ids of the shapes to change (defaults to selection).
  1943. */
  1944. align = (type: AlignType, ids = this.selectedIds): this => {
  1945. if (ids.length < 2) return this
  1946. return this.setState(Commands.alignShapes(this, ids, type))
  1947. }
  1948. /**
  1949. * Distribute one or more shapes.
  1950. * @param direction Whether to distribute horizontally or vertically..
  1951. * @param ids The ids of the shapes to change (defaults to selection).
  1952. */
  1953. distribute = (direction: DistributeType, ids = this.selectedIds): this => {
  1954. if (ids.length < 3) return this
  1955. return this.setState(Commands.distributeShapes(this, ids, direction))
  1956. }
  1957. /**
  1958. * Stretch one or more shapes to their common bounds.
  1959. * @param direction Whether to stretch horizontally or vertically.
  1960. * @param ids The ids of the shapes to change (defaults to selection).
  1961. */
  1962. stretch = (direction: StretchType, ids = this.selectedIds): this => {
  1963. if (ids.length < 2) return this
  1964. return this.setState(Commands.stretchShapes(this, ids, direction))
  1965. }
  1966. /**
  1967. * Flip one or more shapes horizontally.
  1968. * @param ids The ids of the shapes to change (defaults to selection).
  1969. */
  1970. flipHorizontal = (ids = this.selectedIds): this => {
  1971. if (ids.length === 0) return this
  1972. return this.setState(Commands.flipShapes(this, ids, FlipType.Horizontal))
  1973. }
  1974. /**
  1975. * Flip one or more shapes vertically.
  1976. * @param ids The ids of the shapes to change (defaults to selection).
  1977. */
  1978. flipVertical = (ids = this.selectedIds): this => {
  1979. if (ids.length === 0) return this
  1980. return this.setState(Commands.flipShapes(this, ids, FlipType.Vertical))
  1981. }
  1982. /**
  1983. * Move one or more shapes to a new page. Will also break or move bindings.
  1984. * @param toPageId The id of the page to move the shapes to.
  1985. * @param fromPageId The id of the page to move the shapes from (defaults to current page).
  1986. * @param ids The ids of the shapes to move (defaults to selection).
  1987. */
  1988. moveToPage = (
  1989. toPageId: string,
  1990. fromPageId = this.currentPageId,
  1991. ids = this.selectedIds
  1992. ): this => {
  1993. if (ids.length === 0) return this
  1994. const { rendererBounds } = this
  1995. this.setState(Commands.moveShapesToPage(this, ids, rendererBounds, fromPageId, toPageId))
  1996. return this
  1997. }
  1998. /**
  1999. * Move one or more shapes to the back of the page.
  2000. * @param ids The ids of the shapes to change (defaults to selection).
  2001. */
  2002. moveToBack = (ids = this.selectedIds): this => {
  2003. if (ids.length === 0) return this
  2004. return this.setState(Commands.reorderShapes(this, ids, MoveType.ToBack))
  2005. }
  2006. /**
  2007. * Move one or more shapes backward on of the page.
  2008. * @param ids The ids of the shapes to change (defaults to selection).
  2009. */
  2010. moveBackward = (ids = this.selectedIds): this => {
  2011. if (ids.length === 0) return this
  2012. return this.setState(Commands.reorderShapes(this, ids, MoveType.Backward))
  2013. }
  2014. /**
  2015. * Move one or more shapes forward on the page.
  2016. * @param ids The ids of the shapes to change (defaults to selection).
  2017. */
  2018. moveForward = (ids = this.selectedIds): this => {
  2019. if (ids.length === 0) return this
  2020. return this.setState(Commands.reorderShapes(this, ids, MoveType.Forward))
  2021. }
  2022. /**
  2023. * Move one or more shapes to the front of the page.
  2024. * @param ids The ids of the shapes to change (defaults to selection).
  2025. */
  2026. moveToFront = (ids = this.selectedIds): this => {
  2027. if (ids.length === 0) return this
  2028. return this.setState(Commands.reorderShapes(this, ids, MoveType.ToFront))
  2029. }
  2030. /**
  2031. * Nudge one or more shapes in a direction.
  2032. * @param delta The direction to nudge the shapes.
  2033. * @param isMajor Whether this is a major (i.e. shift) nudge.
  2034. * @param ids The ids to change (defaults to selection).
  2035. */
  2036. nudge = (delta: number[], isMajor = false, ids = this.selectedIds): this => {
  2037. if (ids.length === 0) return this
  2038. const size = isMajor
  2039. ? this.settings.showGrid
  2040. ? this.currentGrid * 4
  2041. : 10
  2042. : this.settings.showGrid
  2043. ? this.currentGrid
  2044. : 1
  2045. return this.setState(Commands.translateShapes(this, ids, Vec.mul(delta, size)))
  2046. }
  2047. /**
  2048. * Duplicate one or more shapes.
  2049. * @param ids The ids to duplicate (defaults to selection).
  2050. */
  2051. duplicate = (ids = this.selectedIds, point?: number[]): this => {
  2052. if (this.readOnly) return this
  2053. if (ids.length === 0) return this
  2054. return this.setState(Commands.duplicateShapes(this, ids, point))
  2055. }
  2056. /**
  2057. * Reset the bounds for one or more shapes. Usually when the
  2058. * bounding box of a shape is double-clicked. Different shapes may
  2059. * handle this differently.
  2060. * @param ids The ids to change (defaults to selection).
  2061. */
  2062. resetBounds = (ids = this.selectedIds): this => {
  2063. const command = Commands.resetBounds(this, ids, this.currentPageId)
  2064. return this.setState(Commands.resetBounds(this, ids, this.currentPageId), command.id)
  2065. }
  2066. /**
  2067. * Toggle the hidden property of one or more shapes.
  2068. * @param ids The ids to change (defaults to selection).
  2069. */
  2070. toggleHidden = (ids = this.selectedIds): this => {
  2071. if (ids.length === 0) return this
  2072. return this.setState(Commands.toggleShapeProp(this, ids, 'isHidden'))
  2073. }
  2074. /**
  2075. * Toggle the locked property of one or more shapes.
  2076. * @param ids The ids to change (defaults to selection).
  2077. */
  2078. toggleLocked = (ids = this.selectedIds): this => {
  2079. if (ids.length === 0) return this
  2080. return this.setState(Commands.toggleShapeProp(this, ids, 'isLocked'))
  2081. }
  2082. /**
  2083. * Toggle the fixed-aspect-ratio property of one or more shapes.
  2084. * @param ids The ids to change (defaults to selection).
  2085. */
  2086. toggleAspectRatioLocked = (ids = this.selectedIds): this => {
  2087. if (ids.length === 0) return this
  2088. return this.setState(Commands.toggleShapeProp(this, ids, 'isAspectRatioLocked'))
  2089. }
  2090. /**
  2091. * Toggle the decoration at a handle of one or more shapes.
  2092. * @param handleId The handle to toggle.
  2093. * @param ids The ids of the shapes to toggle the decoration on.
  2094. */
  2095. toggleDecoration = (handleId: string, ids = this.selectedIds): this => {
  2096. if (ids.length === 0 || !(handleId === 'start' || handleId === 'end')) return this
  2097. return this.setState(Commands.toggleShapesDecoration(this, ids, handleId))
  2098. }
  2099. /**
  2100. * Set the props of one or more shapes
  2101. * @param props The props to set on the shapes.
  2102. * @param ids The ids of the shapes to set props on.
  2103. */
  2104. setShapeProps = <T extends TDShape>(props: Partial<T>, ids = this.selectedIds) => {
  2105. return this.setState(Commands.setShapesProps(this, ids, props))
  2106. }
  2107. /**
  2108. * Rotate one or more shapes by a delta.
  2109. * @param delta The delta in radians.
  2110. * @param ids The ids to rotate (defaults to selection).
  2111. */
  2112. rotate = (delta = Math.PI * -0.5, ids = this.selectedIds): this => {
  2113. if (ids.length === 0) return this
  2114. const change = Commands.rotateShapes(this, ids, delta)
  2115. if (!change) return this
  2116. return this.setState(change)
  2117. }
  2118. /**
  2119. * Group the selected shapes.
  2120. * @param ids The ids to group (defaults to selection).
  2121. * @param groupId The new group's id.
  2122. */
  2123. group = (
  2124. ids = this.selectedIds,
  2125. groupId = Utils.uniqueId(),
  2126. pageId = this.currentPageId
  2127. ): this => {
  2128. if (this.readOnly) return this
  2129. if (ids.length === 1 && this.getShape(ids[0], pageId).type === TDShapeType.Group) {
  2130. return this.ungroup(ids, pageId)
  2131. }
  2132. if (ids.length < 2) return this
  2133. const command = Commands.groupShapes(this, ids, groupId, pageId)
  2134. if (!command) return this
  2135. return this.setState(command)
  2136. }
  2137. /**
  2138. * Ungroup the selected groups.
  2139. * @todo
  2140. */
  2141. ungroup = (ids = this.selectedIds, pageId = this.currentPageId): this => {
  2142. if (this.readOnly) return this
  2143. const groups = ids
  2144. .map((id) => this.getShape(id, pageId))
  2145. .filter((shape) => shape.type === TDShapeType.Group)
  2146. if (groups.length === 0) return this
  2147. const command = Commands.ungroupShapes(this, ids, groups as GroupShape[], pageId)
  2148. if (!command) return this
  2149. return this.setState(command)
  2150. }
  2151. /**
  2152. * Cancel the current session.
  2153. */
  2154. cancel = (): this => {
  2155. this.currentTool.onCancel?.()
  2156. return this
  2157. }
  2158. /* -------------------------------------------------- */
  2159. /* Event Handlers */
  2160. /* -------------------------------------------------- */
  2161. /* ----------------- Keyboard Events ---------------- */
  2162. onKeyDown: TLKeyboardEventHandler = (key, info, e) => {
  2163. switch (e.key) {
  2164. case '/': {
  2165. if (this.status === 'idle') {
  2166. const { shiftKey, metaKey, altKey, ctrlKey, spaceKey } = this
  2167. this.onPointerDown(
  2168. {
  2169. target: 'canvas',
  2170. pointerId: 0,
  2171. origin: info.point,
  2172. point: info.point,
  2173. delta: [0, 0],
  2174. pressure: 0.5,
  2175. shiftKey,
  2176. ctrlKey,
  2177. metaKey,
  2178. altKey,
  2179. spaceKey,
  2180. },
  2181. {
  2182. shiftKey,
  2183. altKey,
  2184. ctrlKey,
  2185. pointerId: 0,
  2186. clientX: info.point[0],
  2187. clientY: info.point[1],
  2188. } as unknown as React.PointerEvent<HTMLDivElement>
  2189. )
  2190. }
  2191. break
  2192. }
  2193. case 'Escape': {
  2194. this.cancel()
  2195. break
  2196. }
  2197. case 'Meta': {
  2198. this.metaKey = true
  2199. break
  2200. }
  2201. case 'Alt': {
  2202. this.altKey = true
  2203. break
  2204. }
  2205. case 'Control': {
  2206. this.ctrlKey = true
  2207. break
  2208. }
  2209. case ' ': {
  2210. this.spaceKey = true
  2211. break
  2212. }
  2213. }
  2214. this.currentTool.onKeyDown?.(key, info, e)
  2215. return this
  2216. }
  2217. onKeyUp: TLKeyboardEventHandler = (key, info, e) => {
  2218. if (!info) return
  2219. switch (e.key) {
  2220. case '/': {
  2221. const { currentPoint, shiftKey, metaKey, altKey, ctrlKey, spaceKey } = this
  2222. this.onPointerUp(
  2223. {
  2224. target: 'canvas',
  2225. pointerId: 0,
  2226. origin: currentPoint,
  2227. point: currentPoint,
  2228. delta: [0, 0],
  2229. pressure: 0.5,
  2230. shiftKey,
  2231. ctrlKey,
  2232. metaKey,
  2233. altKey,
  2234. spaceKey,
  2235. },
  2236. {
  2237. shiftKey,
  2238. altKey,
  2239. ctrlKey,
  2240. pointerId: 0,
  2241. clientX: currentPoint[0],
  2242. clientY: currentPoint[1],
  2243. } as unknown as React.PointerEvent<HTMLDivElement>
  2244. )
  2245. break
  2246. }
  2247. case 'Meta': {
  2248. this.metaKey = false
  2249. break
  2250. }
  2251. case 'Alt': {
  2252. this.altKey = false
  2253. break
  2254. }
  2255. case 'Control': {
  2256. this.ctrlKey = false
  2257. break
  2258. }
  2259. case ' ': {
  2260. this.spaceKey = false
  2261. break
  2262. }
  2263. }
  2264. this.currentTool.onKeyUp?.(key, info, e)
  2265. }
  2266. /* ------------- Renderer Event Handlers ------------ */
  2267. onPinchStart: TLPinchEventHandler = (info, e) => this.currentTool.onPinchStart?.(info, e)
  2268. onPinchEnd: TLPinchEventHandler = (info, e) => this.currentTool.onPinchEnd?.(info, e)
  2269. onPinch: TLPinchEventHandler = (info, e) => this.currentTool.onPinch?.(info, e)
  2270. onPan: TLWheelEventHandler = (info, e) => {
  2271. if (this.appState.status === 'pinching') return
  2272. // TODO: Pan and pinchzoom are firing at the same time. Considering turning one of them off!
  2273. const delta = Vec.div(info.delta, this.pageState.camera.zoom)
  2274. const prev = this.pageState.camera.point
  2275. const next = Vec.sub(prev, delta)
  2276. if (Vec.isEqual(next, prev)) return
  2277. this.pan(delta)
  2278. // onPan is called by onPointerMove when spaceKey & middle wheel button is pressed,
  2279. // so we shouldn't call this again.
  2280. if (!info.spaceKey && !(e.buttons === 4)) {
  2281. this.onPointerMove(info, e as unknown as React.PointerEvent)
  2282. }
  2283. }
  2284. onZoom: TLWheelEventHandler = (info, e) => {
  2285. if (this.state.appState.status !== TDStatus.Idle) return
  2286. const delta =
  2287. e.deltaMode === WheelEvent.DOM_DELTA_PIXEL
  2288. ? info.delta[2] / 500
  2289. : e.deltaMode === WheelEvent.DOM_DELTA_LINE
  2290. ? info.delta[2] / 100
  2291. : info.delta[2] / 2
  2292. this.zoomBy(delta, info.delta)
  2293. this.onPointerMove(info, e as unknown as React.PointerEvent)
  2294. }
  2295. /* ----------------- Pointer Events ----------------- */
  2296. updateInputs: TLPointerEventHandler = (info) => {
  2297. this.currentPoint = [...this.getPagePoint(info.point), info.pressure]
  2298. this.shiftKey = info.shiftKey
  2299. this.altKey = info.altKey
  2300. this.ctrlKey = info.ctrlKey
  2301. this.metaKey = info.metaKey
  2302. }
  2303. onPointerMove: TLPointerEventHandler = (info, e) => {
  2304. this.previousPoint = this.currentPoint
  2305. this.updateInputs(info, e)
  2306. // Several events (e.g. pan) can trigger the same "pointer move" behavior
  2307. this.currentTool.onPointerMove?.(info, e)
  2308. // Move this to an emitted event
  2309. if (this.state.room) {
  2310. const { users, userId } = this.state.room
  2311. this.callbacks.onChangePresence?.(this, {
  2312. ...users[userId],
  2313. point: this.getPagePoint(info.point),
  2314. })
  2315. }
  2316. }
  2317. onPointerDown: TLPointerEventHandler = (info, e) => {
  2318. this.originPoint = this.getPagePoint(info.point)
  2319. this.updateInputs(info, e)
  2320. this.currentTool.onPointerDown?.(info, e)
  2321. }
  2322. onPointerUp: TLPointerEventHandler = (info, e) => {
  2323. this.updateInputs(info, e)
  2324. this.currentTool.onPointerUp?.(info, e)
  2325. }
  2326. // Canvas (background)
  2327. onPointCanvas: TLCanvasEventHandler = (info, e) => {
  2328. this.updateInputs(info, e)
  2329. this.currentTool.onPointCanvas?.(info, e)
  2330. }
  2331. onDoubleClickCanvas: TLCanvasEventHandler = (info, e) => {
  2332. this.updateInputs(info, e)
  2333. this.currentTool.onDoubleClickCanvas?.(info, e)
  2334. }
  2335. onRightPointCanvas: TLCanvasEventHandler = (info, e) => {
  2336. this.updateInputs(info, e)
  2337. this.currentTool.onRightPointCanvas?.(info, e)
  2338. }
  2339. onDragCanvas: TLCanvasEventHandler = (info, e) => {
  2340. this.updateInputs(info, e)
  2341. this.currentTool.onDragCanvas?.(info, e)
  2342. }
  2343. onReleaseCanvas: TLCanvasEventHandler = (info, e) => {
  2344. this.updateInputs(info, e)
  2345. this.currentTool.onReleaseCanvas?.(info, e)
  2346. }
  2347. // Shape
  2348. onPointShape: TLPointerEventHandler = (info, e) => {
  2349. this.originPoint = this.getPagePoint(info.point)
  2350. this.updateInputs(info, e)
  2351. this.currentTool.onPointShape?.(info, e)
  2352. }
  2353. onReleaseShape: TLPointerEventHandler = (info, e) => {
  2354. this.updateInputs(info, e)
  2355. this.currentTool.onReleaseShape?.(info, e)
  2356. }
  2357. onDoubleClickShape: TLPointerEventHandler = (info, e) => {
  2358. this.originPoint = this.getPagePoint(info.point)
  2359. this.updateInputs(info, e)
  2360. this.currentTool.onDoubleClickShape?.(info, e)
  2361. }
  2362. onRightPointShape: TLPointerEventHandler = (info, e) => {
  2363. this.originPoint = this.getPagePoint(info.point)
  2364. this.updateInputs(info, e)
  2365. this.currentTool.onRightPointShape?.(info, e)
  2366. }
  2367. onDragShape: TLPointerEventHandler = (info, e) => {
  2368. this.updateInputs(info, e)
  2369. this.currentTool.onDragShape?.(info, e)
  2370. }
  2371. onHoverShape: TLPointerEventHandler = (info, e) => {
  2372. this.updateInputs(info, e)
  2373. this.currentTool.onHoverShape?.(info, e)
  2374. }
  2375. onUnhoverShape: TLPointerEventHandler = (info, e) => {
  2376. this.updateInputs(info, e)
  2377. this.currentTool.onUnhoverShape?.(info, e)
  2378. }
  2379. // Bounds (bounding box background)
  2380. onPointBounds: TLBoundsEventHandler = (info, e) => {
  2381. this.originPoint = this.getPagePoint(info.point)
  2382. this.updateInputs(info, e)
  2383. this.currentTool.onPointBounds?.(info, e)
  2384. }
  2385. onDoubleClickBounds: TLBoundsEventHandler = (info, e) => {
  2386. this.originPoint = this.getPagePoint(info.point)
  2387. this.updateInputs(info, e)
  2388. this.currentTool.onDoubleClickBounds?.(info, e)
  2389. }
  2390. onRightPointBounds: TLBoundsEventHandler = (info, e) => {
  2391. this.originPoint = this.getPagePoint(info.point)
  2392. this.updateInputs(info, e)
  2393. this.currentTool.onRightPointBounds?.(info, e)
  2394. }
  2395. onDragBounds: TLBoundsEventHandler = (info, e) => {
  2396. this.updateInputs(info, e)
  2397. this.currentTool.onDragBounds?.(info, e)
  2398. }
  2399. onHoverBounds: TLBoundsEventHandler = (info, e) => {
  2400. this.updateInputs(info, e)
  2401. this.currentTool.onHoverBounds?.(info, e)
  2402. }
  2403. onUnhoverBounds: TLBoundsEventHandler = (info, e) => {
  2404. this.updateInputs(info, e)
  2405. this.currentTool.onUnhoverBounds?.(info, e)
  2406. }
  2407. onReleaseBounds: TLBoundsEventHandler = (info, e) => {
  2408. this.updateInputs(info, e)
  2409. this.currentTool.onReleaseBounds?.(info, e)
  2410. }
  2411. // Bounds handles (corners, edges)
  2412. onPointBoundsHandle: TLBoundsHandleEventHandler = (info, e) => {
  2413. this.originPoint = this.getPagePoint(info.point)
  2414. this.updateInputs(info, e)
  2415. this.currentTool.onPointBoundsHandle?.(info, e)
  2416. }
  2417. onDoubleClickBoundsHandle: TLBoundsHandleEventHandler = (info, e) => {
  2418. this.originPoint = this.getPagePoint(info.point)
  2419. this.updateInputs(info, e)
  2420. this.currentTool.onDoubleClickBoundsHandle?.(info, e)
  2421. }
  2422. onRightPointBoundsHandle: TLBoundsHandleEventHandler = (info, e) => {
  2423. this.originPoint = this.getPagePoint(info.point)
  2424. this.updateInputs(info, e)
  2425. this.currentTool.onRightPointBoundsHandle?.(info, e)
  2426. }
  2427. onDragBoundsHandle: TLBoundsHandleEventHandler = (info, e) => {
  2428. this.updateInputs(info, e)
  2429. this.currentTool.onDragBoundsHandle?.(info, e)
  2430. }
  2431. onHoverBoundsHandle: TLBoundsHandleEventHandler = (info, e) => {
  2432. this.updateInputs(info, e)
  2433. this.currentTool.onHoverBoundsHandle?.(info, e)
  2434. }
  2435. onUnhoverBoundsHandle: TLBoundsHandleEventHandler = (info, e) => {
  2436. this.updateInputs(info, e)
  2437. this.currentTool.onUnhoverBoundsHandle?.(info, e)
  2438. }
  2439. onReleaseBoundsHandle: TLBoundsHandleEventHandler = (info, e) => {
  2440. this.updateInputs(info, e)
  2441. this.currentTool.onReleaseBoundsHandle?.(info, e)
  2442. }
  2443. // Handles (ie the handles of a selected arrow)
  2444. onPointHandle: TLPointerEventHandler = (info, e) => {
  2445. this.originPoint = this.getPagePoint(info.point)
  2446. this.updateInputs(info, e)
  2447. this.currentTool.onPointHandle?.(info, e)
  2448. }
  2449. onDoubleClickHandle: TLPointerEventHandler = (info, e) => {
  2450. this.originPoint = this.getPagePoint(info.point)
  2451. this.updateInputs(info, e)
  2452. this.currentTool.onDoubleClickHandle?.(info, e)
  2453. }
  2454. onRightPointHandle: TLPointerEventHandler = (info, e) => {
  2455. this.originPoint = this.getPagePoint(info.point)
  2456. this.updateInputs(info, e)
  2457. this.currentTool.onRightPointHandle?.(info, e)
  2458. }
  2459. onDragHandle: TLPointerEventHandler = (info, e) => {
  2460. this.updateInputs(info, e)
  2461. this.currentTool.onDragHandle?.(info, e)
  2462. }
  2463. onHoverHandle: TLPointerEventHandler = (info, e) => {
  2464. this.updateInputs(info, e)
  2465. this.currentTool.onHoverHandle?.(info, e)
  2466. }
  2467. onUnhoverHandle: TLPointerEventHandler = (info, e) => {
  2468. this.updateInputs(info, e)
  2469. this.currentTool.onUnhoverHandle?.(info, e)
  2470. }
  2471. onReleaseHandle: TLPointerEventHandler = (info, e) => {
  2472. this.updateInputs(info, e)
  2473. this.currentTool.onReleaseHandle?.(info, e)
  2474. }
  2475. onShapeChange = (shape: { id: string } & Partial<TDShape>) => {
  2476. this.updateShapes(shape)
  2477. }
  2478. onShapeBlur = () => {
  2479. // This prevents an auto-blur event from Safari
  2480. if (Date.now() - this.editingStartTime < 50) return
  2481. const { editingId } = this.pageState
  2482. const { isToolLocked } = this.getAppState()
  2483. if (editingId) {
  2484. // If we're editing text, then delete the text if it's empty
  2485. const shape = this.getShape(editingId)
  2486. this.setEditingId()
  2487. if (shape.type === TDShapeType.Text) {
  2488. if (shape.text.trim().length <= 0) {
  2489. this.patchState(Commands.deleteShapes(this, [editingId]).after, 'delete_empty_text')
  2490. } else if (!isToolLocked) {
  2491. this.select(editingId)
  2492. }
  2493. }
  2494. }
  2495. this.currentTool.onShapeBlur?.()
  2496. }
  2497. onShapeClone: TLShapeCloneHandler = (info, e) => this.currentTool.onShapeClone?.(info, e)
  2498. onRenderCountChange = (ids: string[]) => {
  2499. const appState = this.getAppState()
  2500. if (appState.isEmptyCanvas && ids.length > 0) {
  2501. this.patchState(
  2502. {
  2503. appState: {
  2504. isEmptyCanvas: false,
  2505. },
  2506. },
  2507. 'empty_canvas:false'
  2508. )
  2509. } else if (!appState.isEmptyCanvas && ids.length <= 0) {
  2510. this.patchState(
  2511. {
  2512. appState: {
  2513. isEmptyCanvas: true,
  2514. },
  2515. },
  2516. 'empty_canvas:true'
  2517. )
  2518. }
  2519. }
  2520. onError = () => {
  2521. // TODO
  2522. }
  2523. isSelected(id: string) {
  2524. return this.selectedIds.includes(id)
  2525. }
  2526. get room() {
  2527. return this.state.room
  2528. }
  2529. get isLocal() {
  2530. return this.state.room === undefined || this.state.room.id === 'local'
  2531. }
  2532. get status() {
  2533. return this.appState.status
  2534. }
  2535. get currentUser() {
  2536. if (!this.state.room) return
  2537. return this.state.room.users[this.state.room.userId]
  2538. }
  2539. // The center of the component (in screen space)
  2540. get centerPoint() {
  2541. const { width, height } = this.rendererBounds
  2542. return Vec.toFixed([width / 2, height / 2])
  2543. }
  2544. get currentGrid() {
  2545. const { zoom } = this.pageState.camera
  2546. if (zoom < 0.15) {
  2547. return GRID_SIZE * 16
  2548. } else if (zoom < 1) {
  2549. return GRID_SIZE * 4
  2550. } else {
  2551. return GRID_SIZE * 1
  2552. }
  2553. }
  2554. getShapeUtil = TLDR.getShapeUtil
  2555. static version = 14
  2556. static defaultDocument: TDDocument = {
  2557. id: 'doc',
  2558. name: 'New Document',
  2559. version: 14,
  2560. pages: {
  2561. page: {
  2562. id: 'page',
  2563. name: 'Page 1',
  2564. childIndex: 1,
  2565. shapes: {},
  2566. bindings: {},
  2567. },
  2568. },
  2569. pageStates: {
  2570. page: {
  2571. id: 'page',
  2572. selectedIds: [],
  2573. camera: {
  2574. point: [0, 0],
  2575. zoom: 1,
  2576. },
  2577. },
  2578. },
  2579. }
  2580. static defaultState: TDSnapshot = {
  2581. settings: {
  2582. isPenMode: false,
  2583. isDarkMode: false,
  2584. isZoomSnap: false,
  2585. isFocusMode: false,
  2586. isSnapping: false,
  2587. isDebugMode: process.env.NODE_ENV === 'development',
  2588. isReadonlyMode: false,
  2589. nudgeDistanceLarge: 16,
  2590. nudgeDistanceSmall: 1,
  2591. showRotateHandles: true,
  2592. showBindingHandles: true,
  2593. showCloneHandles: false,
  2594. showGrid: false,
  2595. },
  2596. appState: {
  2597. status: TDStatus.Idle,
  2598. activeTool: 'select',
  2599. hoveredId: undefined,
  2600. currentPageId: 'page',
  2601. currentStyle: defaultStyle,
  2602. isToolLocked: false,
  2603. isStyleOpen: false,
  2604. isEmptyCanvas: false,
  2605. snapLines: [],
  2606. },
  2607. document: TldrawApp.defaultDocument,
  2608. }
  2609. }