Squashed commit of the following:

commit 1a642387de
Author: Mime Čuvalo <mimecuvalo@gmail.com>
Date:   Fri Apr 5 10:56:14 2024 +0100

    lint

commit c5059f15bb
Author: Mime Čuvalo <mimecuvalo@gmail.com>
Date:   Fri Apr 5 10:49:54 2024 +0100

    textfields: bring shape into view when editing (#3362)

    This actually is a problem in production anyway, for any shape, not
    particular to new auto-editing or stickies.
    Case in point:
    - shape is selected
    - move the shape offscreen
    - hit Enter
    - you'll be editing the shape but it won't be visible to you.

    This change consolidates some of the duplicate logic in `Idle.ts` and in
    `noteHelpers.ts`.

    Two questions
    - `Idle.ts` didn't have the `select()` call but `noteHelpers.ts` did -
    is this really important?
    - `noteHelpers` didn't have the `mark()` call but `Idle.ts` did - seems
    like it was missing in noteHelpers, but lemme know if that was intended
    to be left out.

    ### Change Type

    <!--  Please select a 'Scope' label ️ -->

    - [x] `sdk` — Changes the tldraw SDK
    - [ ] `dotcom` — Changes the tldraw.com web app
    - [ ] `docs` — Changes to the documentation, examples, or templates.
    - [ ] `vs code` — Changes to the vscode plugin
    - [ ] `internal` — Does not affect user-facing stuff

    <!--  Please select a 'Type' label ️ -->

    - [x] `bugfix` — Bug fix
    - [ ] `feature` — New feature
    - [ ] `improvement` — Improving existing features
    - [ ] `chore` — Updating dependencies, other boring stuff
    - [ ] `galaxy brain` — Architectural changes
    - [ ] `tests` — Changes to any test code
    - [ ] `tools` — Changes to infrastructure, CI, internal scripts,
    debugging tools, etc.
    - [ ] `dunno` — I don't know

    ### Release Notes

    - Textfields: bring shape to view that's being edited.

    ---------

    Co-authored-by: Steve Ruiz <steveruizok@gmail.com>

commit 7671e6291e
Merge: 1df05133a 58286db90
Author: Mime Čuvalo <mimecuvalo@gmail.com>
Date:   Fri Apr 5 10:29:41 2024 +0100

    merge

commit 1df05133af
Author: Mime Čuvalo <mimecuvalo@gmail.com>
Date:   Fri Apr 5 09:15:07 2024 +0100

    stickies: update geometry to differentiate between shape and text label (#3361)

    Separates out the geometry - we want clicking in the negative space to
    not have the text editing mechanics. This definitely feels better
    without the sticky note textfield assuming it takes up the whole note.

    Before:
    <img width="614" alt="Screenshot 2024-04-04 at 15 35 58"
    src="https://github.com/tldraw/tldraw/assets/469604/f4673fc5-5b9a-4ab9-a169-a91ba7f49d17">
    After:
    <img width="636" alt="Screenshot 2024-04-04 at 15 35 42"
    src="https://github.com/tldraw/tldraw/assets/469604/645c439a-662b-4244-af7f-6c718e5c5fc2">

    ### Change Type

    <!--  Please select a 'Scope' label ️ -->

    - [x] `sdk` — Changes the tldraw SDK
    - [ ] `dotcom` — Changes the tldraw.com web app
    - [ ] `docs` — Changes to the documentation, examples, or templates.
    - [ ] `vs code` — Changes to the vscode plugin
    - [ ] `internal` — Does not affect user-facing stuff

    <!--  Please select a 'Type' label ️ -->

    - [ ] `bugfix` — Bug fix
    - [ ] `feature` — New feature
    - [x] `improvement` — Improving existing features
    - [ ] `chore` — Updating dependencies, other boring stuff
    - [ ] `galaxy brain` — Architectural changes
    - [ ] `tests` — Changes to any test code
    - [ ] `tools` — Changes to infrastructure, CI, internal scripts,
    debugging tools, etc.
    - [ ] `dunno` — I don't know

    ---------

    Co-authored-by: Steve Ruiz <steveruizok@gmail.com>

commit 58286db90c
Author: Steve Ruiz <steveruizok@gmail.com>
Date:   Thu Apr 4 22:50:01 2024 +0100

    Add long press event (#3275)

    This PR adds a "long press" event that fires when pointing for more than
    500ms. This event is used in the same way that dragging is used (e.g. to
    transition to from pointing_selection to translating) but only on
    desktop. On mobile, long presses are used to open the context menu.

    ![Kapture 2024-03-26 at 18 57
    15](https://github.com/tldraw/tldraw/assets/23072548/34a7ee2b-bde6-443b-93e0-082453a1cb61)

    ## Background

    This idea came out of @TodePond's #3208 PR. We use a "dead zone" to
    avoid accidentally moving / rotating things when clicking on them, which
    is especially common on mobile if a dead zone feature isn't implemented.
    However, this makes it difficult to make "fine adjustments" because you
    need to drag out of the dead zone (to start translating) and then drag
    back to where you want to go.

    ![Kapture 2024-03-26 at 19 00
    38](https://github.com/tldraw/tldraw/assets/23072548/9a15852d-03d0-4b88-b594-27dbd3b68780)

    With this change, you can long press on desktop to get to that
    translating state. It's a micro UX optimization but especially nice if
    apps want to display different UI for "dragging" shapes before the user
    leaves the dead zone.

    ![Kapture 2024-03-26 at 19 02
    59](https://github.com/tldraw/tldraw/assets/23072548/f0ff337e-2cbd-4b73-9ef5-9b7deaf0ae91)

    ### Change Type

    <!--  Please select a 'Scope' label ️ -->

    - [x] `sdk` — Changes the tldraw SDK
    - [ ] `dotcom` — Changes the tldraw.com web app
    - [ ] `docs` — Changes to the documentation, examples, or templates.
    - [ ] `vs code` — Changes to the vscode plugin
    - [ ] `internal` — Does not affect user-facing stuff

    <!--  Please select a 'Type' label ️ -->

    - [ ] `bugfix` — Bug fix
    - [x] `feature` — New feature
    - [ ] `improvement` — Improving existing features
    - [ ] `chore` — Updating dependencies, other boring stuff
    - [ ] `galaxy brain` — Architectural changes
    - [ ] `tests` — Changes to any test code
    - [ ] `tools` — Changes to infrastructure, CI, internal scripts,
    debugging tools, etc.
    - [ ] `dunno` — I don't know

    ### Test Plan

    1. Long press shapes, selections, resize handles, rotate handles, crop
    handles.
    2. You should enter the corresponding states, just as you would have
    with a drag.

    - [ ] Unit Tests TODO

    ### Release Notes

    - Add support for long pressing on desktop.

commit 4a84b3482c
Author: Steve Ruiz <steveruizok@gmail.com>
Date:   Thu Apr 4 22:49:28 2024 +0100

    Simplify RTL

commit e3ceb270e7
Merge: 8683c642d 43edeb09b
Author: Steve Ruiz <steveruizok@gmail.com>
Date:   Thu Apr 4 22:17:27 2024 +0100

    Merge branch 'main' into stickies-rc

commit 43edeb09b5
Author: Steve Ruiz <steveruizok@gmail.com>
Date:   Thu Apr 4 19:16:17 2024 +0100

    Add white migration (#3334)

    This PR adds a down migration for #3321.

    ### Change Type

    - [x] `sdk` — Changes the tldraw SDK
    - [x] `dunno` — I don't know

commit 8683c642d3
Author: Lu Wilson <l2wilson94@gmail.com>
Date:   Thu Apr 4 16:05:24 2024 +0100

    Stickies: Kickout occluded shapes after nudge (frames and stickies) (#3360)

    This PR kicks out occluded shapes after nudging. This affects frames as
    well as stickies.

    ### Change Type

    <!--  Please select a 'Scope' label ️ -->

    - [x] `sdk` — Changes the tldraw SDK
    - [x] `dotcom` — Changes the tldraw.com web app
    - [ ] `docs` — Changes to the documentation, examples, or templates.
    - [ ] `vs code` — Changes to the vscode plugin
    - [ ] `internal` — Does not affect user-facing stuff

    <!--  Please select a 'Type' label ️ -->

    - [x] `bugfix` — Bug fix
    - [ ] `feature` — New feature
    - [ ] `improvement` — Improving existing features
    - [ ] `chore` — Updating dependencies, other boring stuff
    - [ ] `galaxy brain` — Architectural changes
    - [ ] `tests` — Changes to any test code
    - [ ] `tools` — Changes to infrastructure, CI, internal scripts,
    debugging tools, etc.
    - [ ] `dunno` — I don't know

    ### Test Plan

    1. Add a step-by-step description of how to test your PR here.
    2.

    - [ ] Unit Tests
    - [ ] End to end tests

    ### Release Notes

    - Add a brief release note for your PR here.

commit 574724be57
Author: Lu Wilson <l2wilson94@gmail.com>
Date:   Thu Apr 4 16:05:03 2024 +0100

    Stickies: Bring stickies to front when you move them (#3353)

    When you start translating stickies, bring them to front.

    https://github.com/tldraw/tldraw/assets/15892272/bdd1bc0c-8e94-435a-98ef-d09f9f93f4cb

    ### Change Type

    <!--  Please select a 'Scope' label ️ -->

    - [ ] `sdk` — Changes the tldraw SDK
    - [ ] `dotcom` — Changes the tldraw.com web app
    - [ ] `docs` — Changes to the documentation, examples, or templates.
    - [ ] `vs code` — Changes to the vscode plugin
    - [ ] `internal` — Does not affect user-facing stuff

    <!--  Please select a 'Type' label ️ -->

    - [ ] `bugfix` — Bug fix
    - [ ] `feature` — New feature
    - [ ] `improvement` — Improving existing features
    - [ ] `chore` — Updating dependencies, other boring stuff
    - [ ] `galaxy brain` — Architectural changes
    - [ ] `tests` — Changes to any test code
    - [ ] `tools` — Changes to infrastructure, CI, internal scripts,
    debugging tools, etc.
    - [ ] `dunno` — I don't know

    ### Test Plan

    1. Add a step-by-step description of how to test your PR here.
    2.

    - [ ] Unit Tests
    - [ ] End to end tests

    ### Release Notes

    - Add a brief release note for your PR here.

commit 2ae2b6516c
Merge: a590e1ddb 0161ec796
Author: Mime Čuvalo <mimecuvalo@gmail.com>
Date:   Thu Apr 4 15:09:00 2024 +0100

    merge

commit a590e1ddb5
Author: Mime Čuvalo <mimecuvalo@gmail.com>
Date:   Thu Apr 4 11:39:55 2024 +0100

    textfields: fix very long words not causing growY to be calculated correctly

commit 00d5400e15
Author: Lu Wilson <l2wilson94@gmail.com>
Date:   Thu Apr 4 10:41:45 2024 +0100

    Stickies: Use page space for sticky shadow rotation (#3352)

    This PR makes sticky shadows use page space rotation, not shape space
    rotation. This brings it in line with what our other shapes do (eg:
    bookmarks)

    https://github.com/tldraw/tldraw/assets/15892272/6b0ae307-7811-4a11-a29d-2c61cafd3d1d

    ### Change Type

    <!--  Please select a 'Scope' label ️ -->

    - [ ] `sdk` — Changes the tldraw SDK
    - [ ] `dotcom` — Changes the tldraw.com web app
    - [ ] `docs` — Changes to the documentation, examples, or templates.
    - [ ] `vs code` — Changes to the vscode plugin
    - [ ] `internal` — Does not affect user-facing stuff

    <!--  Please select a 'Type' label ️ -->

    - [ ] `bugfix` — Bug fix
    - [ ] `feature` — New feature
    - [ ] `improvement` — Improving existing features
    - [ ] `chore` — Updating dependencies, other boring stuff
    - [ ] `galaxy brain` — Architectural changes
    - [ ] `tests` — Changes to any test code
    - [ ] `tools` — Changes to infrastructure, CI, internal scripts,
    debugging tools, etc.
    - [ ] `dunno` — I don't know

    ### Test Plan

    1. Add a step-by-step description of how to test your PR here.
    2.

    - [ ] Unit Tests
    - [ ] End to end tests

    ### Release Notes

    - Add a brief release note for your PR here.

commit 3c91ed78da
Author: Mime Čuvalo <mimecuvalo@gmail.com>
Date:   Thu Apr 4 10:21:05 2024 +0100

    textareas: place cursor at intended position (#3328)

    This is take 2 on this original PR:
    https://github.com/tldraw/tldraw/pull/3132

    It goes further to try to address these issues that came up:
    - [x] fixes up problem with being able to click "through" an occluded
    shape because of z-layer issues. This is done by only "raising" the
    textareas when a shape is selected, priming them for easy selection.
    - [x] add a `data-isediting` property which lets us stay in editing mode
    and keep the z-indices of the textareas raised when in editing mode.
    - [x] Initiating a drag from within a textarea was losing pointer events
    when going over the UI. Would love feedback on this piece - had to
    disable pointer events to the UI while dragging which I think is the
    right move anyway. But wiring that up properly could use work since it
    relies on cursor changes, heh.

    Btw, @MitjaBezensek I had to undo your PR
    https://github.com/tldraw/tldraw/pull/3283 because that would conflict
    with this sticky work where we want text editing to be more fluid.

    ### Change Type

    <!--  Please select a 'Scope' label ️ -->

    - [x] `sdk` — Changes the tldraw SDK
    - [ ] `dotcom` — Changes the tldraw.com web app
    - [ ] `docs` — Changes to the documentation, examples, or templates.
    - [ ] `vs code` — Changes to the vscode plugin
    - [ ] `internal` — Does not affect user-facing stuff

    <!--  Please select a 'Type' label ️ -->

    - [x] `bugfix` — Bug fix
    - [ ] `feature` — New feature
    - [ ] `improvement` — Improving existing features
    - [ ] `chore` — Updating dependencies, other boring stuff
    - [ ] `galaxy brain` — Architectural changes
    - [ ] `tests` — Changes to any test code
    - [ ] `tools` — Changes to infrastructure, CI, internal scripts,
    debugging tools, etc.
    - [ ] `dunno` — I don't know

    ---------

    Co-authored-by: Steve Ruiz <steveruizok@gmail.com>

commit 0161ec796e
Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Date:   Thu Apr 4 09:34:07 2024 +0200

    Bump the npm_and_yarn group across 1 directory with 1 update (#3348)

    Bumps the npm_and_yarn group with 1 update in the / directory:
    [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).

    Updates `vite` from 5.2.7 to 5.2.8
    <details>
    <summary>Changelog</summary>
    <p><em>Sourced from <a
    href="https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md">vite's
    changelog</a>.</em></p>
    <blockquote>
    <h2><!-- raw HTML omitted -->5.2.8 (2024-04-03)<!-- raw HTML omitted
    --></h2>
    <ul>
    <li>fix: csp nonce injection when no closing tag (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/16281">#16281</a>)
    (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/16282">#16282</a>)
    (<a href="https://github.com/vitejs/vite/commit/3c85c6b">3c85c6b</a>),
    closes <a
    href="https://redirect.github.com/vitejs/vite/issues/16281">#16281</a>
    <a
    href="https://redirect.github.com/vitejs/vite/issues/16282">#16282</a></li>
    <li>fix: do not access document in <code>/@vite/client</code> when not
    defined (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/16318">#16318</a>)
    (<a href="https://github.com/vitejs/vite/commit/646319c">646319c</a>),
    closes <a
    href="https://redirect.github.com/vitejs/vite/issues/16318">#16318</a></li>
    <li>fix: fix sourcemap when using object as <code>define</code> value
    (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/15805">#15805</a>)
    (<a href="https://github.com/vitejs/vite/commit/445c4f2">445c4f2</a>),
    closes <a
    href="https://redirect.github.com/vitejs/vite/issues/15805">#15805</a></li>
    <li>fix(css): unknown file error happened with lightningcss (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/16306">#16306</a>)
    (<a href="https://github.com/vitejs/vite/commit/01af308">01af308</a>),
    closes <a
    href="https://redirect.github.com/vitejs/vite/issues/16306">#16306</a></li>
    <li>fix(hmr): multiple updates happened when invalidate is called while
    multiple tabs open (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/16307">#16307</a>)
    (<a href="https://github.com/vitejs/vite/commit/21cc10b">21cc10b</a>),
    closes <a
    href="https://redirect.github.com/vitejs/vite/issues/16307">#16307</a></li>
    <li>fix(scanner): duplicate modules for same id if glob is used in
    html-like types (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/16305">#16305</a>)
    (<a href="https://github.com/vitejs/vite/commit/eca68fa">eca68fa</a>),
    closes <a
    href="https://redirect.github.com/vitejs/vite/issues/16305">#16305</a></li>
    <li>chore(deps): update all non-major dependencies (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/16325">#16325</a>)
    (<a href="https://github.com/vitejs/vite/commit/a78e265">a78e265</a>),
    closes <a
    href="https://redirect.github.com/vitejs/vite/issues/16325">#16325</a></li>
    <li>refactor: use types from sass instead of <code>@​types/sass</code>
    (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/16340">#16340</a>)
    (<a href="https://github.com/vitejs/vite/commit/4581e83">4581e83</a>),
    closes <a
    href="https://redirect.github.com/vitejs/vite/issues/16340">#16340</a></li>
    </ul>
    </blockquote>
    </details>
    <details>
    <summary>Commits</summary>
    <ul>
    <li><a
    href="8b8d4024fb"><code>8b8d402</code></a>
    release: v5.2.8</li>
    <li><a
    href="646319cc84"><code>646319c</code></a>
    fix: do not access document in <code>/@vite/client</code> when not
    defined (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/16318">#16318</a>)</li>
    <li><a
    href="445c4f2158"><code>445c4f2</code></a>
    fix: fix sourcemap when using object as <code>define</code> value (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/15805">#15805</a>)</li>
    <li><a
    href="a78e265822"><code>a78e265</code></a>
    chore(deps): update all non-major dependencies (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/16325">#16325</a>)</li>
    <li><a
    href="4581e8371d"><code>4581e83</code></a>
    refactor: use types from sass instead of <code>@​types/sass</code> (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/16340">#16340</a>)</li>
    <li><a
    href="3c85c6b52e"><code>3c85c6b</code></a>
    fix: csp nonce injection when no closing tag (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/16281">#16281</a>)
    (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/16282">#16282</a>)</li>
    <li><a
    href="21cc10bfda"><code>21cc10b</code></a>
    fix(hmr): multiple updates happened when invalidate is called while
    multiple ...</li>
    <li><a
    href="01af308dfd"><code>01af308</code></a>
    fix(css): unknown file error happened with lightningcss (<a
    href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/16306">#16306</a>)</li>
    <li><a
    href="eca68fa942"><code>eca68fa</code></a>
    fix(scanner): duplicate modules for same id if glob is used in html-like
    type...</li>
    <li>See full diff in <a
    href="https://github.com/vitejs/vite/commits/v5.2.8/packages/vite">compare
    view</a></li>
    </ul>
    </details>
    <br />

    [![Dependabot compatibility
    score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vite&package-manager=npm_and_yarn&previous-version=5.2.7&new-version=5.2.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

    Dependabot will resolve any conflicts with this PR as long as you don't
    alter it yourself. You can also trigger a rebase manually by commenting
    `@dependabot rebase`.

    [//]: # (dependabot-automerge-start)
    [//]: # (dependabot-automerge-end)

    ---

    <details>
    <summary>Dependabot commands and options</summary>
    <br />

    You can trigger Dependabot actions by commenting on this PR:
    - `@dependabot rebase` will rebase this PR
    - `@dependabot recreate` will recreate this PR, overwriting any edits
    that have been made to it
    - `@dependabot merge` will merge this PR after your CI passes on it
    - `@dependabot squash and merge` will squash and merge this PR after
    your CI passes on it
    - `@dependabot cancel merge` will cancel a previously requested merge
    and block automerging
    - `@dependabot reopen` will reopen this PR if it is closed
    - `@dependabot close` will close this PR and stop Dependabot recreating
    it. You can achieve the same result by closing it manually
    - `@dependabot show <dependency name> ignore conditions` will show all
    of the ignore conditions of the specified dependency
    - `@dependabot ignore <dependency name> major version` will close this
    group update PR and stop Dependabot creating any more for the specific
    dependency's major version (unless you unignore this specific
    dependency's major version or upgrade to it yourself)
    - `@dependabot ignore <dependency name> minor version` will close this
    group update PR and stop Dependabot creating any more for the specific
    dependency's minor version (unless you unignore this specific
    dependency's minor version or upgrade to it yourself)
    - `@dependabot ignore <dependency name>` will close this group update PR
    and stop Dependabot creating any more for the specific dependency
    (unless you unignore this specific dependency or upgrade to it yourself)
    - `@dependabot unignore <dependency name>` will remove all of the ignore
    conditions of the specified dependency
    - `@dependabot unignore <dependency name> <ignore condition>` will
    remove the ignore condition of the specified dependency and ignore
    conditions
    You can disable automated security fix PRs for this repo from the
    [Security Alerts page](https://github.com/tldraw/tldraw/network/alerts).

    </details>

    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

commit 3f6b385880
Author: Lu Wilson <l2wilson94@gmail.com>
Date:   Wed Apr 3 17:15:19 2024 +0100

    Stickies: parenting (of frames and stickies) (#3297)

    This PR adds parenting behaviour to stickies, and also changes how
    parenting works on the whole.

    - Rescaled timings for drag and drop manager. It should feel tighter,
    closer to figma.

    https://github.com/tldraw/tldraw/assets/15892272/c36c5bba-9964-403e-a5cd-6dcb35e1fe27

    - We now 'kick out' any occluded children from their parents. We
    manually do this after many interactions and actions.
    - We now only parent shapes if their geometry overlaps *as well* as your
    cursor dragging over.
    - We now hint parents when translating a child inside it.
    - We hide the hint if your selected shape will be kicked out of a
    frame/note when you let go.
    - We now *only* hint if a child will successfully drop inside a parent.
    - Removed `shouldHint` option from `onDragShapesOver`. The editor
    handles that now.

    I will add more gifs demonstrating all these cases.

    ### Change Type

    <!--  Please select a 'Scope' label ️ -->

    - [x] `sdk` — Changes the tldraw SDK
    - [x] `dotcom` — Changes the tldraw.com web app
    - [ ] `docs` — Changes to the documentation, examples, or templates.
    - [ ] `vs code` — Changes to the vscode plugin
    - [ ] `internal` — Does not affect user-facing stuff

    <!--  Please select a 'Type' label ️ -->

    - [ ] `bugfix` — Bug fix
    - [x] `feature` — New feature
    - [x] `improvement` — Improving existing features
    - [ ] `chore` — Updating dependencies, other boring stuff
    - [ ] `galaxy brain` — Architectural changes
    - [ ] `tests` — Changes to any test code
    - [ ] `tools` — Changes to infrastructure, CI, internal scripts,
    debugging tools, etc.
    - [ ] `dunno` — I don't know

    ### Test Plan

    1. Add a step-by-step description of how to test your PR here.
    2.

    - [ ] Unit Tests
    - [ ] End to end tests

    ### Release Notes

    - Add a brief release note for your PR here.

    ---------

    Co-authored-by: Steve Ruiz <steveruizok@gmail.com>

commit df7e3c4d31
Author: Steve Ruiz <steveruizok@gmail.com>
Date:   Wed Apr 3 17:05:58 2024 +0100

    Stickies: fix sticky shadows (#3345)

    This PR tweaks sticky shadows.

    ### Change Type

    - [x] `sdk` — Changes the tldraw SDK
    - [x] `bugfix` — Bug fix

commit 1ba9cbfa2a
Author: Steve Ruiz <steveruizok@gmail.com>
Date:   Wed Apr 3 16:42:52 2024 +0100

    only buffer pointer events (#3337)

    This PR changes our input buffering to only buffer pointer events. We
    were already moving in this direction with the complete / cancel
    flushes, now it's just more explicit. If we want to add other events
    into here, then we can.

    ### Change Type

    - [x] `sdk` — Changes the tldraw SDK
    - [x] `bugfix` — Bug fix

commit 3f4a170968
Author: Steve Ruiz <steveruizok@gmail.com>
Date:   Wed Apr 3 16:41:56 2024 +0100

    Fix blur bug in editable text (#3343)

    This PR fixes a bug that was introduced by #3223. There was a code path
    that normally used to never run (a blur event running when the shape was
    no longer editing) but which was being run now that shapes aren't
    immediately removed on pointer down.

    ### Change Type

    - [x] `sdk` — Changes the tldraw SDK
    - [x] `bugfix` — Bug fix

    ### Test Plan

    1. Create a sticky note
    2. Begin editing the note
    3. click on the canvas
    4. You should be in pointing_canvas

commit 5d7e38a7ed
Author: Steve Ruiz <steveruizok@gmail.com>
Date:   Wed Apr 3 16:32:47 2024 +0100

    Update noteHelpers.ts

commit e60f8d56a0
Author: Steve Ruiz <steveruizok@gmail.com>
Date:   Wed Apr 3 16:17:40 2024 +0100

    Fix weird text complete (#3342)

    This PR fixes a weird issue where editing text notes would call blur /
    complete, preventing certain interactions.

    Before:
    1. Select a sticky note
    2. Start editing the note
    3. pointer down on the canvas
    4. Instead of being in pointing_canvas, you'll be in idle

    After:
    4. You'll be in pointing_canvas

    ### Change Type

    - [x] `sdk` — Changes the tldraw SDK
    - [x] `bugfix` — Bug fix

commit 30e605ef7e
Author: Steve Ruiz <steveruizok@gmail.com>
Date:   Wed Apr 3 15:56:56 2024 +0100

    Font size adjustment migration (#3338)

    This PR adds migrations for font size adjustment.

    ### Change Type

    - [x] `sdk` — Changes the tldraw SDK
    - [x] `improvement` — Improving existing features

commit 139ea35171
Author: Mime Čuvalo <mimecuvalo@gmail.com>
Date:   Wed Apr 3 15:53:15 2024 +0100

    fix colors

commit 4ef1fb3c70
Author: Mime Čuvalo <mimecuvalo@gmail.com>
Date:   Wed Apr 3 15:16:26 2024 +0100

    fix up long growY on some notes, we dont have a border anymore

commit cf51f5c2f4
Merge: d4a162d1a 4f2cf3dee
Author: Steve Ruiz <steveruizok@gmail.com>
Date:   Wed Apr 3 15:14:50 2024 +0100

    Merge branch 'main' into stickies-rc

commit 4f2cf3dee0
Author: Taha <98838967+Taha-Hassan-Git@users.noreply.github.com>
Date:   Wed Apr 3 12:25:07 2024 +0100

    Tool with child states (#3074)

    Adds an example of a tool with child states. I'm going over the
    annotations at the moment, just wanted to validate the idea in the
    meantime.
    Closes tld-2114
    - [x] `documentation` — Changes to the documentation only[^2]

    ### Release Notes

    - Add an example of a tool with child states

    ---------

    Co-authored-by: Steve Ruiz <steveruizok@gmail.com>

commit d4a162d1ad
Author: Mime Čuvalo <mimecuvalo@gmail.com>
Date:   Wed Apr 3 12:00:22 2024 +0100

    some nit cleanup

commit 7ae03ca32b
Author: Mime Čuvalo <mimecuvalo@gmail.com>
Date:   Wed Apr 3 11:52:42 2024 +0100

    textfields: pull out change where textareas are present all the time

commit ded85e4ebf
Merge: 16b84ef38 03e4c8575
Author: Mime Čuvalo <mimecuvalo@gmail.com>
Date:   Wed Apr 3 11:12:13 2024 +0100

    Merge branch 'main' into stickies-rc

commit 03e4c8575c
Author: Mime Čuvalo <mimecuvalo@gmail.com>
Date:   Wed Apr 3 11:01:04 2024 +0100

    textfields: fix regression with Text shape and resizing (#3333)

    The refactor of the textfields in this PR
    https://github.com/tldraw/tldraw/pull/3050 caused a regression in
    resizing Text shapes. (as demonstrated in this PR's video:
    https://github.com/tldraw/tldraw/pull/3327)
    We reverted that PR and now this PR updates the CSS to fix the gap that
    was introduced when it was refactored.

    ### Change Type

    <!--  Please select a 'Scope' label ️ -->

    - [x] `sdk` — Changes the tldraw SDK
    - [ ] `dotcom` — Changes the tldraw.com web app
    - [ ] `docs` — Changes to the documentation, examples, or templates.
    - [ ] `vs code` — Changes to the vscode plugin
    - [ ] `internal` — Does not affect user-facing stuff

    <!--  Please select a 'Type' label ️ -->

    - [x] `bugfix` — Bug fix
    - [ ] `feature` — New feature
    - [ ] `improvement` — Improving existing features
    - [ ] `chore` — Updating dependencies, other boring stuff
    - [ ] `galaxy brain` — Architectural changes
    - [ ] `tests` — Changes to any test code
    - [ ] `tools` — Changes to infrastructure, CI, internal scripts,
    debugging tools, etc.
    - [ ] `dunno` — I don't know

commit 843347bde1
Author: Mime Čuvalo <mimecuvalo@gmail.com>
Date:   Wed Apr 3 10:59:11 2024 +0100

    Revert "Fix text resizing bug (#3327)" (#3332)

    This reverts commit 0e912fe0f2.

    (The fix is more to do with a CSS regression instead of a JS fix.)

    ### Change Type

    <!--  Please select a 'Scope' label ️ -->

    - [x] `sdk` — Changes the tldraw SDK
    - [ ] `dotcom` — Changes the tldraw.com web app
    - [ ] `docs` — Changes to the documentation, examples, or templates.
    - [ ] `vs code` — Changes to the vscode plugin
    - [ ] `internal` — Does not affect user-facing stuff

    <!--  Please select a 'Type' label ️ -->

    - [x] `bugfix` — Bug fix
    - [ ] `feature` — New feature
    - [ ] `improvement` — Improving existing features
    - [ ] `chore` — Updating dependencies, other boring stuff
    - [ ] `galaxy brain` — Architectural changes
    - [ ] `tests` — Changes to any test code
    - [ ] `tools` — Changes to infrastructure, CI, internal scripts,
    debugging tools, etc.
    - [ ] `dunno` — I don't know

commit 16b84ef38e
Merge: 4c854f4cb 5557f6be5
Author: Steve Ruiz <steveruizok@gmail.com>
Date:   Wed Apr 3 10:53:20 2024 +0100

    Merge branch 'main' into stickies-rc

commit 5557f6be5b
Author: David Sheldrick <d.j.sheldrick@gmail.com>
Date:   Wed Apr 3 10:31:28 2024 +0100

    Revert "squish sync data events before sending them out" (#3331)

    Reverts tldraw/tldraw#3118

commit 4c854f4cb8
Author: Lu[ke] Wilson <l2wilson94@gmail.com>
Date:   Wed Apr 3 09:45:09 2024 +0100

    fix api

commit 0e912fe0f2
Author: Taha <98838967+Taha-Hassan-Git@users.noreply.github.com>
Date:   Tue Apr 2 17:22:58 2024 +0100

    Fix text resizing bug (#3327)

    Fixes a bug with text resizing on text shapes, now the transform origin
    is set depending on the alignment.

    ![2024-04-02 at 16 50 49 - Aqua
    Snail](https://github.com/tldraw/tldraw/assets/98838967/86b59691-e950-4367-8632-03ae6dfef7f6)

    ![2024-04-02 at 16 49 37 - Teal
    Tuna](https://github.com/tldraw/tldraw/assets/98838967/6b6c97a8-fc53-45a0-8282-6bd63e77507b)

    ### Change Type

    <!--  Please select a 'Scope' label ️ -->

    - [x] `sdk` — Changes the tldraw SDK
    - [ ] `dotcom` — Changes the tldraw.com web app
    - [ ] `docs` — Changes to the documentation, examples, or templates.
    - [ ] `vs code` — Changes to the vscode plugin
    - [ ] `internal` — Does not affect user-facing stuff

    <!--  Please select a 'Type' label ️ -->

    - [x] `bugfix` — Bug fix
    - [ ] `feature` — New feature
    - [ ] `improvement` — Improving existing features
    - [ ] `chore` — Updating dependencies, other boring stuff
    - [ ] `galaxy brain` — Architectural changes
    - [ ] `tests` — Changes to any test code
    - [ ] `tools` — Changes to infrastructure, CI, internal scripts,
    debugging tools, etc.
    - [ ] `dunno` — I don't know

    ### Test Plan

    1. Make a text shape
    2. resize it
    3. It should stay within the bounds

    ### Release Notes

    - Fixes an issue with text shapes overflowing their bounds when resized.

commit 584380ba8b
Author: Mitja Bezenšek <mitja.bezensek@gmail.com>
Date:   Tue Apr 2 16:29:14 2024 +0200

    Input buffering (#3223)

    This PR buffs input events.

    ## The story so far

    In the olde days, we throttled events from the canvas events hook so
    that a pointer event would only be sent every 1/60th of a second. This
    was fine but made drawing on the iPad / 120FPS displays a little sad.

    Then we removed this throttle. It seemed fine! Drawing at 120FPS was
    great. We improved some rendering speeds and tightened some loops so
    that the engine could keep up with 2x the number of points in a line.

    Then we started noticing that iPads and other screens could start
    choking on events as it received new inputs and tried to process and
    render inputs while still recovering from a previous dropped frame. Even
    worse, on iPad the work of rendering at 120FPS was causing the browser
    to throttle the app after some sustained drawing. Yikes!

    ### Batching

    I did an experimental PR (#3180) to bring back batching but do it in the
    editor instead. What we would do is: rather than immediately processing
    an event when we get it, we would instead put the event into a buffer.
    On the next 60FPS tick, we would flush the buffer and process all of the
    events. We'd have them all in the same transaction so that the app would
    only render once.

    ### Render batching?

    We then tried batching the renders, so that the app would only ever
    render once per (next) frame. This added a bunch of complexity around
    events that needed to happen synchronously, such as writing text in a
    text field. Some inputs could "lag" in a way familiar to anyone who's
    tried to update an input's state asynchronously. So we backed out of
    this.

    ### Coalescing?

    Another idea from @ds300 was to "coalesce" the events. This would be
    useful because, while some interactions like drawing would require the
    in-between frames in order to avoid data loss, most interactions (like
    resizing) didn't actually need the in-between frames, they could just
    use the last input of a given type.

    Coalescing turned out to be trickier than we thought, though. Often a
    state node required information from elsewhere in the app when
    processing an event (such as camera position or page point, which is
    derived from the camera position), and so the coalesced events would
    need to also include this information or else the handlers wouldn't work
    the way they should when processing the "final" event during a tick.

    So we backed out of the coalescing strategy for now. Here's the [PR that
    removes](937469d69d)
    it.

    ### Let's just buffer the fuckers

    So this PR now should only include input buffering.

    I think there are ways to achieve the same coalescing-like results
    through the state nodes, which could gather information during the
    `onPointerMove` handler and then actually make changes during the
    `onTick` handler, so that the changes are only done as many time as
    necessary. This should help with e.g. resizing lots of shapes at once.

    But first let's land the buffering!

    ---

    Mitja's original text:

    This PR builds on top of Steve's [experiment
    PR](https://github.com/tldraw/tldraw/pull/3180) here. It also adds event
    coalescing for [`pointerMove`
    events](https://github.com/tldraw/tldraw/blob/mitja/input-buffering/packages/editor/src/lib/editor/Editor.ts#L8364-L8368).
    The API is [somewhat similar
    ](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/getCoalescedEvents)
    to `getCoalescedEvent`. In `StateNodes` we register an `onPointerMove`
    handler. When the event happens it gets called with the event `info`.
    There's now an additional field on `TLMovePointerEvent` called
    `coalescedInfo` which includes all the events. It's then on the user to
    process all of these.

    I decided on this API since it allows us to only expose one event
    handler, but it still gives the users access to all events if they need
    them.

    We would otherwise either need to:

    - Expose two events (coalesced and non-coalesced one and complicate the
    api) so that state nodes like Resizing would not be triggered for each
    pointer move.
    - Offer some methods on the editor that would allow use to get the
    coalesced information. Then the nodes that need that info could request
    it. I [tried
    this](9ad973da3a (diff-32f1de9a5a9ec72aa49a8d18a237fbfff301610f4689a4af6b37f47af435aafcR67)),
    but it didn't feel good.

    This also complicated the editor inputs. The events need to store
    information about the event (like the mouse position when the event
    happened for `onPointerMove`). But we cannot immediately update inputs
    when the event happens. To make this work for `pointerMove` events I've
    added `pagePoint`. It's
    [calculated](https://github.com/tldraw/tldraw/pull/3223/files#diff-980beb0aa0ee9aa6d1cd386cef3dc05a500c030638ffb58d45fd11b79126103fR71)
    when the event triggers and then consumers can get it straight from the
    event (like
    [Drawing](https://github.com/tldraw/tldraw/pull/3223/files#diff-32f1de9a5a9ec72aa49a8d18a237fbfff301610f4689a4af6b37f47af435aafcR104)).

    ### Change Type

    <!--  Please select a 'Scope' label ️ -->

    - [x] `sdk` — Changes the tldraw SDK
    - [ ] `dotcom` — Changes the tldraw.com web app
    - [ ] `docs` — Changes to the documentation, examples, or templates.
    - [ ] `vs code` — Changes to the vscode plugin
    - [ ] `internal` — Does not affect user-facing stuff

    <!--  Please select a 'Type' label ️ -->

    - [ ] `bugfix` — Bug fix
    - [ ] `feature` — New feature
    - [x] `improvement` — Improving existing features
    - [ ] `chore` — Updating dependencies, other boring stuff
    - [ ] `galaxy brain` — Architectural changes
    - [ ] `tests` — Changes to any test code
    - [ ] `tools` — Changes to infrastructure, CI, internal scripts,
    debugging tools, etc.
    - [ ] `dunno` — I don't know

    ### Test Plan

    1. Add a step-by-step description of how to test your PR here.
    4.

    - [ ] Unit Tests
    - [ ] End to end tests

    ### Release Notes

    - Add a brief release note for your PR here.

    ---------

    Co-authored-by: Steve Ruiz <steveruizok@gmail.com>

commit b87f007df4
Merge: 5ee01f1c0 d96b69394
Author: Steve Ruiz <steveruizok@gmail.com>
Date:   Tue Apr 2 14:45:46 2024 +0100

    Merge branch 'stickies-rc' of https://github.com/tldraw/tldraw into stickies-rc

commit 5ee01f1c07
Author: Steve Ruiz <steveruizok@gmail.com>
Date:   Tue Apr 2 14:45:38 2024 +0100

    highest one first
pull/3329/head
Steve Ruiz 2024-04-05 11:37:45 +01:00
rodzic 82c3be1619
commit 316ec7d5c0
80 zmienionych plików z 2967 dodań i 2012 usunięć

Wyświetl plik

@ -21,6 +21,7 @@ test.describe('Canvas events', () => {
await page.mouse.move(200, 200) // to kill any double clicks
await page.mouse.move(100, 100)
await page.mouse.down()
await page.waitForTimeout(20)
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
target: 'canvas',
type: 'pointer',
@ -46,6 +47,7 @@ test.describe('Canvas events', () => {
await page.mouse.down()
await page.mouse.move(101, 101)
await page.mouse.up()
await page.waitForTimeout(20)
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
target: 'canvas',
type: 'pointer',
@ -118,6 +120,7 @@ test.describe('Shape events', () => {
test('pointer down', async () => {
await page.mouse.move(51, 51)
await page.mouse.down()
await page.waitForTimeout(20)
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
target: 'canvas',
type: 'pointer',
@ -128,6 +131,7 @@ test.describe('Shape events', () => {
test('pointer move', async () => {
await page.mouse.move(51, 51)
await page.mouse.move(52, 52)
await page.waitForTimeout(20)
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
target: 'canvas',
type: 'pointer',
@ -139,6 +143,7 @@ test.describe('Shape events', () => {
await page.mouse.move(51, 51)
await page.mouse.down()
await page.mouse.up()
await page.waitForTimeout(20)
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
target: 'canvas',
type: 'pointer',

Wyświetl plik

@ -112,6 +112,7 @@ test.describe('Shape Tools', () => {
// Click on the page
await page.mouse.click(200, 200)
await page.waitForTimeout(20)
// We should have a corresponding shape in the page
expect(await getAllShapeTypes(page)).toEqual([shape])
@ -119,6 +120,7 @@ test.describe('Shape Tools', () => {
// Reset for next time
await page.mouse.click(50, 50) // to ensure we're not focused
await page.keyboard.press('v') // go to the select tool
await page.waitForTimeout(20)
await page.keyboard.press('Control+a')
await page.keyboard.press('Backspace')
}
@ -156,6 +158,7 @@ test.describe('Shape Tools', () => {
// Reset for next time
await page.mouse.click(50, 50) // to ensure we're not focused
await page.keyboard.press('v')
await page.waitForTimeout(20)
await page.keyboard.press('Control+a')
await page.keyboard.press('Backspace')
}

Wyświetl plik

@ -1,4 +1,3 @@
import { ShapePropsType } from '@tldraw/tlschema/src/shapes/TLBaseShape'
import {
DefaultColorStyle,
DefaultFontStyle,
@ -9,6 +8,7 @@ import {
Geometry2d,
LABEL_FONT_SIZES,
Polygon2d,
ShapePropsType,
ShapeUtil,
T,
TEXT_PROPS,
@ -20,11 +20,11 @@ import {
TextLabel,
Vec,
ZERO_INDEX_KEY,
getDefaultColorTheme,
resizeBox,
structuredClone,
vecModelValidator,
} from 'tldraw'
import { useDefaultColorTheme } from 'tldraw/src/lib/shapes/shared/ShapeFill'
import { getSpeechBubbleVertices, getTailIntersectionPoint } from './helpers'
// Copied from tldraw/tldraw
@ -176,11 +176,11 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
type,
props: { color, font, size, align, text },
} = shape
const theme = getDefaultColorTheme({
isDarkMode: this.editor.user.getIsDarkMode(),
})
const vertices = getSpeechBubbleVertices(shape)
const pathData = 'M' + vertices[0] + 'L' + vertices.slice(1) + 'Z'
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
// eslint-disable-next-line react-hooks/rules-of-hooks
const theme = useDefaultColorTheme()
return (
<>
@ -192,7 +192,6 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
fill={'none'}
/>
</svg>
<TextLabel
id={id}
type={type}
@ -202,7 +201,8 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
align={align}
verticalAlign="start"
text={text}
labelColor={color}
labelColor={theme[color].solid}
isSelected={isSelected}
wrap
/>
</>

Wyświetl plik

@ -0,0 +1,12 @@
---
title: Tool with child states
component: ./ToolWithChildStatesExample.tsx
category: shapes/tools
priority: 2
---
You can implement more complex behaviour in a custom tool by using child states
---
Tools are nodes in tldraw's state machine. They are responsible for handling user input. You can create custom tools by extending the StateNode class and overriding its methods. In this example we expand on the sticker tool from the custom tool example to show how to create a tool that can handle more complex interactions by using child states.

Wyświetl plik

@ -0,0 +1,258 @@
import {
StateNode,
TLEventHandlers,
TLShapePartial,
TLTextShape,
Tldraw,
createShapeId,
} from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
const OFFSET = -12
// [1]
class StickerTool extends StateNode {
static override id = 'sticker'
static override initial = 'idle'
static override children = () => [Idle, Pointing, Dragging]
}
// [2]
class Idle extends StateNode {
static override id = 'idle'
//[a]
override onEnter = () => {
this.editor.setCursor({ type: 'cross' })
}
//[b]
override onPointerDown: TLEventHandlers['onPointerDown'] = (info) => {
const { editor } = this
switch (info.target) {
case 'canvas': {
const hitShape = editor.getShapeAtPoint(editor.inputs.currentPagePoint)
if (hitShape) {
this.onPointerDown({
...info,
shape: hitShape,
target: 'shape',
})
return
}
this.parent.transition('pointing', { shape: null })
break
}
case 'shape': {
if (editor.inputs.shiftKey) {
editor.updateShape({
id: info.shape.id,
type: 'text',
props: { text: '👻 boo!' },
})
} else {
this.parent.transition('pointing', { shape: info.shape })
}
break
}
}
}
//[c]
override onDoubleClick: TLEventHandlers['onDoubleClick'] = (info) => {
const { editor } = this
if (info.phase !== 'up') return
switch (info.target) {
case 'canvas': {
const hitShape = editor.getShapeAtPoint(editor.inputs.currentPagePoint)
if (hitShape) {
this.onDoubleClick({
...info,
shape: hitShape,
target: 'shape',
})
return
}
const { currentPagePoint } = editor.inputs
editor.createShape({
type: 'text',
x: currentPagePoint.x + OFFSET,
y: currentPagePoint.y + OFFSET,
props: { text: '❤️' },
})
break
}
case 'shape': {
editor.deleteShapes([info.shape.id])
break
}
}
}
}
// [3]
class Pointing extends StateNode {
static override id = 'pointing'
private shape: TLTextShape | null = null
override onEnter = (info: { shape: TLTextShape | null }) => {
this.shape = info.shape
}
override onPointerUp: TLEventHandlers['onPointerUp'] = () => {
this.parent.transition('idle')
}
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
if (this.editor.inputs.isDragging) {
this.parent.transition('dragging', { shape: this.shape })
}
}
}
// [4]
class Dragging extends StateNode {
static override id = 'dragging'
// [a]
private shape: TLShapePartial | null = null
private emojiArray = ['❤️', '🔥', '👍', '👎', '😭', '🤣']
// [b]
override onEnter = (info: { shape: TLShapePartial }) => {
const { currentPagePoint } = this.editor.inputs
const newShape = {
id: createShapeId(),
type: 'text',
x: currentPagePoint.x + OFFSET,
y: currentPagePoint.y + OFFSET,
props: { text: '❤️' },
}
if (info.shape) {
this.shape = info.shape
} else {
this.editor.createShape(newShape)
this.shape = { ...newShape }
}
}
//[c]
override onPointerUp: TLEventHandlers['onPointerUp'] = () => {
this.parent.transition('idle')
}
//[d]
override onPointerMove: TLEventHandlers['onPointerUp'] = () => {
const { shape } = this
const { originPagePoint, currentPagePoint } = this.editor.inputs
const distance = originPagePoint.dist(currentPagePoint)
if (shape) {
this.editor.updateShape({
id: shape.id,
type: 'text',
props: {
text: this.emojiArray[Math.floor(distance / 20) % this.emojiArray.length],
},
})
}
}
}
// [5]
const customTools = [StickerTool]
export default function ToolWithChildStatesExample() {
return (
<div className="tldraw__editor">
<Tldraw
// Pass in the array of custom tool classes
tools={customTools}
// Set the initial state to the sticker tool
initialState="sticker"
// hide the ui
hideUi
// Put some helpful text on the canvas
onMount={(editor) => {
editor.createShape({
type: 'text',
x: 50,
y: 50,
props: {
text: '-Double click the canvas to add a sticker\n-Double click a sticker to delete it\n-Click and drag on a sticker to change it\n-Click and drag on the canvas to create a sticker\n-Shift click a sticker for a surprise!',
size: 's',
align: 'start',
},
})
}}
/>
</div>
)
}
/*
Introduction:
Tools are nodes in tldraw's state machine. They are responsible for handling user input.
You can create custom tools by extending the `StateNode` class and overriding its
methods. In this example we expand on the sticker tool from the custom tool example to
show how to create a tool that can handle more complex interactions by using child states.
[1]
This is our custom tool. It has three child states: `Idle`, `Pointing`, and `Dragging`.
We need to define the `id` and `initial` properties, the id is a unique string that
identifies the tool to the editor, and the initial property is the initial state of the
tool. We also need to define a `children` method that returns an array of the tool's
child states.
[2]
This is our Idle state. It is the initial state of the tool. It's job is to figure out
what the user is trying to do and transition to the appropriate state. When transitioning
between states we can use the second argument to pass data to the new state. It has three
methods:
[a] `onEnter`
When entering any state, the `onEnter` method is called. In this case, we set the cursor to
a crosshair.
[b] `onPointerDown`
This method is called when the user presses the mouse button. The target parameter is always
the canvas, so we can use an editor method to check if we're over a shape, and call the
method again with the shape as the target. If we are over a shape, we transition to the
`pointing` state with the shape in the info object. If we're over a shape and holding the
shift key, we update the shape's text. If we're over the canvas, we transition to the
`pointing` state with a null shape in the info object.
[c] `onDoubleClick`
This method is called when the user double clicks the mouse button. We're using some similar
logic here to check if we're over a shape, and if we are, we delete it. If we're over the canvas,
we create a new shape.
[3]
This is our `Pointing` state. It's a transitionary state, we use it to store the shape we're pointing
at, and transition to the dragging state if the user starts dragging. It has three methods:
[a] `onEnter`
When entering this state, we store the shape we're pointing at by getting it from the info object.
[b] `onPointerUp`
This method is called when the user releases the mouse button. We transition to the `idle` state.
[c] `onPointerMove`
This method is called when the user moves the mouse. If the user starts dragging, we transition to
the `dragging` state and pass the shape we're pointing at.
[4]
This is our `Dragging` state. It's responsible for creating and updating the shape that the user is
dragging.
[a] `onEnter`
When entering this state, we create a new shape if we're not dragging an existing one. If we are,
we store the shape we're dragging.
[b] `onPointerUp`
This method is called when the user releases the mouse button. We transition to the `idle` state.
[c] `onPointerMove`
This method is called when the user moves the mouse. We use the distance between the origin and
current mouse position to cycle through an array of emojis and update the shape's text.
[5]
We pass our custom tool to the `Tldraw` component as an array. We also set the initial state to our
custom tool. For the purposes of this demo, we're also hiding the UI and adding some helpful text to
the canvas.
*/

Wyświetl plik

@ -1,8 +1,7 @@
[
{
"locale": "ar",
"label": "عربي",
"isRTL": true
"label": "عربي"
},
{
"locale": "ca",
@ -30,8 +29,7 @@
},
{
"locale": "fa",
"label": "فارسی",
"isRTL": true
"label": "فارسی"
},
{
"locale": "fi",
@ -47,8 +45,7 @@
},
{
"locale": "he",
"label": "עברית",
"isRTL": true
"label": "עברית"
},
{
"locale": "hr",
@ -68,8 +65,7 @@
},
{
"locale": "ku",
"label": "کوردی",
"isRTL": true
"label": "کوردی"
},
{
"locale": "hi-in",

Wyświetl plik

@ -702,6 +702,7 @@ export class Editor extends EventEmitter<TLEventMap> {
getInstanceState(): TLInstance;
getIsMenuOpen(): boolean;
getOnlySelectedShape(): null | TLShape;
getOnlySelectedShapeId(): null | TLShapeId;
getOpenMenus(): string[];
getOutermostSelectableShape(shape: TLShape | TLShapeId, filter?: (shape: TLShape) => boolean): TLShape;
getPage(page: TLPage | TLPageId): TLPage | undefined;
@ -814,7 +815,6 @@ export class Editor extends EventEmitter<TLEventMap> {
pointerVelocity: Vec;
};
interrupt(): this;
isAncestorSelected(shape: TLShape | TLShapeId): boolean;
isIn(path: string): boolean;
isInAny(...paths: string[]): boolean;
isPointInShape(shape: TLShape | TLShapeId, point: VecLike, opts?: {
@ -1446,6 +1446,9 @@ export class Polygon2d extends Polyline2d {
});
}
// @public (undocumented)
export function polygonIntersectsPolyline(polygon: VecLike[], polyline: VecLike[]): boolean;
// @public (undocumented)
export function polygonsIntersect(a: VecLike[], b: VecLike[]): boolean;
@ -1655,9 +1658,7 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
onDoubleClickEdge?: TLOnDoubleClickHandler<Shape>;
onDoubleClickHandle?: TLOnDoubleClickHandleHandler<Shape>;
onDragShapesOut?: TLOnDragHandler<Shape>;
onDragShapesOver?: TLOnDragHandler<Shape, {
shouldHint: boolean;
}>;
onDragShapesOver?: TLOnDragHandler<Shape>;
onDropShapesOver?: TLOnDragHandler<Shape>;
onEditEnd?: TLOnEditEndHandler<Shape>;
onHandleDrag?: TLOnHandleDragHandler<Shape>;
@ -1829,6 +1830,8 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
// (undocumented)
onKeyUp?: TLEventHandlers['onKeyUp'];
// (undocumented)
onLongPress?: TLEventHandlers['onLongPress'];
// (undocumented)
onMiddleClick?: TLEventHandlers['onMiddleClick'];
// (undocumented)
onPointerDown?: TLEventHandlers['onPointerDown'];
@ -2149,6 +2152,8 @@ export interface TLEventHandlers {
// (undocumented)
onKeyUp: TLKeyboardEvent;
// (undocumented)
onLongPress: TLPointerEvent;
// (undocumented)
onMiddleClick: TLPointerEvent;
// (undocumented)
onPointerDown: TLPointerEvent;
@ -2423,7 +2428,7 @@ export type TLPointerEventInfo = TLBaseEventInfo & {
} & TLPointerEventTarget;
// @public (undocumented)
export type TLPointerEventName = 'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click';
export type TLPointerEventName = 'long_press' | 'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click';
// @public (undocumented)
export type TLPointerEventTarget = {

Wyświetl plik

@ -11339,6 +11339,42 @@
"isAbstract": false,
"name": "getOnlySelectedShape"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getOnlySelectedShapeId:member(1)",
"docComment": "/**\n * The id of the app's only selected shape.\n *\n * @returns Null if there is no shape or more than one selected shape, otherwise the selected shape's id.\n *\n * @public @readonly\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "getOnlySelectedShapeId(): "
},
{
"kind": "Content",
"text": "null | "
},
{
"kind": "Reference",
"text": "TLShapeId",
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [],
"isOptional": false,
"isAbstract": false,
"name": "getOnlySelectedShapeId"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getOpenMenus:member(1)",
@ -14578,64 +14614,6 @@
"isAbstract": false,
"name": "interrupt"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#isAncestorSelected:member(1)",
"docComment": "/**\n * Determine whether or not any of a shape's ancestors are selected.\n *\n * @param id - The id of the shape to check.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "isAncestorSelected(shape: "
},
{
"kind": "Reference",
"text": "TLShape",
"canonicalReference": "@tldraw/tlschema!TLShape:type"
},
{
"kind": "Content",
"text": " | "
},
{
"kind": "Reference",
"text": "TLShapeId",
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "boolean"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "shape",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "isAncestorSelected"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#isIn:member(1)",
@ -28079,6 +28057,77 @@
},
"implementsTokenRanges": []
},
{
"kind": "Function",
"canonicalReference": "@tldraw/editor!polygonIntersectsPolyline:function(1)",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function polygonIntersectsPolyline(polygon: "
},
{
"kind": "Reference",
"text": "VecLike",
"canonicalReference": "@tldraw/editor!VecLike:type"
},
{
"kind": "Content",
"text": "[]"
},
{
"kind": "Content",
"text": ", polyline: "
},
{
"kind": "Reference",
"text": "VecLike",
"canonicalReference": "@tldraw/editor!VecLike:type"
},
{
"kind": "Content",
"text": "[]"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "boolean"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/editor/src/lib/primitives/intersect.ts",
"returnTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [
{
"parameterName": "polygon",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"isOptional": false
},
{
"parameterName": "polyline",
"parameterTypeTokenRange": {
"startIndex": 4,
"endIndex": 6
},
"isOptional": false
}
],
"name": "polygonIntersectsPolyline"
},
{
"kind": "Function",
"canonicalReference": "@tldraw/editor!polygonsIntersect:function(1)",
@ -31678,7 +31727,7 @@
{
"kind": "Property",
"canonicalReference": "@tldraw/editor!ShapeUtil#onDragShapesOver:member",
"docComment": "/**\n * A callback called when some other shapes are dragged over this one.\n *\n * @param shape - The shape.\n *\n * @param shapes - The shapes that are being dragged over this one.\n *\n * @returns An object specifying whether the shape should hint that it can receive the dragged shapes.\n *\n * @example\n * ```ts\n * onDragShapesOver = (shape, shapes) => {\n * \treturn { shouldHint: true }\n * }\n * ```\n *\n * @public\n */\n",
"docComment": "/**\n * A callback called when some other shapes are dragged over this one.\n *\n * @param shape - The shape.\n *\n * @param shapes - The shapes that are being dragged over this one.\n *\n * @example\n * ```ts\n * onDragShapesOver = (shape, shapes) => {\n * \tthis.editor.reparentShapes(shapes, shape.id)\n * }\n * ```\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
@ -31691,7 +31740,7 @@
},
{
"kind": "Content",
"text": "<Shape, {\n shouldHint: boolean;\n }>"
"text": "<Shape>"
},
{
"kind": "Content",
@ -34963,6 +35012,41 @@
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "@tldraw/editor!StateNode#onLongPress:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "onLongPress?: "
},
{
"kind": "Reference",
"text": "TLEventHandlers",
"canonicalReference": "@tldraw/editor!TLEventHandlers:interface"
},
{
"kind": "Content",
"text": "['onLongPress']"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "onLongPress",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "@tldraw/editor!StateNode#onMiddleClick:member",
@ -38378,6 +38462,34 @@
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/editor!TLEventHandlers#onLongPress:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "onLongPress: "
},
{
"kind": "Reference",
"text": "TLPointerEvent",
"canonicalReference": "@tldraw/editor!TLPointerEvent:type"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "onLongPress",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/editor!TLEventHandlers#onMiddleClick:member",
@ -41027,7 +41139,7 @@
},
{
"kind": "Content",
"text": "'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click'"
"text": "'long_press' | 'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click'"
},
{
"kind": "Content",

Wyświetl plik

@ -28,6 +28,12 @@
--layer-shapes: 300;
--layer-overlays: 400;
--layer-following-indicator: 1000;
/* z index for text editors */
--layer-text-container: 1;
--layer-text-content: 3;
--layer-text-editor: 4;
/* Misc */
--tl-zoom: 1;
@ -703,7 +709,8 @@ input,
padding: 0px;
margin: 0px;
border: none;
height: 100%;
width: fit-content;
height: fit-content;
font-variant: normal;
font-style: normal;
pointer-events: all;
@ -769,7 +776,6 @@ input,
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
pointer-events: all;
text-rendering: auto;
text-transform: none;
text-indent: 0px;
@ -812,6 +818,17 @@ input,
outline: none;
}
.tl-text-content__wrapper {
position: relative;
width: fit-content;
height: fit-content;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
min-height: auto;
}
.tl-text-content {
pointer-events: none;
}
@ -824,9 +841,8 @@ input,
cursor: var(--tl-cursor-text);
}
.tl-text-shape__wrapper[data-isediting='false'] .tl-text-input,
.tl-arrow-label[data-isediting='false'] .tl-text-input,
.tl-text-label[data-isediting='false'] .tl-text-input {
.tl-text-wrapper[data-isediting='false'] .tl-text-input,
.tl-arrow-label[data-isediting='false'] .tl-text-input {
opacity: 0;
cursor: var(--tl-cursor-default);
}
@ -1028,15 +1044,9 @@ input,
pointer-events: all;
}
.tl-text-label__inner {
position: relative;
width: fit-content;
height: fit-content;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
min-height: auto;
.tl-text-wrapper .tl-text-content {
pointer-events: all;
z-index: var(--layer-text-content);
}
.tl-text-label__inner > .tl-text-content {
@ -1048,7 +1058,6 @@ input,
width: fit-content;
border-radius: var(--radius-1);
max-width: 100%;
z-index: 3;
}
.tl-text-label__inner > .tl-text-input {
@ -1057,7 +1066,29 @@ input,
height: 100%;
width: 100%;
padding: 16px;
z-index: 4;
}
.tl-text-wrapper[data-isselected='true'] .tl-text-input {
z-index: var(--layer-text-editor);
pointer-events: all;
}
/* These three rules help preserve clicking into specific points in text areas *while*
* already in edit mode when jumping from shape to shape. */
.tl-canvas[data-iseditinganything='true'] .tl-text-wrapper:hover .tl-text-input {
z-index: var(--layer-text-editor);
pointer-events: all;
}
/* This part of the rule helps preserve the occlusion rules for the shapes so we
* don't click on shapes that are behind other shapes.
* One extra nuance is arrows which have weird geometry and just gets in the way.
*/
.tl-canvas[data-iseditinganything='true'] .tl-shape:not([data-shape-type='arrow']) {
pointer-events: all;
}
/* But, re-disable the pointer-events rule for the svg container. */
.tl-canvas[data-iseditinganything='true'] .tl-shape .tl-svg-container {
pointer-events: none;
}
.tl-text-label[data-textwrap='true'] > .tl-text-label__inner {
@ -1111,7 +1142,7 @@ input,
position: relative;
height: max-content;
width: max-content;
pointer-events: all;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
@ -1120,24 +1151,12 @@ input,
.tl-arrow-label .tl-arrow {
position: relative;
height: max-content;
z-index: 2;
padding: 4px;
overflow: visible;
}
.tl-arrow-label textarea {
z-index: 3;
margin: 0px;
padding: 4px;
height: 100%;
width: 100%;
position: absolute;
resize: none;
border: 0px;
user-select: all;
-webkit-user-select: text;
caret-color: var(--color-text);
border-image: none;
/* Don't allow textarea to be zero width */
min-width: 4px;
}
@ -1150,14 +1169,13 @@ input,
height: 100%;
pointer-events: all;
opacity: 1;
z-index: 1;
z-index: var(--layer-text-container);
border-radius: 1px;
}
.tl-note__container > .tl-text-label {
text-shadow: none;
color: currentColor;
z-index: 4;
}
/* --------------------- Loading -------------------- */

Wyświetl plik

@ -301,6 +301,7 @@ export {
intersectPolygonBounds,
intersectPolygonPolygon,
linesIntersect,
polygonIntersectsPolyline,
polygonsIntersect,
} from './lib/primitives/intersect'
export {

Wyświetl plik

@ -1,3 +1,4 @@
import classNames from 'classnames'
import * as React from 'react'
/** @public */
@ -6,7 +7,7 @@ export type HTMLContainerProps = React.HTMLAttributes<HTMLDivElement>
/** @public */
export function HTMLContainer({ children, className = '', ...rest }: HTMLContainerProps) {
return (
<div {...rest} className={`tl-html-container ${className}`}>
<div {...rest} className={classNames('tl-html-container', className)}>
{children}
</div>
)

Wyświetl plik

@ -1,3 +1,4 @@
import classNames from 'classnames'
import * as React from 'react'
/** @public */
@ -6,7 +7,7 @@ export type SVGContainerProps = React.HTMLAttributes<SVGElement>
/** @public */
export function SVGContainer({ children, className = '', ...rest }: SVGContainerProps) {
return (
<svg {...rest} className={`tl-svg-container ${className}`}>
<svg {...rest} className={classNames('tl-svg-container', className)}>
{children}
</svg>
)

Wyświetl plik

@ -88,6 +88,11 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
const debugGeometry = useValue('debug_geometry', () => debugFlags.debugGeometry.get(), [
debugFlags,
])
const isEditingAnything = useValue(
'isEditingAnything',
() => editor.getEditingShapeId() !== null,
[editor]
)
return (
<div
@ -95,6 +100,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
draggable={false}
className={classNames('tl-canvas', className)}
data-testid="canvas"
data-iseditinganything={isEditingAnything}
{...events}
>
<svg className="tl-svg-context">

Wyświetl plik

@ -107,3 +107,6 @@ export const HANDLE_RADIUS = 12
/** @public */
export const SIDES = ['top', 'right', 'bottom', 'left'] as const
/** @internal */
export const LONG_PRESS_DURATION = 500

Wyświetl plik

@ -79,6 +79,7 @@ import {
FOLLOW_CHASE_ZOOM_UNSNAP,
HIT_TEST_MARGIN,
INTERNAL_POINTER_IDS,
LONG_PRESS_DURATION,
MAX_PAGES,
MAX_SHAPES_PER_PAGE,
MAX_ZOOM,
@ -637,7 +638,7 @@ export class Editor extends EventEmitter<TLEventMap> {
this.updateRenderingBounds()
this.on('tick', this.tick)
this.on('tick', this._flushEventsForTick)
requestAnimationFrame(() => {
this._tickManager.start()
@ -795,6 +796,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
undo(): this {
this._flushEventsForTick(0)
this.history.undo()
return this
}
@ -819,6 +821,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
redo(): this {
this._flushEventsForTick(0)
this.history.redo()
return this
}
@ -1515,21 +1518,6 @@ export class Editor extends EventEmitter<TLEventMap> {
}
)
/**
* Determine whether or not any of a shape's ancestors are selected.
*
* @param id - The id of the shape to check.
*
* @public
*/
isAncestorSelected(shape: TLShape | TLShapeId): boolean {
const id = typeof shape === 'string' ? shape : shape?.id ?? null
const _shape = this.getShape(id)
if (!_shape) return false
const selectedShapeIds = this.getSelectedShapeIds()
return !!this.findShapeAncestor(_shape, (parent) => selectedShapeIds.includes(parent.id))
}
/**
* Select one or more shapes.
*
@ -1611,11 +1599,22 @@ export class Editor extends EventEmitter<TLEventMap> {
return this
}
/**
* The id of the app's only selected shape.
*
* @returns Null if there is no shape or more than one selected shape, otherwise the selected shape's id.
*
* @public
* @readonly
*/
@computed getOnlySelectedShapeId(): TLShapeId | null {
return this.getOnlySelectedShape()?.id ?? null
}
/**
* The app's only selected shape.
*
* @returns Null if there is no shape or more than one selected shape, otherwise the selected
* shape.
* @returns Null if there is no shape or more than one selected shape, otherwise the selected shape.
*
* @public
* @readonly
@ -2085,7 +2084,7 @@ export class Editor extends EventEmitter<TLEventMap> {
}
/** @internal */
private _setCamera(point: VecLike): this {
private _setCamera(point: VecLike, immediate = false): this {
const currentCamera = this.getCamera()
if (currentCamera.x === point.x && currentCamera.y === point.y && currentCamera.z === point.z) {
@ -2107,7 +2106,7 @@ export class Editor extends EventEmitter<TLEventMap> {
currentScreenPoint.y / camera.z - camera.y !== currentPagePoint.y
) {
// If it's changed, dispatch a pointer event
this.dispatch({
const event: TLPointerEventInfo = {
type: 'pointer',
target: 'canvas',
name: 'pointer_move',
@ -2119,7 +2118,12 @@ export class Editor extends EventEmitter<TLEventMap> {
shiftKey: this.inputs.shiftKey,
button: 0,
isPen: this.getInstanceState().isPenMode ?? false,
})
}
if (immediate) {
this._flushEventForTick(event)
} else {
this.dispatch(event)
}
}
this._tickCameraState()
@ -2495,6 +2499,7 @@ export class Editor extends EventEmitter<TLEventMap> {
if (!this.getInstanceState().canMoveCamera) return this
const { x: cx, y: cy, z: cz } = this.getCamera()
this.setCamera({ x: cx + offset.x / cz, y: cy + offset.y / cz, z: cz }, animation)
this._flushEventsForTick(0)
return this
}
@ -4045,7 +4050,13 @@ export class Editor extends EventEmitter<TLEventMap> {
/** @internal */
@computed private _getShapeMaskCache(): ComputedCache<Vec[], TLShape> {
return this.store.createComputedCache('pageMaskCache', (shape) => {
if (isPageId(shape.parentId)) return undefined
// todo: Consider adding a flag for this hardcoded behaviour
if (
isPageId(shape.parentId) ||
shape.type === 'note' ||
this.findShapeAncestor(shape, (v) => v.type === 'note')
)
return undefined
const frameAncestors = this.getShapeAncestors(shape.id).filter((shape) =>
this.isShapeOfType<TLFrameShape>(shape, 'frame')
@ -5019,6 +5030,8 @@ export class Editor extends EventEmitter<TLEventMap> {
const shape = currentPageShapesSorted[i]
if (
// don't allow dropping on selected shapes
this.getSelectedShapeIds().includes(shape.id) ||
// only allow shapes that can receive children
!this.getShapeUtil(shape).canDropShapes(shape, droppingShapes) ||
// don't allow dropping a shape on itself or one of it's children
@ -8174,7 +8187,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const sx = info.point.x - screenBounds.x
const sy = info.point.y - screenBounds.y
const sz = info.point.z
const sz = info.point.z ?? 0.5
previousScreenPoint.setTo(currentScreenPoint)
previousPagePoint.setTo(currentPagePoint)
@ -8184,7 +8197,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// it will be 0,0 when its actual screen position is equal
// to screenBounds.point. This is confusing!
currentScreenPoint.set(sx, sy)
currentPagePoint.set(sx / cz - cx, sy / cz - cy, sz ?? 0.5)
currentPagePoint.set(sx / cz - cx, sy / cz - cy, sz)
this.inputs.isPen = info.type === 'pointer' && info.isPen
@ -8211,12 +8224,6 @@ export class Editor extends EventEmitter<TLEventMap> {
])
}
/** @internal */
private tick = (elapsed = 0) => {
this.dispatch({ type: 'misc', name: 'tick', elapsed })
this.scribbles.tick(elapsed)
}
/**
* Dispatch a cancel event.
*
@ -8348,6 +8355,9 @@ export class Editor extends EventEmitter<TLEventMap> {
/** @internal */
private _selectedShapeIdsAtPointerDown: TLShapeId[] = []
/** @internal */
private _longPressTimeout = -1 as any
/** @internal */
capturedPointerId: number | null = null
@ -8364,6 +8374,32 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
dispatch = (info: TLEventInfo): this => {
this._pendingEventsForNextTick.push(info)
if (!(info.type === 'pointer' || info.type === 'wheel' || info.type === 'pinch')) {
this._flushEventsForTick(0)
}
return this
}
private _pendingEventsForNextTick: TLEventInfo[] = []
private _flushEventsForTick = (elapsed: number) => {
this.batch(() => {
if (this._pendingEventsForNextTick.length > 0) {
const events = [...this._pendingEventsForNextTick]
this._pendingEventsForNextTick.length = 0
for (const info of events) {
this._flushEventForTick(info)
}
}
if (elapsed > 0) {
this.root.handleEvent({ type: 'misc', name: 'tick', elapsed })
}
this.scribbles.tick(elapsed)
})
}
private _flushEventForTick = (info: TLEventInfo) => {
// prevent us from spamming similar event errors if we're crashed.
// todo: replace with new readonly mode?
if (this.getCrashingError()) return this
@ -8371,161 +8407,266 @@ export class Editor extends EventEmitter<TLEventMap> {
const { inputs } = this
const { type } = info
this.batch(() => {
if (info.type === 'misc') {
// stop panning if the interaction is cancelled or completed
if (info.name === 'cancel' || info.name === 'complete') {
this.inputs.isDragging = false
if (info.type === 'misc') {
// stop panning if the interaction is cancelled or completed
if (info.name === 'cancel' || info.name === 'complete') {
this.inputs.isDragging = false
if (this.inputs.isPanning) {
this.inputs.isPanning = false
this.updateInstanceState({
cursor: {
type: this._prevCursor,
rotation: 0,
},
})
}
if (this.inputs.isPanning) {
this.inputs.isPanning = false
this.updateInstanceState({
cursor: {
type: this._prevCursor,
rotation: 0,
},
})
}
this.root.handleEvent(info)
return
}
if (info.shiftKey) {
clearInterval(this._shiftKeyTimeout)
this._shiftKeyTimeout = -1
inputs.shiftKey = true
} else if (!info.shiftKey && inputs.shiftKey && this._shiftKeyTimeout === -1) {
this._shiftKeyTimeout = setTimeout(this._setShiftKeyTimeout, 150)
}
this.root.handleEvent(info)
return
}
if (info.altKey) {
clearInterval(this._altKeyTimeout)
this._altKeyTimeout = -1
inputs.altKey = true
} else if (!info.altKey && inputs.altKey && this._altKeyTimeout === -1) {
this._altKeyTimeout = setTimeout(this._setAltKeyTimeout, 150)
}
if (info.shiftKey) {
clearInterval(this._shiftKeyTimeout)
this._shiftKeyTimeout = -1
inputs.shiftKey = true
} else if (!info.shiftKey && inputs.shiftKey && this._shiftKeyTimeout === -1) {
this._shiftKeyTimeout = setTimeout(this._setShiftKeyTimeout, 150)
}
if (info.ctrlKey) {
clearInterval(this._ctrlKeyTimeout)
this._ctrlKeyTimeout = -1
inputs.ctrlKey = true /** @internal */ /** @internal */ /** @internal */
} else if (!info.ctrlKey && inputs.ctrlKey && this._ctrlKeyTimeout === -1) {
this._ctrlKeyTimeout = setTimeout(this._setCtrlKeyTimeout, 150)
}
if (info.altKey) {
clearInterval(this._altKeyTimeout)
this._altKeyTimeout = -1
inputs.altKey = true
} else if (!info.altKey && inputs.altKey && this._altKeyTimeout === -1) {
this._altKeyTimeout = setTimeout(this._setAltKeyTimeout, 150)
}
const { originPagePoint, originScreenPoint, currentPagePoint, currentScreenPoint } = inputs
if (info.ctrlKey) {
clearInterval(this._ctrlKeyTimeout)
this._ctrlKeyTimeout = -1
inputs.ctrlKey = true /** @internal */ /** @internal */ /** @internal */
} else if (!info.ctrlKey && inputs.ctrlKey && this._ctrlKeyTimeout === -1) {
this._ctrlKeyTimeout = setTimeout(this._setCtrlKeyTimeout, 150)
}
if (!inputs.isPointing) {
inputs.isDragging = false
}
const { originPagePoint, originScreenPoint, currentPagePoint, currentScreenPoint } = inputs
switch (type) {
case 'pinch': {
if (!this.getInstanceState().canMoveCamera) return
this._updateInputsFromEvent(info)
if (!inputs.isPointing) {
inputs.isDragging = false
}
switch (info.name) {
case 'pinch_start': {
if (inputs.isPinching) return
switch (type) {
case 'pinch': {
if (!this.getInstanceState().canMoveCamera) return
clearTimeout(this._longPressTimeout)
this._updateInputsFromEvent(info)
if (!inputs.isEditing) {
this._pinchStart = this.getCamera().z
if (!this._selectedShapeIdsAtPointerDown.length) {
this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds()
}
switch (info.name) {
case 'pinch_start': {
if (inputs.isPinching) return
this._didPinch = true
inputs.isPinching = true
this.interrupt()
if (!inputs.isEditing) {
this._pinchStart = this.getCamera().z
if (!this._selectedShapeIdsAtPointerDown.length) {
this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds()
}
return // Stop here!
this._didPinch = true
inputs.isPinching = true
this.interrupt()
}
case 'pinch': {
if (!inputs.isPinching) return
const {
point: { z = 1 },
delta: { x: dx, y: dy },
} = info
return // Stop here!
}
case 'pinch': {
if (!inputs.isPinching) return
const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
const { x, y } = Vec.SubXY(info.point, screenBounds.x, screenBounds.y)
const {
point: { z = 1 },
delta: { x: dx, y: dy },
} = info
const { x: cx, y: cy, z: cz } = this.getCamera()
const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
const { x, y } = Vec.SubXY(info.point, screenBounds.x, screenBounds.y)
const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z))
const { x: cx, y: cy, z: cz } = this.getCamera()
this.setCamera({
const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z))
this.stopCameraAnimation()
if (this.getInstanceState().followingUserId) {
this.stopFollowingUser()
}
this._setCamera(
{
x: cx + dx / cz - x / cz + x / zoom,
y: cy + dy / cz - y / cz + y / zoom,
z: zoom,
},
true
)
return // Stop here!
}
case 'pinch_end': {
if (!inputs.isPinching) return this
inputs.isPinching = false
const { _selectedShapeIdsAtPointerDown } = this
this.setSelectedShapes(this._selectedShapeIdsAtPointerDown, { squashing: true })
this._selectedShapeIdsAtPointerDown = []
if (this._didPinch) {
this._didPinch = false
this.once('tick', () => {
if (!this._didPinch) {
this.setSelectedShapes(_selectedShapeIdsAtPointerDown, { squashing: true })
}
})
return // Stop here!
}
case 'pinch_end': {
if (!inputs.isPinching) return this
inputs.isPinching = false
const { _selectedShapeIdsAtPointerDown } = this
this.setSelectedShapes(this._selectedShapeIdsAtPointerDown, { squashing: true })
this._selectedShapeIdsAtPointerDown = []
if (this._didPinch) {
this._didPinch = false
requestAnimationFrame(() => {
if (!this._didPinch) {
this.setSelectedShapes(_selectedShapeIdsAtPointerDown, { squashing: true })
}
})
}
return // Stop here!
}
return // Stop here!
}
}
case 'wheel': {
if (!this.getInstanceState().canMoveCamera) return
}
case 'wheel': {
if (!this.getInstanceState().canMoveCamera) return
this._updateInputsFromEvent(info)
this._updateInputsFromEvent(info)
if (this.getIsMenuOpen()) {
// noop
} else {
if (inputs.ctrlKey) {
// todo: Start or update the zoom end interval
if (this.getIsMenuOpen()) {
// noop
} else {
this.stopCameraAnimation()
if (this.getInstanceState().followingUserId) {
this.stopFollowingUser()
}
if (inputs.ctrlKey) {
// todo: Start or update the zoom end interval
// If the alt or ctrl keys are pressed,
// zoom or pan the camera and then return.
// If the alt or ctrl keys are pressed,
// zoom or pan the camera and then return.
// Subtract the top left offset from the user's point
// Subtract the top left offset from the user's point
const { x, y } = this.inputs.currentScreenPoint
const { x, y } = this.inputs.currentScreenPoint
const { x: cx, y: cy, z: cz } = this.getCamera()
const { x: cx, y: cy, z: cz } = this.getCamera()
const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, cz + (info.delta.z ?? 0) * cz))
const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, cz + (info.delta.z ?? 0) * cz))
this.setCamera({
this._setCamera(
{
x: cx + (x / zoom - x) - (x / cz - x),
y: cy + (y / zoom - y) - (y / cz - y),
z: zoom,
})
},
true
)
// We want to return here because none of the states in our
// statechart should respond to this event (a camera zoom)
// We want to return here because none of the states in our
// statechart should respond to this event (a camera zoom)
return
}
// Update the camera here, which will dispatch a pointer move...
// this will also update the pointer position, etc
const { x: cx, y: cy, z: cz } = this.getCamera()
this._setCamera({ x: cx + info.delta.x / cz, y: cy + info.delta.y / cz, z: cz }, true)
if (
!inputs.isDragging &&
inputs.isPointing &&
originPagePoint.dist(currentPagePoint) >
(this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) /
this.getZoomLevel()
) {
clearTimeout(this._longPressTimeout)
inputs.isDragging = true
}
}
break
}
case 'pointer': {
// If we're pinching, return
if (inputs.isPinching) return
this._updateInputsFromEvent(info)
const { isPen } = info
switch (info.name) {
case 'pointer_down': {
this.clearOpenMenus()
this._longPressTimeout = setTimeout(() => {
this.dispatch({ ...info, name: 'long_press' })
}, LONG_PRESS_DURATION)
this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds()
// Firefox bug fix...
// If it's a left-mouse-click, we store the pointer id for later user
if (info.button === 0) {
this.capturedPointerId = info.pointerId
}
// Add the button from the buttons set
inputs.buttons.add(info.button)
inputs.isPointing = true
inputs.isDragging = false
if (this.getInstanceState().isPenMode) {
if (!isPen) {
return
}
} else {
if (isPen) {
this.updateInstanceState({ isPenMode: true })
}
}
if (info.button === 5) {
// Eraser button activates eraser
this._restoreToolId = this.getCurrentToolId()
this.complete()
this.setCurrentTool('eraser')
} else if (info.button === 1) {
// Middle mouse pan activates panning
if (!this.inputs.isPanning) {
this._prevCursor = this.getInstanceState().cursor.type
}
this.inputs.isPanning = true
}
if (this.inputs.isPanning) {
this.stopCameraAnimation()
this.setCursor({ type: 'grabbing', rotation: 0 })
return this
}
originScreenPoint.setTo(currentScreenPoint)
originPagePoint.setTo(currentPagePoint)
break
}
case 'pointer_move': {
// If the user is in pen mode, but the pointer is not a pen, stop here.
if (!isPen && this.getInstanceState().isPenMode) {
return
}
// Update the camera here, which will dispatch a pointer move...
// this will also update the pointer position, etc
this.pan(info.delta)
if (this.inputs.isPanning && this.inputs.isPointing) {
// Handle panning
const { currentScreenPoint, previousScreenPoint } = this.inputs
this.pan(Vec.Sub(currentScreenPoint, previousScreenPoint))
return
}
if (
!inputs.isDragging &&
@ -8534,272 +8675,174 @@ export class Editor extends EventEmitter<TLEventMap> {
(this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) /
this.getZoomLevel()
) {
clearTimeout(this._longPressTimeout)
inputs.isDragging = true
}
break
}
break
}
case 'pointer': {
// If we're pinching, return
if (inputs.isPinching) return
case 'pointer_up': {
// Remove the button from the buttons set
inputs.buttons.delete(info.button)
this._updateInputsFromEvent(info)
inputs.isPointing = false
inputs.isDragging = false
const { isPen } = info
switch (info.name) {
case 'pointer_down': {
this.clearOpenMenus()
this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds()
// Firefox bug fix...
// If it's a left-mouse-click, we store the pointer id for later user
if (info.button === 0) {
this.capturedPointerId = info.pointerId
}
// Add the button from the buttons set
inputs.buttons.add(info.button)
inputs.isPointing = true
inputs.isDragging = false
if (this.getInstanceState().isPenMode) {
if (!isPen) {
return
}
} else {
if (isPen) {
this.updateInstanceState({ isPenMode: true })
}
}
if (info.button === 5) {
// Eraser button activates eraser
this._restoreToolId = this.getCurrentToolId()
this.complete()
this.setCurrentTool('eraser')
} else if (info.button === 1) {
// Middle mouse pan activates panning
if (!this.inputs.isPanning) {
this._prevCursor = this.getInstanceState().cursor.type
}
this.inputs.isPanning = true
}
if (this.inputs.isPanning) {
this.stopCameraAnimation()
this.updateInstanceState({
cursor: {
type: 'grabbing',
rotation: 0,
},
})
return this
}
originScreenPoint.setTo(currentScreenPoint)
originPagePoint.setTo(currentPagePoint)
break
if (this.getIsMenuOpen()) {
// Suppressing pointerup here as <ContextMenu/> doesn't seem to do what we what here.
return
}
case 'pointer_move': {
// If the user is in pen mode, but the pointer is not a pen, stop here.
if (!isPen && this.getInstanceState().isPenMode) {
return
}
if (this.inputs.isPanning && this.inputs.isPointing) {
// Handle panning
const { currentScreenPoint, previousScreenPoint } = this.inputs
this.pan(Vec.Sub(currentScreenPoint, previousScreenPoint))
return
}
if (
!inputs.isDragging &&
inputs.isPointing &&
originPagePoint.dist(currentPagePoint) >
(this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) /
this.getZoomLevel()
) {
inputs.isDragging = true
}
break
if (!isPen && this.getInstanceState().isPenMode) {
return
}
case 'pointer_up': {
// Remove the button from the buttons set
inputs.buttons.delete(info.button)
inputs.isPointing = false
inputs.isDragging = false
// Firefox bug fix...
// If it's the same pointer that we stored earlier...
// ... then it's probably still a left-mouse-click!
if (this.capturedPointerId === info.pointerId) {
this.capturedPointerId = null
info.button = 0
}
if (this.getIsMenuOpen()) {
// Suppressing pointerup here as <ContextMenu/> doesn't seem to do what we what here.
return
}
if (inputs.isPanning) {
if (info.button === 1) {
if (!this.inputs.keys.has(' ')) {
inputs.isPanning = false
if (!isPen && this.getInstanceState().isPenMode) {
return
}
// Firefox bug fix...
// If it's the same pointer that we stored earlier...
// ... then it's probably still a left-mouse-click!
if (this.capturedPointerId === info.pointerId) {
this.capturedPointerId = null
info.button = 0
}
if (inputs.isPanning) {
if (info.button === 1) {
if (!this.inputs.keys.has(' ')) {
inputs.isPanning = false
this.slideCamera({
speed: Math.min(2, this.inputs.pointerVelocity.len()),
direction: this.inputs.pointerVelocity,
friction: CAMERA_SLIDE_FRICTION,
})
this.updateInstanceState({
cursor: { type: this._prevCursor, rotation: 0 },
})
} else {
this.slideCamera({
speed: Math.min(2, this.inputs.pointerVelocity.len()),
direction: this.inputs.pointerVelocity,
friction: CAMERA_SLIDE_FRICTION,
})
this.updateInstanceState({
cursor: {
type: 'grab',
rotation: 0,
},
})
}
} else if (info.button === 0) {
this.slideCamera({
speed: Math.min(2, this.inputs.pointerVelocity.len()),
direction: this.inputs.pointerVelocity,
friction: CAMERA_SLIDE_FRICTION,
})
this.updateInstanceState({
cursor: {
type: 'grab',
rotation: 0,
},
this.setCursor({ type: this._prevCursor, rotation: 0 })
} else {
this.slideCamera({
speed: Math.min(2, this.inputs.pointerVelocity.len()),
direction: this.inputs.pointerVelocity,
friction: CAMERA_SLIDE_FRICTION,
})
this.setCursor({
type: 'grab',
rotation: 0,
})
}
} else {
if (info.button === 5) {
// Eraser button activates eraser
this.complete()
this.setCurrentTool(this._restoreToolId)
}
}
break
}
}
break
}
case 'keyboard': {
// please, please
if (info.key === 'ShiftRight') info.key = 'ShiftLeft'
if (info.key === 'AltRight') info.key = 'AltLeft'
if (info.code === 'ControlRight') info.code = 'ControlLeft'
switch (info.name) {
case 'key_down': {
// Add the key from the keys set
inputs.keys.add(info.code)
// If the space key is pressed (but meta / control isn't!) activate panning
if (!info.ctrlKey && info.code === 'Space') {
if (!this.inputs.isPanning) {
this._prevCursor = this.getInstanceState().cursor.type
}
this.inputs.isPanning = true
this.updateInstanceState({
cursor: { type: this.inputs.isPointing ? 'grabbing' : 'grab', rotation: 0 },
} else if (info.button === 0) {
this.slideCamera({
speed: Math.min(2, this.inputs.pointerVelocity.len()),
direction: this.inputs.pointerVelocity,
friction: CAMERA_SLIDE_FRICTION,
})
this.setCursor({
type: 'grab',
rotation: 0,
})
}
break
} else {
if (info.button === 5) {
// Eraser button activates eraser
this.complete()
this.setCurrentTool(this._restoreToolId)
}
}
case 'key_up': {
// Remove the key from the keys set
inputs.keys.delete(info.code)
if (info.code === 'Space' && !this.inputs.buttons.has(1)) {
this.inputs.isPanning = false
this.updateInstanceState({
cursor: { type: this._prevCursor, rotation: 0 },
})
break
}
}
break
}
case 'keyboard': {
// please, please
if (info.key === 'ShiftRight') info.key = 'ShiftLeft'
if (info.key === 'AltRight') info.key = 'AltLeft'
if (info.code === 'ControlRight') info.code = 'ControlLeft'
switch (info.name) {
case 'key_down': {
// Add the key from the keys set
inputs.keys.add(info.code)
// If the space key is pressed (but meta / control isn't!) activate panning
if (!info.ctrlKey && info.code === 'Space') {
if (!this.inputs.isPanning) {
this._prevCursor = this.getInstanceState().cursor.type
}
break
}
case 'key_repeat': {
// noop
break
this.inputs.isPanning = true
this.setCursor({ type: this.inputs.isPointing ? 'grabbing' : 'grab', rotation: 0 })
}
break
}
case 'key_up': {
// Remove the key from the keys set
inputs.keys.delete(info.code)
if (info.code === 'Space' && !this.inputs.buttons.has(1)) {
this.inputs.isPanning = false
this.setCursor({ type: this._prevCursor, rotation: 0 })
}
break
}
case 'key_repeat': {
// noop
break
}
break
}
break
}
}
// Correct the info name for right / middle clicks
if (info.type === 'pointer') {
if (info.button === 1) {
info.name = 'middle_click'
} else if (info.button === 2) {
info.name = 'right_click'
}
// Correct the info name for right / middle clicks
if (info.type === 'pointer') {
if (info.button === 1) {
info.name = 'middle_click'
} else if (info.button === 2) {
info.name = 'right_click'
}
// If a pointer event, send the event to the click manager.
if (info.isPen === this.getInstanceState().isPenMode) {
switch (info.name) {
case 'pointer_down': {
const otherEvent = this._clickManager.transformPointerDownEvent(info)
if (info.name !== otherEvent.name) {
this.root.handleEvent(info)
this.emit('event', info)
this.root.handleEvent(otherEvent)
this.emit('event', otherEvent)
return
}
break
// If a pointer event, send the event to the click manager.
if (info.isPen === this.getInstanceState().isPenMode) {
switch (info.name) {
case 'pointer_down': {
const otherEvent = this._clickManager.transformPointerDownEvent(info)
if (info.name !== otherEvent.name) {
this.root.handleEvent(info)
this.emit('event', info)
this.root.handleEvent(otherEvent)
this.emit('event', otherEvent)
return
}
case 'pointer_up': {
const otherEvent = this._clickManager.transformPointerUpEvent(info)
if (info.name !== otherEvent.name) {
this.root.handleEvent(info)
this.emit('event', info)
this.root.handleEvent(otherEvent)
this.emit('event', otherEvent)
return
}
break
}
case 'pointer_move': {
this._clickManager.handleMove()
break
break
}
case 'pointer_up': {
clearTimeout(this._longPressTimeout)
const otherEvent = this._clickManager.transformPointerUpEvent(info)
if (info.name !== otherEvent.name) {
this.root.handleEvent(info)
this.emit('event', info)
this.root.handleEvent(otherEvent)
this.emit('event', otherEvent)
return
}
break
}
case 'pointer_move': {
this._clickManager.handleMove()
break
}
}
}
}
// Send the event to the statechart. It will be handled by all
// active states, starting at the root.
this.root.handleEvent(info)
this.emit('event', info)
})
// Send the event to the statechart. It will be handled by all
// active states, starting at the root.
this.root.handleEvent(info)
this.emit('event', info)
return this
}

Wyświetl plik

@ -334,16 +334,15 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
*
* ```ts
* onDragShapesOver = (shape, shapes) => {
* return { shouldHint: true }
* this.editor.reparentShapes(shapes, shape.id)
* }
* ```
*
* @param shape - The shape.
* @param shapes - The shapes that are being dragged over this one.
* @returns An object specifying whether the shape should hint that it can receive the dragged shapes.
* @public
*/
onDragShapesOver?: TLOnDragHandler<Shape, { shouldHint: boolean }>
onDragShapesOver?: TLOnDragHandler<Shape>
/**
* A callback called when some other shapes are dragged out of this one.

Wyświetl plik

@ -198,6 +198,7 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
onWheel?: TLEventHandlers['onWheel']
onPointerDown?: TLEventHandlers['onPointerDown']
onPointerMove?: TLEventHandlers['onPointerMove']
onLongPress?: TLEventHandlers['onLongPress']
onPointerUp?: TLEventHandlers['onPointerUp']
onDoubleClick?: TLEventHandlers['onDoubleClick']
onTripleClick?: TLEventHandlers['onTripleClick']

Wyświetl plik

@ -16,6 +16,7 @@ export type TLPointerEventTarget =
export type TLPointerEventName =
| 'pointer_down'
| 'pointer_move'
| 'long_press'
| 'pointer_up'
| 'right_click'
| 'middle_click'
@ -152,6 +153,7 @@ export type TLExitEventHandler = (info: any, to: string) => void
export interface TLEventHandlers {
onPointerDown: TLPointerEvent
onPointerMove: TLPointerEvent
onLongPress: TLPointerEvent
onRightClick: TLPointerEvent
onDoubleClick: TLClickEvent
onTripleClick: TLClickEvent
@ -176,6 +178,7 @@ export const EVENT_NAME_MAP: Record<
wheel: 'onWheel',
pointer_down: 'onPointerDown',
pointer_move: 'onPointerMove',
long_press: 'onLongPress',
pointer_up: 'onPointerUp',
right_click: 'onRightClick',
middle_click: 'onMiddleClick',

Wyświetl plik

@ -316,3 +316,19 @@ export function polygonsIntersect(a: VecLike[], b: VecLike[]) {
}
return false
}
/** @public */
export function polygonIntersectsPolyline(polygon: VecLike[], polyline: VecLike[]) {
let a: VecLike, b: VecLike, c: VecLike, d: VecLike
for (let i = 0, n = polygon.length; i < n; i++) {
a = polygon[i]
b = polygon[(i + 1) % n]
for (let j = 1, m = polyline.length; j < m; j++) {
c = polyline[j - 1]
d = polyline[j]
if (linesIntersect(a, b, c, d)) return true
}
}
return false
}

Wyświetl plik

@ -62,7 +62,6 @@ import { TLBookmarkShape } from '@tldraw/editor';
import { TLCancelEvent } from '@tldraw/editor';
import { TLClickEvent } from '@tldraw/editor';
import { TLClickEventInfo } from '@tldraw/editor';
import { TLDefaultColorStyle } from '@tldraw/editor';
import { TLDefaultColorTheme } from '@tldraw/editor';
import { TLDefaultFillStyle } from '@tldraw/editor';
import { TLDefaultFontStyle } from '@tldraw/editor';
@ -94,7 +93,6 @@ import { TLOnBeforeUpdateHandler } from '@tldraw/editor';
import { TLOnDoubleClickHandler } from '@tldraw/editor';
import { TLOnEditEndHandler } from '@tldraw/editor';
import { TLOnHandleDragHandler } from '@tldraw/editor';
import { TLOnResizeEndHandler } from '@tldraw/editor';
import { TLOnResizeHandler } from '@tldraw/editor';
import { TLOnTranslateHandler } from '@tldraw/editor';
import { TLOnTranslateStartHandler } from '@tldraw/editor';
@ -659,14 +657,10 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
// (undocumented)
onDragShapesOut: (_shape: TLFrameShape, shapes: TLShape[]) => void;
// (undocumented)
onDragShapesOver: (frame: TLFrameShape, shapes: TLShape[]) => {
shouldHint: boolean;
};
onDragShapesOver: (frame: TLFrameShape, shapes: TLShape[]) => void;
// (undocumented)
onResize: TLOnResizeHandler<any>;
// (undocumented)
onResizeEnd: TLOnResizeEndHandler<TLFrameShape>;
// (undocumented)
static props: {
w: Validator<number>;
h: Validator<number>;
@ -974,9 +968,15 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
// @public (undocumented)
export function isGifAnimated(file: Blob): Promise<boolean>;
// @internal (undocumented)
export function isShapeOccluded(editor: Editor, occluder: TLShape, shape: TLShapeId): boolean;
// @public (undocumented)
export function KeyboardShortcutsMenuItem(): JSX_2.Element | null;
// @internal (undocumented)
export function kickoutOccludedShapes(editor: Editor, shapeIds: TLShapeId[]): void;
// @public (undocumented)
export const LABEL_FONT_SIZES: Record<TLDefaultSizeStyle, number>;
@ -1096,21 +1096,23 @@ export class NoteShapeTool extends StateNode {
// @public (undocumented)
export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
// (undocumented)
canDropShapes: (shape: TLNoteShape, _shapes: TLShape[]) => boolean;
// (undocumented)
canEdit: () => boolean;
// (undocumented)
canReceiveNewChildrenOfType: (shape: TLNoteShape, type: string) => boolean;
// (undocumented)
component(shape: TLNoteShape): JSX_2.Element;
// (undocumented)
doesAutoEditOnKeyStroke: () => boolean;
// (undocumented)
getDefaultProps(): TLNoteShape['props'];
// (undocumented)
getGeometry(shape: TLNoteShape): Rectangle2d;
getGeometry(shape: TLNoteShape): Group2d;
// (undocumented)
getHandles(shape: TLNoteShape): TLHandle[];
// (undocumented)
getHeight(shape: TLNoteShape): number;
// (undocumented)
hideResizeHandles: () => boolean;
// (undocumented)
hideSelectionBoundsFg: () => boolean;
@ -1169,12 +1171,18 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
typeName: "shape";
} | undefined;
// (undocumented)
onDragShapesOut: (note: TLNoteShape, shapes: TLShape[]) => void;
// (undocumented)
onDragShapesOver: (note: TLNoteShape, shapes: TLShape[]) => void;
// (undocumented)
onEditEnd: TLOnEditEndHandler<TLNoteShape>;
// (undocumented)
onTranslateStart: (shape: TLNoteShape) => void;
// (undocumented)
static props: {
color: EnumStyleProp<"black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow">;
size: EnumStyleProp<"l" | "m" | "s" | "xl">;
fontSizeAdjustment: Validator<number | undefined>;
fontSizeAdjustment: Validator<number>;
font: EnumStyleProp<"draw" | "mono" | "sans" | "serif">;
align: EnumStyleProp<"end-legacy" | "end" | "middle-legacy" | "middle" | "start-legacy" | "start">;
verticalAlign: EnumStyleProp<"end" | "middle" | "start">;
@ -2398,8 +2406,8 @@ export type TLUiToolsProviderProps = {
export type TLUiTranslation = {
readonly locale: string;
readonly label: string;
readonly isRTL?: boolean;
readonly messages: Record<TLUiTranslationKey, string>;
readonly dir: 'ltr' | 'rtl';
};
// @public (undocumented)
@ -2518,13 +2526,14 @@ export function useDialogs(): TLUiDialogsContextType;
export function useEditableText(id: TLShapeId, type: string, text: string): {
rInput: React_2.RefObject<HTMLTextAreaElement>;
isEditing: boolean;
handleFocus: () => void;
handleFocus: typeof noop;
handleBlur: () => void;
handleKeyDown: (e: React_2.KeyboardEvent<HTMLTextAreaElement>) => void;
handleChange: (e: React_2.ChangeEvent<HTMLTextAreaElement>) => void;
handleInputPointerDown: (e: React_2.PointerEvent) => void;
handleDoubleClick: (e: any) => any;
isEmpty: boolean;
isEditingAnything: boolean;
};
// @public (undocumented)

Wyświetl plik

@ -7611,7 +7611,7 @@
},
{
"kind": "Content",
"text": "[]) => {\n shouldHint: boolean;\n }"
"text": "[]) => void"
},
{
"kind": "Content",
@ -7665,50 +7665,6 @@
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!FrameShapeUtil#onResizeEnd:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "onResizeEnd: "
},
{
"kind": "Reference",
"text": "TLOnResizeEndHandler",
"canonicalReference": "@tldraw/editor!TLOnResizeEndHandler:type"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TLFrameShape",
"canonicalReference": "@tldraw/tlschema!TLFrameShape:type"
},
{
"kind": "Content",
"text": ">"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "onResizeEnd",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 5
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!FrameShapeUtil.props:member",
@ -12824,6 +12780,54 @@
"name": "NoteShapeUtil",
"preserveMemberOrder": false,
"members": [
{
"kind": "Property",
"canonicalReference": "tldraw!NoteShapeUtil#canDropShapes:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "canDropShapes: "
},
{
"kind": "Content",
"text": "(shape: "
},
{
"kind": "Reference",
"text": "TLNoteShape",
"canonicalReference": "@tldraw/tlschema!TLNoteShape:type"
},
{
"kind": "Content",
"text": ", _shapes: "
},
{
"kind": "Reference",
"text": "TLShape",
"canonicalReference": "@tldraw/tlschema!TLShape:type"
},
{
"kind": "Content",
"text": "[]) => boolean"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "canDropShapes",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 6
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!NoteShapeUtil#canEdit:member",
@ -12854,6 +12858,45 @@
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!NoteShapeUtil#canReceiveNewChildrenOfType:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "canReceiveNewChildrenOfType: "
},
{
"kind": "Content",
"text": "(shape: "
},
{
"kind": "Reference",
"text": "TLNoteShape",
"canonicalReference": "@tldraw/tlschema!TLNoteShape:type"
},
{
"kind": "Content",
"text": ", type: string) => boolean"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "canReceiveNewChildrenOfType",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Method",
"canonicalReference": "tldraw!NoteShapeUtil#component:member(1)",
@ -12994,8 +13037,8 @@
},
{
"kind": "Reference",
"text": "Rectangle2d",
"canonicalReference": "@tldraw/editor!Rectangle2d:class"
"text": "Group2d",
"canonicalReference": "@tldraw/editor!Group2d:class"
},
{
"kind": "Content",
@ -13078,55 +13121,6 @@
"isAbstract": false,
"name": "getHandles"
},
{
"kind": "Method",
"canonicalReference": "tldraw!NoteShapeUtil#getHeight:member(1)",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "getHeight(shape: "
},
{
"kind": "Reference",
"text": "TLNoteShape",
"canonicalReference": "@tldraw/tlschema!TLNoteShape:type"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "shape",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "getHeight"
},
{
"kind": "Property",
"canonicalReference": "tldraw!NoteShapeUtil#hideResizeHandles:member",
@ -13435,6 +13429,102 @@
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!NoteShapeUtil#onDragShapesOut:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "onDragShapesOut: "
},
{
"kind": "Content",
"text": "(note: "
},
{
"kind": "Reference",
"text": "TLNoteShape",
"canonicalReference": "@tldraw/tlschema!TLNoteShape:type"
},
{
"kind": "Content",
"text": ", shapes: "
},
{
"kind": "Reference",
"text": "TLShape",
"canonicalReference": "@tldraw/tlschema!TLShape:type"
},
{
"kind": "Content",
"text": "[]) => void"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "onDragShapesOut",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 6
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!NoteShapeUtil#onDragShapesOver:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "onDragShapesOver: "
},
{
"kind": "Content",
"text": "(note: "
},
{
"kind": "Reference",
"text": "TLNoteShape",
"canonicalReference": "@tldraw/tlschema!TLNoteShape:type"
},
{
"kind": "Content",
"text": ", shapes: "
},
{
"kind": "Reference",
"text": "TLShape",
"canonicalReference": "@tldraw/tlschema!TLShape:type"
},
{
"kind": "Content",
"text": "[]) => void"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "onDragShapesOver",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 6
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!NoteShapeUtil#onEditEnd:member",
@ -13479,6 +13569,45 @@
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!NoteShapeUtil#onTranslateStart:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "onTranslateStart: "
},
{
"kind": "Content",
"text": "(shape: "
},
{
"kind": "Reference",
"text": "TLNoteShape",
"canonicalReference": "@tldraw/tlschema!TLNoteShape:type"
},
{
"kind": "Content",
"text": ") => void"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "onTranslateStart",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!NoteShapeUtil.props:member",
@ -13517,7 +13646,7 @@
},
{
"kind": "Content",
"text": "<number | undefined>;\n font: import(\"@tldraw/editor\")."
"text": "<number>;\n font: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
@ -26432,7 +26561,7 @@
},
{
"kind": "Content",
"text": "{\n readonly locale: string;\n readonly label: string;\n readonly isRTL?: boolean;\n readonly messages: "
"text": "{\n readonly locale: string;\n readonly label: string;\n readonly messages: "
},
{
"kind": "Reference",
@ -26450,7 +26579,7 @@
},
{
"kind": "Content",
"text": ", string>;\n}"
"text": ", string>;\n readonly dir: 'ltr' | 'rtl';\n}"
},
{
"kind": "Content",
@ -27649,7 +27778,16 @@
},
{
"kind": "Content",
"text": ">;\n isEditing: boolean;\n handleFocus: () => void;\n handleBlur: () => void;\n handleKeyDown: (e: "
"text": ">;\n isEditing: boolean;\n handleFocus: typeof "
},
{
"kind": "Reference",
"text": "noop",
"canonicalReference": "tldraw!~noop:function"
},
{
"kind": "Content",
"text": ";\n handleBlur: () => void;\n handleKeyDown: (e: "
},
{
"kind": "Reference",
@ -27694,7 +27832,7 @@
},
{
"kind": "Content",
"text": ") => void;\n handleDoubleClick: (e: any) => any;\n isEmpty: boolean;\n}"
"text": ") => void;\n handleDoubleClick: (e: any) => any;\n isEmpty: boolean;\n isEditingAnything: boolean;\n}"
},
{
"kind": "Content",
@ -27704,7 +27842,7 @@
"fileUrlPath": "packages/tldraw/src/lib/shapes/shared/useEditableText.ts",
"returnTypeTokenRange": {
"startIndex": 7,
"endIndex": 22
"endIndex": 24
},
"releaseTag": "Public",
"overloadIndex": 1,

Wyświetl plik

@ -41,6 +41,7 @@ export { EraserTool } from './lib/tools/EraserTool/EraserTool'
export { HandTool } from './lib/tools/HandTool/HandTool'
export { LaserTool } from './lib/tools/LaserTool/LaserTool'
export { SelectTool } from './lib/tools/SelectTool/SelectTool'
export { isShapeOccluded, kickoutOccludedShapes } from './lib/tools/SelectTool/selectHelpers'
export { ZoomTool } from './lib/tools/ZoomTool/ZoomTool'
// UI
export { useEditableText } from './lib/shapes/shared/useEditableText'

Wyświetl plik

@ -306,15 +306,20 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
// If no bound shapes are in the selection, unbind any bound shapes
const selectedShapeIds = this.editor.getSelectedShapeIds()
if (
(startBindingId &&
(selectedShapeIds.includes(startBindingId) ||
this.editor.isAncestorSelected(startBindingId))) ||
(endBindingId &&
(selectedShapeIds.includes(endBindingId) || this.editor.isAncestorSelected(endBindingId)))
) {
return
const shapesToCheck = new Set<string>()
if (startBindingId) {
// Add shape and all ancestors to set
shapesToCheck.add(startBindingId)
this.editor.getShapeAncestors(startBindingId).forEach((a) => shapesToCheck.add(a.id))
}
if (endBindingId) {
// Add shape and all ancestors to set
shapesToCheck.add(endBindingId)
this.editor.getShapeAncestors(endBindingId).forEach((a) => shapesToCheck.add(a.id))
}
// If any of the shapes are selected, return
for (const id of selectedShapeIds) {
if (shapesToCheck.has(id)) return
}
let result = shape
@ -530,6 +535,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
if (!info?.isValid) return null
const labelPosition = getArrowLabelPosition(this.editor, shape)
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
const isEditing = this.editor.getEditingShapeId() === shape.id
const showArrowLabel = isEditing || shape.props.text
@ -549,6 +555,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
size={shape.props.size}
position={labelPosition.box.center}
width={labelPosition.box.w}
isSelected={isSelected}
labelColor={shape.props.labelColor}
/>
)}

Wyświetl plik

@ -1,5 +1,6 @@
import { TLArrowShape, TLDefaultColorStyle, TLShapeId, VecLike } from '@tldraw/editor'
import * as React from 'react'
import { useDefaultColorTheme } from '../../shared/ShapeFill'
import { TextLabel } from '../../shared/TextLabel'
import { ARROW_LABEL_FONT_SIZES, TEXT_PROPS } from '../../shared/default-shape-constants'
@ -10,11 +11,16 @@ export const ArrowTextLabel = React.memo(function ArrowTextLabel({
font,
position,
width,
isSelected,
labelColor,
}: { id: TLShapeId; position: VecLike; width?: number; labelColor: TLDefaultColorStyle } & Pick<
TLArrowShape['props'],
'text' | 'size' | 'font'
>) {
}: {
id: TLShapeId
position: VecLike
width?: number
labelColor: TLDefaultColorStyle
isSelected: boolean
} & Pick<TLArrowShape['props'], 'text' | 'size' | 'font'>) {
const theme = useDefaultColorTheme()
return (
<TextLabel
id={id}
@ -26,8 +32,9 @@ export const ArrowTextLabel = React.memo(function ArrowTextLabel({
align="middle"
verticalAlign="middle"
text={text}
labelColor={labelColor}
labelColor={theme[labelColor].solid}
textWidth={width}
isSelected={isSelected}
style={{
transform: `translate(${position.x}px, ${position.y}px)`,
}}

Wyświetl plik

@ -62,9 +62,7 @@ export class Drawing extends StateNode {
}
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
const {
editor: { inputs },
} = this
const { inputs } = this.editor
if (this.isPen !== inputs.isPen) {
// The user made a palm gesture before starting a pen gesture;
@ -282,8 +280,8 @@ export class Drawing extends StateNode {
}
private updateShapes() {
const { inputs } = this.editor
const { initialShape } = this
const { inputs } = this.editor
if (!initialShape) return
@ -440,7 +438,7 @@ export class Drawing extends StateNode {
const newSegment = newSegments[newSegments.length - 1]
const { pagePointWhereCurrentSegmentChanged } = this
const { currentPagePoint, ctrlKey } = this.editor.inputs
const { ctrlKey, currentPagePoint } = this.editor.inputs
if (!pagePointWhereCurrentSegmentChanged)
throw Error('We should have a point where the segment changed')
@ -623,16 +621,14 @@ export class Drawing extends StateNode {
if (newPoints.length > 500) {
this.editor.updateShapes([{ id, type: this.shapeType, props: { isComplete: true } }])
const { currentPagePoint } = this.editor.inputs
const newShapeId = createShapeId()
this.editor.createShapes<DrawableShape>([
{
id: newShapeId,
type: this.shapeType,
x: toFixed(currentPagePoint.x),
y: toFixed(currentPagePoint.y),
x: toFixed(inputs.currentPagePoint.x),
y: toFixed(inputs.currentPagePoint.y),
props: {
isPen: this.isPen,
segments: [
@ -647,7 +643,7 @@ export class Drawing extends StateNode {
this.initialShape = structuredClone(this.editor.getShape<DrawableShape>(newShapeId)!)
this.mergeNextPoint = false
this.lastRecordedPoint = this.editor.inputs.currentPagePoint.clone()
this.lastRecordedPoint = inputs.currentPagePoint.clone()
this.currentLineLength = 0
}

Wyświetl plik

@ -6,10 +6,8 @@ import {
SvgExportContext,
TLFrameShape,
TLGroupShape,
TLOnResizeEndHandler,
TLOnResizeHandler,
TLShape,
TLShapeId,
canonicalizeRotation,
frameShapeMigrations,
frameShapeProps,
@ -68,7 +66,7 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
const info = (resizingState as typeof resizingState & { info: { isCreating: boolean } })
?.info
if (!info) return false
return info.isCreating && this.editor.getOnlySelectedShape()?.id === shape.id
return info.isCreating && this.editor.getOnlySelectedShapeId() === shape.id
},
[shape.id]
)
@ -110,18 +108,18 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
let labelTranslate: string
switch (labelSide) {
case 0:
case 0: // top
labelTranslate = ``
break
case 3:
case 3: // right
labelTranslate = `translate(${toDomPrecision(shape.props.w)}, 0) rotate(90)`
break
case 2:
case 2: // bottom
labelTranslate = `translate(${toDomPrecision(shape.props.w)}, ${toDomPrecision(
shape.props.h
)}) rotate(180)`
break
case 1:
case 1: // left
labelTranslate = `translate(0, ${toDomPrecision(shape.props.h)}) rotate(270)`
break
default:
@ -207,15 +205,10 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
return !shape.isLocked
}
override onDragShapesOver = (frame: TLFrameShape, shapes: TLShape[]): { shouldHint: boolean } => {
override onDragShapesOver = (frame: TLFrameShape, shapes: TLShape[]) => {
if (!shapes.every((child) => child.parentId === frame.id)) {
this.editor.reparentShapes(
shapes.map((shape) => shape.id),
frame.id
)
return { shouldHint: true }
this.editor.reparentShapes(shapes, frame.id)
}
return { shouldHint: false }
}
override onDragShapesOut = (_shape: TLFrameShape, shapes: TLShape[]): void => {
@ -232,24 +225,6 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
}
}
override onResizeEnd: TLOnResizeEndHandler<TLFrameShape> = (shape) => {
const bounds = this.editor.getShapePageBounds(shape)!
const children = this.editor.getSortedChildIdsForParent(shape.id)
const shapesToReparent: TLShapeId[] = []
for (const childId of children) {
const childBounds = this.editor.getShapePageBounds(childId)!
if (!bounds.includes(childBounds)) {
shapesToReparent.push(childId)
}
}
if (shapesToReparent.length > 0) {
this.editor.reparentShapes(shapesToReparent, this.editor.getCurrentPageId())
}
}
override onResize: TLOnResizeHandler<any> = (shape, info) => {
return resizeBox(shape, info)
}

Wyświetl plik

@ -1,3 +1,4 @@
/* eslint-disable react-hooks/rules-of-hooks */
import {
BaseBoxShapeUtil,
Editor,
@ -26,6 +27,7 @@ import {
} from '@tldraw/editor'
import { HyperlinkButton } from '../shared/HyperlinkButton'
import { useDefaultColorTheme } from '../shared/ShapeFill'
import { SvgTextLabel } from '../shared/SvgTextLabel'
import { TextLabel } from '../shared/TextLabel'
import {
@ -381,10 +383,11 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
component(shape: TLGeoShape) {
const { id, type, props } = shape
const { labelColor, fill, font, align, verticalAlign, size, text } = props
const { fill, font, align, verticalAlign, size, text } = props
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
const theme = useDefaultColorTheme()
const isEditing = this.editor.getEditingShapeId() === id
const showHtmlContainer = isEditing || shape.props.url || shape.props.text
const showHtmlContainer = isEditing || shape.props.text
return (
<>
@ -410,15 +413,15 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
align={align}
verticalAlign={verticalAlign}
text={text}
labelColor={labelColor}
isSelected={isSelected}
labelColor={theme[props.labelColor].solid}
wrap
bounds={props.geo === 'cloud' ? this.getGeometry(shape).bounds : undefined}
/>
{shape.props.url && (
<HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} />
)}
</HTMLContainer>
)}
{shape.props.url && (
<HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} />
)}
</>
)
}

Wyświetl plik

@ -50,7 +50,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
const asset = shape.props.assetId ? this.editor.getAsset(shape.props.assetId) : undefined
const isSelected = shape.id === this.editor.getOnlySelectedShape()?.id
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
useEffect(() => {
if (asset?.props.src && 'mimeType' in asset.props && asset?.props.mimeType === 'image/gif') {

Wyświetl plik

@ -204,17 +204,17 @@ describe('Grid placement helpers', () => {
})
})
it('Does note create a new sticky note in a sticky pit if a note is already there', () => {
it('Does not create a new sticky note in a sticky pit if a note is already there', () => {
editor
.createShape({ type: 'note', x: 0, y: 0 })
.createShape({ type: 'note', x: 228, y: 8 }) // make a shape kinda there already!
.createShape({ type: 'note', x: 330, y: 8 }) // make a shape kinda there already!
.setCurrentTool('note')
.pointerMove(324, 104)
.pointerMove(300, 104)
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
// outta da pit
x: 224,
x: 200,
y: 4,
})
})

Wyświetl plik

@ -1,14 +1,18 @@
import {
Editor,
Group2d,
IndexKey,
Rectangle2d,
ShapeUtil,
SvgExportContext,
TLGroupShape,
TLHandle,
TLNoteShape,
TLOnEditEndHandler,
TLShape,
TLShapeId,
Vec,
WeakMapCache,
getDefaultColorTheme,
noteShapeMigrations,
noteShapeProps,
@ -24,16 +28,22 @@ import { HyperlinkButton } from '../shared/HyperlinkButton'
import { useDefaultColorTheme } from '../shared/ShapeFill'
import { SvgTextLabel } from '../shared/SvgTextLabel'
import { TextLabel } from '../shared/TextLabel'
import { FONT_FAMILIES, LABEL_FONT_SIZES, TEXT_PROPS } from '../shared/default-shape-constants'
import {
FONT_FAMILIES,
LABEL_FONT_SIZES,
LABEL_PADDING,
TEXT_PROPS,
} from '../shared/default-shape-constants'
import { getFontDefForExport } from '../shared/defaultStyleDefs'
import { startEditingShapeWithLabel } from '../shared/TextHelpers'
import { useForceSolid } from '../shared/useForceSolid'
import {
ADJACENT_NOTE_MARGIN,
CENTER_OFFSET,
CLONE_HANDLE_MARGIN,
NOTE_CENTER_OFFSET,
NOTE_SIZE,
getNoteShapeForAdjacentPosition,
startEditingNoteShape,
} from './noteHelpers'
/** @public */
@ -47,6 +57,36 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
override hideResizeHandles = () => true
override hideSelectionBoundsFg = () => false
override canReceiveNewChildrenOfType = (shape: TLNoteShape, type: string) => {
return !shape.isLocked && type !== 'frame'
}
override canDropShapes = (shape: TLNoteShape, _shapes: TLShape[]): boolean => {
return !shape.isLocked
}
override onDragShapesOver = (note: TLNoteShape, shapes: TLShape[]) => {
if (!shapes.every((child) => child.parentId === note.id)) {
const shapesWithoutFrames = shapes.filter(
(shape) => !this.editor.isShapeOfType(shape, 'frame')
)
this.editor.reparentShapes(shapesWithoutFrames, note.id)
}
}
override onDragShapesOut = (note: TLNoteShape, shapes: TLShape[]) => {
const parent = this.editor.getShape(note.parentId)
const isInGroup = parent && this.editor.isShapeOfType<TLGroupShape>(parent, 'group')
// If sticky is in a group, keep the shape in that group
if (isInGroup) {
this.editor.reparentShapes(shapes, parent.id)
} else {
this.editor.reparentShapes(shapes, this.editor.getCurrentPageId())
}
}
getDefaultProps(): TLNoteShape['props'] {
return {
color: 'black',
@ -61,18 +101,39 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
}
}
getHeight(shape: TLNoteShape) {
return NOTE_SIZE + shape.props.growY
}
getGeometry(shape: TLNoteShape) {
const height = this.getHeight(shape)
return new Rectangle2d({ width: NOTE_SIZE, height, isFilled: true, isLabel: true })
const noteHeight = getNoteHeight(shape)
const { labelHeight, labelWidth } = getLabelSize(this.editor, shape)
return new Group2d({
children: [
new Rectangle2d({ width: NOTE_SIZE, height: noteHeight, isFilled: true }),
new Rectangle2d({
x:
shape.props.align === 'start'
? 0
: shape.props.align === 'end'
? NOTE_SIZE - labelWidth
: (NOTE_SIZE - labelWidth) / 2,
y:
shape.props.verticalAlign === 'start'
? 0
: shape.props.verticalAlign === 'end'
? noteHeight - labelHeight
: (noteHeight - labelHeight) / 2,
width: labelWidth,
height: labelHeight,
isFilled: true,
isLabel: true,
}),
],
})
}
override getHandles(shape: TLNoteShape): TLHandle[] {
const zoom = this.editor.getZoomLevel()
const offset = CLONE_HANDLE_MARGIN / zoom
const noteHeight = getNoteHeight(shape)
if (zoom < 0.25) return []
@ -89,21 +150,21 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
index: 'a2' as IndexKey,
type: 'clone',
x: NOTE_SIZE + offset,
y: this.getHeight(shape) / 2,
y: noteHeight / 2,
},
{
id: 'bottom',
index: 'a3' as IndexKey,
type: 'clone',
x: NOTE_SIZE / 2,
y: this.getHeight(shape) + offset,
y: noteHeight + offset,
},
{
id: 'left',
index: 'a4' as IndexKey,
type: 'clone',
x: -offset,
y: this.getHeight(shape) / 2,
y: noteHeight / 2,
},
]
}
@ -120,59 +181,67 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
// eslint-disable-next-line react-hooks/rules-of-hooks
const theme = useDefaultColorTheme()
const noteHeight = this.getHeight(shape)
const noteHeight = getNoteHeight(shape)
// eslint-disable-next-line react-hooks/rules-of-hooks
const rotation = useValue('shape rotation', () => this.editor.getShape(id)?.rotation ?? 0, [
this.editor,
])
const rotation = useValue(
'shape rotation',
() => this.editor.getShapePageTransform(id)?.rotation() ?? 0,
[this.editor]
)
// todo: consider hiding shadows on dark mode if they're invisible anyway
// eslint-disable-next-line react-hooks/rules-of-hooks
const hideShadows = useForceSolid()
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
// Shadow stuff
const oy = Math.cos(rotation)
const ox = Math.sin(rotation)
const random = rng(id)
const lift = 1 + random() * 0.5
const zoom = this.editor.getZoomLevel()
return (
<div
id={id}
className="tl-note__container"
style={{
width: NOTE_SIZE,
height: noteHeight,
color: theme[color].note.text,
backgroundColor: theme[color].note.fill,
borderBottom: hideShadows ? `3px solid rgb(144, 144, 144)` : 'none',
opacity: hideShadows ? 1 : 0.99,
boxShadow: hideShadows
? 'none'
: `${ox * 3}px ${4 - lift}px 4px -4px rgba(0,0,0,.8),
${ox * 6}px ${(6 + lift * 8) * oy}px ${6 + lift * 8}px -${6 + lift * 6}px rgba(0,0,0,${0.3 + lift * 0.1}),
0px 50px 8px -10px inset rgba(0,0,0,${0.0375 + 0.025 * random()})`,
}}
>
<TextLabel
<>
<div
id={id}
type={type}
font={font}
fontSize={fontSizeAdjustment || LABEL_FONT_SIZES[size]}
lineHeight={TEXT_PROPS.lineHeight}
align={align}
verticalAlign={verticalAlign}
text={text}
isNote
labelColor={color}
wrap
onKeyDown={handleKeyDown}
/>
className="tl-note__container"
style={{
width: NOTE_SIZE,
height: noteHeight,
backgroundColor: theme[color].note.fill,
borderBottom: hideShadows ? `3px solid rgb(15, 23, 31, .2)` : 'none',
opacity: hideShadows ? 1 : 0.99,
boxShadow: hideShadows
? 'none'
: `${ox * 3}px ${4 - lift}px 5px -5px rgba(15, 23, 31,1),
${ox * 6}px ${(4 + lift * 7) * Math.max(0, oy)}px ${6 + lift * 8}px -${4 + lift * 6}px rgba(15, 23, 31,${0.3 + lift * 0.1}),
0px 48px 10px -10px inset rgba(15, 23, 31,${0.02 + random() * 0.005})`,
}}
>
<TextLabel
id={id}
type={type}
font={font}
fontSize={fontSizeAdjustment || LABEL_FONT_SIZES[size]}
lineHeight={TEXT_PROPS.lineHeight}
align={align}
verticalAlign={verticalAlign}
text={text}
isNote
isSelected={isSelected}
labelColor={theme[color].note.text}
wrap
onKeyDown={handleKeyDown}
/>
</div>
{'url' in shape.props && shape.props.url && (
<HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} />
<HyperlinkButton url={shape.props.url} zoomLevel={zoom} />
)}
</div>
</>
)
}
@ -181,7 +250,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
<rect
rx="1"
width={toDomPrecision(NOTE_SIZE)}
height={toDomPrecision(this.getHeight(shape))}
height={toDomPrecision(getNoteHeight(shape))}
/>
)
}
@ -219,7 +288,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
}
override onBeforeCreate = (next: TLNoteShape) => {
return getGrowY(this.editor, next, next.props.growY)
return getSizeAdjustments(this.editor, next)
}
override onBeforeUpdate = (prev: TLNoteShape, next: TLNoteShape) => {
@ -231,7 +300,11 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
return
}
return getGrowY(this.editor, next, prev.props.growY)
return getSizeAdjustments(this.editor, next)
}
override onTranslateStart = (shape: TLNoteShape) => {
this.editor.bringToFront([shape])
}
override onEditEnd: TLOnEditEndHandler<TLNoteShape> = (shape) => {
@ -255,62 +328,87 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
}
}
function getGrowY(editor: Editor, shape: TLNoteShape, prevGrowY = 0) {
const BORDER = 1
const PADDING = 16 + BORDER
/**
* Get the growY and fontSizeAdjustment for a shape.
*/
function getSizeAdjustments(editor: Editor, shape: TLNoteShape) {
const { labelHeight, fontSizeAdjustment } = getLabelSize(editor, shape)
// When the label height is more than the height of the shape, we add extra height to it
const growY = Math.max(0, labelHeight - NOTE_SIZE)
if (growY !== shape.props.growY || fontSizeAdjustment !== shape.props.fontSizeAdjustment) {
return {
...shape,
props: {
...shape.props,
growY,
fontSizeAdjustment,
},
}
}
}
/**
* Get the label size for a note.
*/
function _getLabelSize(editor: Editor, shape: TLNoteShape) {
const text = shape.props.text
if (!text) {
return { labelHeight: 0, labelWidth: 0, fontSizeAdjustment: 0 }
}
const unadjustedFontSize = LABEL_FONT_SIZES[shape.props.size]
let fontSizeAdjustment = 0
let iterations = 0
let nextHeight = NOTE_SIZE
let labelHeight = NOTE_SIZE
let labelWidth = NOTE_SIZE
// We slightly make the font smaller if the text is too big for the note, width-wise.
do {
fontSizeAdjustment = Math.min(unadjustedFontSize, unadjustedFontSize - iterations)
const nextTextSize = editor.textMeasure.measureText(shape.props.text, {
const nextTextSize = editor.textMeasure.measureText(text, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: fontSizeAdjustment,
maxWidth: NOTE_SIZE - PADDING * 2,
maxWidth: NOTE_SIZE - LABEL_PADDING * 2,
disableOverflowWrapBreaking: true,
})
nextHeight = nextTextSize.h + PADDING * 2
labelHeight = nextTextSize.h + LABEL_PADDING * 2
labelWidth = nextTextSize.w + LABEL_PADDING * 2
if (fontSizeAdjustment <= 14) {
// Too small, just rely now on CSS `overflow-wrap: break-word`
// We need to recalculate the text measurement here with break-word enabled.
const nextTextSizeWithOverflowBreak = editor.textMeasure.measureText(text, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: fontSizeAdjustment,
maxWidth: NOTE_SIZE - LABEL_PADDING * 2,
})
labelHeight = nextTextSizeWithOverflowBreak.h + LABEL_PADDING * 2
labelWidth = nextTextSizeWithOverflowBreak.w + LABEL_PADDING * 2
break
}
if (nextTextSize.scrollWidth.toFixed(0) === nextTextSize.w.toFixed(0)) {
break
}
} while (iterations++ < 50)
let growY: number | null = null
if (nextHeight > NOTE_SIZE) {
growY = nextHeight - NOTE_SIZE
} else {
if (prevGrowY) {
growY = 0
}
return {
labelHeight,
labelWidth,
fontSizeAdjustment,
}
}
if (
growY !== null ||
(shape.props.fontSizeAdjustment === 0
? fontSizeAdjustment !== unadjustedFontSize
: fontSizeAdjustment !== shape.props.fontSizeAdjustment)
) {
return {
...shape,
props: {
...shape.props,
growY: growY ?? 0,
fontSizeAdjustment,
},
}
}
const labelSizesForNote = new WeakMapCache<TLShape, ReturnType<typeof _getLabelSize>>()
function getLabelSize(editor: Editor, shape: TLNoteShape) {
return labelSizesForNote.get(shape, () => _getLabelSize(editor, shape))
}
function useNoteKeydownHandler(id: TLShapeId) {
@ -333,7 +431,7 @@ function useNoteKeydownHandler(id: TLShapeId) {
// Based on the inputs, calculate the offset to the next note
// tab controls x axis (shift inverts direction set by RTL)
// cmd enter is the y axis (shift inverts direction)
const isRTL = !!(translation.isRTL || isRightToLeftLanguage(shape.props.text))
const isRTL = !!(translation.dir === 'rtl' || isRightToLeftLanguage(shape.props.text))
const offsetLength =
NOTE_SIZE +
@ -346,17 +444,21 @@ function useNoteKeydownHandler(id: TLShapeId) {
isCmdEnter ? (e.shiftKey ? -1 : 1) : 0
)
.mul(offsetLength)
.add(CENTER_OFFSET)
.add(NOTE_CENTER_OFFSET)
.rot(pageRotation)
.add(pageTransform.point())
const newNote = getNoteShapeForAdjacentPosition(editor, shape, adjacentCenter, pageRotation)
if (newNote) {
startEditingNoteShape(editor, newNote)
startEditingShapeWithLabel(editor, newNote, true /* selectAll */)
}
}
},
[id, editor, translation.isRTL]
[id, editor, translation.dir]
)
}
function getNoteHeight(shape: TLNoteShape) {
return NOTE_SIZE + shape.props.growY
}

Wyświetl plik

@ -1,11 +1,14 @@
import { Vec } from '@tldraw/editor'
import { Box, Vec } from '@tldraw/editor'
import { TestEditor } from '../../../test/TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
// We don't want the camera to move when the shape gets created off screen
editor.updateViewportScreenBounds(new Box(0, 0, 2000, 2000))
})
afterEach(() => {
editor?.dispose()
})
@ -24,8 +27,14 @@ function testCloneHandles(x: number, y: number, rotation: number) {
)
handles.forEach((handle, i) => {
const handleInPageSpace = editor.getShapePageTransform(shape).applyToPoint(handle)
editor.select(shape.id)
editor.pointerDown(handle.x, handle.y, {
editor.pointerMove(handleInPageSpace.x, handleInPageSpace.y)
expect(editor.inputs.currentPagePoint).toMatchObject({
x: handleInPageSpace.x,
y: handleInPageSpace.y,
})
editor.pointerDown(handleInPageSpace.x, handleInPageSpace.y, {
target: 'handle',
shape,
handle,
@ -50,25 +59,21 @@ function testCloneHandles(x: number, y: number, rotation: number) {
editor.expectToBeIn('select.editing_shape')
editor.cancel().undo()
editor.cancel().undo().forceTick()
})
}
describe('Note clone handles', () => {
it('Creates a new sticky note using handles', () => {
testCloneHandles(0, 0, 0)
})
it('Creates a new sticky note when translated', () => {
testCloneHandles(100, 100, 0)
testCloneHandles(1000, 1000, 0)
})
it('Creates a new sticky when rotated', () => {
testCloneHandles(0, 0, Math.PI / 2)
testCloneHandles(1000, 1000, Math.PI / 2)
})
it('Creates a new sticky when translated and rotated', () => {
testCloneHandles(100, 100, Math.PI / 2)
testCloneHandles(1000, 1000, Math.PI / 2)
})
})
@ -82,9 +87,10 @@ function testDragCloneHandles(x: number, y: number, rotation: number) {
const handles = editor.getShapeHandles(shape.id)!
handles.forEach((handle) => {
const handleInPageSpace = editor.getShapePageTransform(shape).applyToPoint(handle)
editor.select(shape.id)
editor.pointerMove(handle.x, handle.y)
editor.pointerDown(handle.x, handle.y, {
editor.pointerMove(handleInPageSpace.x, handleInPageSpace.y)
editor.pointerDown(handleInPageSpace.x, handleInPageSpace.y, {
target: 'handle',
shape,
handle,
@ -92,7 +98,7 @@ function testDragCloneHandles(x: number, y: number, rotation: number) {
editor.expectToBeIn('select.pointing_handle')
editor.pointerMove(handle.x + 30, handle.y + 30)
editor.pointerMove(handleInPageSpace.x + 30, handleInPageSpace.y + 30)
editor.expectToBeIn('select.translating')
@ -105,8 +111,8 @@ function testDragCloneHandles(x: number, y: number, rotation: number) {
editor.expectShapeToMatch({
id: newShape.id,
type: 'note',
x: handle.x + 30 - offset.x,
y: handle.y + 30 - offset.y,
x: handleInPageSpace.x + 30 - offset.x,
y: handleInPageSpace.y + 30 - offset.y,
})
editor.pointerUp()
@ -119,27 +125,23 @@ function testDragCloneHandles(x: number, y: number, rotation: number) {
describe('Dragging clone handles', () => {
it('Creates a new sticky note using handles', () => {
testDragCloneHandles(0, 0, 0)
})
it('Creates a new sticky note when translated', () => {
testDragCloneHandles(100, 100, 0)
testDragCloneHandles(1000, 1000, 0)
})
it('Creates a new sticky when rotated', () => {
testDragCloneHandles(0, 0, Math.PI / 2)
testDragCloneHandles(1000, 1000, Math.PI / 2)
})
it('Creates a new sticky when translated and rotated', () => {
testDragCloneHandles(100, 100, Math.PI / 2)
testDragCloneHandles(1000, 1000, Math.PI / 2)
})
})
it('Selects an adjacent note when clicking the clone handle', () => {
editor.createShape({ type: 'note', x: 220, y: 0 })
editor.createShape({ type: 'note', x: 1220, y: 1000 })
const shapeA = editor.getLastCreatedShape()!
editor.createShape({ type: 'note', x: 0, y: 0 })
editor.createShape({ type: 'note', x: 1000, y: 1000 })
const shapeB = editor.getLastCreatedShape()!
editor.select(shapeB.id)
@ -171,17 +173,17 @@ it('Selects an adjacent note when clicking the clone handle', () => {
})
it('Creates an adjacent note when dragging the clone handle', () => {
editor.createShape({ type: 'note', x: 220, y: 0 })
editor.createShape({ type: 'note', x: 1220, y: 1000 })
const shapeA = editor.getLastCreatedShape()!
editor.createShape({ type: 'note', x: 0, y: 0 })
editor.createShape({ type: 'note', x: 1000, y: 1000 })
const shapeB = editor.getLastCreatedShape()!
editor.select(shapeB.id)
const handles = editor.getShapeHandles(shapeB.id)!
const handle = handles[1]
const handle = handles[0]
editor.select(shapeB.id)
editor.pointerDown(handle.x, handle.y, {
@ -214,10 +216,10 @@ it('Creates an adjacent note when dragging the clone handle', () => {
})
it('Does not put the new shape into a frame if its center is not in the frame', () => {
editor.createShape({ type: 'frame', x: 321, y: 100 }) // one pixel too far...
editor.createShape({ type: 'frame', x: 1321, y: 1000 }) // one pixel too far...
const frameA = editor.getLastCreatedShape()!
// center no longer in the frame
editor.createShape({ type: 'note', x: 0, y: 0 })
editor.createShape({ type: 'note', x: 1000, y: 1000 })
const shapeA = editor.getLastCreatedShape()!
// to the right
const handle = editor.getShapeHandles(shapeA.id)![1]
@ -237,10 +239,10 @@ it('Does not put the new shape into a frame if its center is not in the frame',
})
it('Puts the new shape into a frame based on its center', () => {
editor.createShape({ type: 'frame', x: 320, y: 100 })
editor.createShape({ type: 'frame', x: 1320, y: 1100 })
const frameA = editor.getLastCreatedShape()!
// top left won't be in the frame, but the center will (barely but yes)
editor.createShape({ type: 'note', x: 0, y: 0 })
editor.createShape({ type: 'note', x: 1000, y: 1000 })
const shapeA = editor.getLastCreatedShape()!
// to the right
const handle = editor.getShapeHandles(shapeA.id)![1]
@ -260,10 +262,10 @@ it('Puts the new shape into a frame based on its center', () => {
})
function testNoteShapeFrameRotations(sourceRotation: number, rotation: number) {
editor.createShape({ type: 'frame', x: 220, y: 0, rotation: rotation })
editor.createShape({ type: 'frame', x: 1220, y: 1000, rotation: rotation })
const frameA = editor.getLastCreatedShape()!
// top left won't be in the frame, but the center will (barely but yes)
editor.createShape({ type: 'note', x: 0, y: 0, rotation: sourceRotation })
editor.createShape({ type: 'note', x: 1000, y: 1000, rotation: sourceRotation })
const shapeA = editor.getLastCreatedShape()!
// to the right
const handle = editor.getShapeHandles(shapeA.id)![1]

Wyświetl plik

@ -1,12 +1,5 @@
import {
ANIMATION_MEDIUM_MS,
Editor,
TLNoteShape,
TLShape,
Vec,
compact,
createShapeId,
} from '@tldraw/editor'
import { Editor, TLNoteShape, TLShape, Vec, compact, createShapeId } from '@tldraw/editor'
import { zoomToShapeIfOffscreen } from '../shared/TextHelpers'
/** @internal */
export const ADJACENT_NOTE_MARGIN = 20
@ -15,13 +8,11 @@ export const CLONE_HANDLE_MARGIN = 0
/** @internal */
export const NOTE_SIZE = 200
/** @internal */
export const CENTER_OFFSET = { x: NOTE_SIZE / 2, y: NOTE_SIZE / 2 }
export const NOTE_CENTER_OFFSET = { x: NOTE_SIZE / 2, y: NOTE_SIZE / 2 }
/** @internal */
export const NOTE_PIT_RADIUS = 10
/** @internal */
export type NotePit = Vec
const DEFAULT_PITS: NotePit[] = [
const DEFAULT_PITS = [
new Vec(NOTE_SIZE * 0.5, NOTE_SIZE * -0.5 - ADJACENT_NOTE_MARGIN), // t
new Vec(NOTE_SIZE * 1.5 + ADJACENT_NOTE_MARGIN, NOTE_SIZE * 0.5), // r
new Vec(NOTE_SIZE * 0.5, NOTE_SIZE * 1.5 + ADJACENT_NOTE_MARGIN), // b
@ -159,7 +150,7 @@ export function getNoteShapeForAdjacentPosition(
// space as the newly created shape (i.e its parent's space)
const topLeft = editor.getPointInParentSpace(
createdShape,
Vec.Sub(center, Vec.Rot(CENTER_OFFSET, pageRotation))
Vec.Sub(center, Vec.Rot(NOTE_CENTER_OFFSET, pageRotation))
)
editor.updateShape({
@ -172,36 +163,6 @@ export function getNoteShapeForAdjacentPosition(
nextNote = editor.getShape(id)!
}
// Animate to the next sticky if it would be off screen
const selectionPageBounds = editor.getSelectionPageBounds()
const viewportPageBounds = editor.getViewportPageBounds()
if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) {
editor.centerOnPoint(selectionPageBounds.center, {
duration: ANIMATION_MEDIUM_MS,
})
}
zoomToShapeIfOffscreen(editor)
return nextNote
}
/** @internal */
export function startEditingNoteShape(editor: Editor, shape: TLShape) {
// Finish this sticky and start editing the next one
editor.select(shape)
editor.setEditingShape(shape)
editor.setCurrentTool('select.editing_shape', {
target: 'shape',
shape: shape,
})
// Select any text that's in the newly selected sticky
;(document.getElementById(`text-input-${shape.id}`) as HTMLTextAreaElement)?.select()
const selectionPageBounds = editor.getSelectionPageBounds()
const viewportPageBounds = editor.getViewportPageBounds()
if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) {
editor.centerOnPoint(selectionPageBounds.center, {
duration: ANIMATION_MEDIUM_MS,
})
}
}

Wyświetl plik

@ -0,0 +1,77 @@
import { TLShapeId } from '@tldraw/editor'
import { TestEditor } from '../../../test/TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
afterEach(() => {
editor?.dispose()
})
function clickCreate(tool: string, [x, y]: [number, number]): TLShapeId {
editor.setCurrentTool('note')
editor.pointerDown(x, y)
editor.pointerUp(x, y)
const shapes = editor.getSelectedShapes()
const noteId = shapes[0].id
return noteId
}
function dragCreate(
tool: string,
{
from,
to,
}: {
from: [number, number]
to: [number, number]
}
): TLShapeId {
editor.setCurrentTool(tool)
editor.pointerDown(...from)
editor.pointerMove(...to)
editor.pointerUp(...to)
const shapes = editor.getSelectedShapes()
const rectId = shapes[0].id
return rectId
}
describe('note parenting', () => {
it('accepts shapes as children', () => {
const noteId = clickCreate('note', [0, 0])
const rectId = dragCreate('geo', { from: [-50, -50], to: [50, 50] })
expect(editor.getShape(rectId)!.parentId).toBe(noteId)
})
it("doesn't accept frames as children", () => {
clickCreate('note', [0, 0])
const frameId = dragCreate('frame', { from: [-50, -50], to: [50, 50] })
expect(editor.getShape(frameId)!.parentId).toBe(editor.getCurrentPageId())
})
it('parents shapes when you drag them onto the note', () => {
const noteId = clickCreate('note', [0, 0])
const rectId = dragCreate('geo', { from: [200, 200], to: [300, 300] })
expect(editor.getShape(rectId)!.parentId).toBe(editor.getCurrentPageId())
editor.pointerDown(250, 250)
editor.pointerMove(0, 0)
jest.advanceTimersByTime(200)
expect(editor.getShape(rectId)!.parentId).toBe(noteId)
editor.pointerUp()
})
it("doesn't parent frames when you drag them onto the note", () => {
clickCreate('note', [0, 0])
const frameId = dragCreate('frame', { from: [200, 200], to: [300, 300] })
expect(editor.getShape(frameId)!.parentId).toBe(editor.getCurrentPageId())
editor.pointerDown(250, 250)
editor.pointerMove(0, 0)
jest.advanceTimersByTime(200)
expect(editor.getShape(frameId)!.parentId).toBe(editor.getCurrentPageId())
editor.pointerUp()
})
})

Wyświetl plik

@ -4,6 +4,8 @@
* Copyright (c) Federico Brigante <opensource@bfred.it> (bfred.it)
*/
import { ANIMATION_MEDIUM_MS, Editor, TLShape } from '@tldraw/editor'
// TODO: Most of this file can be moved into a DOM utils library.
/** @internal */
@ -290,3 +292,50 @@ function getCaretIndex(element: HTMLElement) {
}
return position
}
/** @internal */
export function startEditingShapeWithLabel(
editor: Editor,
shape: TLShape,
shouldSelectAll?: boolean
) {
// Finish this shape and start editing the next one
editor.select(shape)
editor.mark('editing shape')
editor.setEditingShape(shape)
editor.setCurrentTool('select.editing_shape', {
target: 'shape',
shape: shape,
})
if (shouldSelectAll) {
// Select any text that's in the newly selected sticky
;(document.getElementById(`text-input-${shape.id}`) as HTMLTextAreaElement)?.select()
}
zoomToShapeIfOffscreen(editor)
}
const ZOOM_TO_SHAPE_PADDING = 16
export function zoomToShapeIfOffscreen(editor: Editor) {
const selectionPageBounds = editor.getSelectionPageBounds()
const viewportPageBounds = editor.getViewportPageBounds()
if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) {
const eb = selectionPageBounds
.clone()
// Expand the bounds by the padding
.expandBy(ZOOM_TO_SHAPE_PADDING / editor.getZoomLevel())
// then expand the bounds to include the viewport bounds
.expand(viewportPageBounds)
// then use the difference between the centers to calculate the offset
const nextBounds = viewportPageBounds.clone().translate({
x: (eb.center.x - viewportPageBounds.center.x) * 2,
y: (eb.center.y - viewportPageBounds.center.y) * 2,
})
editor.zoomToBounds(nextBounds, {
duration: ANIMATION_MEDIUM_MS,
inset: 0,
})
}
}

Wyświetl plik

@ -1,13 +1,10 @@
import {
Box,
TLDefaultColorStyle,
TLDefaultFillStyle,
TLDefaultFontStyle,
TLDefaultHorizontalAlignStyle,
TLDefaultVerticalAlignStyle,
TLShapeId,
getDefaultColorTheme,
useIsDarkMode,
} from '@tldraw/editor'
import React, { useEffect, useState } from 'react'
import { TextArea } from '../text/TextArea'
@ -26,9 +23,10 @@ type TextLabelProps = {
verticalAlign: TLDefaultVerticalAlignStyle
wrap?: boolean
text: string
labelColor: TLDefaultColorStyle
labelColor: string
bounds?: Box
isNote?: boolean
isSelected: boolean
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
classNamePrefix?: string
style?: React.CSSProperties
@ -48,15 +46,18 @@ export const TextLabel = React.memo(function TextLabel({
align,
verticalAlign,
wrap,
bounds,
isNote,
isSelected,
onKeyDown: handleKeyDownCustom,
classNamePrefix,
style,
textWidth,
textHeight,
}: TextLabelProps) {
const { rInput, isEmpty, isEditing, ...editableTextRest } = useEditableText(id, type, text)
const { rInput, isEmpty, isEditing, isEditingAnything, ...editableTextRest } = useEditableText(
id,
type,
text
)
const [initialText, setInitialText] = useState(text)
useEffect(() => {
@ -69,7 +70,6 @@ export const TextLabel = React.memo(function TextLabel({
const hasText = finalText.length > 0
const legacyAlign = isLegacyAlign(align)
const theme = getDefaultColorTheme({ isDarkMode: useIsDarkMode() })
if (!isEditing && !hasText) {
return null
@ -84,30 +84,23 @@ export const TextLabel = React.memo(function TextLabel({
data-align={align}
data-hastext={!isEmpty}
data-isediting={isEditing}
data-iseditinganything={isEditingAnything}
data-textwrap={!!wrap}
data-isselected={isSelected}
style={{
justifyContent: align === 'middle' || legacyAlign ? 'center' : align,
alignItems: verticalAlign === 'middle' ? 'center' : verticalAlign,
...(bounds
? {
top: bounds.minY,
left: bounds.minX,
width: bounds.width,
height: bounds.height,
position: 'absolute',
}
: {}),
...style,
}}
>
<div
className={`${cssPrefix}-label__inner`}
className={`${cssPrefix}-label__inner tl-text-content__wrapper`}
style={{
fontSize,
lineHeight: fontSize * lineHeight + 'px',
minHeight: lineHeight + 32,
minWidth: textWidth || 0,
color: isNote ? theme[labelColor].note.text : 'solid',
color: labelColor,
width: textWidth,
height: textHeight,
}}
@ -115,17 +108,19 @@ export const TextLabel = React.memo(function TextLabel({
<div className={`${cssPrefix} tl-text tl-text-content`} dir="ltr">
{finalText}
</div>
<TextArea
id={`text-input-${id}`}
ref={rInput}
// We need to add the initial value as the key here because we need this component to
// 'reset' when this state changes and grab the latest defaultValue.
key={initialText}
text={text}
isEditing={isEditing}
{...editableTextRest}
handleKeyDown={handleKeyDownCustom ?? editableTextRest.handleKeyDown}
/>
{(isEditingAnything || isSelected) && (
<TextArea
id={`text-input-${id}`}
ref={rInput}
// We need to add the initial value as the key here because we need this component to
// 'reset' when this state changes and grab the latest defaultValue.
key={initialText}
text={text}
isEditing={isEditing}
{...editableTextRest}
handleKeyDown={handleKeyDownCustom ?? editableTextRest.handleKeyDown}
/>
)}
</div>
</div>
)

Wyświetl plik

@ -4,6 +4,7 @@ import {
TLShapeId,
TLUnknownShape,
getPointerInfo,
setPointerCapture,
stopEventPropagation,
useEditor,
useValue,
@ -18,6 +19,12 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
const rInput = useRef<HTMLTextAreaElement>(null)
const rSelectionRanges = useRef<Range[] | null>()
const isEditingAnything = useValue(
'isEditingAnything',
() => editor.getEditingShapeId() !== null,
[editor]
)
const isEditing = useValue('isEditing', () => editor.getEditingShapeId() === id, [editor, id])
// If the shape is editing but the input element not focused, focus the element
@ -38,10 +45,10 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
const elm = rInput.current
const editingShapeId = editor.getEditingShapeId()
// Did we move to a different shape?
if (elm && editingShapeId) {
if (editingShapeId) {
// important! these ^v are two different things
// is that shape OUR shape?
if (editingShapeId === id) {
if (elm && editingShapeId === id) {
if (ranges) {
if (!ranges.length) {
// If we don't have any ranges, restore selection
@ -61,7 +68,6 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
}
} else {
window.getSelection()?.removeAllRanges()
editor.complete()
}
})
}, [editor, id])
@ -151,6 +157,10 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
})
stopEventPropagation(e) // we need to prevent blurring the input
// This is important so that when dragging a shape using the text label,
// the shape continues to be dragged, even if the cursor is over the UI.
setPointerCapture(e.currentTarget, e)
},
[editor, id]
)
@ -160,14 +170,17 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
return {
rInput,
isEditing,
handleFocus: () => {
/* noop */
},
handleFocus: noop,
handleBlur,
handleKeyDown,
handleChange,
handleInputPointerDown,
handleDoubleClick,
isEmpty,
isEditingAnything,
}
}
function noop() {
return
}

Wyświetl plik

@ -52,9 +52,7 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onTouchEnd={stopEventPropagation}
onContextMenu={(e) => {
isEditing && stopEventPropagation(e)
}}
onContextMenu={isEditing ? stopEventPropagation : undefined}
onPointerDown={handleInputPointerDown}
onDoubleClick={handleDoubleClick}
/>

Wyświetl plik

@ -2,7 +2,6 @@
import {
Box,
Editor,
HTMLContainer,
Rectangle2d,
ShapeUtil,
SvgExportContext,
@ -17,6 +16,7 @@ import {
toDomPrecision,
useEditor,
} from '@tldraw/editor'
import { useDefaultColorTheme } from '../shared/ShapeFill'
import { SvgTextLabel } from '../shared/SvgTextLabel'
import { TextLabel } from '../shared/TextLabel'
import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from '../shared/default-shape-constants'
@ -70,29 +70,30 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
} = shape
const { width, height } = this.getMinDimensions(shape)
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
const theme = useDefaultColorTheme()
return (
<HTMLContainer id={shape.id}>
<TextLabel
id={id}
classNamePrefix="tl-text-shape"
type="text"
font={font}
fontSize={FONT_SIZES[size]}
lineHeight={TEXT_PROPS.lineHeight}
align={align}
verticalAlign="middle"
text={text}
labelColor={color}
textWidth={width}
textHeight={height}
style={{
transform: `scale(${scale})`,
transformOrigin: 'top left',
}}
wrap
/>
</HTMLContainer>
<TextLabel
id={id}
classNamePrefix="tl-text-shape"
type="text"
font={font}
fontSize={FONT_SIZES[size]}
lineHeight={TEXT_PROPS.lineHeight}
align={align}
verticalAlign="middle"
text={text}
labelColor={theme[color].solid}
isSelected={isSelected}
textWidth={width}
textHeight={height}
style={{
transform: `scale(${scale})`,
transformOrigin: 'top left',
}}
wrap
/>
)
}

Wyświetl plik

@ -1,6 +1,8 @@
import { Editor, TLShape, TLShapeId, Vec, compact } from '@tldraw/editor'
import { isShapeOccluded } from './selectHelpers'
const LAG_DURATION = 100
const INITIAL_POINTER_LAG_DURATION = 20
const FAST_POINTER_LAG_DURATION = 100
/** @public */
export class DragAndDropManager {
@ -16,6 +18,12 @@ export class DragAndDropManager {
updateDroppingNode(movingShapes: TLShape[], cb: () => void) {
if (this.first) {
this.editor.setHintingShapes(
movingShapes
.map((s) => this.editor.findShapeAncestor(s, (v) => v.type !== 'group'))
.filter((s) => s) as TLShape[]
)
this.prevDroppingShapeId =
this.editor.getDroppingOverShape(this.editor.inputs.originPagePoint, movingShapes)?.id ??
null
@ -23,10 +31,10 @@ export class DragAndDropManager {
}
if (this.droppingNodeTimer === null) {
this.setDragTimer(movingShapes, LAG_DURATION * 10, cb)
this.setDragTimer(movingShapes, INITIAL_POINTER_LAG_DURATION, cb)
} else if (this.editor.inputs.pointerVelocity.len() > 0.5) {
clearInterval(this.droppingNodeTimer)
this.setDragTimer(movingShapes, LAG_DURATION, cb)
this.setDragTimer(movingShapes, FAST_POINTER_LAG_DURATION, cb)
}
}
@ -46,6 +54,7 @@ export class DragAndDropManager {
// is the next dropping shape id different than the last one?
if (nextDroppingShapeId === this.prevDroppingShapeId) {
this.hintParents(movingShapes)
return
}
@ -64,24 +73,42 @@ export class DragAndDropManager {
}
if (nextDroppingShape) {
const res = this.editor
this.editor
.getShapeUtil(nextDroppingShape)
.onDragShapesOver?.(nextDroppingShape, movingShapes)
if (res && res.shouldHint) {
this.editor.setHintingShapes([nextDroppingShape.id])
}
} else {
// If we're dropping onto the page, then clear hinting ids
this.editor.setHintingShapes([])
}
this.hintParents(movingShapes)
cb?.()
// next -> curr
this.prevDroppingShapeId = nextDroppingShapeId
}
hintParents(movingShapes: TLShape[]) {
// Group moving shapes by their ancestor
const shapesGroupedByAncestor = new Map<TLShapeId, TLShape[]>()
for (const shape of movingShapes) {
const ancestor = this.editor.findShapeAncestor(shape, (v) => v.type !== 'group')
if (!ancestor) continue
const shapes = shapesGroupedByAncestor.get(ancestor.id) ?? []
shapes.push(shape)
shapesGroupedByAncestor.set(ancestor.id, shapes)
}
// Only hint an ancestor if some shapes will drop into it on pointer up
const hintingShapes = []
for (const [ancestorId, shapes] of shapesGroupedByAncestor) {
const ancestor = this.editor.getShape(ancestorId)
if (!ancestor) continue
if (shapes.some((shape) => !isShapeOccluded(this.editor, ancestor, shape.id))) {
hintingShapes.push(ancestor.id)
}
}
this.editor.setHintingShapes(hintingShapes)
}
dropShapes(shapes: TLShape[]) {
const { prevDroppingShapeId } = this

Wyświetl plik

@ -11,6 +11,7 @@ import {
Vec,
structuredClone,
} from '@tldraw/editor'
import { kickoutOccludedShapes } from '../selectHelpers'
import { MIN_CROP_SIZE } from './Crop/crop-constants'
import { CursorTypeMap } from './PointingResizeHandle'
@ -206,6 +207,7 @@ export class Cropping extends StateNode {
private complete() {
this.updateShapes()
kickoutOccludedShapes(this.editor, [this.snapshot.shape.id])
if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else {

Wyświetl plik

@ -16,6 +16,7 @@ import {
sortByIndex,
structuredClone,
} from '@tldraw/editor'
import { kickoutOccludedShapes } from '../selectHelpers'
export class DraggingHandle extends StateNode {
static override id = 'dragging_handle'
@ -203,6 +204,7 @@ export class DraggingHandle extends StateNode {
private complete() {
this.editor.snaps.clearIndicators()
kickoutOccludedShapes(this.editor, [this.shapeId])
const { onInteractionEnd } = this.info
if (this.editor.getInstanceState().isToolLocked && onInteractionEnd) {

Wyświetl plik

@ -1,4 +1,4 @@
import { StateNode, TLEventHandlers, TLFrameShape, TLTextShape } from '@tldraw/editor'
import { StateNode, TLEventHandlers, TLFrameShape, TLShape, TLTextShape } from '@tldraw/editor'
import { getTextLabels } from '../../../utils/shapes/shapes'
import { getHitShapeOnCanvasPointerDown } from '../../selection-logic/getHitShapeOnCanvasPointerDown'
import { updateHoveredId } from '../../selection-logic/updateHoveredId'
@ -6,9 +6,12 @@ import { updateHoveredId } from '../../selection-logic/updateHoveredId'
export class EditingShape extends StateNode {
static override id = 'editing_shape'
hitShapeForPointerUp: TLShape | null = null
override onEnter = () => {
const editingShape = this.editor.getEditingShape()
if (!editingShape) throw Error('Entered editing state without an editing shape')
this.hitShapeForPointerUp = null
updateHoveredId(this.editor)
this.editor.select(editingShape)
}
@ -28,6 +31,16 @@ export class EditingShape extends StateNode {
}
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {
// In the case where on pointer down we hit a shape's label, we need to check if the user is dragging.
// and if they are, we need to transition to translating instead.
if (this.hitShapeForPointerUp && this.editor.inputs.isDragging) {
if (this.editor.getInstanceState().isReadonly) return
this.editor.select(this.hitShapeForPointerUp)
this.parent.transition('translating', info)
this.hitShapeForPointerUp = null
return
}
switch (info.target) {
case 'shape':
case 'canvas': {
@ -36,6 +49,7 @@ export class EditingShape extends StateNode {
}
}
}
override onPointerDown: TLEventHandlers['onPointerDown'] = (info) => {
switch (info.target) {
case 'canvas': {
@ -80,22 +94,11 @@ export class EditingShape extends StateNode {
// If we clicked on the editing geo / arrow shape's label, do nothing
return
} else {
// Stay in edit mode to maintain flow of editing.
this.editor.batch(() => {
this.editor.mark('editing on pointer up')
this.editor.select(selectingShape.id)
this.hitShapeForPointerUp = selectingShape
const util = this.editor.getShapeUtil(selectingShape)
if (this.editor.getInstanceState().isReadonly) {
if (!util.canEditInReadOnly(selectingShape)) {
this.parent.transition('pointing_shape', info)
return
}
}
this.editor.setEditingShape(selectingShape.id)
this.editor.setCurrentTool('select.editing_shape')
})
// When clicking on a different shape's label, we need to clear the other selection
// proactively until the pointer up happens.
requestAnimationFrame(() => window.getSelection()?.removeAllRanges())
return
}
}
@ -123,6 +126,32 @@ export class EditingShape extends StateNode {
this.editor.root.handleEvent(info)
}
override onPointerUp: TLEventHandlers['onPointerUp'] = (info) => {
// If we're not dragging, and it's a hit to the label, begin editing the shape.
const hitShape = this.hitShapeForPointerUp
if (hitShape) {
// Stay in edit mode to maintain flow of editing.
this.editor.batch(() => {
if (!hitShape) return
this.editor.mark('editing on pointer up')
this.editor.select(hitShape.id)
const util = this.editor.getShapeUtil(hitShape)
if (this.editor.getInstanceState().isReadonly) {
if (!util.canEditInReadOnly(hitShape)) {
this.parent.transition('pointing_shape', info)
return
}
}
this.editor.setEditingShape(hitShape.id)
this.editor.setCurrentTool('select.editing_shape')
})
return
}
}
override onComplete: TLEventHandlers['onComplete'] = (info) => {
this.parent.transition('idle', info)
}

Wyświetl plik

@ -15,10 +15,12 @@ import {
createShapeId,
pointInPolygon,
} from '@tldraw/editor'
import { startEditingShapeWithLabel } from '../../../shapes/shared/TextHelpers'
import { getHitShapeOnCanvasPointerDown } from '../../selection-logic/getHitShapeOnCanvasPointerDown'
import { getShouldEnterCropMode } from '../../selection-logic/getShouldEnterCropModeOnPointerDown'
import { selectOnCanvasPointerUp } from '../../selection-logic/selectOnCanvasPointerUp'
import { updateHoveredId } from '../../selection-logic/updateHoveredId'
import { kickoutOccludedShapes } from '../selectHelpers'
const SKIPPED_KEYS_FOR_AUTO_EDITING = [
'Delete',
@ -268,6 +270,7 @@ export class Idle extends StateNode {
if (change) {
this.editor.mark('double click edge')
this.editor.updateShapes([change])
kickoutOccludedShapes(this.editor, [onlySelectedShape.id])
return
}
}
@ -282,7 +285,7 @@ export class Idle extends StateNode {
}
if (this.shouldStartEditingShape(onlySelectedShape)) {
this.startEditingShape(onlySelectedShape, info)
this.startEditingShape(onlySelectedShape, info, true /* select all */)
}
}
break
@ -316,7 +319,7 @@ export class Idle extends StateNode {
// If the shape can edit, then begin editing
if (this.shouldStartEditingShape(shape)) {
this.startEditingShape(shape, info)
this.startEditingShape(shape, info, true /* select all */)
} else {
// If the shape's double click handler has not created a change,
// and if the shape cannot edit, then create a text shape and
@ -338,7 +341,7 @@ export class Idle extends StateNode {
// If the shape's double click handler has not created a change,
// and if the shape can edit, then begin editing the shape.
if (this.shouldStartEditingShape(shape)) {
this.startEditingShape(shape, info)
this.startEditingShape(shape, info, true /* select all */)
}
}
}
@ -444,11 +447,15 @@ export class Idle extends StateNode {
this.shouldStartEditingShape(onlySelectedShape) &&
this.editor.getShapeUtil(onlySelectedShape).doesAutoEditOnKeyStroke(onlySelectedShape)
) {
this.startEditingShape(onlySelectedShape, {
...info,
target: 'shape',
shape: onlySelectedShape,
})
this.startEditingShape(
onlySelectedShape,
{
...info,
target: 'shape',
shape: onlySelectedShape,
},
true /* select all */
)
return
}
}
@ -484,17 +491,15 @@ export class Idle extends StateNode {
// If the only selected shape is editable, then begin editing it
const onlySelectedShape = this.editor.getOnlySelectedShape()
if (onlySelectedShape && this.shouldStartEditingShape(onlySelectedShape)) {
this.startEditingShape(onlySelectedShape, {
...info,
target: 'shape',
shape: onlySelectedShape,
})
// XXX this is a hack to select the text in the textarea when we hit enter.
// Open to other ideas! I don't see how else to currently do this in the codebase.
;(
document.getElementById(`text-input-${onlySelectedShape.id}`) as HTMLTextAreaElement
)?.select()
this.startEditingShape(
onlySelectedShape,
{
...info,
target: 'shape',
shape: onlySelectedShape,
},
true /* select all */
)
return
}
@ -516,10 +521,13 @@ export class Idle extends StateNode {
return this.editor.getShapeUtil(shape).canEdit(shape)
}
private startEditingShape(shape: TLShape, info: TLClickEventInfo | TLKeyboardEventInfo) {
private startEditingShape(
shape: TLShape,
info: TLClickEventInfo | TLKeyboardEventInfo,
shouldSelectAll?: boolean
) {
if (this.editor.isShapeOrAncestorLocked(shape) && shape.type !== 'embed') return
this.editor.mark('editing shape')
this.editor.setEditingShape(shape.id)
startEditingShapeWithLabel(this.editor, shape, shouldSelectAll)
this.parent.transition('editing_shape', info)
}
@ -618,7 +626,9 @@ export class Idle extends StateNode {
? MAJOR_NUDGE_FACTOR
: MINOR_NUDGE_FACTOR
this.editor.nudgeShapes(this.editor.getSelectedShapeIds(), delta.mul(step))
const selectedShapeIds = this.editor.getSelectedShapeIds()
this.editor.nudgeShapes(selectedShapeIds, delta.mul(step))
kickoutOccludedShapes(this.editor, selectedShapeIds)
}
private canInteractWithShapeInReadOnly(shape: TLShape) {

Wyświetl plik

@ -41,7 +41,7 @@ export class PointingArrowLabel extends StateNode {
this.info = info
this.shapeId = shape.id
this.didDrag = false
this.wasAlreadySelected = this.editor.getOnlySelectedShape()?.id === shape.id
this.wasAlreadySelected = this.editor.getOnlySelectedShapeId() === shape.id
this.updateCursor()
const geometry = this.editor.getShapeGeometry<Group2d>(shape)

Wyświetl plik

@ -37,16 +37,23 @@ export class PointingCropHandle extends StateNode {
}
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
const isDragging = this.editor.inputs.isDragging
if (isDragging) {
this.parent.transition('cropping', {
...this.info,
onInteractionEnd: this.info.onInteractionEnd,
})
if (this.editor.inputs.isDragging) {
this.startCropping()
}
}
override onLongPress: TLEventHandlers['onLongPress'] = () => {
this.startCropping()
}
private startCropping() {
if (this.editor.getInstanceState().isReadonly) return
this.parent.transition('cropping', {
...this.info,
onInteractionEnd: this.info.onInteractionEnd,
})
}
override onPointerUp: TLEventHandlers['onPointerUp'] = () => {
if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)

Wyświetl plik

@ -9,11 +9,11 @@ import {
Vec,
} from '@tldraw/editor'
import {
CENTER_OFFSET,
NOTE_CENTER_OFFSET,
getNoteAdjacentPositions,
getNoteShapeForAdjacentPosition,
startEditingNoteShape,
} from '../../../shapes/note/noteHelpers'
import { startEditingShapeWithLabel } from '../../../shapes/shared/TextHelpers'
export class PointingHandle extends StateNode {
static override id = 'pointing_handle'
@ -53,7 +53,7 @@ export class PointingHandle extends StateNode {
const { editor } = this
const nextNote = getNoteForPit(editor, shape, handle, false)
if (nextNote) {
startEditingNoteShape(editor, nextNote)
startEditingShapeWithLabel(editor, nextNote, true /* selectAll */)
return
}
}
@ -64,6 +64,8 @@ export class PointingHandle extends StateNode {
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
const { editor } = this
if (editor.inputs.isDragging) {
if (this.editor.getInstanceState().isReadonly) return
const { shape, handle } = this.info
if (editor.isShapeOfType<TLNoteShape>(shape, 'note')) {
@ -72,7 +74,7 @@ export class PointingHandle extends StateNode {
// Center the shape on the current pointer
const centeredOnPointer = editor
.getPointInParentSpace(nextNote, editor.inputs.originPagePoint)
.sub(Vec.Rot(CENTER_OFFSET, nextNote.rotation))
.sub(Vec.Rot(NOTE_CENTER_OFFSET, nextNote.rotation))
editor.updateShape({ ...nextNote, x: centeredOnPointer.x, y: centeredOnPointer.y })
// Then select and begin translating the shape
@ -87,17 +89,26 @@ export class PointingHandle extends StateNode {
isCreating: true,
onCreate: () => {
// When we're done, start editing it
startEditingNoteShape(editor, nextNote)
startEditingShapeWithLabel(editor, nextNote, true /* selectAll */)
},
})
return
}
}
this.parent.transition('dragging_handle', this.info)
this.startDraggingHandle()
}
}
override onLongPress: TLEventHandlers['onLongPress'] = () => {
this.startDraggingHandle()
}
private startDraggingHandle() {
if (this.editor.getInstanceState().isReadonly) return
this.parent.transition('dragging_handle', this.info)
}
override onCancel: TLEventHandlers['onCancel'] = () => {
this.cancel()
}

Wyświetl plik

@ -48,13 +48,20 @@ export class PointingResizeHandle extends StateNode {
}
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
const isDragging = this.editor.inputs.isDragging
if (isDragging) {
this.parent.transition('resizing', this.info)
if (this.editor.inputs.isDragging) {
this.startResizing()
}
}
override onLongPress: TLEventHandlers['onLongPress'] = () => {
this.startResizing()
}
private startResizing() {
if (this.editor.getInstanceState().isReadonly) return
this.parent.transition('resizing', this.info)
}
override onPointerUp: TLEventHandlers['onPointerUp'] = () => {
this.complete()
}

Wyświetl plik

@ -33,14 +33,21 @@ export class PointingRotateHandle extends StateNode {
)
}
override onPointerMove = () => {
const { isDragging } = this.editor.inputs
if (isDragging) {
this.parent.transition('rotating', this.info)
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
if (this.editor.inputs.isDragging) {
this.startRotating()
}
}
override onLongPress: TLEventHandlers['onLongPress'] = () => {
this.startRotating()
}
private startRotating() {
if (this.editor.getInstanceState().isReadonly) return
this.parent.transition('rotating', this.info)
}
override onPointerUp = () => {
this.complete()
}

Wyświetl plik

@ -25,11 +25,19 @@ export class PointingSelection extends StateNode {
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {
if (this.editor.inputs.isDragging) {
if (this.editor.getInstanceState().isReadonly) return
this.parent.transition('translating', info)
this.startTranslating(info)
}
}
override onLongPress: TLEventHandlers['onLongPress'] = (info) => {
this.startTranslating(info)
}
private startTranslating(info: TLPointerEventInfo) {
if (this.editor.getInstanceState().isReadonly) return
this.parent.transition('translating', info)
}
override onDoubleClick?: TLClickEvent | undefined = (info) => {
const hoveredShape = this.editor.getHoveredShape()
const hitShape =

Wyświetl plik

@ -12,6 +12,7 @@ export class PointingShape extends StateNode {
hitShape = {} as TLShape
hitShapeForPointerUp = {} as TLShape
isDoubleClick = false
didSelectOnEnter = false
@ -24,7 +25,11 @@ export class PointingShape extends StateNode {
} = this.editor
this.hitShape = info.shape
this.isDoubleClick = false
const outermostSelectingShape = this.editor.getOutermostSelectableShape(info.shape)
const selectedAncestor = this.editor.findShapeAncestor(outermostSelectingShape, (parent) =>
selectedShapeIds.includes(parent.id)
)
if (
// If the shape has an onClick handler
@ -33,7 +38,9 @@ export class PointingShape extends StateNode {
outermostSelectingShape.id === focusedGroupId ||
// ...or if the shape is within the selection
selectedShapeIds.includes(outermostSelectingShape.id) ||
this.editor.isAncestorSelected(outermostSelectingShape.id) ||
// ...or if an ancestor of the shape is selected (except note shapes)...
// todo: Consider adding a flag for this hardcoded behaviour
(selectedAncestor && selectedAncestor.type !== 'note') ||
// ...or if the current point is NOT within the selection bounds
(selectedShapeIds.length > 1 && selectionBounds?.containsPoint(currentPagePoint))
) {
@ -155,6 +162,16 @@ export class PointingShape extends StateNode {
this.editor.setEditingShape(selectingShape.id)
this.editor.setCurrentTool('select.editing_shape')
if (this.isDoubleClick) {
// XXX this is a hack to select the text in the textarea when we hit enter.
// Open to other ideas! I don't see how else to currently do this in the codebase.
;(
document.getElementById(
`text-input-${selectingShape.id}`
) as HTMLTextAreaElement
).select()
}
})
return
}
@ -189,13 +206,25 @@ export class PointingShape extends StateNode {
this.parent.transition('idle', info)
}
override onDoubleClick: TLEventHandlers['onDoubleClick'] = () => {
this.isDoubleClick = true
}
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {
if (this.editor.inputs.isDragging) {
if (this.editor.getInstanceState().isReadonly) return
this.parent.transition('translating', info)
this.startTranslating(info)
}
}
override onLongPress: TLEventHandlers['onLongPress'] = (info) => {
this.startTranslating(info)
}
private startTranslating(info: TLPointerEventInfo) {
if (this.editor.getInstanceState().isReadonly) return
this.parent.transition('translating', info)
}
override onCancel: TLEventHandlers['onCancel'] = () => {
this.cancel()
}

Wyświetl plik

@ -19,6 +19,7 @@ import {
compact,
moveCameraWhenCloseToEdge,
} from '@tldraw/editor'
import { kickoutOccludedShapes } from '../selectHelpers'
type ResizingInfo = TLPointerEventInfo & {
target: 'selection'
@ -111,6 +112,8 @@ export class Resizing extends StateNode {
}
private complete() {
kickoutOccludedShapes(this.editor, this.snapshot.selectedShapeIds)
this.handleResizeEnd()
if (this.info.isCreating && this.info.onCreate) {

Wyświetl plik

@ -10,6 +10,7 @@ import {
shortAngleDist,
snapAngle,
} from '@tldraw/editor'
import { kickoutOccludedShapes } from '../selectHelpers'
import { CursorTypeMap } from './PointingResizeHandle'
const ONE_DEGREE = Math.PI / 180
@ -128,6 +129,10 @@ export class Rotating extends StateNode {
snapshot: this.snapshot,
stage: 'end',
})
kickoutOccludedShapes(
this.editor,
this.snapshot.shapeSnapshots.map((s) => s.shape.id)
)
if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else {

Wyświetl plik

@ -42,9 +42,7 @@ export class ScribbleBrushing extends StateNode {
this.updateScribbleSelection(true)
requestAnimationFrame(() => {
this.editor.updateInstanceState({ brush: null })
})
this.editor.updateInstanceState({ brush: null })
}
override onExit = () => {

Wyświetl plik

@ -18,10 +18,10 @@ import {
import {
NOTE_PIT_RADIUS,
NOTE_SIZE,
NotePit,
getAvailableNoteAdjacentPositions,
} from '../../../shapes/note/noteHelpers'
import { DragAndDropManager } from '../DragAndDropManager'
import { kickoutOccludedShapes } from '../selectHelpers'
export class Translating extends StateNode {
static override id = 'translating'
@ -174,6 +174,10 @@ export class Translating extends StateNode {
protected complete() {
this.updateShapes()
this.dragAndDropManager.dropShapes(this.snapshot.movingShapes)
kickoutOccludedShapes(
this.editor,
this.snapshot.movingShapes.map((s) => s.id)
)
this.handleEnd()
if (this.editor.getInstanceState().isToolLocked && this.info.onInteractionEnd) {
@ -349,24 +353,48 @@ function getTranslatingSnapshot(editor: Editor) {
}
}
let noteAdjacentPositions: NotePit[] | undefined
let noteAdjacentPositions: Vec[] | undefined
let noteSnapshot: MovingShapeSnapshot | undefined
const underCursor = editor.getHoveredShape()
if (underCursor) {
if (editor.isShapeOfType<TLNoteShape>(underCursor, 'note')) {
const snapshot = shapeSnapshots.find((s) => s.shape.id === underCursor.id)
if (snapshot) {
noteSnapshot = snapshot
noteAdjacentPositions = getAvailableNoteAdjacentPositions(
editor,
snapshot.pageRotation,
underCursor.props.growY ?? 0
)
}
const { originPagePoint } = editor.inputs
if (
shapeSnapshots.length === 1 &&
editor.isShapeOfType<TLNoteShape>(shapeSnapshots[0].shape, 'note')
) {
noteSnapshot = shapeSnapshots[0]
} else {
const allHoveredNotes = shapeSnapshots.filter(
(s) =>
editor.isShapeOfType<TLNoteShape>(s.shape, 'note') &&
editor.isPointInShape(s.shape, originPagePoint)
)
if (allHoveredNotes.length === 0) {
// noop
} else if (allHoveredNotes.length === 1) {
// just one, easy
noteSnapshot = allHoveredNotes[0]
} else {
// More than one under the cursor, so we need to find the highest shape in z-order
const allShapesSorted = editor.getCurrentPageShapesSorted()
noteSnapshot = allHoveredNotes
.map((s) => ({
snapshot: s,
index: allShapesSorted.findIndex((shape) => shape.id === s.shape.id),
}))
.sort((a, b) => b.index - a.index)[0]?.snapshot // highest up first
}
}
if (noteSnapshot) {
noteAdjacentPositions = getAvailableNoteAdjacentPositions(
editor,
noteSnapshot.pageRotation,
(noteSnapshot.shape as TLNoteShape).props.growY ?? 0
)
}
return {
averagePagePoint: Vec.Average(pagePoints),
movingShapes,

Wyświetl plik

@ -0,0 +1,71 @@
import {
Editor,
TLShape,
TLShapeId,
polygonIntersectsPolyline,
polygonsIntersect,
} from '@tldraw/editor'
/** @internal */
export function kickoutOccludedShapes(editor: Editor, shapeIds: TLShapeId[]) {
const shapes = shapeIds.map((id) => editor.getShape(id)).filter((s) => s) as TLShape[]
const effectedParents: TLShape[] = shapes
.map((shape) => {
const parent = editor.getShape(shape.parentId)
if (!parent) return shape
return parent
})
.filter((shape) => shape.type === 'frame' || shape.type === 'note')
const kickedOutChildren: TLShapeId[] = []
for (const parent of effectedParents) {
const childIds = editor.getSortedChildIdsForParent(parent.id)
// Get the bounds of the parent shape
const parentPageBounds = editor.getShapePageBounds(parent)
if (!parentPageBounds) continue
// For each child, check whether its bounds overlap with the parent's bounds
for (const childId of childIds) {
if (isShapeOccluded(editor, parent, childId)) {
kickedOutChildren.push(childId)
}
}
}
// now kick out the children
// TODO: make this reparent to the parent's parent?
editor.reparentShapes(kickedOutChildren, editor.getCurrentPageId())
}
/** @internal */
export function isShapeOccluded(editor: Editor, occluder: TLShape, shape: TLShapeId) {
const occluderPageBounds = editor.getShapePageBounds(occluder)
if (!occluderPageBounds) return false
const shapePageBounds = editor.getShapePageBounds(shape)
if (!shapePageBounds) return true
// If the shape's bounds are completely inside the occluder, it's not occluded
if (occluderPageBounds.contains(shapePageBounds)) {
return false
}
// If the shape's bounds are completely outside the occluder, it's occluded
if (!occluderPageBounds.includes(shapePageBounds)) {
return true
}
// If we've made it this far, the shape's bounds must intersect the edge of the occluder
// In this case, we need to look at the shape's geometry for a more fine-grained check
const shapeGeometry = editor.getShapeGeometry(shape)
const occluderCornersInShapeSpace = occluderPageBounds.corners.map((v) => {
return editor.getPointInShapeSpace(shape, v)
})
if (shapeGeometry.isClosed) {
return !polygonsIntersect(occluderCornersInShapeSpace, shapeGeometry.vertices)
}
return !polygonIntersectsPolyline(occluderCornersInShapeSpace, shapeGeometry.vertices)
}

Wyświetl plik

@ -22,6 +22,7 @@ import {
} from '@tldraw/editor'
import React from 'react'
import { STYLES } from '../../../styles'
import { kickoutOccludedShapes } from '../../../tools/SelectTool/selectHelpers'
import { useUiEvents } from '../../context/events'
import { useRelevantStyles } from '../../hooks/useRelevantStyles'
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
@ -101,6 +102,7 @@ export function CommonStylePickerSet({
theme: TLDefaultColorTheme
}) {
const msg = useTranslation()
const editor = useEditor()
const handleValueChange = useStyleChangeCallback()
@ -163,7 +165,13 @@ export function CommonStylePickerSet({
style={DefaultSizeStyle}
items={STYLES.size}
value={size}
onValueChange={handleValueChange}
onValueChange={(style, value, squashing) => {
handleValueChange(style, value, squashing)
const selectedShapeIds = editor.getSelectedShapeIds()
if (selectedShapeIds.length > 0) {
kickoutOccludedShapes(editor, selectedShapeIds)
}
}}
theme={theme}
/>
)}

Wyświetl plik

@ -20,6 +20,7 @@ import {
useEditor,
} from '@tldraw/editor'
import * as React from 'react'
import { kickoutOccludedShapes } from '../../tools/SelectTool/selectHelpers'
import { getEmbedInfo } from '../../utils/embeds/embeds'
import { fitFrameToContent, removeFrame } from '../../utils/frames/frames'
import { EditLinkDialog } from '../components/EditLinkDialog'
@ -321,24 +322,28 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('toggle-auto-size', { source })
editor.mark('toggling auto size')
const shapes = editor
.getSelectedShapes()
.filter(
(shape): shape is TLTextShape =>
editor.isShapeOfType<TLTextShape>(shape, 'text') && shape.props.autoSize === false
)
editor.updateShapes(
editor
.getSelectedShapes()
.filter(
(shape): shape is TLTextShape =>
editor.isShapeOfType<TLTextShape>(shape, 'text') && shape.props.autoSize === false
)
.map((shape) => {
return {
id: shape.id,
type: shape.type,
props: {
...shape.props,
w: 8,
autoSize: true,
},
}
})
shapes.map((shape) => {
return {
id: shape.id,
type: shape.type,
props: {
...shape.props,
w: 8,
autoSize: true,
},
}
})
)
kickoutOccludedShapes(
editor,
shapes.map((shape) => shape.id)
)
},
},
@ -602,7 +607,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'left', source })
editor.mark('align left')
editor.alignShapes(editor.getSelectedShapeIds(), 'left')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'left')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -619,7 +626,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'center-horizontal', source })
editor.mark('align center horizontal')
editor.alignShapes(editor.getSelectedShapeIds(), 'center-horizontal')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'center-horizontal')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -633,7 +642,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'right', source })
editor.mark('align right')
editor.alignShapes(editor.getSelectedShapeIds(), 'right')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'right')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -650,7 +661,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'center-vertical', source })
editor.mark('align center vertical')
editor.alignShapes(editor.getSelectedShapeIds(), 'center-vertical')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'center-vertical')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -664,7 +677,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'top', source })
editor.mark('align top')
editor.alignShapes(editor.getSelectedShapeIds(), 'top')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'top')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -678,7 +693,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'bottom', source })
editor.mark('align bottom')
editor.alignShapes(editor.getSelectedShapeIds(), 'bottom')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'bottom')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -695,7 +712,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('distribute-shapes', { operation: 'horizontal', source })
editor.mark('distribute horizontal')
editor.distributeShapes(editor.getSelectedShapeIds(), 'horizontal')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.distributeShapes(selectedShapeIds, 'horizontal')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -712,7 +731,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('distribute-shapes', { operation: 'vertical', source })
editor.mark('distribute vertical')
editor.distributeShapes(editor.getSelectedShapeIds(), 'vertical')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.distributeShapes(selectedShapeIds, 'vertical')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -728,7 +749,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('stretch-shapes', { operation: 'horizontal', source })
editor.mark('stretch horizontal')
editor.stretchShapes(editor.getSelectedShapeIds(), 'horizontal')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.stretchShapes(selectedShapeIds, 'horizontal')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -744,7 +767,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('stretch-shapes', { operation: 'vertical', source })
editor.mark('stretch vertical')
editor.stretchShapes(editor.getSelectedShapeIds(), 'vertical')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.stretchShapes(selectedShapeIds, 'vertical')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -760,7 +785,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('flip-shapes', { operation: 'horizontal', source })
editor.mark('flip horizontal')
editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.flipShapes(selectedShapeIds, 'horizontal')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -773,7 +800,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('flip-shapes', { operation: 'vertical', source })
editor.mark('flip vertical')
editor.flipShapes(editor.getSelectedShapeIds(), 'vertical')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.flipShapes(selectedShapeIds, 'vertical')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -786,7 +815,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('pack-shapes', { source })
editor.mark('pack')
editor.packShapes(editor.getSelectedShapeIds(), 16)
const selectedShapeIds = editor.getSelectedShapeIds()
editor.packShapes(selectedShapeIds, 16)
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -802,7 +833,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('stack-shapes', { operation: 'vertical', source })
editor.mark('stack-vertical')
editor.stackShapes(editor.getSelectedShapeIds(), 'vertical', 16)
const selectedShapeIds = editor.getSelectedShapeIds()
editor.stackShapes(selectedShapeIds, 'vertical', 16)
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -818,7 +851,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('stack-shapes', { operation: 'horizontal', source })
editor.mark('stack-horizontal')
editor.stackShapes(editor.getSelectedShapeIds(), 'horizontal', 16)
const selectedShapeIds = editor.getSelectedShapeIds()
editor.stackShapes(selectedShapeIds, 'horizontal', 16)
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -970,10 +1005,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
editor.mark('rotate-cw')
const offset = editor.getSelectionRotation() % (HALF_PI / 2)
const dontUseOffset = approximately(offset, 0) || approximately(offset, HALF_PI / 2)
editor.rotateShapesBy(
editor.getSelectedShapeIds(),
HALF_PI / 2 - (dontUseOffset ? 0 : offset)
)
const selectedShapeIds = editor.getSelectedShapeIds()
editor.rotateShapesBy(selectedShapeIds, HALF_PI / 2 - (dontUseOffset ? 0 : offset))
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -988,10 +1022,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
editor.mark('rotate-ccw')
const offset = editor.getSelectionRotation() % (HALF_PI / 2)
const offsetCloseToZero = approximately(offset, 0)
editor.rotateShapesBy(
editor.getSelectedShapeIds(),
offsetCloseToZero ? -(HALF_PI / 2) : -offset
)
const selectedShapeIds = editor.getSelectedShapeIds()
editor.rotateShapesBy(selectedShapeIds, offsetCloseToZero ? -(HALF_PI / 2) : -offset)
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{

Wyświetl plik

@ -10,18 +10,21 @@ import { DEFAULT_TRANSLATION } from './defaultTranslation'
/* ----------------- (do not change) ---------------- */
export const RTL_LANGUAGES = new Set(['ar', 'fa', 'he', 'ur', 'ku'])
/** @public */
export type TLUiTranslation = {
readonly locale: string
readonly label: string
readonly isRTL?: boolean
readonly messages: Record<TLUiTranslationKey, string>
readonly dir: 'rtl' | 'ltr'
}
const EN_TRANSLATION: TLUiTranslation = {
locale: 'en',
label: 'English',
messages: DEFAULT_TRANSLATION as TLUiTranslation['messages'],
dir: 'ltr',
}
/** @internal */
@ -70,7 +73,7 @@ export async function fetchTranslation(
return {
locale,
label: language.label,
isRTL: 'isRTL' in language ? language.isRTL : false,
dir: RTL_LANGUAGES.has(language.locale) ? 'rtl' : 'ltr',
messages: { ...EN_TRANSLATION.messages, ...messages },
}
}

Wyświetl plik

@ -48,6 +48,7 @@ export const TranslationProvider = track(function TranslationProvider({
return {
locale: 'en',
label: 'English',
dir: 'ltr',
messages: { ...DEFAULT_TRANSLATION, ...overrides['en'] },
}
}
@ -55,6 +56,7 @@ export const TranslationProvider = track(function TranslationProvider({
return {
locale: 'en',
label: 'English',
dir: 'ltr',
messages: DEFAULT_TRANSLATION,
}
})

Wyświetl plik

@ -434,11 +434,11 @@ describe('When editing shapes', () => {
// start editing the geo shape
editor.doubleClick(50, 50, { target: 'shape', shape: editor.getShape(ids.geo1) })
expect(editor.getEditingShapeId()).toBe(ids.geo1)
expect(editor.getOnlySelectedShape()?.id).toBe(ids.geo1)
expect(editor.getOnlySelectedShapeId()).toBe(ids.geo1)
// point the text shape
editor.pointerDown(50, 50, { target: 'shape', shape: editor.getShape(ids.text1) })
expect(editor.getEditingShapeId()).toBe(null)
expect(editor.getOnlySelectedShape()?.id).toBe(ids.text1)
expect(editor.getOnlySelectedShapeId()).toBe(ids.text1)
})
// The behavior described here will only work end to end, not with the library,
@ -450,12 +450,12 @@ describe('When editing shapes', () => {
// start editing the geo shape
editor.doubleClick(50, 50, { target: 'shape', shape: editor.getShape(ids.geo1) })
expect(editor.getEditingShapeId()).toBe(ids.geo1)
expect(editor.getOnlySelectedShape()?.id).toBe(ids.geo1)
expect(editor.getOnlySelectedShapeId()).toBe(ids.geo1)
// point the other geo shape
editor.pointerDown(50, 50, { target: 'shape', shape: editor.getShape(ids.geo2) })
// that other shape should now be editing and selected!
expect(editor.getEditingShapeId()).toBe(ids.geo2)
expect(editor.getOnlySelectedShape()?.id).toBe(ids.geo2)
expect(editor.getOnlySelectedShapeId()).toBe(ids.geo2)
})
// This works but only end to end — the logic had to move to React
@ -468,7 +468,7 @@ describe('When editing shapes', () => {
editor.pointerDown(50, 50, { target: 'shape', shape: editor.getShape(ids.text2) })
// that other shape should now be editing and selected!
expect(editor.getEditingShapeId()).toBe(ids.text2)
expect(editor.getOnlySelectedShape()?.id).toBe(ids.text2)
expect(editor.getOnlySelectedShapeId()).toBe(ids.text2)
})
it('Double clicking the canvas creates a new text shape', () => {
@ -559,3 +559,14 @@ describe('When in readonly mode', () => {
expect(editor.getEditingShapeId()).toBe(ids.embed1)
})
})
// This should be end to end, the problem is the blur handler of the react component
it('goes into pointing canvas', () => {
editor
.createShape({ type: 'note' })
.pointerMove(50, 50)
.doubleClick()
.expectToBeIn('select.editing_shape')
.pointerDown(300, 300)
.expectToBeIn('select.pointing_canvas')
})

Wyświetl plik

@ -335,6 +335,17 @@ export class TestEditor extends Editor {
/* ------------------ Input Events ------------------ */
/**
Some of our updates are not synchronous any longer. For example, drawing happens on tick instead of on pointer move.
You can use this helper to force the tick, which will then process all the updates.
*/
forceTick = (count = 1) => {
for (let i = 0; i < count; i++) {
this.emit('tick', 16)
}
return this
}
pointerMove = (
x = this.inputs.currentScreenPoint.x,
y = this.inputs.currentScreenPoint.y,
@ -344,7 +355,7 @@ export class TestEditor extends Editor {
this.dispatch({
...this.getPointerEventInfo(x, y, options, modifiers),
name: 'pointer_move',
})
}).forceTick()
return this
}
@ -357,7 +368,7 @@ export class TestEditor extends Editor {
this.dispatch({
...this.getPointerEventInfo(x, y, options, modifiers),
name: 'pointer_down',
})
}).forceTick()
return this
}
@ -370,7 +381,7 @@ export class TestEditor extends Editor {
this.dispatch({
...this.getPointerEventInfo(x, y, options, modifiers),
name: 'pointer_up',
})
}).forceTick()
return this
}
@ -404,17 +415,17 @@ export class TestEditor extends Editor {
type: 'click',
name: 'double_click',
phase: 'up',
})
}).forceTick()
return this
}
keyDown = (key: string, options = {} as Partial<Exclude<TLKeyboardEventInfo, 'key'>>) => {
this.dispatch({ ...this.getKeyboardEventInfo(key, 'key_down', options) })
this.dispatch({ ...this.getKeyboardEventInfo(key, 'key_down', options) }).forceTick()
return this
}
keyRepeat = (key: string, options = {} as Partial<Exclude<TLKeyboardEventInfo, 'key'>>) => {
this.dispatch({ ...this.getKeyboardEventInfo(key, 'key_repeat', options) })
this.dispatch({ ...this.getKeyboardEventInfo(key, 'key_repeat', options) }).forceTick()
return this
}
@ -426,7 +437,7 @@ export class TestEditor extends Editor {
altKey: this.inputs.altKey && key !== 'Alt',
...options,
}),
})
}).forceTick()
return this
}
@ -440,7 +451,7 @@ export class TestEditor extends Editor {
altKey: this.inputs.altKey,
...options,
delta: { x: dx, y: dy },
})
}).forceTick(2)
return this
}
@ -462,7 +473,7 @@ export class TestEditor extends Editor {
...options,
point: { x, y, z },
delta: { x: dx, y: dy, z: dz },
})
}).forceTick()
return this
}
@ -506,7 +517,7 @@ export class TestEditor extends Editor {
...options,
point: { x, y, z },
delta: { x: dx, y: dy, z: dz },
})
}).forceTick()
return this
}
/* ------ Interaction Helpers ------ */

Wyświetl plik

@ -1,5 +1,6 @@
import {
DefaultFillStyle,
GeoShapeGeoStyle,
TLArrowShape,
TLFrameShape,
TLShapeId,
@ -275,8 +276,8 @@ describe('frame shapes', () => {
expect(parentBefore).toBe(frameId)
// resize the frame so the shape is partially out of bounds
editor.pointerDown(100, 50, { target: 'selection', handle: 'right' })
editor.pointerMove(70, 50)
editor.pointerUp(70, 50)
editor.pointerMove(80, 50)
editor.pointerUp(80, 50)
const parentAfter = editor.getShape(rectId)?.parentId
expect(parentAfter).toBe(frameId)
})
@ -405,7 +406,7 @@ describe('frame shapes', () => {
expect(editor.getOnlySelectedShape()!.id).toBe(boxAid)
expect(editor.getOnlySelectedShape()!.parentId).toBe(frameId)
expect(editor.getHintingShapeIds()).toHaveLength(0)
expect(editor.getHintingShapeIds()).toHaveLength(1)
// box A should still be beneath box B
expect(editor.getShape(boxAid)!.index.localeCompare(editor.getShape(boxBid)!.index)).toBe(-1)
})
@ -926,7 +927,7 @@ describe('When dragging a shape inside a group inside a frame', () => {
editor.pointerMove(100, 100).click().click()
expect(editor.getOnlySelectedShape()?.id).toBe(ids.box1)
expect(editor.getOnlySelectedShapeId()).toBe(ids.box1)
editor.pointerMove(150, 150).pointerDown().pointerMove(140, 140)
@ -946,7 +947,7 @@ describe('When dragging a shape inside a group inside a frame', () => {
editor.pointerMove(100, 100).click().click()
expect(editor.getOnlySelectedShape()?.id).toBe(ids.box1)
expect(editor.getOnlySelectedShapeId()).toBe(ids.box1)
expect(editor.getFocusedGroupId()).toBe(ids.group1)
editor
@ -1024,6 +1025,65 @@ function dragCreateFrame({
return frameId
}
function dragCreateRect({
down,
move,
up,
}: {
down: [number, number]
move: [number, number]
up: [number, number]
}): TLShapeId {
editor.setCurrentTool('geo')
editor.pointerDown(...down)
editor.pointerMove(...move)
editor.pointerUp(...up)
const shapes = editor.getSelectedShapes()
const rectId = shapes[0].id
return rectId
}
function dragCreateTriangle({
down,
move,
up,
}: {
down: [number, number]
move: [number, number]
up: [number, number]
}): TLShapeId {
editor.setCurrentTool('geo')
const originalStyle = editor.getStyleForNextShape(GeoShapeGeoStyle)
editor.setStyleForNextShapes(GeoShapeGeoStyle, 'triangle')
editor.pointerDown(...down)
editor.pointerMove(...move)
editor.pointerUp(...up)
const shapes = editor.getSelectedShapes()
editor.selectNone()
editor.setStyleForNextShapes(GeoShapeGeoStyle, originalStyle)
const rectId = shapes[0].id
editor.select(shapes[0].id)
return rectId
}
function dragCreateLine({
down,
move,
up,
}: {
down: [number, number]
move: [number, number]
up: [number, number]
}): TLShapeId {
editor.setCurrentTool('line')
editor.pointerDown(...down)
editor.pointerMove(...move)
editor.pointerUp(...up)
const shapes = editor.getSelectedShapes()
const lineId = shapes[0].id
return lineId
}
function createRect({ pos, size }: { pos: [number, number]; size: [number, number] }) {
const rectId: TLShapeId = createShapeId()
editor.createShapes([
@ -1037,3 +1097,117 @@ function createRect({ pos, size }: { pos: [number, number]; size: [number, numbe
])
return rectId
}
describe('Unparenting behavior', () => {
it("unparents a shape when it's completely dragged out of a frame, even when the pointer doesn't move across the edge of the frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateRect({ down: [80, 50], move: [120, 60], up: [120, 60] })
const [frame, rect] = editor.getLastCreatedShapes(2)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerDown(110, 50)
editor.pointerMove(140, 50)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerUp(140, 50)
expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
})
it("doesn't unparent a shape when it's partially dragged out of a frame, when the pointer doesn't move across the edge of the frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateRect({ down: [80, 50], move: [120, 60], up: [120, 60] })
const [frame, rect] = editor.getLastCreatedShapes(2)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerDown(110, 50)
editor.pointerMove(120, 50)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerUp(120, 50)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
})
it('unparents a shape when the pointer drags across the edge of a frame, even if its geometry overlaps with the frame', () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateRect({ down: [80, 50], move: [120, 60], up: [120, 60] })
const [frame, rect] = editor.getLastCreatedShapes(2)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerDown(90, 50)
editor.pointerMove(110, 50)
jest.advanceTimersByTime(200)
expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
editor.pointerUp(110, 50)
expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
})
it("unparents a shape when it's rotated out of a frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateRect({ down: [95, 10], move: [200, 20], up: [200, 20] })
const [frame, rect] = editor.getLastCreatedShapes(2)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerDown(200, 20, {
target: 'selection',
handle: 'top_right_rotate',
})
editor.pointerMove(200, 200)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerUp(200, 200)
expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
})
it("unparents shapes if they're resized out of a frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateRect({ down: [10, 10], move: [20, 20], up: [20, 20] })
dragCreateRect({ down: [80, 80], move: [90, 90], up: [90, 90] })
const [frame, rect1, rect2] = editor.getLastCreatedShapes(3)
editor.select(rect1.id, rect2.id)
editor.pointerDown(90, 90, { target: 'selection', handle: 'top_right' })
expect(editor.getShape(rect2.id)!.parentId).toBe(frame.id)
editor.pointerMove(200, 200)
expect(editor.getShape(rect2.id)!.parentId).toBe(frame.id)
editor.pointerUp(200, 200)
expect(editor.getShape(rect2.id)!.parentId).toBe(editor.getCurrentPageId())
})
it("unparents a shape if its geometry doesn't overlap with the frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateTriangle({ down: [80, 80], move: [120, 120], up: [120, 120] })
const [frame, triangle] = editor.getLastCreatedShapes(2)
expect(editor.getShape(triangle.id)!.parentId).toBe(frame.id)
editor.pointerDown(85, 85)
editor.pointerMove(95, 95)
expect(editor.getShape(triangle.id)!.parentId).toBe(frame.id)
editor.pointerUp(95, 95)
expect(editor.getShape(triangle.id)!.parentId).toBe(editor.getCurrentPageId())
})
it("only parents on pointer up if the shape's geometry overlaps with the frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateTriangle({ down: [120, 120], move: [160, 160], up: [160, 160] })
const [frame, triangle] = editor.getLastCreatedShapes(2)
expect(editor.getShape(triangle.id)!.parentId).toBe(editor.getCurrentPageId())
editor.pointerDown(125, 125)
editor.pointerMove(95, 95)
jest.advanceTimersByTime(200)
expect(editor.getShape(triangle.id)!.parentId).toBe(frame.id)
expect(editor.getHintingShapeIds()).toHaveLength(0)
editor.pointerUp(95, 95)
expect(editor.getShape(triangle.id)!.parentId).toBe(editor.getCurrentPageId())
})
it('unparents an occluded shape after dragging a handle out of a frame', () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateLine({ down: [90, 90], move: [120, 120], up: [120, 120] })
const [frame, line] = editor.getLastCreatedShapes(2)
expect(editor.getShape(line.id)!.parentId).toBe(frame.id)
editor.pointerDown(90, 90)
editor.pointerMove(110, 110)
expect(editor.getShape(line.id)!.parentId).toBe(frame.id)
editor.pointerUp(110, 110)
expect(editor.getShape(line.id)!.parentId).toBe(editor.getCurrentPageId())
})
})

Wyświetl plik

@ -15,7 +15,7 @@ describe(SelectTool, () => {
describe('pointer down while shape is being edited', () => {
it('captures the pointer down event if it is on the shape', () => {
editor.setCurrentTool('geo').pointerDown(0, 0).pointerMove(100, 100).pointerUp(100, 100)
const shapeId = editor.getOnlySelectedShape()?.id
const shapeId = editor.getLastCreatedShape().id
editor._transformPointerDownSpy.mockRestore()
editor._transformPointerUpSpy.mockRestore()
editor.setCurrentTool('select')
@ -42,7 +42,7 @@ describe(SelectTool, () => {
})
it('does not allow pressing undo to end up in the editing state', () => {
editor.setCurrentTool('geo').pointerDown(0, 0).pointerMove(100, 100).pointerUp(100, 100)
const shapeId = editor.getOnlySelectedShape()?.id
const shapeId = editor.getLastCreatedShape().id
editor._transformPointerDownSpy.mockRestore()
editor._transformPointerUpSpy.mockRestore()
editor.setCurrentTool('select')

Wyświetl plik

@ -1,5 +1,6 @@
import {
GapsSnapIndicator,
IndexKey,
PointsSnapIndicator,
SnapIndicator,
TLArrowShape,
@ -167,9 +168,9 @@ describe('When translating...', () => {
.pointerMove(1080, 800)
jest.advanceTimersByTime(100)
editor
.expectShapeToMatch({ id: ids.box1, x: 1300, y: 845.68 })
.expectShapeToMatch({ id: ids.box1, x: 1320, y: 845.68 })
.pointerUp()
.expectShapeToMatch({ id: ids.box1, x: 1300, y: 845.68 })
.expectShapeToMatch({ id: ids.box1, x: 1340, y: 857.92 })
})
it('translates multiple shapes', () => {
@ -2048,7 +2049,7 @@ describe('Note shape grid helper positions / pits', () => {
it('Snaps multiple notes to the pit using the note under the cursor', () => {
editor.createShape({ type: 'note' })
editor.createShape({ type: 'note', x: 500, y: 500 })
editor.createShape({ type: 'note', x: 700, y: 500 })
editor.createShape({ type: 'note', x: 700, y: 500, parentId: editor.getCurrentPageId() })
const [shapeB, shapeC] = editor.getLastCreatedShapes(2)
const pit = { x: 320, y: 100 } // right of shapeA
@ -2063,7 +2064,8 @@ describe('Note shape grid helper positions / pits', () => {
.pointerMove(pit.x - 4, pit.y - 4) // not exactly in the pit...
// B snaps the selection to the pit
editor.expectShapeToMatch({ ...shapeB, x: 220, y: 0 })
// (index is manually set because the sticky gets brought to front)
editor.expectShapeToMatch({ ...shapeB, x: 220, y: 0, index: 'a4' as IndexKey })
expect(editor.getSelectionPageBounds()).toMatchObject({ x: 220, y: 0, w: 400, h: 200 })
editor.cancel()
@ -2083,7 +2085,49 @@ describe('Note shape grid helper positions / pits', () => {
// Even though B is in the same place as it was when it snapped (while dragging over B),
// because our cursor is over C it won't fall into the pit—because it's not hovered
editor.expectShapeToMatch({ ...shapeB, x: 216, y: -4 })
// (index is manually set because the sticky gets brought to front)
editor.expectShapeToMatch({ ...shapeB, x: 216, y: -4, index: 'a4' as IndexKey })
expect(editor.getSelectionPageBounds()).toMatchObject({ x: 216, y: -4, w: 400, h: 200 })
})
it('When multiple notes are under the cursor, uses the top-most one', () => {
editor.createShape({ type: 'note' })
editor.createShape({ type: 'note', x: 500, y: 500 })
editor.createShape({ type: 'note', x: 501, y: 501 })
const [shapeB, shapeC] = editor.getLastCreatedShapes(2)
// For the purposes of this test, let's leave the stickies unparented
editor.reparentShapes([shapeC], editor.getCurrentPageId())
const pit = { x: 320, y: 100 } // right of shapeA
editor.select(shapeB, shapeC)
expect(editor.getSelectionPageBounds()).toMatchObject({ x: 500, y: 500, w: 201, h: 201 })
// First we do it with C in front
editor.bringToFront([shapeC])
editor
.pointerMove(600, 600) // center of b but overlapping C
.pointerDown()
.pointerMove(pit.x - 4, pit.y - 4) // not exactly in the pit...
// B snaps the selection to the pit
editor.expectShapeToMatch({ id: shapeB.id, x: 219, y: -1 }) // not snapped
editor.expectShapeToMatch({ id: shapeC.id, x: 220, y: 0 }) // snapped
editor.cancel()
// Now let's do it with B in front
editor.bringToFront([shapeB])
editor
.pointerMove(600, 600) // center of b but overlapping C
.pointerDown()
.pointerMove(pit.x - 4, pit.y - 4) // not exactly in the pit...
// B snaps the selection to the pit
editor.expectShapeToMatch({ id: shapeB.id, x: 220, y: 0 }) // snapped
editor.expectShapeToMatch({ id: shapeC.id, x: 221, y: 1 }) // not snapped
})
})

Wyświetl plik

@ -636,19 +636,15 @@ export const LANGUAGES: readonly [{
}, {
readonly locale: "he";
readonly label: "עברית";
readonly isRTL: true;
}, {
readonly locale: "ar";
readonly label: "عربي";
readonly isRTL: true;
}, {
readonly locale: "fa";
readonly label: "فارسی";
readonly isRTL: true;
}, {
readonly locale: "ku";
readonly label: "کوردی";
readonly isRTL: true;
}, {
readonly locale: "ne";
readonly label: "नेपाली";
@ -705,7 +701,7 @@ export const noteShapeMigrations: Migrations;
export const noteShapeProps: {
color: EnumStyleProp<"black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow">;
size: EnumStyleProp<"l" | "m" | "s" | "xl">;
fontSizeAdjustment: T.Validator<number | undefined>;
fontSizeAdjustment: T.Validator<number>;
font: EnumStyleProp<"draw" | "mono" | "sans" | "serif">;
align: EnumStyleProp<"end-legacy" | "end" | "middle-legacy" | "middle" | "start-legacy" | "start">;
verticalAlign: EnumStyleProp<"end" | "middle" | "start">;
@ -750,6 +746,11 @@ export type ShapeProps<Shape extends TLBaseShape<any, any>> = {
[K in keyof Shape['props']]: T.Validatable<Shape['props'][K]>;
};
// @public (undocumented)
export type ShapePropsType<Config extends Record<string, T.Validatable<any>>> = Expand<{
[K in keyof Config]: T.TypeOf<Config[K]>;
}>;
// @public
export class StyleProp<Type> implements T.Validatable<Type> {
// @internal

Wyświetl plik

@ -2750,7 +2750,7 @@
},
{
"kind": "Content",
"text": "readonly [{\n readonly locale: \"ca\";\n readonly label: \"Català\";\n}, {\n readonly locale: \"cs\";\n readonly label: \"Čeština\";\n}, {\n readonly locale: \"da\";\n readonly label: \"Danish\";\n}, {\n readonly locale: \"de\";\n readonly label: \"Deutsch\";\n}, {\n readonly locale: \"en\";\n readonly label: \"English\";\n}, {\n readonly locale: \"es\";\n readonly label: \"Español\";\n}, {\n readonly locale: \"fr\";\n readonly label: \"Français\";\n}, {\n readonly locale: \"gl\";\n readonly label: \"Galego\";\n}, {\n readonly locale: \"hr\";\n readonly label: \"Hrvatski\";\n}, {\n readonly locale: \"it\";\n readonly label: \"Italiano\";\n}, {\n readonly locale: \"hu\";\n readonly label: \"Magyar\";\n}, {\n readonly locale: \"no\";\n readonly label: \"Norwegian\";\n}, {\n readonly locale: \"pl\";\n readonly label: \"Polski\";\n}, {\n readonly locale: \"pt-br\";\n readonly label: \"Português - Brasil\";\n}, {\n readonly locale: \"pt-pt\";\n readonly label: \"Português - Europeu\";\n}, {\n readonly locale: \"ro\";\n readonly label: \"Română\";\n}, {\n readonly locale: \"ru\";\n readonly label: \"Russian\";\n}, {\n readonly locale: \"sl\";\n readonly label: \"Slovenščina\";\n}, {\n readonly locale: \"fi\";\n readonly label: \"Suomi\";\n}, {\n readonly locale: \"sv\";\n readonly label: \"Svenska\";\n}, {\n readonly locale: \"vi\";\n readonly label: \"Tiếng Việt\";\n}, {\n readonly locale: \"tr\";\n readonly label: \"Türkçe\";\n}, {\n readonly locale: \"uk\";\n readonly label: \"Ukrainian\";\n}, {\n readonly locale: \"he\";\n readonly label: \"עברית\";\n readonly isRTL: true;\n}, {\n readonly locale: \"ar\";\n readonly label: \"عربي\";\n readonly isRTL: true;\n}, {\n readonly locale: \"fa\";\n readonly label: \"فارسی\";\n readonly isRTL: true;\n}, {\n readonly locale: \"ku\";\n readonly label: \"کوردی\";\n readonly isRTL: true;\n}, {\n readonly locale: \"ne\";\n readonly label: \"नेपाली\";\n}, {\n readonly locale: \"hi-in\";\n readonly label: \"हिन्दी\";\n}, {\n readonly locale: \"te\";\n readonly label: \"తెలుగు\";\n}, {\n readonly locale: \"th\";\n readonly label: \"ภาษาไทย\";\n}, {\n readonly locale: \"my\";\n readonly label: \"မြန်မာစာ\";\n}, {\n readonly locale: \"ko-kr\";\n readonly label: \"한국어\";\n}, {\n readonly locale: \"ja\";\n readonly label: \"日本語\";\n}, {\n readonly locale: \"zh-cn\";\n readonly label: \"简体中文\";\n}, {\n readonly locale: \"zh-tw\";\n readonly label: \"繁體中文 (台灣)\";\n}]"
"text": "readonly [{\n readonly locale: \"ca\";\n readonly label: \"Català\";\n}, {\n readonly locale: \"cs\";\n readonly label: \"Čeština\";\n}, {\n readonly locale: \"da\";\n readonly label: \"Danish\";\n}, {\n readonly locale: \"de\";\n readonly label: \"Deutsch\";\n}, {\n readonly locale: \"en\";\n readonly label: \"English\";\n}, {\n readonly locale: \"es\";\n readonly label: \"Español\";\n}, {\n readonly locale: \"fr\";\n readonly label: \"Français\";\n}, {\n readonly locale: \"gl\";\n readonly label: \"Galego\";\n}, {\n readonly locale: \"hr\";\n readonly label: \"Hrvatski\";\n}, {\n readonly locale: \"it\";\n readonly label: \"Italiano\";\n}, {\n readonly locale: \"hu\";\n readonly label: \"Magyar\";\n}, {\n readonly locale: \"no\";\n readonly label: \"Norwegian\";\n}, {\n readonly locale: \"pl\";\n readonly label: \"Polski\";\n}, {\n readonly locale: \"pt-br\";\n readonly label: \"Português - Brasil\";\n}, {\n readonly locale: \"pt-pt\";\n readonly label: \"Português - Europeu\";\n}, {\n readonly locale: \"ro\";\n readonly label: \"Română\";\n}, {\n readonly locale: \"ru\";\n readonly label: \"Russian\";\n}, {\n readonly locale: \"sl\";\n readonly label: \"Slovenščina\";\n}, {\n readonly locale: \"fi\";\n readonly label: \"Suomi\";\n}, {\n readonly locale: \"sv\";\n readonly label: \"Svenska\";\n}, {\n readonly locale: \"vi\";\n readonly label: \"Tiếng Việt\";\n}, {\n readonly locale: \"tr\";\n readonly label: \"Türkçe\";\n}, {\n readonly locale: \"uk\";\n readonly label: \"Ukrainian\";\n}, {\n readonly locale: \"he\";\n readonly label: \"עברית\";\n}, {\n readonly locale: \"ar\";\n readonly label: \"عربي\";\n}, {\n readonly locale: \"fa\";\n readonly label: \"فارسی\";\n}, {\n readonly locale: \"ku\";\n readonly label: \"کوردی\";\n}, {\n readonly locale: \"ne\";\n readonly label: \"नेपाली\";\n}, {\n readonly locale: \"hi-in\";\n readonly label: \"हिन्दी\";\n}, {\n readonly locale: \"te\";\n readonly label: \"తెలుగు\";\n}, {\n readonly locale: \"th\";\n readonly label: \"ภาษาไทย\";\n}, {\n readonly locale: \"my\";\n readonly label: \"မြန်မာစာ\";\n}, {\n readonly locale: \"ko-kr\";\n readonly label: \"한국어\";\n}, {\n readonly locale: \"ja\";\n readonly label: \"日本語\";\n}, {\n readonly locale: \"zh-cn\";\n readonly label: \"简体中文\";\n}, {\n readonly locale: \"zh-tw\";\n readonly label: \"繁體中文 (台灣)\";\n}]"
}
],
"fileUrlPath": "packages/tlschema/src/translations/languages.ts",
@ -2909,7 +2909,7 @@
},
{
"kind": "Content",
"text": "<number | undefined>;\n font: import(\"..\")."
"text": "<number>;\n font: import(\"..\")."
},
{
"kind": "Reference",
@ -3263,6 +3263,81 @@
"endIndex": 7
}
},
{
"kind": "TypeAlias",
"canonicalReference": "@tldraw/tlschema!ShapePropsType:type",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export type ShapePropsType<Config extends "
},
{
"kind": "Reference",
"text": "Record",
"canonicalReference": "!Record:type"
},
{
"kind": "Content",
"text": "<string, "
},
{
"kind": "Reference",
"text": "T.Validatable",
"canonicalReference": "@tldraw/validate!Validatable:type"
},
{
"kind": "Content",
"text": "<any>>"
},
{
"kind": "Content",
"text": "> = "
},
{
"kind": "Reference",
"text": "Expand",
"canonicalReference": "@tldraw/utils!Expand:type"
},
{
"kind": "Content",
"text": "<{\n [K in keyof Config]: "
},
{
"kind": "Reference",
"text": "T.TypeOf",
"canonicalReference": "@tldraw/validate!TypeOf:type"
},
{
"kind": "Content",
"text": "<Config[K]>;\n}>"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/tlschema/src/shapes/TLBaseShape.ts",
"releaseTag": "Public",
"name": "ShapePropsType",
"typeParameters": [
{
"typeParameterName": "Config",
"constraintTokenRange": {
"startIndex": 1,
"endIndex": 5
},
"defaultTypeTokenRange": {
"startIndex": 0,
"endIndex": 0
}
}
],
"typeTokenRange": {
"startIndex": 6,
"endIndex": 10
}
},
{
"kind": "Class",
"canonicalReference": "@tldraw/tlschema!StyleProp:class",
@ -3977,7 +4052,7 @@
{
"kind": "Reference",
"text": "ShapePropsType",
"canonicalReference": "@tldraw/tlschema!~ShapePropsType:type"
"canonicalReference": "@tldraw/tlschema!ShapePropsType:type"
},
{
"kind": "Content",
@ -6576,7 +6651,7 @@
{
"kind": "Reference",
"text": "ShapePropsType",
"canonicalReference": "@tldraw/tlschema!~ShapePropsType:type"
"canonicalReference": "@tldraw/tlschema!ShapePropsType:type"
},
{
"kind": "Content",
@ -9411,7 +9486,7 @@
{
"kind": "Reference",
"text": "ShapePropsType",
"canonicalReference": "@tldraw/tlschema!~ShapePropsType:type"
"canonicalReference": "@tldraw/tlschema!ShapePropsType:type"
},
{
"kind": "Content",

Wyświetl plik

@ -80,6 +80,7 @@ export {
parentIdValidator,
shapeIdValidator,
type ShapeProps,
type ShapePropsType,
type TLBaseShape,
} from './shapes/TLBaseShape'
export {

Wyświetl plik

@ -20,7 +20,7 @@ import { embedShapeMigrations } from './shapes/TLEmbedShape'
import { GeoShapeVersions, geoShapeMigrations } from './shapes/TLGeoShape'
import { imageShapeMigrations } from './shapes/TLImageShape'
import { lineShapeMigrations, lineShapeVersions } from './shapes/TLLineShape'
import { noteShapeMigrations } from './shapes/TLNoteShape'
import { noteShapeMigrations, noteShapeVersions } from './shapes/TLNoteShape'
import { textShapeMigrations } from './shapes/TLTextShape'
import { videoShapeMigrations } from './shapes/TLVideoShape'
import { storeMigrations, storeVersions } from './store-migrations'
@ -2030,6 +2030,46 @@ describe('Fractional indexing for line points', () => {
})
})
describe('Add font size adjustment to notes', () => {
const { up, down } = noteShapeMigrations.migrators[noteShapeVersions.AddFontSizeAdjustment]
test('up works as expected', () => {
expect(up({ props: {} })).toEqual({ props: { fontSizeAdjustment: 0 } })
})
test('down works as expected', () => {
expect(down({ props: { fontSizeAdjustment: 0 } })).toEqual({ props: {} })
})
})
describe('add white', () => {
const { up, down } = rootShapeMigrations.migrators[rootShapeVersions.AddWhite]
test('up works as expected', () => {
expect(
up({
props: {},
})
).toEqual({
props: {},
})
})
test('down works as expected', () => {
expect(
down({
props: {
color: 'white',
},
})
).toEqual({
props: {
color: 'black',
},
})
})
})
/* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */
for (const migrator of allMigrators) {

Wyświetl plik

@ -87,11 +87,12 @@ export const rootShapeVersions = {
AddIsLocked: 1,
HoistOpacity: 2,
AddMeta: 3,
AddWhite: 4,
} as const
/** @internal */
export const rootShapeMigrations = defineMigrations({
currentVersion: rootShapeVersions.AddMeta,
currentVersion: rootShapeVersions.AddWhite,
migrators: {
[rootShapeVersions.AddIsLocked]: {
up: (record) => {
@ -147,6 +148,22 @@ export const rootShapeMigrations = defineMigrations({
}
},
},
[rootShapeVersions.AddWhite]: {
up: (record) => {
return {
...record,
}
},
down: (record) => {
return {
...record,
props: {
...record.props,
color: record.props.color === 'white' ? 'black' : record.props.color,
},
}
},
},
},
})

Wyświetl plik

@ -62,6 +62,7 @@ export type ShapeProps<Shape extends TLBaseShape<any, any>> = {
[K in keyof Shape['props']]: T.Validatable<Shape['props'][K]>
}
/** @public */
export type ShapePropsType<Config extends Record<string, T.Validatable<any>>> = Expand<{
[K in keyof Config]: T.TypeOf<Config[K]>
}>

Wyświetl plik

@ -14,7 +14,7 @@ import { ShapePropsType, TLBaseShape } from './TLBaseShape'
export const noteShapeProps = {
color: DefaultColorStyle,
size: DefaultSizeStyle,
fontSizeAdjustment: T.optional(T.positiveNumber),
fontSizeAdjustment: T.positiveNumber,
font: DefaultFontStyle,
align: DefaultHorizontalAlignStyle,
verticalAlign: DefaultVerticalAlignStyle,
@ -29,19 +29,20 @@ export type TLNoteShapeProps = ShapePropsType<typeof noteShapeProps>
/** @public */
export type TLNoteShape = TLBaseShape<'note', TLNoteShapeProps>
const Versions = {
export const noteShapeVersions = {
AddUrlProp: 1,
RemoveJustify: 2,
MigrateLegacyAlign: 3,
AddVerticalAlign: 4,
MakeUrlsValid: 5,
AddFontSizeAdjustment: 6,
} as const
/** @internal */
export const noteShapeMigrations = defineMigrations({
currentVersion: Versions.MakeUrlsValid,
currentVersion: noteShapeVersions.AddFontSizeAdjustment,
migrators: {
[Versions.AddUrlProp]: {
[noteShapeVersions.AddUrlProp]: {
up: (shape) => {
return { ...shape, props: { ...shape.props, url: '' } }
},
@ -50,7 +51,7 @@ export const noteShapeMigrations = defineMigrations({
return { ...shape, props }
},
},
[Versions.RemoveJustify]: {
[noteShapeVersions.RemoveJustify]: {
up: (shape) => {
let newAlign = shape.props.align
if (newAlign === 'justify') {
@ -70,7 +71,7 @@ export const noteShapeMigrations = defineMigrations({
},
},
[Versions.MigrateLegacyAlign]: {
[noteShapeVersions.MigrateLegacyAlign]: {
up: (shape) => {
let newAlign: TLDefaultHorizontalAlignStyle
switch (shape.props.align) {
@ -116,7 +117,7 @@ export const noteShapeMigrations = defineMigrations({
}
},
},
[Versions.AddVerticalAlign]: {
[noteShapeVersions.AddVerticalAlign]: {
up: (shape) => {
return {
...shape,
@ -135,7 +136,7 @@ export const noteShapeMigrations = defineMigrations({
}
},
},
[Versions.MakeUrlsValid]: {
[noteShapeVersions.MakeUrlsValid]: {
up: (shape) => {
const url = shape.props.url
if (url !== '' && !T.linkUrl.isValid(shape.props.url)) {
@ -145,5 +146,14 @@ export const noteShapeMigrations = defineMigrations({
},
down: (shape) => shape,
},
[noteShapeVersions.AddFontSizeAdjustment]: {
up: (shape) => {
return { ...shape, props: { ...shape.props, fontSizeAdjustment: 0 } }
},
down: (shape) => {
const { fontSizeAdjustment: _, ...props } = shape.props
return { ...shape, props }
},
},
},
})

Wyświetl plik

@ -26,10 +26,10 @@ export const LANGUAGES = [
{ locale: 'vi', label: 'Tiếng Việt' },
{ locale: 'tr', label: 'Türkçe' },
{ locale: 'uk', label: 'Ukrainian' },
{ locale: 'he', label: 'עברית', isRTL: true },
{ locale: 'ar', label: 'عربي', isRTL: true },
{ locale: 'fa', label: 'فارسی', isRTL: true },
{ locale: 'ku', label: 'کوردی', isRTL: true },
{ locale: 'he', label: 'עברית' },
{ locale: 'ar', label: 'عربي' },
{ locale: 'fa', label: 'فارسی' },
{ locale: 'ku', label: 'کوردی' },
{ locale: 'ne', label: 'नेपाली' },
{ locale: 'hi-in', label: 'हिन्दी' },
{ locale: 'te', label: 'తెలుగు' },

Wyświetl plik

@ -37,7 +37,6 @@
"lint": "yarn run -T tsx ../../scripts/lint.ts"
},
"devDependencies": {
"fast-check": "^3.16.0",
"tldraw": "workspace:*",
"typescript": "^5.3.3",
"uuid-by-string": "^4.0.0",

Wyświetl plik

@ -49,43 +49,6 @@ export type TLPersistentClientSocket<R extends UnknownRecord = UnknownRecord> =
const PING_INTERVAL = 5000
const MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION = PING_INTERVAL * 2
export function _applyNetworkDiffToStore<R extends UnknownRecord, S extends Store<R> = Store<R>>(
diff: NetworkDiff<R>,
store: S
): RecordsDiff<R> | null {
const changes: RecordsDiff<R> = { added: {} as any, updated: {} as any, removed: {} as any }
type k = keyof typeof changes.updated
let hasChanges = false
for (const [id, op] of objectMapEntries(diff)) {
if (op[0] === RecordOpType.Put) {
const existing = store.get(id as RecordId<any>)
if (existing && !isEqual(existing, op[1])) {
hasChanges = true
changes.updated[id as k] = [existing, op[1]]
} else {
hasChanges = true
changes.added[id as k] = op[1]
}
} else if (op[0] === RecordOpType.Patch) {
const record = store.get(id as RecordId<any>)
if (!record) {
// the record was removed upstream
continue
}
const patched = applyObjectDiff(record, op[1])
hasChanges = true
changes.updated[id as k] = [record, patched]
} else if (op[0] === RecordOpType.Remove) {
if (store.has(id as RecordId<any>)) {
hasChanges = true
changes.removed[id as k] = store.get(id as RecordId<any>)
}
}
}
return hasChanges ? changes : null
}
// Should connect support chunking the response to allow for large payloads?
/**
@ -532,8 +495,36 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
*/
private applyNetworkDiff(diff: NetworkDiff<R>, runCallbacks: boolean) {
this.debug('applyNetworkDiff', diff)
const changes = _applyNetworkDiffToStore(diff, this.store)
if (changes !== null) {
const changes: RecordsDiff<R> = { added: {} as any, updated: {} as any, removed: {} as any }
type k = keyof typeof changes.updated
let hasChanges = false
for (const [id, op] of objectMapEntries(diff)) {
if (op[0] === RecordOpType.Put) {
const existing = this.store.get(id as RecordId<any>)
if (existing && !isEqual(existing, op[1])) {
hasChanges = true
changes.updated[id as k] = [existing, op[1]]
} else {
hasChanges = true
changes.added[id as k] = op[1]
}
} else if (op[0] === RecordOpType.Patch) {
const record = this.store.get(id as RecordId<any>)
if (!record) {
// the record was removed upstream
continue
}
const patched = applyObjectDiff(record, op[1])
hasChanges = true
changes.updated[id as k] = [record, patched]
} else if (op[0] === RecordOpType.Remove) {
if (this.store.has(id as RecordId<any>)) {
hasChanges = true
changes.removed[id as k] = this.store.get(id as RecordId<any>)
}
}
}
if (hasChanges) {
this.store.applyDiff(changes, runCallbacks)
}
}

Wyświetl plik

@ -48,7 +48,6 @@ import {
TLSocketServerSentDataEvent,
TLSocketServerSentEvent,
} from './protocol'
import { squishDataEvents } from './squish'
/** @public */
export type TLRoomSocket<R extends UnknownRecord> = {
@ -457,10 +456,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
session.socket.sendMessage(message)
}
} else {
session.socket.sendMessage({
type: 'data',
data: squishDataEvents(session.outstandingDataMessages),
})
session.socket.sendMessage({ type: 'data', data: session.outstandingDataMessages })
}
session.outstandingDataMessages.length = 0
}

Wyświetl plik

@ -236,11 +236,7 @@ export function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectD
break
}
case ValueOpType.Patch: {
if (
object[key as keyof T] &&
typeof object[key as keyof T] === 'object' &&
!Array.isArray(object[key as keyof T])
) {
if (object[key as keyof T] && typeof object[key as keyof T] === 'object') {
const diff = op[1]
const patched = applyObjectDiff(object[key as keyof T] as object, diff)
if (patched !== object[key as keyof T]) {

Wyświetl plik

@ -1,191 +0,0 @@
import { UnknownRecord } from '@tldraw/store'
import { exhaustiveSwitchError, objectMapEntries, structuredClone } from '@tldraw/utils'
import {
NetworkDiff,
ObjectDiff,
RecordOp,
RecordOpType,
ValueOpType,
applyObjectDiff,
} from './diff'
import { TLSocketServerSentDataEvent } from './protocol'
interface State<R extends UnknownRecord> {
lastPatch: (TLSocketServerSentDataEvent<R> & { type: 'patch' }) | null
squished: TLSocketServerSentDataEvent<R>[]
}
type Bailed = boolean
function patchThePatch(lastPatch: ObjectDiff, newPatch: ObjectDiff): Bailed {
for (const [newKey, newOp] of Object.entries(newPatch)) {
switch (newOp[0]) {
case ValueOpType.Put:
lastPatch[newKey] = newOp
break
case ValueOpType.Append:
if (lastPatch[newKey] === undefined) {
lastPatch[newKey] = newOp
} else {
const lastOp = lastPatch[newKey]
switch (lastOp[0]) {
case ValueOpType.Put: {
const lastValues = lastOp[1]
if (Array.isArray(lastValues)) {
const newValues = newOp[1]
lastValues.push(...newValues)
} else {
// we're trying to append to something that was put previously, but
// is not an array; bail out
return true
}
break
}
case ValueOpType.Append: {
const lastValues = lastOp[1]
const lastOffset = lastOp[2]
const newValues = newOp[1]
const newOffset = newOp[2]
if (newOffset === lastOffset + lastValues.length) {
lastValues.push(...newValues)
} else {
// something weird is going on, bail out
return true
}
break
}
default:
// trying to append to either a deletion or a patch, bail out
return true
}
}
break
case ValueOpType.Patch:
if (lastPatch[newKey] === undefined) {
lastPatch[newKey] = newOp
} else {
// bail out, recursive patching is too hard
return true
}
break
case ValueOpType.Delete:
// overwrite whatever was there previously, no point if it's going to be removed
// todo: check if it was freshly put and don't add if it wasn't?
lastPatch[newKey] = newOp
break
default:
exhaustiveSwitchError(newOp[0])
}
}
return false
}
function patchTheOp<R extends UnknownRecord>(
lastRecordOp: RecordOp<R>,
newPatch: ObjectDiff
): Bailed {
switch (lastRecordOp[0]) {
case RecordOpType.Put:
// patching a freshly added value is easy, just patch as normal
lastRecordOp[1] = applyObjectDiff(lastRecordOp[1], newPatch)
break
case RecordOpType.Patch: {
// both are patches, merge them
const bailed = patchThePatch(lastRecordOp[1], newPatch)
if (bailed) {
return true
}
break
}
case RecordOpType.Remove:
// we're trying to patch an object that was removed, just disregard the update
break
default:
exhaustiveSwitchError(lastRecordOp[0])
}
return false
}
function squishInto<R extends UnknownRecord>(
lastDiff: NetworkDiff<R>,
newDiff: NetworkDiff<R>
): Bailed {
for (const [newId, newOp] of objectMapEntries(newDiff)) {
switch (newOp[0]) {
case RecordOpType.Put:
// we Put the same record several times, just overwrite whatever came previously
lastDiff[newId] = newOp
break
case RecordOpType.Patch:
if (lastDiff[newId] === undefined) {
// this is the patch now
lastDiff[newId] = newOp
} else {
// patch the previous RecordOp!
const bailed = patchTheOp(lastDiff[newId], newOp[1])
if (bailed) {
return true
}
}
break
case RecordOpType.Remove:
// overwrite whatever was there previously
// todo: check if it was freshly put and don't add if it wasn't?
lastDiff[newId] = newOp
break
default:
exhaustiveSwitchError(newOp[0])
}
}
return false
}
export function squishDataEvents<R extends UnknownRecord>(
dataEvents: TLSocketServerSentDataEvent<R>[]
): TLSocketServerSentDataEvent<R>[] {
if (dataEvents.length < 2) {
// most common case
return dataEvents
}
const state: State<R> = { lastPatch: null, squished: [] }
for (const e of dataEvents) {
switch (e.type) {
case 'push_result':
if (state.lastPatch !== null) {
state.squished.push(state.lastPatch)
state.lastPatch = null
}
state.squished.push(e)
break
case 'patch':
if (state.lastPatch !== null) {
// this structuredClone is necessary to avoid modifying the original list of events
// (otherwise objects can get reused on put and then modified on patch)
const bailed = squishInto(state.lastPatch.diff, structuredClone(e.diff))
if (bailed) {
// this is unfortunate, but some patches were too hard to patch, give up
// and return the original list
return dataEvents
}
state.lastPatch.serverClock = e.serverClock
} else {
state.lastPatch = structuredClone(e)
}
break
default:
exhaustiveSwitchError(e, 'type')
}
}
if (state.lastPatch !== null) {
state.squished.push(state.lastPatch)
}
return state.squished
}

Wyświetl plik

@ -1,574 +0,0 @@
import { createRecordType, IdOf, RecordId, Store, StoreSchema, UnknownRecord } from '@tldraw/store'
import { assert, structuredClone } from '@tldraw/utils'
import fc, { Arbitrary } from 'fast-check'
import { NetworkDiff, ObjectDiff, RecordOpType, ValueOpType } from '../lib/diff'
import { TLSocketServerSentDataEvent } from '../lib/protocol'
import { squishDataEvents } from '../lib/squish'
import { _applyNetworkDiffToStore } from '../lib/TLSyncClient'
test('basic squishing', () => {
const capture = [
{
type: 'patch',
diff: {
'instance_presence:nlyxdltolNVL0VONRr9Bz': [
RecordOpType.Patch,
{
brush: [
'put',
{
x: 610.02734375,
y: 71.4609375,
w: 929.58203125,
h: 500.14453125,
},
],
},
],
},
serverClock: 9237,
},
{
type: 'patch',
diff: {
'instance_presence:nlyxdltolNVL0VONRr9Bz': [
RecordOpType.Patch,
{
lastActivityTimestamp: ['put', 1710188679590],
cursor: [
'put',
{
x: 1526.07421875,
y: 565.66796875,
rotation: 0,
type: 'default',
},
],
},
],
},
serverClock: 9238,
},
{
type: 'patch',
diff: {
'instance_presence:nlyxdltolNVL0VONRr9Bz': [
RecordOpType.Patch,
{
brush: [
'put',
{
x: 610.02734375,
y: 71.4609375,
w: 916.046875,
h: 494.20703125,
},
],
},
],
},
serverClock: 9239,
},
{
type: 'patch',
diff: {
'instance_presence:nlyxdltolNVL0VONRr9Bz': [
RecordOpType.Patch,
{
lastActivityTimestamp: ['put', 1710188679599],
cursor: [
'put',
{
x: 1519.26171875,
y: 563.71875,
rotation: 0,
type: 'default',
},
],
},
],
},
serverClock: 9240,
},
{
type: 'patch',
diff: {
'instance_presence:nlyxdltolNVL0VONRr9Bz': [
RecordOpType.Patch,
{
brush: [
'put',
{
x: 610.02734375,
y: 71.4609375,
w: 909.234375,
h: 492.2578125,
},
],
},
],
},
serverClock: 9241,
},
{
type: 'patch',
diff: {
'instance_presence:nlyxdltolNVL0VONRr9Bz': [
RecordOpType.Patch,
{
lastActivityTimestamp: ['put', 1710188679608],
cursor: [
'put',
{
x: 1512.41015625,
y: 562.23046875,
rotation: 0,
type: 'default',
},
],
},
],
},
serverClock: 9242,
},
{
type: 'patch',
diff: {
'instance_presence:nlyxdltolNVL0VONRr9Bz': [
RecordOpType.Patch,
{
brush: [
'put',
{
x: 610.02734375,
y: 71.4609375,
w: 902.3828125,
h: 490.76953125,
},
],
},
],
},
serverClock: 9243,
},
{
type: 'patch',
diff: {
'instance_presence:nlyxdltolNVL0VONRr9Bz': [
RecordOpType.Patch,
{
lastActivityTimestamp: ['put', 1710188679617],
cursor: [
'put',
{
x: 1506.71484375,
y: 561.29296875,
rotation: 0,
type: 'default',
},
],
},
],
},
serverClock: 9244,
},
{
type: 'patch',
diff: {
'instance_presence:nlyxdltolNVL0VONRr9Bz': [
RecordOpType.Patch,
{
brush: [
'put',
{
x: 610.02734375,
y: 71.4609375,
w: 896.6875,
h: 489.83203125,
},
],
},
],
},
serverClock: 9245,
},
{
type: 'patch',
diff: {
'instance_presence:nlyxdltolNVL0VONRr9Bz': [
RecordOpType.Patch,
{
lastActivityTimestamp: ['put', 1710188679625],
cursor: [
'put',
{
x: 1501.734375,
y: 560.88671875,
rotation: 0,
type: 'default',
},
],
},
],
},
serverClock: 9246,
},
{
type: 'patch',
diff: {
'instance_presence:nlyxdltolNVL0VONRr9Bz': [
RecordOpType.Patch,
{
brush: [
'put',
{
x: 610.02734375,
y: 71.4609375,
w: 891.70703125,
h: 489.42578125,
},
],
},
],
},
serverClock: 9247,
},
{
type: 'patch',
diff: {
'instance_presence:nlyxdltolNVL0VONRr9Bz': [
RecordOpType.Patch,
{
lastActivityTimestamp: ['put', 1710188679633],
cursor: [
'put',
{
x: 1497.22265625,
y: 560.6875,
rotation: 0,
type: 'default',
},
],
},
],
},
serverClock: 9248,
},
{
type: 'patch',
diff: {
'instance_presence:nlyxdltolNVL0VONRr9Bz': [
RecordOpType.Patch,
{
brush: [
'put',
{
x: 610.02734375,
y: 71.4609375,
w: 887.1953125,
h: 489.2265625,
},
],
},
],
},
serverClock: 9249,
},
] as const satisfies TLSocketServerSentDataEvent<UnknownRecord>[]
const squished = squishDataEvents(capture)
const manuallySquished = [
{
type: 'patch',
diff: {
'instance_presence:nlyxdltolNVL0VONRr9Bz': [
'patch',
{
lastActivityTimestamp: ['put', 1710188679633],
cursor: [
'put',
{
x: 1497.22265625,
y: 560.6875,
rotation: 0,
type: 'default',
},
],
brush: [
'put',
{
x: 610.02734375,
y: 71.4609375,
w: 887.1953125,
h: 489.2265625,
},
],
},
],
},
serverClock: 9249,
},
]
// see https://github.com/jestjs/jest/issues/14011 for why the second clone is needed
expect(squished).toStrictEqual(structuredClone(manuallySquished))
})
const TEST_RECORD_TYPENAME = 'testRecord' as const
interface TestRecord extends UnknownRecord {
fieldA?: TestRecordValue
fieldB?: TestRecordValue
fieldC?: TestRecordValue
}
type TestRecordValue =
| string
| number[]
| { fieldA?: TestRecordValue; fieldB?: TestRecordValue; fieldC?: TestRecordValue }
const TestRecord = createRecordType<TestRecord>(TEST_RECORD_TYPENAME, {
validator: {
validate(value) {
return value as TestRecord
},
},
scope: 'document',
})
class Model {
diffs: NetworkDiff<TestRecord>[] = []
idMap: IdOf<TestRecord>[]
private readonly initialStoreData: Record<IdOf<TestRecord>, TestRecord>
constructor(public initialStoreContent: TestRecord[]) {
this.idMap = initialStoreContent.map((r) => r.id)
this.initialStoreData = Object.fromEntries(initialStoreContent.map((r) => [r.id, r]))
}
trueIdx(idx: number) {
return idx % this.idMap.length
}
getId(idx: number) {
return this.idMap[this.trueIdx(idx)]
}
private getFreshStore(): Store<TestRecord> {
return new Store({
initialData: this.initialStoreData,
schema: StoreSchema.create<TestRecord>({ testRecord: TestRecord }),
props: {},
})
}
private getStoreWithDiffs(diffs: NetworkDiff<TestRecord>[]) {
const store = this.getFreshStore()
for (const diff of diffs) {
const changes = _applyNetworkDiffToStore(diff, store)
if (changes !== null) {
store.applyDiff(changes, false)
}
}
return store
}
runTest() {
const dataEvents = this.diffs.map((diff, idx) => ({
type: 'patch' as const,
diff,
serverClock: idx,
}))
const squishedDiffs = squishDataEvents(dataEvents).map((e) => {
assert(e.type === 'patch')
return e.diff
})
const baseStore = this.getStoreWithDiffs(this.diffs)
const squishedStore = this.getStoreWithDiffs(squishedDiffs)
// see https://github.com/jestjs/jest/issues/14011 for the explanation for that structuredClone
expect(squishedStore.serialize()).toEqual(structuredClone(baseStore.serialize()))
}
// offsets are a MAJOR pain because they depend on the entire history of diffs so far, and
// the store silently discards append patches if their offsets don't match, so they need
// to be correct to exercise the squisher
// NOTE: modifies the diff
fixOffsets(recordId: IdOf<TestRecord>, fullDiff: ObjectDiff) {
const fixed = structuredClone(fullDiff)
const store = this.getStoreWithDiffs(this.diffs)
const record = store.get(recordId)
if (record === undefined) {
return fixed
}
const fixer = (obj: any, diff: ObjectDiff) => {
for (const [k, v] of Object.entries(diff)) {
if (v[0] === ValueOpType.Append && Array.isArray(obj[k])) {
v[2] = obj[k].length
} else if (v[0] === ValueOpType.Patch && typeof obj[k] === 'object') {
fixer(obj[k], v[1])
}
}
}
fixer(record, fixed)
return fixed
}
}
type Real = 'whatever'
class RecordPut implements fc.Command<Model, Real> {
constructor(readonly record: TestRecord) {}
check(_m: Readonly<Model>) {
return true
}
run(m: Model): void {
m.diffs.push({ [this.record.id]: [RecordOpType.Put, this.record] })
m.idMap.push(this.record.id)
m.runTest()
}
toString = () => `Put(${JSON.stringify(this.record)})`
}
class RecordRemove implements fc.Command<Model, Real> {
constructor(readonly idx: number) {}
check(m: Readonly<Model>) {
return m.idMap.length > 0
}
run(m: Model) {
m.diffs.push({ [m.getId(this.idx)]: [RecordOpType.Remove] })
m.idMap.splice(m.trueIdx(this.idx), 1)
m.runTest()
}
toString = () => `Remove(#${this.idx})`
}
class RecordPatch implements fc.Command<Model, Real> {
constructor(
readonly idx: number,
readonly patch: ObjectDiff
) {}
check(m: Readonly<Model>) {
return m.idMap.length > 0
}
run(m: Model) {
const fixedPatch = m.fixOffsets(m.getId(this.idx), this.patch)
m.diffs.push({ [m.getId(this.idx)]: [RecordOpType.Patch, fixedPatch] })
m.runTest()
}
toString = () => `Patch(#${this.idx}, ${JSON.stringify(this.patch)})`
}
const { TestRecordValueArb }: { TestRecordValueArb: Arbitrary<TestRecordValue> } = fc.letrec(
(tie) => ({
TestRecordValueArb: fc.oneof(
fc.string(),
fc.array(fc.integer()),
fc.record(
{
fieldA: tie('TestRecordValueArb'),
fieldB: tie('TestRecordValueArb'),
fieldC: tie('TestRecordValueArb'),
},
{ requiredKeys: ['fieldA'] }
)
),
})
)
const TestRecordKeyArb = fc.oneof(
fc.constant('fieldA' as const),
fc.constant('fieldB' as const),
fc.constant('fieldC' as const)
)
const TestRecordArb = fc.record(
{
id: fc.oneof(fc.constant('idA'), fc.constant('idB'), fc.constant('idC')) as Arbitrary<
RecordId<TestRecord>
>,
typeName: fc.constant(TEST_RECORD_TYPENAME),
fieldA: TestRecordValueArb,
fieldB: TestRecordValueArb,
fieldC: TestRecordValueArb,
},
{ requiredKeys: ['id', 'typeName'] }
)
const { ObjectDiffArb }: { ObjectDiffArb: Arbitrary<ObjectDiff> } = fc.letrec((tie) => ({
ObjectDiffArb: fc.dictionary(
TestRecordKeyArb,
fc.oneof(
fc.tuple(fc.constant(ValueOpType.Put), TestRecordValueArb),
// The offset is -1 because it depends on the length of the array *in the current state*,
// so it can't be generated here. Instead, it's patched up in the command
fc.tuple(fc.constant(ValueOpType.Append), fc.array(fc.integer()), fc.constant(-1)),
fc.tuple(fc.constant(ValueOpType.Patch), tie('ObjectDiffArb')),
fc.tuple(fc.constant(ValueOpType.Delete))
),
{ minKeys: 1, maxKeys: 3 }
),
}))
const allCommands = [
TestRecordArb.map((r) => new RecordPut(r)),
fc.nat(10).map((idx) => new RecordRemove(idx)),
fc.tuple(fc.nat(), ObjectDiffArb).map(([idx, diff]) => new RecordPatch(idx, diff)),
]
const initialStoreContentArb: Arbitrary<TestRecord[]> = fc.uniqueArray(TestRecordArb, {
selector: (r) => r.id,
maxLength: 3,
})
test('fast-checking squish', () => {
// If you see this test failing, to reproduce you need both seed and path in fc.assert,
// and replayPath in fc.commands. See the next test for an examples
fc.assert(
fc.property(
initialStoreContentArb,
fc.commands(allCommands, {}),
(initialStoreContent, cmds) => {
fc.modelRun(
() => ({
model: new Model(initialStoreContent),
real: 'whatever',
}),
cmds
)
}
),
{
verbose: 1,
numRuns: 1_000,
}
)
})
test('problem: applying a patch to an array', () => {
fc.assert(
fc.property(
initialStoreContentArb,
fc.commands(allCommands, {
replayPath: 'CDJ:F',
}),
(initialStoreContent, cmds) => {
fc.modelRun(
() => ({
model: new Model(initialStoreContent),
real: 'whatever',
}),
cmds
)
}
),
{ seed: -1883357795, path: '7653:1:2:2:4:3:3:3:3', endOnFailure: true }
)
})

Wyświetl plik

@ -6,7 +6,7 @@ const isTest = () =>
const fpsQueue: Array<() => void> = []
const targetFps = 60
const targetTimePerFrame = 1000 / targetFps
const targetTimePerFrame = Math.ceil(1000 / targetFps)
let frame: number | undefined
let time = 0
let last = 0

466
yarn.lock
Wyświetl plik

@ -2616,6 +2616,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/aix-ppc64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/aix-ppc64@npm:0.20.2"
conditions: os=aix & cpu=ppc64
languageName: node
linkType: hard
"@esbuild/android-arm64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/android-arm64@npm:0.16.3"
@ -2651,6 +2658,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/android-arm64@npm:0.20.2"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
"@esbuild/android-arm@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/android-arm@npm:0.16.3"
@ -2686,6 +2700,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/android-arm@npm:0.20.2"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
"@esbuild/android-x64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/android-x64@npm:0.16.3"
@ -2721,6 +2742,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-x64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/android-x64@npm:0.20.2"
conditions: os=android & cpu=x64
languageName: node
linkType: hard
"@esbuild/darwin-arm64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/darwin-arm64@npm:0.16.3"
@ -2756,6 +2784,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-arm64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/darwin-arm64@npm:0.20.2"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@esbuild/darwin-x64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/darwin-x64@npm:0.16.3"
@ -2791,6 +2826,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-x64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/darwin-x64@npm:0.20.2"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@esbuild/freebsd-arm64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/freebsd-arm64@npm:0.16.3"
@ -2826,6 +2868,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-arm64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/freebsd-arm64@npm:0.20.2"
conditions: os=freebsd & cpu=arm64
languageName: node
linkType: hard
"@esbuild/freebsd-x64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/freebsd-x64@npm:0.16.3"
@ -2861,6 +2910,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-x64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/freebsd-x64@npm:0.20.2"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/linux-arm64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/linux-arm64@npm:0.16.3"
@ -2896,6 +2952,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/linux-arm64@npm:0.20.2"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
"@esbuild/linux-arm@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/linux-arm@npm:0.16.3"
@ -2931,6 +2994,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/linux-arm@npm:0.20.2"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@esbuild/linux-ia32@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/linux-ia32@npm:0.16.3"
@ -2966,6 +3036,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ia32@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/linux-ia32@npm:0.20.2"
conditions: os=linux & cpu=ia32
languageName: node
linkType: hard
"@esbuild/linux-loong64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/linux-loong64@npm:0.16.3"
@ -3001,6 +3078,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-loong64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/linux-loong64@npm:0.20.2"
conditions: os=linux & cpu=loong64
languageName: node
linkType: hard
"@esbuild/linux-mips64el@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/linux-mips64el@npm:0.16.3"
@ -3036,6 +3120,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-mips64el@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/linux-mips64el@npm:0.20.2"
conditions: os=linux & cpu=mips64el
languageName: node
linkType: hard
"@esbuild/linux-ppc64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/linux-ppc64@npm:0.16.3"
@ -3071,6 +3162,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ppc64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/linux-ppc64@npm:0.20.2"
conditions: os=linux & cpu=ppc64
languageName: node
linkType: hard
"@esbuild/linux-riscv64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/linux-riscv64@npm:0.16.3"
@ -3106,6 +3204,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-riscv64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/linux-riscv64@npm:0.20.2"
conditions: os=linux & cpu=riscv64
languageName: node
linkType: hard
"@esbuild/linux-s390x@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/linux-s390x@npm:0.16.3"
@ -3141,6 +3246,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-s390x@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/linux-s390x@npm:0.20.2"
conditions: os=linux & cpu=s390x
languageName: node
linkType: hard
"@esbuild/linux-x64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/linux-x64@npm:0.16.3"
@ -3176,6 +3288,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-x64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/linux-x64@npm:0.20.2"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
"@esbuild/netbsd-x64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/netbsd-x64@npm:0.16.3"
@ -3211,6 +3330,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/netbsd-x64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/netbsd-x64@npm:0.20.2"
conditions: os=netbsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/openbsd-x64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/openbsd-x64@npm:0.16.3"
@ -3246,6 +3372,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openbsd-x64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/openbsd-x64@npm:0.20.2"
conditions: os=openbsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/sunos-x64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/sunos-x64@npm:0.16.3"
@ -3281,6 +3414,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/sunos-x64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/sunos-x64@npm:0.20.2"
conditions: os=sunos & cpu=x64
languageName: node
linkType: hard
"@esbuild/win32-arm64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/win32-arm64@npm:0.16.3"
@ -3316,6 +3456,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-arm64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/win32-arm64@npm:0.20.2"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@esbuild/win32-ia32@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/win32-ia32@npm:0.16.3"
@ -3351,6 +3498,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-ia32@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/win32-ia32@npm:0.20.2"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@esbuild/win32-x64@npm:0.16.3":
version: 0.16.3
resolution: "@esbuild/win32-x64@npm:0.16.3"
@ -3386,6 +3540,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-x64@npm:0.20.2":
version: 0.20.2
resolution: "@esbuild/win32-x64@npm:0.20.2"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0":
version: 4.4.0
resolution: "@eslint-community/eslint-utils@npm:4.4.0"
@ -5841,93 +6002,107 @@ __metadata:
languageName: node
linkType: hard
"@rollup/rollup-android-arm-eabi@npm:4.9.5":
version: 4.9.5
resolution: "@rollup/rollup-android-arm-eabi@npm:4.9.5"
"@rollup/rollup-android-arm-eabi@npm:4.13.2":
version: 4.13.2
resolution: "@rollup/rollup-android-arm-eabi@npm:4.13.2"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
"@rollup/rollup-android-arm64@npm:4.9.5":
version: 4.9.5
resolution: "@rollup/rollup-android-arm64@npm:4.9.5"
"@rollup/rollup-android-arm64@npm:4.13.2":
version: 4.13.2
resolution: "@rollup/rollup-android-arm64@npm:4.13.2"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-darwin-arm64@npm:4.9.5":
version: 4.9.5
resolution: "@rollup/rollup-darwin-arm64@npm:4.9.5"
"@rollup/rollup-darwin-arm64@npm:4.13.2":
version: 4.13.2
resolution: "@rollup/rollup-darwin-arm64@npm:4.13.2"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-darwin-x64@npm:4.9.5":
version: 4.9.5
resolution: "@rollup/rollup-darwin-x64@npm:4.9.5"
"@rollup/rollup-darwin-x64@npm:4.13.2":
version: 4.13.2
resolution: "@rollup/rollup-darwin-x64@npm:4.13.2"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@rollup/rollup-linux-arm-gnueabihf@npm:4.9.5":
version: 4.9.5
resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.9.5"
"@rollup/rollup-linux-arm-gnueabihf@npm:4.13.2":
version: 4.13.2
resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.13.2"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@rollup/rollup-linux-arm64-gnu@npm:4.9.5":
version: 4.9.5
resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.9.5"
"@rollup/rollup-linux-arm64-gnu@npm:4.13.2":
version: 4.13.2
resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.13.2"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-arm64-musl@npm:4.9.5":
version: 4.9.5
resolution: "@rollup/rollup-linux-arm64-musl@npm:4.9.5"
"@rollup/rollup-linux-arm64-musl@npm:4.13.2":
version: 4.13.2
resolution: "@rollup/rollup-linux-arm64-musl@npm:4.13.2"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-linux-riscv64-gnu@npm:4.9.5":
version: 4.9.5
resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.9.5"
"@rollup/rollup-linux-powerpc64le-gnu@npm:4.13.2":
version: 4.13.2
resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.13.2"
conditions: os=linux & cpu=ppc64le & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-riscv64-gnu@npm:4.13.2":
version: 4.13.2
resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.13.2"
conditions: os=linux & cpu=riscv64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-x64-gnu@npm:4.9.5":
version: 4.9.5
resolution: "@rollup/rollup-linux-x64-gnu@npm:4.9.5"
"@rollup/rollup-linux-s390x-gnu@npm:4.13.2":
version: 4.13.2
resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.13.2"
conditions: os=linux & cpu=s390x & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-x64-gnu@npm:4.13.2":
version: 4.13.2
resolution: "@rollup/rollup-linux-x64-gnu@npm:4.13.2"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-x64-musl@npm:4.9.5":
version: 4.9.5
resolution: "@rollup/rollup-linux-x64-musl@npm:4.9.5"
"@rollup/rollup-linux-x64-musl@npm:4.13.2":
version: 4.13.2
resolution: "@rollup/rollup-linux-x64-musl@npm:4.13.2"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-win32-arm64-msvc@npm:4.9.5":
version: 4.9.5
resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.9.5"
"@rollup/rollup-win32-arm64-msvc@npm:4.13.2":
version: 4.13.2
resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.13.2"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-win32-ia32-msvc@npm:4.9.5":
version: 4.9.5
resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.9.5"
"@rollup/rollup-win32-ia32-msvc@npm:4.13.2":
version: 4.13.2
resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.13.2"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@rollup/rollup-win32-x64-msvc@npm:4.9.5":
version: 4.9.5
resolution: "@rollup/rollup-win32-x64-msvc@npm:4.9.5"
"@rollup/rollup-win32-x64-msvc@npm:4.13.2":
version: 4.13.2
resolution: "@rollup/rollup-win32-x64-msvc@npm:4.13.2"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@ -7487,7 +7662,6 @@ __metadata:
"@tldraw/store": "workspace:*"
"@tldraw/tlschema": "workspace:*"
"@tldraw/utils": "workspace:*"
fast-check: "npm:^3.16.0"
lodash.isequal: "npm:^4.5.0"
nanoevents: "npm:^7.0.1"
nanoid: "npm:4.0.2"
@ -9999,12 +10173,12 @@ __metadata:
languageName: node
linkType: hard
"body-parser@npm:1.20.1":
version: 1.20.1
resolution: "body-parser@npm:1.20.1"
"body-parser@npm:1.20.2":
version: 1.20.2
resolution: "body-parser@npm:1.20.2"
dependencies:
bytes: "npm:3.1.2"
content-type: "npm:~1.0.4"
content-type: "npm:~1.0.5"
debug: "npm:2.6.9"
depd: "npm:2.0.0"
destroy: "npm:1.2.0"
@ -10012,10 +10186,10 @@ __metadata:
iconv-lite: "npm:0.4.24"
on-finished: "npm:2.4.1"
qs: "npm:6.11.0"
raw-body: "npm:2.5.1"
raw-body: "npm:2.5.2"
type-is: "npm:~1.6.18"
unpipe: "npm:1.0.0"
checksum: 5f8d128022a2fb8b6e7990d30878a0182f300b70e46b3f9d358a9433ad6275f0de46add6d63206da3637c01c3b38b6111a7480f7e7ac2e9f7b989f6133fe5510
checksum: 3cf171b82190cf91495c262b073e425fc0d9e25cc2bf4540d43f7e7bbca27d6a9eae65ca367b6ef3993eea261159d9d2ab37ce444e8979323952e12eb3df319a
languageName: node
linkType: hard
@ -10911,7 +11085,7 @@ __metadata:
languageName: node
linkType: hard
"content-type@npm:~1.0.4":
"content-type@npm:~1.0.4, content-type@npm:~1.0.5":
version: 1.0.5
resolution: "content-type@npm:1.0.5"
checksum: 585847d98dc7fb8035c02ae2cb76c7a9bd7b25f84c447e5ed55c45c2175e83617c8813871b4ee22f368126af6b2b167df655829007b21aa10302873ea9c62662
@ -10946,6 +11120,13 @@ __metadata:
languageName: node
linkType: hard
"cookie@npm:0.6.0":
version: 0.6.0
resolution: "cookie@npm:0.6.0"
checksum: c1f8f2ea7d443b9331680598b0ae4e6af18a618c37606d1bbdc75bec8361cce09fe93e727059a673f2ba24467131a9fb5a4eec76bb1b149c1b3e1ccb268dc583
languageName: node
linkType: hard
"cookie@npm:^0.4.1":
version: 0.4.2
resolution: "cookie@npm:0.4.2"
@ -12693,7 +12874,87 @@ __metadata:
languageName: node
linkType: hard
"esbuild@npm:^0.19.3, esbuild@npm:~0.19.10":
"esbuild@npm:^0.20.1":
version: 0.20.2
resolution: "esbuild@npm:0.20.2"
dependencies:
"@esbuild/aix-ppc64": "npm:0.20.2"
"@esbuild/android-arm": "npm:0.20.2"
"@esbuild/android-arm64": "npm:0.20.2"
"@esbuild/android-x64": "npm:0.20.2"
"@esbuild/darwin-arm64": "npm:0.20.2"
"@esbuild/darwin-x64": "npm:0.20.2"
"@esbuild/freebsd-arm64": "npm:0.20.2"
"@esbuild/freebsd-x64": "npm:0.20.2"
"@esbuild/linux-arm": "npm:0.20.2"
"@esbuild/linux-arm64": "npm:0.20.2"
"@esbuild/linux-ia32": "npm:0.20.2"
"@esbuild/linux-loong64": "npm:0.20.2"
"@esbuild/linux-mips64el": "npm:0.20.2"
"@esbuild/linux-ppc64": "npm:0.20.2"
"@esbuild/linux-riscv64": "npm:0.20.2"
"@esbuild/linux-s390x": "npm:0.20.2"
"@esbuild/linux-x64": "npm:0.20.2"
"@esbuild/netbsd-x64": "npm:0.20.2"
"@esbuild/openbsd-x64": "npm:0.20.2"
"@esbuild/sunos-x64": "npm:0.20.2"
"@esbuild/win32-arm64": "npm:0.20.2"
"@esbuild/win32-ia32": "npm:0.20.2"
"@esbuild/win32-x64": "npm:0.20.2"
dependenciesMeta:
"@esbuild/aix-ppc64":
optional: true
"@esbuild/android-arm":
optional: true
"@esbuild/android-arm64":
optional: true
"@esbuild/android-x64":
optional: true
"@esbuild/darwin-arm64":
optional: true
"@esbuild/darwin-x64":
optional: true
"@esbuild/freebsd-arm64":
optional: true
"@esbuild/freebsd-x64":
optional: true
"@esbuild/linux-arm":
optional: true
"@esbuild/linux-arm64":
optional: true
"@esbuild/linux-ia32":
optional: true
"@esbuild/linux-loong64":
optional: true
"@esbuild/linux-mips64el":
optional: true
"@esbuild/linux-ppc64":
optional: true
"@esbuild/linux-riscv64":
optional: true
"@esbuild/linux-s390x":
optional: true
"@esbuild/linux-x64":
optional: true
"@esbuild/netbsd-x64":
optional: true
"@esbuild/openbsd-x64":
optional: true
"@esbuild/sunos-x64":
optional: true
"@esbuild/win32-arm64":
optional: true
"@esbuild/win32-ia32":
optional: true
"@esbuild/win32-x64":
optional: true
bin:
esbuild: bin/esbuild
checksum: 663215ab7e599651e00d61b528a63136e1f1d397db8b9c3712540af928c9476d61da95aefa81b7a8dfc7a9fdd7616fcf08395c27be68be8c99953fb461863ce4
languageName: node
linkType: hard
"esbuild@npm:~0.19.10":
version: 0.19.11
resolution: "esbuild@npm:0.19.11"
dependencies:
@ -13456,15 +13717,15 @@ __metadata:
linkType: hard
"express@npm:^4.17.1":
version: 4.18.2
resolution: "express@npm:4.18.2"
version: 4.19.2
resolution: "express@npm:4.19.2"
dependencies:
accepts: "npm:~1.3.8"
array-flatten: "npm:1.1.1"
body-parser: "npm:1.20.1"
body-parser: "npm:1.20.2"
content-disposition: "npm:0.5.4"
content-type: "npm:~1.0.4"
cookie: "npm:0.5.0"
cookie: "npm:0.6.0"
cookie-signature: "npm:1.0.6"
debug: "npm:2.6.9"
depd: "npm:2.0.0"
@ -13490,7 +13751,7 @@ __metadata:
type-is: "npm:~1.6.18"
utils-merge: "npm:1.0.1"
vary: "npm:~1.1.2"
checksum: 869ae89ed6ff4bed7b373079dc58e5dddcf2915a2669b36037ff78c99d675ae930e5fe052b35c24f56557d28a023bb1cbe3e2f2fb87eaab96a1cedd7e597809d
checksum: 3fcd792536f802c059789ef48db3851b87e78fba103423e524144d79af37da7952a2b8d4e1a007f423329c7377d686d9476ac42e7d9ea413b80345d495e30a3a
languageName: node
linkType: hard
@ -13553,15 +13814,6 @@ __metadata:
languageName: node
linkType: hard
"fast-check@npm:^3.16.0":
version: 3.16.0
resolution: "fast-check@npm:3.16.0"
dependencies:
pure-rand: "npm:^6.0.0"
checksum: 4a14945b885ef2d75c3252a067a4cfa2440a2c0da18341d514be3803fafb616b0ec68806071f29e1267a85c7a9e4a5e192ae5e592727d8d2e66389f946be472c
languageName: node
linkType: hard
"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3":
version: 3.1.3
resolution: "fast-deep-equal@npm:3.1.3"
@ -20425,14 +20677,14 @@ __metadata:
languageName: node
linkType: hard
"postcss@npm:^8.4.19, postcss@npm:^8.4.27, postcss@npm:^8.4.35, postcss@npm:^8.4.4":
version: 8.4.35
resolution: "postcss@npm:8.4.35"
"postcss@npm:^8.4.19, postcss@npm:^8.4.27, postcss@npm:^8.4.38, postcss@npm:^8.4.4":
version: 8.4.38
resolution: "postcss@npm:8.4.38"
dependencies:
nanoid: "npm:^3.3.7"
picocolors: "npm:^1.0.0"
source-map-js: "npm:^1.0.2"
checksum: 93a7ce50cd6188f5f486a9ca98950ad27c19dfed996c45c414fa242944497e4d084a8760d3537f078630226f2bd3c6ab84b813b488740f4432e7c7039cd73a20
source-map-js: "npm:^1.2.0"
checksum: 6e44a7ed835ffa9a2b096e8d3e5dfc6bcf331a25c48aeb862dd54e3aaecadf814fa22be224fd308f87d08adf2299164f88c5fd5ab1c4ef6cbd693ceb295377f4
languageName: node
linkType: hard
@ -20833,19 +21085,7 @@ __metadata:
languageName: node
linkType: hard
"raw-body@npm:2.5.1":
version: 2.5.1
resolution: "raw-body@npm:2.5.1"
dependencies:
bytes: "npm:3.1.2"
http-errors: "npm:2.0.0"
iconv-lite: "npm:0.4.24"
unpipe: "npm:1.0.0"
checksum: 280bedc12db3490ecd06f740bdcf66093a07535374b51331242382c0e130bb273ebb611b7bc4cba1b4b4e016cc7b1f4b05a6df885a6af39c2bc3b94c02291c84
languageName: node
linkType: hard
"raw-body@npm:^2.2.0":
"raw-body@npm:2.5.2, raw-body@npm:^2.2.0":
version: 2.5.2
resolution: "raw-body@npm:2.5.2"
dependencies:
@ -21838,23 +22078,25 @@ __metadata:
languageName: node
linkType: hard
"rollup@npm:^4.2.0":
version: 4.9.5
resolution: "rollup@npm:4.9.5"
"rollup@npm:^4.13.0":
version: 4.13.2
resolution: "rollup@npm:4.13.2"
dependencies:
"@rollup/rollup-android-arm-eabi": "npm:4.9.5"
"@rollup/rollup-android-arm64": "npm:4.9.5"
"@rollup/rollup-darwin-arm64": "npm:4.9.5"
"@rollup/rollup-darwin-x64": "npm:4.9.5"
"@rollup/rollup-linux-arm-gnueabihf": "npm:4.9.5"
"@rollup/rollup-linux-arm64-gnu": "npm:4.9.5"
"@rollup/rollup-linux-arm64-musl": "npm:4.9.5"
"@rollup/rollup-linux-riscv64-gnu": "npm:4.9.5"
"@rollup/rollup-linux-x64-gnu": "npm:4.9.5"
"@rollup/rollup-linux-x64-musl": "npm:4.9.5"
"@rollup/rollup-win32-arm64-msvc": "npm:4.9.5"
"@rollup/rollup-win32-ia32-msvc": "npm:4.9.5"
"@rollup/rollup-win32-x64-msvc": "npm:4.9.5"
"@rollup/rollup-android-arm-eabi": "npm:4.13.2"
"@rollup/rollup-android-arm64": "npm:4.13.2"
"@rollup/rollup-darwin-arm64": "npm:4.13.2"
"@rollup/rollup-darwin-x64": "npm:4.13.2"
"@rollup/rollup-linux-arm-gnueabihf": "npm:4.13.2"
"@rollup/rollup-linux-arm64-gnu": "npm:4.13.2"
"@rollup/rollup-linux-arm64-musl": "npm:4.13.2"
"@rollup/rollup-linux-powerpc64le-gnu": "npm:4.13.2"
"@rollup/rollup-linux-riscv64-gnu": "npm:4.13.2"
"@rollup/rollup-linux-s390x-gnu": "npm:4.13.2"
"@rollup/rollup-linux-x64-gnu": "npm:4.13.2"
"@rollup/rollup-linux-x64-musl": "npm:4.13.2"
"@rollup/rollup-win32-arm64-msvc": "npm:4.13.2"
"@rollup/rollup-win32-ia32-msvc": "npm:4.13.2"
"@rollup/rollup-win32-x64-msvc": "npm:4.13.2"
"@types/estree": "npm:1.0.5"
fsevents: "npm:~2.3.2"
dependenciesMeta:
@ -21872,8 +22114,12 @@ __metadata:
optional: true
"@rollup/rollup-linux-arm64-musl":
optional: true
"@rollup/rollup-linux-powerpc64le-gnu":
optional: true
"@rollup/rollup-linux-riscv64-gnu":
optional: true
"@rollup/rollup-linux-s390x-gnu":
optional: true
"@rollup/rollup-linux-x64-gnu":
optional: true
"@rollup/rollup-linux-x64-musl":
@ -21888,7 +22134,7 @@ __metadata:
optional: true
bin:
rollup: dist/bin/rollup
checksum: 4debf528e63edea5c3f5d38e399c6dd7287e2977d90d2d3ce38d4b3412289e2081aff8f8488a11b1699c786f2e904e9e150f30d576fe9316b5b97df0e80b1bce
checksum: 08ad387c16ac3595bbcc7d2cbb7c3cb4366306244b92145819e5671dbf6a7540760985b87656a7cd4238a507b6deaaa020065c688b2249d0baadce4594d0d0c1
languageName: node
linkType: hard
@ -22422,10 +22668,10 @@ __metadata:
languageName: node
linkType: hard
"source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2":
version: 1.0.2
resolution: "source-map-js@npm:1.0.2"
checksum: 38e2d2dd18d2e331522001fc51b54127ef4a5d473f53b1349c5cca2123562400e0986648b52e9407e348eaaed53bce49248b6e2641e6d793ca57cb2c360d6d51
"source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.0":
version: 1.2.0
resolution: "source-map-js@npm:1.2.0"
checksum: 74f331cfd2d121c50790c8dd6d3c9de6be21926de80583b23b37029b0f37aefc3e019fa91f9a10a5e120c08135297e1ecf312d561459c45908cb1e0e365f49e5
languageName: node
linkType: hard
@ -24667,8 +24913,8 @@ __metadata:
linkType: hard
"vite@npm:^3.0.0 || ^4.0.0, vite@npm:^4.1.4":
version: 4.5.2
resolution: "vite@npm:4.5.2"
version: 4.5.3
resolution: "vite@npm:4.5.3"
dependencies:
esbuild: "npm:^0.18.10"
fsevents: "npm:~2.3.2"
@ -24702,18 +24948,18 @@ __metadata:
optional: true
bin:
vite: bin/vite.js
checksum: 3feb39f8da038fb2b1ad074c19a9579c263c1d7a872c5c6e0269b82d67805bb8c93cf9fc393e852807483ae9a918b1ac2861c72f73ee92fb3935ea68333a2cf7
checksum: 82efe1bc6d6848f8c97b71f1dc5b2fba2c3f30b2207ef2451c8df1a0ed5903c55714d7cd8ecb75879b488661d97f6e01a4ad758b5ef6a50a14338f916233bfa4
languageName: node
linkType: hard
"vite@npm:^5.0.0":
version: 5.1.6
resolution: "vite@npm:5.1.6"
version: 5.2.8
resolution: "vite@npm:5.2.8"
dependencies:
esbuild: "npm:^0.19.3"
esbuild: "npm:^0.20.1"
fsevents: "npm:~2.3.3"
postcss: "npm:^8.4.35"
rollup: "npm:^4.2.0"
postcss: "npm:^8.4.38"
rollup: "npm:^4.13.0"
peerDependencies:
"@types/node": ^18.0.0 || >=20.0.0
less: "*"
@ -24742,7 +24988,7 @@ __metadata:
optional: true
bin:
vite: bin/vite.js
checksum: f48073e93ead62fa58034398442de4517c824b3e50184f8b4059fb24077a26f2c04e910e29d7fb7ec51ea53eb61b9c7d94d56b14a38851de80c67480094cc79d
checksum: caa40343c2c4e6d8e257fccb4c3029f62909c319a86063ce727ed550925c0a834460b0d1ca20c4d6c915f35302aa1052f6ec5193099a47ce21d74b9b817e69e1
languageName: node
linkType: hard