@keenmate/svelte-treeview 5.0.0-rc11 → 5.0.0-rc12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +10 -10
- package/dist/components/ContextMenuLevel.svelte +12 -1
- package/dist/components/Node.svelte +6 -2
- package/dist/components/Tree.svelte +47 -0
- package/dist/components/Tree.svelte.d.ts +26 -2
- package/dist/constants.generated.d.ts +1 -1
- package/dist/constants.generated.js +1 -1
- package/dist/core/TreeController.svelte.d.ts +21 -8
- package/dist/core/TreeController.svelte.js +81 -34
- package/dist/ltree/types.d.ts +9 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [5.0.0-rc12] - 2026-06-28 [PUBLISHED]
|
|
9
|
+
|
|
10
|
+
Showcase / docs polish (a context-menu doc note and a theming-playground CSS fix surfaced by manual review of `/examples/theming` and `/examples/tree-editor`), plus one library correctness fix: keyboard copy/cut/paste threw on default `$state` data.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Context menu: three fixes surfaced by driving the library menu from the Windows Explorer demo's right pane**. (1) **Re-opening on a different node no longer leaves the menu stuck** — the Floating-UI positioning `$effect` in `Tree.svelte` only depended on `contextMenuVisible`, so a second right-click on another node (visible stays `true`) didn't re-run it; `autoUpdate` stayed anchored to the first node's coordinates and only recomputes on scroll/resize, so the menu sat at the old spot until you left-clicked to close it (`visible → false`) and right-clicked again. The effect now also tracks `contextMenuX` / `contextMenuY` / `contextMenuNode`, so it tears down and re-anchors when the menu jumps nodes. (2) **Clicking a menu item now auto-closes the menu** — `ContextMenuLevel` (the `getContextMenuItemsCallback` render path) ran the item's `onclick` but never dismissed the menu, relying on each consumer to call the provided `close()` in every handler (the test/demo callbacks did so 15×). Leaf-item activation (click + Enter/Space) now closes the menu in a `finally`, so `close()` is optional — calling it anyway is harmless. Items that act incrementally (a toggle, a multi-step action) can opt out with the new `shouldCloseOnClick: false` and keep the menu open, dismissing it themselves via the captured `close` when finished (see Added). The snippet-based `ContextMenuItem` path is unchanged (its contract still hands `close` to the consumer). (3) **Dropped a `binding_property_non_reactive` warning** — `ContextMenuLevel` held item element refs in a plain array while `bind:this={itemEls[i]}` wrote into it; it's now `$state`, which also lets the submenu-positioning effect re-run once the parent item attaches. Regression coverage: `e2e/context-menu.spec.ts` gains a reposition-on-second-right-click case; `e2e/windows-explorer.spec.ts` asserts a menu item with no manual `close()` still dismisses the menu.
|
|
14
|
+
- **A copied node now stays on the clipboard across multiple pastes — `pasteNodes` only clears the clipboard on cut**: `pasteNodes` called `clearClipboard()` unconditionally after every paste, so a `copy` was single-use — paste once and the clipboard was empty, contradicting every file manager / IDE (Finder, Explorer, VS Code) where a copy can be pasted repeatedly. Now the clipboard is cleared only when `clip.operation === 'cut'` (a move is one-shot — its source nodes were just removed); a `copy` persists so it can be pasted again (each paste re-runs `beforePasteCallback`, so successive pastes into the same parent get `Copy 1`, `Copy 2`, … via the demo's collision-naming, which strips any prior ` Copy N` first so repeats don't compound into `Copy 1 Copy 1`). Applied to both the auto-handled path and the `shouldAutoHandlePaste=false` forwarding path; cut-dimming (`cutPaths`) is still cleared on every paste. This brings svelte-treeview into parity with `@keenmate/web-treeview`, whose `pasteNodes` already clears the clipboard only inside its `if (operation === 'cut')` block — svelte-treeview was the diverging side. Regression coverage in `e2e/clipboard.spec.ts`: a copy pasted twice adds two nodes, and a cut's second paste is a no-op (clipboard emptied by the move).
|
|
15
|
+
- **Keyboard copy/cut/paste (`copyNodes` / `cutNodes`) no longer throw on `$state` data — `structuredClone` can't clone a Svelte reactive proxy**: `TreeController._collectClipboardEntry` snapshotted each node's data with `structuredClone(node.data)`. For any consumer passing the default deeply-reactive `$state` array (the common case), `node.data` is a `Proxy`, and `structuredClone` throws `DataCloneError: ... could not be cloned` — so the copy silently failed and a subsequent paste found an empty clipboard. Swapped both clone sites (the root entry and the recursive descendant walk) to `$state.snapshot(...)`, which deproxies reactive state into a plain deep clone and is a no-op-style passthrough for already-plain data, so it's correct whether the consumer uses `$state`, `$state.raw`, or unproxied arrays. Surfaced while wiring Ctrl/Cmd+C/X/V into `/examples/tree-editor` (which uses plain `$state`). Regression coverage: new `e2e/clipboard.spec.ts` against a new `/test/clipboard` fixture that deliberately uses plain `$state` data — copy→paste duplicates a node, cut→paste moves it (count unchanged), cut dims + Escape un-dims, and Ctrl+click multi-select copies several nodes at once.
|
|
16
|
+
- **Brand themes in the `/examples/theming` "Dark Mode Playground" no longer lose their surface in per-instance light mode — Glass in particular stopped rendering white-on-white**: the demo's generic per-instance surface rules (`:global(.playground-wrapper:has(.stv__container[data-theme='light'])) { background: #f9fafb }` and the matching dark variant) carried specificity `(0,3,0)`, which outranks every brand theme's *base* rule `:global(.playground-wrapper.brand-X)` at `(0,2,0)`. In **inherit** and **dark** modes nothing collided — there's no light `:has()` match, and each brand owns a higher-specificity `.brand-X:has(...dark)` rule that wins — but in **light** mode the brand themes have no light-variant rule, so the generic gray override won and reverted the wrapper to plain `#f9fafb`. Most brand themes degraded quietly (their `--base-*` tokens still themed the tree, dark text stayed readable), but **Glass** forces `--base-text-color-1: #ffffff`, so picking `theme="light"` left white labels on a now-white surface — the node text vanished, only the emoji icons showed. Fix scopes the four generic `:has()` surface-flip rules (two wrapper, two tree) to `.brand-default` only: the unstyled default theme is the sole case that needs the generic flip, and every real brand theme already covers all three modes via its base rule (light) and `.brand-X:has(...dark)` variant (dark). Regression coverage: new `e2e/theming-brand.spec.ts` (6 cases) asserts each gradient brand (material / neon / soft / glass / forest) keeps a `linear-gradient` wrapper surface across inherit / dark / light, with a dedicated check that Glass light mode renders its `rgb(102, 126, 234)` purple — the existing `e2e/theming.spec.ts` only exercised dark-mode *signal precedence* via a synthetic Debug theme and never touched the real brand themes, which is why this slipped through.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **`ContextMenuItem.shouldCloseOnClick?: boolean` (default `true`) — per-item opt-out of the new auto-close**: the callback menu now auto-closes after a leaf item is activated (see Fixed), which is the right default for one-shot commands (Open, Delete, Copy path). But some items act *incrementally* — a toggle, a counter, a multi-step action — and want the menu to stay open so the user can click again or read updated state. Setting `shouldCloseOnClick: false` on a `ContextMenuItem` suppresses the auto-close for that entry only; the handler then owns dismissal and calls the `close` callback it captured from `getContextMenuItemsCallback(node, close, …)` (or the snippet's `close` prop) when it's done. Named per the codebase's `should*` boolean-prop convention (sibling fields `isDisabled` / `isVisible`). Implemented in `ContextMenuLevel.svelte` by gating both the click and Enter/Space `finally` close on `item.shouldCloseOnClick !== false`. The `/test/context-menu` fixture gains a "Bump (stays open)" item that increments a counter without closing; `e2e/context-menu.spec.ts` asserts the menu survives repeated clicks of it and that a normal item still dismisses afterward.
|
|
20
|
+
- **Data-driven per-node class hooks `nodeClass?: (node) => string` and `nodeContentClass?: (node) => string`**: previously the only per-row class hooks were *state* classes (`highlightedNodeClass` / `focusedNodeClass` / `dragOverNodeClass`) — there was no way to tag a row from its own data (e.g. `is-folder` / `is-file`, a status colour, a grid-participation class) without reaching into `[data-tree-path="…"]` selectors. These two callbacks run per node and return class(es) applied to `.stv__node` (`nodeClass`) and `.stv__node-content` (`nodeContentClass`); they recompute on the node's `_rev` bump like the rest of the render. Plumbed through `NodeConfig` (stable context reference, no prop-drilling) so they don't defeat flat-mode diffing. Wired end-to-end on `<Tree>` (prop → `$effect` sync → `update()` passthrough) and on the `createTreeController` props. New `/test/node-class` fixture + `e2e/node-class.spec.ts` (3 cases) assert the classes land on the right elements and are stylable from app CSS. Motivated by the Explorer demo's right pane needing folder/file row styling.
|
|
21
|
+
- **Public `onNodeDoubleClick?: (node) => void` `<Tree>` event — a real double-click notification, reliable in flat rendering where the native `dblclick` is not**: there was previously no way for a consumer to react to a node double-click. The controller already did manual double-click *detection* (tracking last path + timestamp on the controller, 400ms window) but only internally and only for `clickBehavior="select"`, where a double toggles expand/collapse — and it deliberately avoids the browser's native `dblclick` because the first click bumps `node._rev`, the flat-mode `{#each}` destroys and recreates the row, and the second click lands on a fresh element so the browser refuses to synthesize a `dblclick`. That detection is now generalized to **every** `clickBehavior` and fires the new `onNodeDoubleClick` event; `select` mode additionally keeps its built-in expand/collapse-on-double. On a detected double the second click is consumed (early return) so the gesture reads as a single open rather than a re-toggle — a double therefore fires `onNodeClick` once (the first click) plus `onNodeDoubleClick` once. Detection is gated to genuine UI clicks (a new internal `uiClick` flag on the click path), so programmatic `highlightNode` / `selectNode` calls can never be mistaken for a double-click. Wired end-to-end through `<Tree>` (prop → `$effect` sync → `update()` passthrough) and dogfooded in the Windows Explorer demo's nav tree (double-click a folder opens it in the right pane). New `/test/double-click` fixture + `e2e/double-click.spec.ts` (4 cases) cover firing in both `select` and `expand-and-focus` modes, the single-click negative, and the two-different-nodes negative.
|
|
22
|
+
- **Windows File Explorer demo at `/examples/custom-layout`** (`WindowsExplorer.svelte`): a near-complete dual-pane Explorer clone where **both panes are `<Tree>` instances** with custom `nodeTemplate` renderers, showcasing how far the renderer composes. The left nav pane is a hierarchical `<Tree>` fed a folders-only slice (`leafIconClass=""` so childless folders show no marker, like Explorer); the right pane is a **flat `<Tree>`** rendering the details list (Name / Date modified / Type / Size) as a 4-column grid — the columns line up because each row's `.stv__node-content` is a CSS grid sharing the sticky header's track template inside one scroll area (toggle icons hidden, indent zeroed via `--stv-node-indent-per-level: 0`). The right pane gets **selection** (`selectionMode="multi"` + `bind:highlightedPaths`), **keyboard navigation**, the **right-click context menu** (`getContextMenuItemsCallback` → Open / Copy path / Rename / Delete / New folder / Select all), **double-click-to-open** (`onNodeDoubleClick`), and **per-row folder/file classes** (`nodeClass`) all from the library — replacing the earlier hand-rolled list, custom selection set, debounced click handler and bespoke floating menu. The two panes stay in sync through the tree's public API — `expandNodes()` + `focusNode()` — driven from `onNodeClick` / `onNodeDoubleClick`. The dataset models a real `C:\Windows` install (~30 folders, multi-level subfolders + files). Also: per-column sorting (folders-first); **recursive search** across the current folder's subtree with each hit's location shown and **wildcard globs** (`*.dll`, `img?.jpg`); and working New folder / Rename / Delete that mutate the data and restore nav-tree expansion via `getExpandedPaths()` / `setExpandedPaths()`. Rendered as the first card on the existing Custom Layout page (the iOS-Files and Notepad++ demos remain below). Smoke-covered by `e2e/windows-explorer.spec.ts` (12 cases).
|
|
23
|
+
- **Public `onCopy` / `onCut` / `onPaste` `<Tree>` events — post-operation clipboard notifications, symmetric with the `beforeCopy/Cut/Paste` interceptors**: these belong to the library's **Events** family (`on*`), distinct from the `before*Callback` interceptor and `get*Callback` provider families. The controller already fired an `onPaste` handler, but it was only reachable through `createTreeController` props — the `<Tree>` component never forwarded it, and there were no copy/cut equivalents at all. Added `onCopy?: (paths: string[]) => void` and `onCut?: (paths: string[]) => void` (fired after `copyNodes` / `cutNodes` succeed, with the final paths post-interceptor) on the controller, and forwarded all three (`onCopy`, `onCut`, `onPaste`) as `<Tree>` props with the usual `$effect` sync + `update()` passthrough. This completes the clipboard surface: `before*Callback` to rewrite/block, `on*` to react. The "Copy N"-on-collision rename is done in `beforePasteCallback` (mutating the clipboard entries' data before insert), not in `onPaste` — the `on*` events fire after the tree has already changed. Documented in `docs/usage.md` (event table + a new clipboard-interceptor table).
|
|
24
|
+
- **`/examples/tree-editor` gains multi-select + keyboard copy/cut/paste (Ctrl/Cmd + C / X / V)**: the editor now sets `selectionMode="multi"` (Ctrl/Cmd+click and Shift+click extend `highlightedPaths`) and wires the clipboard through the documented `onTreeKeydown(event, controller)` hook. The library already implemented the operations (`copyNodes` / `cutNodes` / `pasteNodes` / `cancelCut`, a shared cross-tree clipboard, and `Escape`→cancel-cut) but intentionally leaves the key *bindings* to the consumer, because paste needs an app-specific `transformData` (the demo assigns fresh ids so pasted copies don't collide) and a target path (the focused node, or root). Copy/cut act on the highlight set when present, else the focused node; paste lands as a child of the focused node. Cut nodes dim until pasted (the demo renders this from a local set in its `nodeTemplate` — the controller exposes `cutPaths` but doesn't paint dimming itself), and `mod` is `event.ctrlKey || event.metaKey` so it works with Cmd on macOS. The demo also wires the new `onCopy` / `onCut` / `onPaste` events (for its activity log) and a `beforePasteCallback` that (a) appends `"Copy 1"` / `"Copy 2"` / … to a pasted copy whose name already exists under the target — stripping any prior ` Copy N` first so repeated pastes number sequentially instead of compounding (only the root is renamed; descendants keep their names; a move keeps the name), and (b) redirects a paste that lands on the copied node itself — Ctrl/Cmd+C then Ctrl/Cmd+V without moving focus — into that node's parent, so the copy drops in as a sibling ("duplicate in the same folder"). Because `beforePasteCallback` runs before the paste-into-self guard, the redirect also sidesteps that guard cleanly.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- **Context Menu Examples page leads with a "Positioning is handled by Floating UI" note**: a short explainer (moved to sit directly below the page header, above the first example card) documenting that the root menu and submenus are placed by `@floating-ui/dom` via `computePosition` + `offset` / `flip` / `shift` / `autoUpdate`, that submenus flip left when there's no room on the right, and that `contextMenuXOffset` / `contextMenuYOffset` feed the root menu's cursor offset.
|
|
28
|
+
- **`/examples/tree-editor` polish**: single click now selects without expanding and double-click toggles expand (`clickBehavior="select"`, was the default `'expand-and-focus'`), and the code-example import was corrected from a default import (`import Tree from …`) to the named `import { Tree } from '@keenmate/svelte-treeview'` — `Tree` is a named export, so the default form resolved to `undefined`.
|
|
29
|
+
|
|
8
30
|
## [5.0.0-rc11] - 2026-06-25 [PUBLISHED]
|
|
9
31
|
|
|
10
32
|
Consolidated release folding the work previously staged under the unpublished rc12 and rc13 headings (npm's last published `rc` was rc10; the rc12/rc13 labels were never shipped, so this lands as rc11). Three themes: a `/validate-web-component` alignment sweep against the BlissFramework `web-components` guidelines (BEM/prefix `ltree`→`stv` rename, `is*/should*` boolean naming, dark-mode Strategy-B rewrite, README split), the three-level selection / highlight / focus API normalization, and a batch of drag-drop and highlight-marker correctness fixes. Within-RC references to "rc12" below denote earlier iterations of this same unpublished cycle.
|
package/README.md
CHANGED
|
@@ -31,6 +31,16 @@ svelte-treeview is broader (two rendering modes, easier vertical guide lines via
|
|
|
31
31
|
|
|
32
32
|
Browse interactive code examples and the full API reference at **[svelte-treeview.keenmate.dev](https://svelte-treeview.keenmate.dev)**
|
|
33
33
|
|
|
34
|
+
## What's New in v5.0.0-rc12
|
|
35
|
+
|
|
36
|
+
- **Context menus — items auto-close on click, with a `shouldCloseOnClick` opt-out, plus a reposition fix** — Activating a leaf item in a `getContextMenuItemsCallback` menu now dismisses the menu in a `finally`, so handlers no longer need to call the provided `close()` themselves (the test/demo callbacks were doing it 15×); calling it anyway is harmless. Items that act incrementally — a toggle, a counter, a multi-step action — opt out with the new `ContextMenuItem.shouldCloseOnClick: false` (default `true`, named per the `should*` convention) and keep the menu open, dismissing it via the captured `close` when finished. Also fixed: re-opening the menu on a *different* node no longer leaves it stuck at the first node's coordinates — the Floating-UI positioning effect now tracks the menu's X/Y/node, not just its visibility — and a `binding_property_non_reactive` warning is gone.
|
|
37
|
+
- **Per-node class hooks — `nodeClass` and `nodeContentClass` callbacks** — Two new data-driven callbacks let you tag a row from its own data (`is-folder`/`is-file`, a status color, a grid class) without reaching into `[data-tree-path="…"]` selectors. They run per node, return class(es) applied to `.stv__node` and `.stv__node-content` respectively, and recompute on the node's `_rev` bump like the rest of the render. Plumbed through `NodeConfig` so they don't defeat flat-mode diffing, and wired end-to-end on `<Tree>` (prop → `$effect` sync → `update()` passthrough) and on `createTreeController`.
|
|
38
|
+
- **Double-click — a real `onNodeDoubleClick` event, reliable in flat rendering** — There was previously no way to react to a node double-click; the native `dblclick` is unreliable in flat mode because the first click bumps `_rev` and recreates the row out from under the browser. The controller's existing manual detection (last-path + timestamp, 400ms window) is now generalized to *every* `clickBehavior` and fires the new `onNodeDoubleClick`; `select` mode still toggles expand/collapse on a double. Detection is gated to genuine UI clicks, so programmatic `highlightNode`/`selectNode` can never be mistaken for a double.
|
|
39
|
+
- **Clipboard events — `onCopy` / `onCut` / `onPaste` on `<Tree>`** — Post-operation notifications symmetric with the `beforeCopy/Cut/Paste` interceptors complete the clipboard surface (`before*Callback` to rewrite or block, `on*` to react). `onPaste` existed on the controller but `<Tree>` never forwarded it and there were no copy/cut equivalents; all three are now controller events forwarded as `<Tree>` props with the usual sync + `update()` passthrough. `/examples/tree-editor` gains multi-select and keyboard Ctrl/Cmd+C/X/V wired through the documented `onTreeKeydown` hook.
|
|
40
|
+
- **Clipboard correctness — a copy now survives repeated pastes, and `$state` data no longer throws** — `pasteNodes` used to clear the clipboard after *every* paste, making a copy single-use (contradicting Finder/Explorer/VS Code); it now clears only on `cut` (a move is one-shot), so a copy can be pasted repeatedly. Separately, copy/cut threw `DataCloneError` on the common default `$state` data because `structuredClone` can't clone a Svelte reactive proxy — both clone sites now use `$state.snapshot(...)`, which deproxies reactive state and passes plain data through unchanged. Both fixed with new Playwright coverage.
|
|
41
|
+
- **New demo — Windows File Explorer, where both panes are `<Tree>` instances** — A near-complete dual-pane Explorer clone at `/examples/custom-layout`: a hierarchical nav `<Tree>` on the left and a *flat* `<Tree>` rendering the details list as a 4-column CSS grid on the right, with selection, keyboard nav, right-click menu, double-click-to-open, and per-row folder/file classes all driven from the library — replacing the earlier hand-rolled list and bespoke menu. It dogfoods this release's new surfaces (`nodeClass`, `onNodeDoubleClick`, the context-menu auto-close) and stays in sync through the public `expandNodes()` / `focusNode()` API.
|
|
42
|
+
- **Theming — brand themes keep their surface in per-instance light mode** — In the `/examples/theming` "Dark Mode Playground", the demo's generic `:has()` surface rules (specificity `(0,3,0)`) outranked each brand theme's base rule `(0,2,0)`, so picking `theme="light"` reverted the wrapper to plain gray — and Glass, which forces white text, rendered white-on-white with the labels vanishing. The four generic surface-flip rules are now scoped to `.brand-default` only; every real brand theme already covers all three modes. New `e2e/theming-brand.spec.ts` (6 cases) guards each gradient brand across inherit/dark/light.
|
|
43
|
+
|
|
34
44
|
## What's New in v5.0.0-rc11
|
|
35
45
|
|
|
36
46
|
- **Selection model — three symmetric imperative families (highlight / select / focus)** — The imperative API had drifted into aliases and asymmetries: `selectNode`/`selectNodes` secretly drove the *highlight* set, the highlight set cleared with `clearHighlight()` while checkboxes cleared with `deselectAll()`, and `highlightNodes()` silently replaced rather than added. The surface is now three concerns × one shape — **highlight** (`highlightNode`/`highlightNodes`/`setHighlightedPaths`/`highlightAll`/`clearHighlight`) for the UI multi-select set, **select** (`selectNode`/`selectNodes`/`setSelectedPaths`/`selectAll`/`deselectNode`/`clearSelection`) for the checkbox set, and **focus** (`focusNode`/`clearFocus`) for the single cursor. Two shared types, `HighlightMode` and `TreeMutationOptions { silent? }`, replace the inline literals. Breaking within the RC: `deselectAll` → `clearSelection`, `highlightNodes` is now additive, and `selectNode`/`selectNodes` are now real checkbox setters.
|
|
@@ -41,16 +51,6 @@ Browse interactive code examples and the full API reference at **[svelte-treevie
|
|
|
41
51
|
- **Context menus — positioning ported to `@floating-ui/dom`** — Root menu and submenus are now placed by `computePosition` + `autoUpdate` with `flip()`/`shift()` instead of raw inline `left/top` and CSS `:hover`, so menus flip above the cursor near the viewport bottom and submenus slide sideways instead of clipping. `contextMenuXOffset`/`contextMenuYOffset` keep their exact semantics.
|
|
42
52
|
- **Docs — README cut to ~350 lines with topical docs split out** — The README dropped from 1103 to 347 lines: Advanced Usage, Styling, and API Reference moved into `docs/usage.md`, `docs/theming.md`, `docs/examples.md`, and `docs/accessibility.md`, with a new intro and a Demos & docs link block up top.
|
|
43
53
|
|
|
44
|
-
## What's New in v5.0.0-rc10
|
|
45
|
-
|
|
46
|
-
- **Built-in dark mode — all four canonical signals covered** — a `dark-mode.css` partial flips the tree's surface, text, border, and accent palette when any of the canonical signals is active: OS preference (`prefers-color-scheme: dark`), page `<html style="color-scheme: dark">` resolved via `light-dark()`, framework theme classes (`[data-theme="dark"]`, `[data-bs-theme="dark"]`, `.dark`), and the new per-instance `theme` prop. Symmetric `light` selectors let a single tree force light on an otherwise-dark page. Zero JavaScript — pure CSS resolution.
|
|
47
|
-
- **`theme` prop on `<Tree>` — per-instance dark/light override** — `'dark' | 'light' | null | undefined`. Forwarded to the root `.stv__container` as `data-theme`, where per-instance CSS selectors take over and beat ambient signals. Leave `undefined` to inherit from the page. (rc10 originally landed this on `.ltree-container`; the BEM rename to `.stv__container` shipped in rc12.)
|
|
48
|
-
- **CSS variables rescoped from `:root` to `.stv__container` — subtree theming actually works** — mirrors the `:host`-scoped pattern from sibling `@keenmate/*` components and is the only way `--base-*` theming flows at subtree scope. A wrapper around the tree that sets `--base-accent-color: red` now re-tints the tree (it previously had no effect because the substitution was frozen at `:root`). Multiple trees on the same page can be themed differently via wrapper-scoped `--base-*` overrides. Consumer note: setting `--stv-*` directly on a wrapper no longer cascades — use `--base-*` on the wrapper or target `.stv__container` explicitly.
|
|
49
|
-
- **`--stv-bg` — the tree paints its own surface** — `.stv__container` gets a `background: var(--stv-bg)` so consumers don't need to wrap the tree in a colored container for a visible surface. Override to `transparent` to restore the pre-rc10 layered behavior. Companion `--stv-elevated-bg` reads through the `--base-elevated-bg` chain for floating chrome (context menu).
|
|
50
|
-
- **CSS file layout aligned with the Bliss web-component guidelines** — all `_xxx.css` partials renamed to `xxx.css`, `main.css` now declares `@layer variables, component, overrides;` and imports each partial into the matching layer. Consumers' unlayered overrides automatically beat every rule in the library — no `!important` needed. Note: if your app ships an unlayered universal CSS reset (`* { padding: 0 }` from normalize / Bootstrap / Tailwind preflight / etc.), wrap it in a low-priority `@layer reset` or the tree's defaults won't apply.
|
|
51
|
-
- **`getIsDropAllowedCallback` prop + `getIsDraggableCallback` seeded at insert-time** — callback variant for `isDropAllowedMember`, matching the pattern rc09 introduced for `getIsExpandedCallback` / `getIsSelectableCallback` / `getIsSelectedCallback`. Also, the existing `getIsDraggableCallback` prop is now actually applied during the seed walk — previously it was only consumed lazily in some paths.
|
|
52
|
-
- **Bug fixes — virtual scroll + `clickBehavior='select'` double-click** — virtual scroll no longer gets stuck at bottom after a filter shrinks the tree. Double-click to expand in `clickBehavior='select'` mode finally works (the browser's native `dblclick` event couldn't fire because the first click destroyed the row via the focus re-render; the controller now detects the double manually).
|
|
53
|
-
|
|
54
54
|
## v5.0: Core/Renderer Split + Virtual Scroll
|
|
55
55
|
|
|
56
56
|
> [!IMPORTANT]
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
|
|
17
17
|
let openIdx = $state<number | null>(null);
|
|
18
18
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
19
|
-
|
|
19
|
+
// $state so `bind:this={itemEls[i]}` writes are reactive and the submenu
|
|
20
|
+
// positioning $effect re-runs once the parent item element is attached.
|
|
21
|
+
let itemEls = $state<HTMLElement[]>([]);
|
|
20
22
|
let submenuEl = $state<HTMLElement | null>(null);
|
|
21
23
|
|
|
22
24
|
function cancelHide() {
|
|
@@ -82,6 +84,13 @@
|
|
|
82
84
|
await item.onclick?.();
|
|
83
85
|
} catch (error) {
|
|
84
86
|
console.error('Context menu callback error:', error);
|
|
87
|
+
} finally {
|
|
88
|
+
// Auto-close after activating a leaf item — selecting an entry dismisses
|
|
89
|
+
// the menu, like every native/desktop menu. Consumers no longer need to
|
|
90
|
+
// call the close callback themselves (doing so anyway is harmless).
|
|
91
|
+
// Opt out with shouldCloseOnClick: false to keep the menu open for incremental
|
|
92
|
+
// actions; the handler then dismisses it via its captured close callback.
|
|
93
|
+
if (item.shouldCloseOnClick !== false) closeContextMenu();
|
|
85
94
|
}
|
|
86
95
|
}}
|
|
87
96
|
onkeydown={async (e) => {
|
|
@@ -91,6 +100,8 @@
|
|
|
91
100
|
await item.onclick?.();
|
|
92
101
|
} catch (error) {
|
|
93
102
|
console.error('Context menu callback error:', error);
|
|
103
|
+
} finally {
|
|
104
|
+
if (item.shouldCloseOnClick !== false) closeContextMenu();
|
|
94
105
|
}
|
|
95
106
|
}
|
|
96
107
|
}}
|
|
@@ -62,6 +62,10 @@
|
|
|
62
62
|
const leafIconClass = $derived(config.leafIconClass);
|
|
63
63
|
const highlightedNodeClass = $derived(config.highlightedNodeClass);
|
|
64
64
|
const focusedNodeClass = $derived(config.focusedNodeClass);
|
|
65
|
+
// Data-driven per-row classes. The callbacks read node data; recompute when the
|
|
66
|
+
// node's _rev bumps (same trigger the rest of the render uses).
|
|
67
|
+
const customNodeClass = $derived(config.nodeClass ? (config.nodeClass(node) ?? '') : '');
|
|
68
|
+
const customNodeContentClass = $derived(config.nodeContentClass ? (config.nodeContentClass(node) ?? '') : '');
|
|
65
69
|
// dragOverNodeClass is applied to the DOM directly by the controller
|
|
66
70
|
// (see hoveredNodeForDrop $effect in TreeController) — no per-Node binding.
|
|
67
71
|
const isCopyAllowed = $derived(config.isCopyAllowed);
|
|
@@ -333,7 +337,7 @@
|
|
|
333
337
|
|
|
334
338
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
335
339
|
<div
|
|
336
|
-
class="stv__node"
|
|
340
|
+
class="stv__node {customNodeClass}"
|
|
337
341
|
id="{node.treeId}-{node.id}"
|
|
338
342
|
data-tree-path="{node.path}"
|
|
339
343
|
style={indentStyle}
|
|
@@ -378,7 +382,7 @@
|
|
|
378
382
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
379
383
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
380
384
|
<div
|
|
381
|
-
class="stv__node-content {node.isHighlighted ? highlightedNodeClass : ''} {node.isFocused && focusedNodeClass ? focusedNodeClass : ''}"
|
|
385
|
+
class="stv__node-content {node.isHighlighted ? highlightedNodeClass : ''} {node.isFocused && focusedNodeClass ? focusedNodeClass : ''} {customNodeContentClass}"
|
|
382
386
|
class:stv__node-content--highlighted={node.isHighlighted && !highlightedNodeClass}
|
|
383
387
|
class:stv__node-content--focused={node.isFocused}
|
|
384
388
|
class:stv__clickable={node.isSelectable}
|
|
@@ -148,11 +148,17 @@
|
|
|
148
148
|
|
|
149
149
|
// EVENTS (on* = fire-and-forget notifications)
|
|
150
150
|
onNodeClick?: (node: LTreeNode<T>) => void;
|
|
151
|
+
onNodeDoubleClick?: (node: LTreeNode<T>) => void;
|
|
151
152
|
onHighlightChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
|
|
152
153
|
onSelectionChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
|
|
153
154
|
onNodeDragStart?: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
154
155
|
onNodeDragOver?: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
155
156
|
onNodeDrop?: (dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => void;
|
|
157
|
+
// Post-operation clipboard notifications (fired AFTER the op). onCopy/onCut
|
|
158
|
+
// receive the final paths; onPaste receives the PasteResult.
|
|
159
|
+
onCopy?: (paths: string[]) => void;
|
|
160
|
+
onCut?: (paths: string[]) => void;
|
|
161
|
+
onPaste?: (result: import('../core/TreeController.svelte.js').PasteResult<T>) => void;
|
|
156
162
|
|
|
157
163
|
// INTERCEPTORS (before*Callback = can modify/block)
|
|
158
164
|
/**
|
|
@@ -178,6 +184,10 @@
|
|
|
178
184
|
bodyClass?: string | null | undefined;
|
|
179
185
|
highlightedNodeClass?: string | null | undefined;
|
|
180
186
|
focusedNodeClass?: string | null | undefined;
|
|
187
|
+
/** Data-driven per-row class hook. Return extra class(es) for `.stv__node`. */
|
|
188
|
+
nodeClass?: (node: LTreeNode<T>) => string | null | undefined;
|
|
189
|
+
/** Data-driven per-row class hook. Return extra class(es) for `.stv__node-content`. */
|
|
190
|
+
nodeContentClass?: (node: LTreeNode<T>) => string | null | undefined;
|
|
181
191
|
dragOverNodeClass?: string | null | undefined;
|
|
182
192
|
expandIconClass?: string | null | undefined;
|
|
183
193
|
collapseIconClass?: string | null | undefined;
|
|
@@ -296,11 +306,15 @@
|
|
|
296
306
|
|
|
297
307
|
// EVENTS
|
|
298
308
|
onNodeClick,
|
|
309
|
+
onNodeDoubleClick,
|
|
299
310
|
onHighlightChange,
|
|
300
311
|
onSelectionChange,
|
|
301
312
|
onNodeDragStart,
|
|
302
313
|
onNodeDragOver,
|
|
303
314
|
onNodeDrop,
|
|
315
|
+
onCopy,
|
|
316
|
+
onCut,
|
|
317
|
+
onPaste,
|
|
304
318
|
// INTERCEPTORS
|
|
305
319
|
beforeDropCallback,
|
|
306
320
|
beforeCopyCallback,
|
|
@@ -318,6 +332,8 @@
|
|
|
318
332
|
toggleIconMode = 'rotate',
|
|
319
333
|
highlightedNodeClass,
|
|
320
334
|
focusedNodeClass,
|
|
335
|
+
nodeClass,
|
|
336
|
+
nodeContentClass,
|
|
321
337
|
dragOverNodeClass,
|
|
322
338
|
scrollHighlightTimeout = 4000,
|
|
323
339
|
scrollHighlightClass = 'stv__node-content--scroll-highlight',
|
|
@@ -405,11 +421,15 @@
|
|
|
405
421
|
shouldAutoHandlePaste,
|
|
406
422
|
isAccordionExpand,
|
|
407
423
|
onNodeClick,
|
|
424
|
+
onNodeDoubleClick,
|
|
408
425
|
onHighlightChange,
|
|
409
426
|
onSelectionChange,
|
|
410
427
|
onNodeDragStart,
|
|
411
428
|
onNodeDragOver,
|
|
412
429
|
onNodeDrop,
|
|
430
|
+
onCopy,
|
|
431
|
+
onCut,
|
|
432
|
+
onPaste,
|
|
413
433
|
beforeDropCallback,
|
|
414
434
|
beforeCopyCallback,
|
|
415
435
|
beforeCutCallback,
|
|
@@ -419,6 +439,8 @@
|
|
|
419
439
|
bodyClass,
|
|
420
440
|
highlightedNodeClass,
|
|
421
441
|
focusedNodeClass,
|
|
442
|
+
nodeClass,
|
|
443
|
+
nodeContentClass,
|
|
422
444
|
dragOverNodeClass,
|
|
423
445
|
expandIconClass,
|
|
424
446
|
collapseIconClass,
|
|
@@ -463,6 +485,13 @@
|
|
|
463
485
|
let contextMenuEl = $state<HTMLDivElement | null>(null);
|
|
464
486
|
$effect(() => {
|
|
465
487
|
if (!controller.contextMenuVisible || !contextMenuEl) return;
|
|
488
|
+
// Track position + target so the effect re-runs (tears down the old autoUpdate
|
|
489
|
+
// and re-anchors) when a right-click moves the menu to a DIFFERENT node while
|
|
490
|
+
// it's already open — otherwise autoUpdate only recomputes on scroll/resize and
|
|
491
|
+
// the menu stays stuck at the first node's position.
|
|
492
|
+
void controller.contextMenuX;
|
|
493
|
+
void controller.contextMenuY;
|
|
494
|
+
void controller.contextMenuNode;
|
|
466
495
|
const xOff = controller.contextMenuXOffset ?? 0;
|
|
467
496
|
const yOff = controller.contextMenuYOffset ?? 0;
|
|
468
497
|
const virtualRef = {
|
|
@@ -523,6 +552,8 @@
|
|
|
523
552
|
$effect(() => { controller.leafIconClass = leafIconClass ?? 'stv__toggle-icon--leaf'; });
|
|
524
553
|
$effect(() => { controller.toggleIconMode = toggleIconMode ?? 'rotate'; });
|
|
525
554
|
$effect(() => { controller.highlightedNodeClass = highlightedNodeClass; });
|
|
555
|
+
$effect(() => { controller.nodeClass = nodeClass; });
|
|
556
|
+
$effect(() => { controller.nodeContentClass = nodeContentClass; });
|
|
526
557
|
$effect(() => { controller.focusedNodeClass = focusedNodeClass; });
|
|
527
558
|
$effect(() => { controller.dragOverNodeClass = dragOverNodeClass; });
|
|
528
559
|
$effect(() => { controller.dropZoneMode = dropZoneMode ?? 'glow'; });
|
|
@@ -536,11 +567,15 @@
|
|
|
536
567
|
|
|
537
568
|
// Callback sync
|
|
538
569
|
$effect(() => { controller.onNodeClickHandler = onNodeClick; });
|
|
570
|
+
$effect(() => { controller.onNodeDoubleClickHandler = onNodeDoubleClick; });
|
|
539
571
|
$effect(() => { controller.onHighlightChangeHandler = onHighlightChange; });
|
|
540
572
|
$effect(() => { controller.onSelectionChangeHandler = onSelectionChange; });
|
|
541
573
|
$effect(() => { controller.onNodeDragStartHandler = onNodeDragStart; });
|
|
542
574
|
$effect(() => { controller.onNodeDragOverHandler = onNodeDragOver; });
|
|
543
575
|
$effect(() => { controller.onNodeDropHandler = onNodeDrop; });
|
|
576
|
+
$effect(() => { controller.onCopyHandler = onCopy; });
|
|
577
|
+
$effect(() => { controller.onCutHandler = onCut; });
|
|
578
|
+
$effect(() => { controller.onPasteHandler = onPaste; });
|
|
544
579
|
$effect(() => { controller.beforeDropHandler = beforeDropCallback; });
|
|
545
580
|
$effect(() => { controller.beforeCopyHandler = beforeCopyCallback; });
|
|
546
581
|
$effect(() => { controller.beforeCutHandler = beforeCutCallback; });
|
|
@@ -825,11 +860,15 @@
|
|
|
825
860
|
| "shouldDisplayDebugInformation"
|
|
826
861
|
| "shouldDisplayContextMenuInDebugMode"
|
|
827
862
|
| "onNodeClick"
|
|
863
|
+
| "onNodeDoubleClick"
|
|
828
864
|
| "onHighlightChange"
|
|
829
865
|
| "onSelectionChange"
|
|
830
866
|
| "onNodeDragStart"
|
|
831
867
|
| "onNodeDragOver"
|
|
832
868
|
| "onNodeDrop"
|
|
869
|
+
| "onCopy"
|
|
870
|
+
| "onCut"
|
|
871
|
+
| "onPaste"
|
|
833
872
|
| "beforeDropCallback"
|
|
834
873
|
| "beforeCopyCallback"
|
|
835
874
|
| "beforeCutCallback"
|
|
@@ -847,6 +886,8 @@
|
|
|
847
886
|
| "leafIconClass"
|
|
848
887
|
| "toggleIconMode"
|
|
849
888
|
| "highlightedNodeClass"
|
|
889
|
+
| "nodeClass"
|
|
890
|
+
| "nodeContentClass"
|
|
850
891
|
| "focusedNodeClass"
|
|
851
892
|
| "dragOverNodeClass"
|
|
852
893
|
| "scrollHighlightTimeout"
|
|
@@ -904,11 +945,15 @@
|
|
|
904
945
|
if (updates.shouldDisplayDebugInformation !== undefined) shouldDisplayDebugInformation = updates.shouldDisplayDebugInformation;
|
|
905
946
|
if (updates.shouldDisplayContextMenuInDebugMode !== undefined) shouldDisplayContextMenuInDebugMode = updates.shouldDisplayContextMenuInDebugMode;
|
|
906
947
|
if (updates.onNodeClick !== undefined) onNodeClick = updates.onNodeClick;
|
|
948
|
+
if (updates.onNodeDoubleClick !== undefined) onNodeDoubleClick = updates.onNodeDoubleClick;
|
|
907
949
|
if (updates.onHighlightChange !== undefined) onHighlightChange = updates.onHighlightChange;
|
|
908
950
|
if (updates.onSelectionChange !== undefined) onSelectionChange = updates.onSelectionChange;
|
|
909
951
|
if (updates.onNodeDragStart !== undefined) onNodeDragStart = updates.onNodeDragStart;
|
|
910
952
|
if (updates.onNodeDragOver !== undefined) onNodeDragOver = updates.onNodeDragOver;
|
|
911
953
|
if (updates.onNodeDrop !== undefined) onNodeDrop = updates.onNodeDrop;
|
|
954
|
+
if (updates.onCopy !== undefined) onCopy = updates.onCopy;
|
|
955
|
+
if (updates.onCut !== undefined) onCut = updates.onCut;
|
|
956
|
+
if (updates.onPaste !== undefined) onPaste = updates.onPaste;
|
|
912
957
|
if (updates.beforeDropCallback !== undefined) beforeDropCallback = updates.beforeDropCallback;
|
|
913
958
|
if (updates.beforeCopyCallback !== undefined) beforeCopyCallback = updates.beforeCopyCallback;
|
|
914
959
|
if (updates.beforeCutCallback !== undefined) beforeCutCallback = updates.beforeCutCallback;
|
|
@@ -926,6 +971,8 @@
|
|
|
926
971
|
if (updates.leafIconClass !== undefined) leafIconClass = updates.leafIconClass;
|
|
927
972
|
if (updates.toggleIconMode !== undefined) toggleIconMode = updates.toggleIconMode;
|
|
928
973
|
if (updates.highlightedNodeClass !== undefined) highlightedNodeClass = updates.highlightedNodeClass;
|
|
974
|
+
if (updates.nodeClass !== undefined) nodeClass = updates.nodeClass;
|
|
975
|
+
if (updates.nodeContentClass !== undefined) nodeContentClass = updates.nodeContentClass;
|
|
929
976
|
if (updates.focusedNodeClass !== undefined) focusedNodeClass = updates.focusedNodeClass;
|
|
930
977
|
if (updates.dragOverNodeClass !== undefined) dragOverNodeClass = updates.dragOverNodeClass;
|
|
931
978
|
if (updates.scrollHighlightTimeout !== undefined) scrollHighlightTimeout = updates.scrollHighlightTimeout;
|
|
@@ -103,11 +103,15 @@ declare function $$render<T>(): {
|
|
|
103
103
|
shouldAutoHandlePaste?: boolean;
|
|
104
104
|
isAccordionExpand?: boolean;
|
|
105
105
|
onNodeClick?: (node: LTreeNode<T>) => void;
|
|
106
|
+
onNodeDoubleClick?: (node: LTreeNode<T>) => void;
|
|
106
107
|
onHighlightChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
|
|
107
108
|
onSelectionChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
|
|
108
109
|
onNodeDragStart?: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
109
110
|
onNodeDragOver?: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
110
111
|
onNodeDrop?: (dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => void;
|
|
112
|
+
onCopy?: (paths: string[]) => void;
|
|
113
|
+
onCut?: (paths: string[]) => void;
|
|
114
|
+
onPaste?: (result: import("../core/TreeController.svelte.js").PasteResult<T>) => void;
|
|
111
115
|
/**
|
|
112
116
|
* Called before a drop is processed. Return false to cancel the drop.
|
|
113
117
|
* Return { position, operation } to override the drop position or operation.
|
|
@@ -136,6 +140,10 @@ declare function $$render<T>(): {
|
|
|
136
140
|
bodyClass?: string | null | undefined;
|
|
137
141
|
highlightedNodeClass?: string | null | undefined;
|
|
138
142
|
focusedNodeClass?: string | null | undefined;
|
|
143
|
+
/** Data-driven per-row class hook. Return extra class(es) for `.stv__node`. */
|
|
144
|
+
nodeClass?: (node: LTreeNode<T>) => string | null | undefined;
|
|
145
|
+
/** Data-driven per-row class hook. Return extra class(es) for `.stv__node-content`. */
|
|
146
|
+
nodeContentClass?: (node: LTreeNode<T>) => string | null | undefined;
|
|
139
147
|
dragOverNodeClass?: string | null | undefined;
|
|
140
148
|
expandIconClass?: string | null | undefined;
|
|
141
149
|
collapseIconClass?: string | null | undefined;
|
|
@@ -325,11 +333,15 @@ declare function $$render<T>(): {
|
|
|
325
333
|
shouldAutoHandlePaste?: boolean;
|
|
326
334
|
isAccordionExpand?: boolean;
|
|
327
335
|
onNodeClick?: (node: LTreeNode<T>) => void;
|
|
336
|
+
onNodeDoubleClick?: (node: LTreeNode<T>) => void;
|
|
328
337
|
onHighlightChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
|
|
329
338
|
onSelectionChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
|
|
330
339
|
onNodeDragStart?: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
331
340
|
onNodeDragOver?: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
332
341
|
onNodeDrop?: (dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => void;
|
|
342
|
+
onCopy?: (paths: string[]) => void;
|
|
343
|
+
onCut?: (paths: string[]) => void;
|
|
344
|
+
onPaste?: (result: import("../core/TreeController.svelte.js").PasteResult<T>) => void;
|
|
333
345
|
/**
|
|
334
346
|
* Called before a drop is processed. Return false to cancel the drop.
|
|
335
347
|
* Return { position, operation } to override the drop position or operation.
|
|
@@ -358,6 +370,10 @@ declare function $$render<T>(): {
|
|
|
358
370
|
bodyClass?: string | null | undefined;
|
|
359
371
|
highlightedNodeClass?: string | null | undefined;
|
|
360
372
|
focusedNodeClass?: string | null | undefined;
|
|
373
|
+
/** Data-driven per-row class hook. Return extra class(es) for `.stv__node`. */
|
|
374
|
+
nodeClass?: (node: LTreeNode<T>) => string | null | undefined;
|
|
375
|
+
/** Data-driven per-row class hook. Return extra class(es) for `.stv__node-content`. */
|
|
376
|
+
nodeContentClass?: (node: LTreeNode<T>) => string | null | undefined;
|
|
361
377
|
dragOverNodeClass?: string | null | undefined;
|
|
362
378
|
expandIconClass?: string | null | undefined;
|
|
363
379
|
collapseIconClass?: string | null | undefined;
|
|
@@ -371,7 +387,7 @@ declare function $$render<T>(): {
|
|
|
371
387
|
onTreeKeydown?: (event: KeyboardEvent, controller: TreeController<T>) => boolean | void;
|
|
372
388
|
/** Override individual navigation methods (e.g. for custom ArrowDown/Up behavior) */
|
|
373
389
|
navigationOverrides?: TreeNavigationOverrides<T>;
|
|
374
|
-
}, "treeId" | "treePathSeparator" | "idMember" | "pathMember" | "parentPathMember" | "levelMember" | "hasChildrenMember" | "isExpandedMember" | "getIsExpandedCallback" | "isSelectableMember" | "getIsSelectableCallback" | "isSelectedMember" | "getIsSelectedCallback" | "isDraggableMember" | "getIsDraggableCallback" | "isDropAllowedMember" | "getIsDropAllowedCallback" | "displayValueMember" | "getDisplayValueCallback" | "searchValueMember" | "getSearchValueCallback" | "isCollapsibleMember" | "getIsCollapsibleCallback" | "orderMember" | "isSorted" | "sortCallback" | "data" | "focusedNode" | "highlightedPaths" | "selectedPaths" | "expandLevel" | "clickBehavior" | "selectionMode" | "shouldShowCheckboxes" | "checkboxMode" | "shouldClickToggleCheckbox" | "beforeCheckboxToggleCallback" | "rangeSelectionMode" | "shouldUseInternalSearchIndex" | "initializeIndexCallback" | "searchText" | "indexerBatchSize" | "indexerTimeout" | "shouldDisplayDebugInformation" | "shouldDisplayContextMenuInDebugMode" | "onNodeClick" | "onHighlightChange" | "onSelectionChange" | "onNodeDragStart" | "onNodeDragOver" | "onNodeDrop" | "beforeDropCallback" | "beforeCopyCallback" | "beforeCutCallback" | "beforePasteCallback" | "getContextMenuItemsCallback" | "isVirtualScrollEnabled" | "virtualRowHeight" | "virtualOverscan" | "virtualContainerHeight" | "dragDropMode" | "dropZoneMode" | "bodyClass" | "expandIconClass" | "collapseIconClass" | "leafIconClass" | "toggleIconMode" | "highlightedNodeClass" | "focusedNodeClass" | "dragOverNodeClass" | "scrollHighlightTimeout" | "scrollHighlightClass" | "contextMenuXOffset" | "contextMenuYOffset" | "isAccordionExpand">>) => void;
|
|
390
|
+
}, "treeId" | "treePathSeparator" | "idMember" | "pathMember" | "parentPathMember" | "levelMember" | "hasChildrenMember" | "isExpandedMember" | "getIsExpandedCallback" | "isSelectableMember" | "getIsSelectableCallback" | "isSelectedMember" | "getIsSelectedCallback" | "isDraggableMember" | "getIsDraggableCallback" | "isDropAllowedMember" | "getIsDropAllowedCallback" | "displayValueMember" | "getDisplayValueCallback" | "searchValueMember" | "getSearchValueCallback" | "isCollapsibleMember" | "getIsCollapsibleCallback" | "orderMember" | "isSorted" | "sortCallback" | "data" | "focusedNode" | "highlightedPaths" | "selectedPaths" | "expandLevel" | "clickBehavior" | "selectionMode" | "shouldShowCheckboxes" | "checkboxMode" | "shouldClickToggleCheckbox" | "beforeCheckboxToggleCallback" | "rangeSelectionMode" | "shouldUseInternalSearchIndex" | "initializeIndexCallback" | "searchText" | "indexerBatchSize" | "indexerTimeout" | "shouldDisplayDebugInformation" | "shouldDisplayContextMenuInDebugMode" | "onNodeClick" | "onNodeDoubleClick" | "onHighlightChange" | "onSelectionChange" | "onNodeDragStart" | "onNodeDragOver" | "onNodeDrop" | "onCopy" | "onCut" | "onPaste" | "beforeDropCallback" | "beforeCopyCallback" | "beforeCutCallback" | "beforePasteCallback" | "getContextMenuItemsCallback" | "isVirtualScrollEnabled" | "virtualRowHeight" | "virtualOverscan" | "virtualContainerHeight" | "dragDropMode" | "dropZoneMode" | "bodyClass" | "expandIconClass" | "collapseIconClass" | "leafIconClass" | "toggleIconMode" | "highlightedNodeClass" | "nodeClass" | "nodeContentClass" | "focusedNodeClass" | "dragOverNodeClass" | "scrollHighlightTimeout" | "scrollHighlightClass" | "contextMenuXOffset" | "contextMenuYOffset" | "isAccordionExpand">>) => void;
|
|
375
391
|
};
|
|
376
392
|
bindings: "data" | "focusedNode" | "highlightedPaths" | "selectedPaths" | "searchText" | "insertResult" | "isRendering";
|
|
377
393
|
slots: {};
|
|
@@ -557,11 +573,15 @@ declare class __sveltets_Render<T> {
|
|
|
557
573
|
shouldAutoHandlePaste?: boolean;
|
|
558
574
|
isAccordionExpand?: boolean;
|
|
559
575
|
onNodeClick?: ((node: LTreeNode<T>) => void) | undefined;
|
|
576
|
+
onNodeDoubleClick?: ((node: LTreeNode<T>) => void) | undefined;
|
|
560
577
|
onHighlightChange?: ((paths: Set<string>, nodes: LTreeNode<T>[]) => void) | undefined;
|
|
561
578
|
onSelectionChange?: ((paths: Set<string>, nodes: LTreeNode<T>[]) => void) | undefined;
|
|
562
579
|
onNodeDragStart?: ((node: LTreeNode<T>, event: DragEvent) => void) | undefined;
|
|
563
580
|
onNodeDragOver?: ((node: LTreeNode<T>, event: DragEvent) => void) | undefined;
|
|
564
581
|
onNodeDrop?: ((dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => void) | undefined;
|
|
582
|
+
onCopy?: ((paths: string[]) => void) | undefined;
|
|
583
|
+
onCut?: ((paths: string[]) => void) | undefined;
|
|
584
|
+
onPaste?: ((result: import("../core/TreeController.svelte.js").PasteResult<T>) => void) | undefined;
|
|
565
585
|
/**
|
|
566
586
|
* Called before a drop is processed. Return false to cancel the drop.
|
|
567
587
|
* Return { position, operation } to override the drop position or operation.
|
|
@@ -590,6 +610,10 @@ declare class __sveltets_Render<T> {
|
|
|
590
610
|
bodyClass?: string | null | undefined;
|
|
591
611
|
highlightedNodeClass?: string | null | undefined;
|
|
592
612
|
focusedNodeClass?: string | null | undefined;
|
|
613
|
+
/** Data-driven per-row class hook. Return extra class(es) for `.stv__node`. */
|
|
614
|
+
nodeClass?: ((node: LTreeNode<T>) => string | null | undefined) | undefined;
|
|
615
|
+
/** Data-driven per-row class hook. Return extra class(es) for `.stv__node-content`. */
|
|
616
|
+
nodeContentClass?: ((node: LTreeNode<T>) => string | null | undefined) | undefined;
|
|
593
617
|
dragOverNodeClass?: string | null | undefined;
|
|
594
618
|
expandIconClass?: string | null | undefined;
|
|
595
619
|
collapseIconClass?: string | null | undefined;
|
|
@@ -603,7 +627,7 @@ declare class __sveltets_Render<T> {
|
|
|
603
627
|
onTreeKeydown?: ((event: KeyboardEvent, controller: TreeController<T>) => boolean | void) | undefined;
|
|
604
628
|
/** Override individual navigation methods (e.g. for custom ArrowDown/Up behavior) */
|
|
605
629
|
navigationOverrides?: Partial<import("../core/navigation.js").TreeNavigation<T>> | undefined;
|
|
606
|
-
}, "treeId" | "data" | "treePathSeparator" | "idMember" | "pathMember" | "parentPathMember" | "levelMember" | "hasChildrenMember" | "isExpandedMember" | "getIsExpandedCallback" | "displayValueMember" | "getDisplayValueCallback" | "searchValueMember" | "getSearchValueCallback" | "orderMember" | "isSorted" | "sortCallback" | "isSelectableMember" | "getIsSelectableCallback" | "isSelectedMember" | "getIsSelectedCallback" | "isDraggableMember" | "getIsDraggableCallback" | "isDropAllowedMember" | "getIsDropAllowedCallback" | "isCollapsibleMember" | "getIsCollapsibleCallback" | "shouldDisplayDebugInformation" | "onNodeDrop" | "beforeDropCallback" | "beforeCopyCallback" | "beforeCutCallback" | "beforePasteCallback" | "beforeCheckboxToggleCallback" | "getContextMenuItemsCallback" | "focusedNode" | "highlightedPaths" | "selectedPaths" | "expandLevel" | "clickBehavior" | "selectionMode" | "shouldShowCheckboxes" | "checkboxMode" | "shouldClickToggleCheckbox" | "rangeSelectionMode" | "initializeIndexCallback" | "searchText" | "shouldUseInternalSearchIndex" | "indexerBatchSize" | "indexerTimeout" | "shouldDisplayContextMenuInDebugMode" | "isVirtualScrollEnabled" | "virtualRowHeight" | "virtualOverscan" | "virtualContainerHeight" | "dragDropMode" | "dropZoneMode" | "isAccordionExpand" | "onNodeClick" | "onNodeDragStart" | "onNodeDragOver" | "onHighlightChange" | "onSelectionChange" | "bodyClass" | "highlightedNodeClass" | "focusedNodeClass" | "dragOverNodeClass" | "expandIconClass" | "collapseIconClass" | "leafIconClass" | "toggleIconMode" | "scrollHighlightTimeout" | "scrollHighlightClass" | "contextMenuXOffset" | "contextMenuYOffset">>) => void;
|
|
630
|
+
}, "treeId" | "data" | "treePathSeparator" | "idMember" | "pathMember" | "parentPathMember" | "levelMember" | "hasChildrenMember" | "isExpandedMember" | "getIsExpandedCallback" | "displayValueMember" | "getDisplayValueCallback" | "searchValueMember" | "getSearchValueCallback" | "orderMember" | "isSorted" | "sortCallback" | "isSelectableMember" | "getIsSelectableCallback" | "isSelectedMember" | "getIsSelectedCallback" | "isDraggableMember" | "getIsDraggableCallback" | "isDropAllowedMember" | "getIsDropAllowedCallback" | "isCollapsibleMember" | "getIsCollapsibleCallback" | "shouldDisplayDebugInformation" | "onNodeDrop" | "beforeDropCallback" | "beforeCopyCallback" | "beforeCutCallback" | "beforePasteCallback" | "beforeCheckboxToggleCallback" | "getContextMenuItemsCallback" | "focusedNode" | "highlightedPaths" | "selectedPaths" | "expandLevel" | "clickBehavior" | "selectionMode" | "shouldShowCheckboxes" | "checkboxMode" | "shouldClickToggleCheckbox" | "rangeSelectionMode" | "initializeIndexCallback" | "searchText" | "shouldUseInternalSearchIndex" | "indexerBatchSize" | "indexerTimeout" | "shouldDisplayContextMenuInDebugMode" | "isVirtualScrollEnabled" | "virtualRowHeight" | "virtualOverscan" | "virtualContainerHeight" | "dragDropMode" | "dropZoneMode" | "isAccordionExpand" | "onNodeClick" | "onNodeDoubleClick" | "onNodeDragStart" | "onNodeDragOver" | "onHighlightChange" | "onSelectionChange" | "onCopy" | "onCut" | "onPaste" | "bodyClass" | "highlightedNodeClass" | "focusedNodeClass" | "nodeClass" | "nodeContentClass" | "dragOverNodeClass" | "expandIconClass" | "collapseIconClass" | "leafIconClass" | "toggleIconMode" | "scrollHighlightTimeout" | "scrollHighlightClass" | "contextMenuXOffset" | "contextMenuYOffset">>) => void;
|
|
607
631
|
};
|
|
608
632
|
}
|
|
609
633
|
interface $$IsomorphicComponent {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Auto-generated file - do not edit manually
|
|
2
2
|
// Generated by scripts/generate-constants.js
|
|
3
|
-
export const VERSION = "5.0.0-
|
|
3
|
+
export const VERSION = "5.0.0-rc12";
|
|
4
4
|
export const PACKAGE_NAME = "@keenmate/svelte-treeview";
|
|
5
5
|
export const AUTHOR = "KeenMate";
|
|
6
6
|
export const LICENSE = "MIT";
|
|
@@ -46,6 +46,8 @@ export interface NodeConfig {
|
|
|
46
46
|
highlightedNodeClass: string | null | undefined;
|
|
47
47
|
focusedNodeClass: string | null | undefined;
|
|
48
48
|
dragOverNodeClass: string | null | undefined;
|
|
49
|
+
nodeClass: ((node: LTreeNode<any>) => string | null | undefined) | undefined;
|
|
50
|
+
nodeContentClass: ((node: LTreeNode<any>) => string | null | undefined) | undefined;
|
|
49
51
|
dropZoneMode: 'floating' | 'glow';
|
|
50
52
|
dropZoneLayout: 'around' | 'above' | 'below' | 'wave' | 'wave2';
|
|
51
53
|
dropZoneStart: number | string;
|
|
@@ -152,11 +154,14 @@ export interface TreeControllerProps<T> {
|
|
|
152
154
|
shouldAutoHandlePaste?: boolean;
|
|
153
155
|
isAccordionExpand?: boolean;
|
|
154
156
|
onNodeClick?: (node: LTreeNode<T>) => void;
|
|
157
|
+
onNodeDoubleClick?: (node: LTreeNode<T>) => void;
|
|
155
158
|
onNodeDragStart?: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
156
159
|
onNodeDragOver?: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
157
160
|
onNodeDrop?: (dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => void;
|
|
158
161
|
onHighlightChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
|
|
159
162
|
onSelectionChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
|
|
163
|
+
onCopy?: (paths: string[]) => void;
|
|
164
|
+
onCut?: (paths: string[]) => void;
|
|
160
165
|
onPaste?: (result: PasteResult<T>) => void;
|
|
161
166
|
beforeDropCallback?: (dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => boolean | {
|
|
162
167
|
position?: DropPosition;
|
|
@@ -176,6 +181,8 @@ export interface TreeControllerProps<T> {
|
|
|
176
181
|
bodyClass?: string | null | undefined;
|
|
177
182
|
highlightedNodeClass?: string | null | undefined;
|
|
178
183
|
focusedNodeClass?: string | null | undefined;
|
|
184
|
+
nodeClass?: (node: LTreeNode<T>) => string | null | undefined;
|
|
185
|
+
nodeContentClass?: (node: LTreeNode<T>) => string | null | undefined;
|
|
179
186
|
dragOverNodeClass?: string | null | undefined;
|
|
180
187
|
expandIconClass?: string | null | undefined;
|
|
181
188
|
collapseIconClass?: string | null | undefined;
|
|
@@ -200,14 +207,15 @@ export declare class TreeController<T> {
|
|
|
200
207
|
* from the focused node. Set on first Shift action, advances on subsequent
|
|
201
208
|
* Shift actions, cleared on any plain navigation. Not exposed via props. */
|
|
202
209
|
private _shiftCursor;
|
|
203
|
-
/** Manual double-click detection state
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
|
|
210
|
-
private
|
|
210
|
+
/** Manual double-click detection state. We can't rely on the browser's native
|
|
211
|
+
* dblclick event because the first click triggers focus → _setFocusedNode bumps
|
|
212
|
+
* node._rev → flat-mode {#each} destroys and recreates the row, so the second
|
|
213
|
+
* click lands on a different DOM element and the browser refuses to synthesize a
|
|
214
|
+
* dblclick. Tracking the click on the controller (which survives the re-render)
|
|
215
|
+
* sidesteps that. Drives both the public onNodeDoubleClick event (all
|
|
216
|
+
* clickBehaviors) and the built-in expand/collapse-on-double for 'select' mode. */
|
|
217
|
+
private _lastClickPath;
|
|
218
|
+
private _lastClickTime;
|
|
211
219
|
selectedPaths: Set<string>;
|
|
212
220
|
insertResult: InsertArrayResult<T> | null | undefined;
|
|
213
221
|
searchText: string | null | undefined;
|
|
@@ -228,11 +236,14 @@ export declare class TreeController<T> {
|
|
|
228
236
|
shouldAutoHandleMove: boolean;
|
|
229
237
|
shouldAutoHandlePaste: boolean;
|
|
230
238
|
onNodeClickHandler: ((node: LTreeNode<T>) => void) | undefined;
|
|
239
|
+
onNodeDoubleClickHandler: ((node: LTreeNode<T>) => void) | undefined;
|
|
231
240
|
onHighlightChangeHandler: ((paths: Set<string>, nodes: LTreeNode<T>[]) => void) | undefined;
|
|
232
241
|
onSelectionChangeHandler: ((paths: Set<string>, nodes: LTreeNode<T>[]) => void) | undefined;
|
|
233
242
|
onNodeDragStartHandler: ((node: LTreeNode<T>, event: DragEvent) => void) | undefined;
|
|
234
243
|
onNodeDragOverHandler: ((node: LTreeNode<T>, event: DragEvent) => void) | undefined;
|
|
235
244
|
onNodeDropHandler: TreeControllerProps<T>['onNodeDrop'];
|
|
245
|
+
onCopyHandler: ((paths: string[]) => void) | undefined;
|
|
246
|
+
onCutHandler: ((paths: string[]) => void) | undefined;
|
|
236
247
|
onPasteHandler: ((result: PasteResult<T>) => void) | undefined;
|
|
237
248
|
onRenderStartHandler: (() => void) | undefined;
|
|
238
249
|
onRenderProgressHandler: ((stats: RenderStats) => void) | undefined;
|
|
@@ -254,6 +265,8 @@ export declare class TreeController<T> {
|
|
|
254
265
|
toggleIconMode: ToggleIconMode;
|
|
255
266
|
highlightedNodeClass: string | null | undefined;
|
|
256
267
|
focusedNodeClass: string | null | undefined;
|
|
268
|
+
nodeClass: ((node: LTreeNode<any>) => string | null | undefined) | undefined;
|
|
269
|
+
nodeContentClass: ((node: LTreeNode<any>) => string | null | undefined) | undefined;
|
|
257
270
|
dragOverNodeClass: string | null | undefined;
|
|
258
271
|
dropZoneMode: "floating" | "glow";
|
|
259
272
|
dropZoneLayout: "around" | "above" | "below" | "wave" | "wave2";
|
|
@@ -26,6 +26,8 @@ export class TreeController {
|
|
|
26
26
|
leafIconClass: 'stv__toggle-icon--leaf',
|
|
27
27
|
highlightedNodeClass: undefined,
|
|
28
28
|
focusedNodeClass: undefined,
|
|
29
|
+
nodeClass: undefined,
|
|
30
|
+
nodeContentClass: undefined,
|
|
29
31
|
dragOverNodeClass: undefined,
|
|
30
32
|
dropZoneMode: 'glow',
|
|
31
33
|
dropZoneLayout: 'around',
|
|
@@ -46,14 +48,15 @@ export class TreeController {
|
|
|
46
48
|
* from the focused node. Set on first Shift action, advances on subsequent
|
|
47
49
|
* Shift actions, cleared on any plain navigation. Not exposed via props. */
|
|
48
50
|
_shiftCursor = null;
|
|
49
|
-
/** Manual double-click detection state
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
/** Manual double-click detection state. We can't rely on the browser's native
|
|
52
|
+
* dblclick event because the first click triggers focus → _setFocusedNode bumps
|
|
53
|
+
* node._rev → flat-mode {#each} destroys and recreates the row, so the second
|
|
54
|
+
* click lands on a different DOM element and the browser refuses to synthesize a
|
|
55
|
+
* dblclick. Tracking the click on the controller (which survives the re-render)
|
|
56
|
+
* sidesteps that. Drives both the public onNodeDoubleClick event (all
|
|
57
|
+
* clickBehaviors) and the built-in expand/collapse-on-double for 'select' mode. */
|
|
58
|
+
_lastClickPath = null;
|
|
59
|
+
_lastClickTime = 0;
|
|
57
60
|
selectedPaths = $state.raw(new Set());
|
|
58
61
|
insertResult = $state.raw(null);
|
|
59
62
|
searchText = $state(undefined);
|
|
@@ -77,11 +80,14 @@ export class TreeController {
|
|
|
77
80
|
shouldAutoHandlePaste = $state(true);
|
|
78
81
|
// Event handlers (on* = fire-and-forget)
|
|
79
82
|
onNodeClickHandler;
|
|
83
|
+
onNodeDoubleClickHandler;
|
|
80
84
|
onHighlightChangeHandler;
|
|
81
85
|
onSelectionChangeHandler;
|
|
82
86
|
onNodeDragStartHandler;
|
|
83
87
|
onNodeDragOverHandler;
|
|
84
88
|
onNodeDropHandler;
|
|
89
|
+
onCopyHandler;
|
|
90
|
+
onCutHandler;
|
|
85
91
|
onPasteHandler;
|
|
86
92
|
onRenderStartHandler;
|
|
87
93
|
onRenderProgressHandler;
|
|
@@ -106,6 +112,8 @@ export class TreeController {
|
|
|
106
112
|
toggleIconMode = $state('rotate');
|
|
107
113
|
highlightedNodeClass = $state(undefined);
|
|
108
114
|
focusedNodeClass = $state(undefined);
|
|
115
|
+
nodeClass = $state(undefined);
|
|
116
|
+
nodeContentClass = $state(undefined);
|
|
109
117
|
dragOverNodeClass = $state(undefined);
|
|
110
118
|
dropZoneMode = $state('glow');
|
|
111
119
|
dropZoneLayout = $state('around');
|
|
@@ -238,6 +246,8 @@ export class TreeController {
|
|
|
238
246
|
this.toggleIconMode = props.toggleIconMode ?? 'rotate';
|
|
239
247
|
this.highlightedNodeClass = props.highlightedNodeClass;
|
|
240
248
|
this.focusedNodeClass = props.focusedNodeClass;
|
|
249
|
+
this.nodeClass = props.nodeClass;
|
|
250
|
+
this.nodeContentClass = props.nodeContentClass;
|
|
241
251
|
this.dragOverNodeClass = props.dragOverNodeClass;
|
|
242
252
|
this.dropZoneMode = props.dropZoneMode ?? 'glow';
|
|
243
253
|
this.dropZoneLayout = props.dropZoneLayout ?? 'around';
|
|
@@ -255,11 +265,14 @@ export class TreeController {
|
|
|
255
265
|
this.virtualContainerHeight = props.virtualContainerHeight;
|
|
256
266
|
// Store callbacks
|
|
257
267
|
this.onNodeClickHandler = props.onNodeClick;
|
|
268
|
+
this.onNodeDoubleClickHandler = props.onNodeDoubleClick;
|
|
258
269
|
this.onHighlightChangeHandler = props.onHighlightChange;
|
|
259
270
|
this.onSelectionChangeHandler = props.onSelectionChange;
|
|
260
271
|
this.onNodeDragStartHandler = props.onNodeDragStart;
|
|
261
272
|
this.onNodeDragOverHandler = props.onNodeDragOver;
|
|
262
273
|
this.onNodeDropHandler = props.onNodeDrop;
|
|
274
|
+
this.onCopyHandler = props.onCopy;
|
|
275
|
+
this.onCutHandler = props.onCut;
|
|
263
276
|
this.onPasteHandler = props.onPaste;
|
|
264
277
|
this.beforeDropHandler = props.beforeDropCallback;
|
|
265
278
|
this.beforeCopyHandler = props.beforeCopyCallback;
|
|
@@ -294,7 +307,7 @@ export class TreeController {
|
|
|
294
307
|
: null;
|
|
295
308
|
// ── Create stable nodeCallbacks ─────────────────────────────────
|
|
296
309
|
this.nodeCallbacks = {
|
|
297
|
-
onNodeClicked: (node, modifiers) => this._onNodeClicked(node, modifiers),
|
|
310
|
+
onNodeClicked: (node, modifiers) => this._onNodeClicked(node, modifiers, { uiClick: true }),
|
|
298
311
|
onCheckboxToggle: (node, options) => this._onCheckboxToggle(node, options),
|
|
299
312
|
onNodeRightClicked: this._onNodeRightClicked.bind(this),
|
|
300
313
|
onNodeDragStart: this._onNodeDragStart.bind(this),
|
|
@@ -318,6 +331,8 @@ export class TreeController {
|
|
|
318
331
|
toggleIconMode: this.toggleIconMode,
|
|
319
332
|
highlightedNodeClass: this.highlightedNodeClass,
|
|
320
333
|
focusedNodeClass: this.focusedNodeClass,
|
|
334
|
+
nodeClass: this.nodeClass,
|
|
335
|
+
nodeContentClass: this.nodeContentClass,
|
|
321
336
|
dragOverNodeClass: this.dragOverNodeClass,
|
|
322
337
|
dropZoneMode: this.dropZoneMode,
|
|
323
338
|
dropZoneLayout: this.dropZoneLayout,
|
|
@@ -348,6 +363,8 @@ export class TreeController {
|
|
|
348
363
|
toggleIconMode: this.toggleIconMode,
|
|
349
364
|
highlightedNodeClass: this.highlightedNodeClass,
|
|
350
365
|
focusedNodeClass: this.focusedNodeClass,
|
|
366
|
+
nodeClass: this.nodeClass,
|
|
367
|
+
nodeContentClass: this.nodeContentClass,
|
|
351
368
|
dragOverNodeClass: this.dragOverNodeClass,
|
|
352
369
|
dropZoneMode: this.dropZoneMode,
|
|
353
370
|
dropZoneLayout: this.dropZoneLayout,
|
|
@@ -757,13 +774,17 @@ export class TreeController {
|
|
|
757
774
|
_collectClipboardEntry(node) {
|
|
758
775
|
const descendants = [];
|
|
759
776
|
const sep = this.treePathSeparator;
|
|
777
|
+
// $state.snapshot deproxies Svelte reactive state into a plain deep clone.
|
|
778
|
+
// structuredClone alone throws ("could not be cloned") when node.data is a
|
|
779
|
+
// $state proxy — which it is for any consumer passing default $state data.
|
|
780
|
+
const snapshot = (data) => $state.snapshot(data);
|
|
760
781
|
const walk = (n) => {
|
|
761
782
|
for (const child of Object.values(n.children)) {
|
|
762
783
|
// relativePath = everything after sourcePath + separator
|
|
763
784
|
const rel = child.path.substring(node.path.length);
|
|
764
785
|
descendants.push({
|
|
765
786
|
relativePath: rel,
|
|
766
|
-
data:
|
|
787
|
+
data: snapshot(child.data)
|
|
767
788
|
});
|
|
768
789
|
walk(child);
|
|
769
790
|
}
|
|
@@ -772,7 +793,7 @@ export class TreeController {
|
|
|
772
793
|
return {
|
|
773
794
|
sourceTreeId: this.treeId,
|
|
774
795
|
sourcePath: node.path,
|
|
775
|
-
data:
|
|
796
|
+
data: snapshot(node.data),
|
|
776
797
|
descendants
|
|
777
798
|
};
|
|
778
799
|
}
|
|
@@ -808,6 +829,7 @@ export class TreeController {
|
|
|
808
829
|
sourceTreeId: this.treeId
|
|
809
830
|
});
|
|
810
831
|
uiLogger.debug(`[clipboard] Copied ${entries.length} node(s)`);
|
|
832
|
+
this.onCopyHandler?.(pathsToUse);
|
|
811
833
|
}
|
|
812
834
|
/**
|
|
813
835
|
* Cut nodes to the shared clipboard. Nodes are dimmed but NOT removed until paste.
|
|
@@ -851,6 +873,7 @@ export class TreeController {
|
|
|
851
873
|
});
|
|
852
874
|
this.cutPaths = cutSet;
|
|
853
875
|
uiLogger.debug(`[clipboard] Cut ${entries.length} node(s), dimming ${cutSet.size} paths`);
|
|
876
|
+
this.onCutHandler?.(pathsToUse);
|
|
854
877
|
}
|
|
855
878
|
/**
|
|
856
879
|
* Paste clipboard content under (or beside) the target node.
|
|
@@ -901,9 +924,12 @@ export class TreeController {
|
|
|
901
924
|
targetPath,
|
|
902
925
|
position
|
|
903
926
|
};
|
|
904
|
-
// Clear
|
|
927
|
+
// Clear cut-dimming. A CUT is a one-shot move, so clear the clipboard;
|
|
928
|
+
// a COPY stays on the clipboard so it can be pasted again (Finder /
|
|
929
|
+
// Explorer / VS Code convention).
|
|
905
930
|
this.cutPaths = new Set();
|
|
906
|
-
|
|
931
|
+
if (clip.operation === 'cut')
|
|
932
|
+
clearClipboard();
|
|
907
933
|
uiLogger.debug(`[clipboard] shouldAutoHandlePaste=false — forwarding ${clip.entries.length} entries to consumer`);
|
|
908
934
|
this.onPasteHandler?.(result);
|
|
909
935
|
return result;
|
|
@@ -950,9 +976,12 @@ export class TreeController {
|
|
|
950
976
|
tick().then(() => {
|
|
951
977
|
this._skipInsertArray = false;
|
|
952
978
|
});
|
|
953
|
-
// Clear
|
|
979
|
+
// Clear cut-dimming. A CUT is a one-shot move (sources were just removed), so
|
|
980
|
+
// clear the clipboard; a COPY stays on the clipboard so it can be pasted again
|
|
981
|
+
// (Finder / Explorer / VS Code convention).
|
|
954
982
|
this.cutPaths = new Set();
|
|
955
|
-
|
|
983
|
+
if (clip.operation === 'cut')
|
|
984
|
+
clearClipboard();
|
|
956
985
|
const result = {
|
|
957
986
|
success: totalCount > 0,
|
|
958
987
|
count: totalCount,
|
|
@@ -1414,6 +1443,10 @@ export class TreeController {
|
|
|
1414
1443
|
this.highlightedNodeClass = updates.highlightedNodeClass;
|
|
1415
1444
|
if (updates.focusedNodeClass !== undefined)
|
|
1416
1445
|
this.focusedNodeClass = updates.focusedNodeClass;
|
|
1446
|
+
if (updates.nodeClass !== undefined)
|
|
1447
|
+
this.nodeClass = updates.nodeClass;
|
|
1448
|
+
if (updates.nodeContentClass !== undefined)
|
|
1449
|
+
this.nodeContentClass = updates.nodeContentClass;
|
|
1417
1450
|
if (updates.dragOverNodeClass !== undefined)
|
|
1418
1451
|
this.dragOverNodeClass = updates.dragOverNodeClass;
|
|
1419
1452
|
if (updates.dropZoneMode !== undefined)
|
|
@@ -1445,6 +1478,8 @@ export class TreeController {
|
|
|
1445
1478
|
// Callbacks
|
|
1446
1479
|
if (updates.onNodeClick !== undefined)
|
|
1447
1480
|
this.onNodeClickHandler = updates.onNodeClick;
|
|
1481
|
+
if (updates.onNodeDoubleClick !== undefined)
|
|
1482
|
+
this.onNodeDoubleClickHandler = updates.onNodeDoubleClick;
|
|
1448
1483
|
if (updates.onNodeDragStart !== undefined)
|
|
1449
1484
|
this.onNodeDragStartHandler = updates.onNodeDragStart;
|
|
1450
1485
|
if (updates.onNodeDragOver !== undefined)
|
|
@@ -1459,6 +1494,10 @@ export class TreeController {
|
|
|
1459
1494
|
this.beforePasteHandler = updates.beforePasteCallback;
|
|
1460
1495
|
if (updates.onNodeDrop !== undefined)
|
|
1461
1496
|
this.onNodeDropHandler = updates.onNodeDrop;
|
|
1497
|
+
if (updates.onCopy !== undefined)
|
|
1498
|
+
this.onCopyHandler = updates.onCopy;
|
|
1499
|
+
if (updates.onCut !== undefined)
|
|
1500
|
+
this.onCutHandler = updates.onCut;
|
|
1462
1501
|
if (updates.onPaste !== undefined)
|
|
1463
1502
|
this.onPasteHandler = updates.onPaste;
|
|
1464
1503
|
if (updates.getContextMenuItemsCallback !== undefined)
|
|
@@ -1473,31 +1512,39 @@ export class TreeController {
|
|
|
1473
1512
|
if (this.contextMenuVisible) {
|
|
1474
1513
|
this.closeContextMenu();
|
|
1475
1514
|
}
|
|
1476
|
-
// Manual
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
//
|
|
1480
|
-
//
|
|
1481
|
-
|
|
1515
|
+
// Manual double-click detection — see the comment on _lastClickPath for why the
|
|
1516
|
+
// browser's native dblclick can't be trusted (focus bumps node._rev → flat-mode
|
|
1517
|
+
// row is recreated → the 2nd click lands on a fresh element). Runs for every
|
|
1518
|
+
// clickBehavior so onNodeDoubleClick fires consistently; the built-in
|
|
1519
|
+
// expand/collapse-on-double-click only applies to clickBehavior='select' (the
|
|
1520
|
+
// other modes already toggle on single click). On a detected double we consume
|
|
1521
|
+
// the 2nd click (return early) so the gesture is a single open, not a re-toggle.
|
|
1522
|
+
// Threshold = 400ms, a touch tighter than Windows' 500ms default to avoid
|
|
1523
|
+
// coupling unrelated clicks. Gated on uiClick so programmatic highlight/select
|
|
1524
|
+
// API calls never get mistaken for a double-click.
|
|
1525
|
+
if (options?.uiClick && !modifiers?.ctrl && !modifiers?.shift) {
|
|
1482
1526
|
const now = Date.now();
|
|
1483
|
-
const isDouble = this.
|
|
1484
|
-
now - this.
|
|
1527
|
+
const isDouble = this._lastClickPath === node.path &&
|
|
1528
|
+
now - this._lastClickTime < 400;
|
|
1485
1529
|
if (isDouble) {
|
|
1486
|
-
this.
|
|
1487
|
-
this.
|
|
1488
|
-
|
|
1489
|
-
if (
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1530
|
+
this._lastClickPath = null;
|
|
1531
|
+
this._lastClickTime = 0;
|
|
1532
|
+
this.onNodeDoubleClickHandler?.(node);
|
|
1533
|
+
if (this.clickBehavior === 'select') {
|
|
1534
|
+
const canonical = this.tree.getNodeByPath(node.path) ?? node;
|
|
1535
|
+
if (canonical.hasChildren && canonical.isCollapsible !== false) {
|
|
1536
|
+
if (canonical.isExpanded) {
|
|
1537
|
+
this.collapseNodes(canonical.path);
|
|
1538
|
+
}
|
|
1539
|
+
else {
|
|
1540
|
+
this.expandNodes(canonical.path);
|
|
1541
|
+
}
|
|
1495
1542
|
}
|
|
1496
1543
|
}
|
|
1497
1544
|
return;
|
|
1498
1545
|
}
|
|
1499
|
-
this.
|
|
1500
|
-
this.
|
|
1546
|
+
this._lastClickPath = node.path;
|
|
1547
|
+
this._lastClickTime = now;
|
|
1501
1548
|
}
|
|
1502
1549
|
// In single mode, mouse Ctrl/Shift+click degrade to plain click. Programmatic
|
|
1503
1550
|
// callers (highlightNode with mode='toggle'/'range') pass forceMultiSemantics
|
package/dist/ltree/types.d.ts
CHANGED
|
@@ -51,6 +51,15 @@ export interface ContextMenuItem {
|
|
|
51
51
|
isVisible?: boolean;
|
|
52
52
|
className?: string;
|
|
53
53
|
onclick?: () => void | Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Whether activating this item auto-closes the menu. Default `true` —
|
|
56
|
+
* selecting an entry dismisses the menu like every native/desktop menu.
|
|
57
|
+
* Set `false` for items that act incrementally (toggle a flag, run a
|
|
58
|
+
* multi-step action) and want the menu to stay open; the handler is then
|
|
59
|
+
* responsible for dismissing it via the `close` callback passed to
|
|
60
|
+
* getContextMenuItemsCallback (or the snippet's close prop).
|
|
61
|
+
*/
|
|
62
|
+
shouldCloseOnClick?: boolean;
|
|
54
63
|
children?: ContextMenuEntry[];
|
|
55
64
|
}
|
|
56
65
|
export type ContextMenuEntry = ContextMenuItem | ContextMenuDivider;
|