@yewdiel21/motion-kit 0.10.0 → 0.14.0
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/README.md +155 -93
- package/dist/auto/index.d.ts +53 -3
- package/dist/auto/index.js +101 -3
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-IKQ5FODN.js → chunk-6P4P7NU6.js} +2 -218
- package/dist/chunk-6P4P7NU6.js.map +1 -0
- package/dist/chunk-CU6SXW3E.js +20334 -0
- package/dist/chunk-CU6SXW3E.js.map +1 -0
- package/dist/{chunk-XAKC2QY7.js → chunk-EY4BJKC4.js} +3 -28
- package/dist/chunk-EY4BJKC4.js.map +1 -0
- package/dist/chunk-QP4QJOEN.js +72 -0
- package/dist/chunk-QP4QJOEN.js.map +1 -0
- package/dist/css/components/activity-feed.css +240 -0
- package/dist/css/components/attachment.css +266 -0
- package/dist/css/components/breadcrumb.css +110 -0
- package/dist/css/components/chart-tooltip.css +168 -0
- package/dist/css/components/chart.css +296 -0
- package/dist/css/components/code-block.css +264 -0
- package/dist/css/components/color-area.css +82 -0
- package/dist/css/components/color-field.css +67 -0
- package/dist/css/components/color-picker.css +131 -0
- package/dist/css/components/color-slider.css +113 -0
- package/dist/css/components/command-search.css +252 -0
- package/dist/css/components/context-menu.css +23 -0
- package/dist/css/components/credit-card.css +288 -0
- package/dist/css/components/data-table.css +591 -0
- package/dist/css/components/date-range-picker.css +168 -0
- package/dist/css/components/date-time-picker.css +178 -0
- package/dist/css/components/drawer.css +58 -0
- package/dist/css/components/editable-table.css +201 -0
- package/dist/css/components/empty-state.css +61 -0
- package/dist/css/components/file-explorer.css +633 -0
- package/dist/css/components/filter-builder.css +232 -0
- package/dist/css/components/hold-confirm.css +87 -0
- package/dist/css/components/hover-card.css +13 -7
- package/dist/css/components/image-preview.css +195 -0
- package/dist/css/components/json-viewer.css +272 -0
- package/dist/css/components/like-button.css +78 -0
- package/dist/css/components/link-preview.css +275 -0
- package/dist/css/components/loading-button.css +77 -0
- package/dist/css/components/multi-select.css +267 -0
- package/dist/css/components/notification-bell.css +127 -0
- package/dist/css/components/password-strength.css +183 -0
- package/dist/css/components/popover.css +25 -0
- package/dist/css/components/progress.css +140 -0
- package/dist/css/components/questionnaire.css +217 -0
- package/dist/css/components/range-calendar.css +186 -0
- package/dist/css/components/range-slider.css +192 -0
- package/dist/css/components/resizable-panel.css +243 -0
- package/dist/css/components/rich-text-editor.css +284 -0
- package/dist/css/components/scratch-card.css +75 -0
- package/dist/css/components/skeleton.css +108 -0
- package/dist/css/components/split-view.css +19 -0
- package/dist/css/components/star-rating.css +80 -0
- package/dist/css/components/stat-card.css +164 -0
- package/dist/css/components/swipe-actions.css +105 -0
- package/dist/css/components/tag-input.css +244 -0
- package/dist/css/components/time-field.css +88 -0
- package/dist/css/components/timeline.css +219 -0
- package/dist/css/components/tooltip.css +79 -0
- package/dist/css/components/tree-view.css +210 -0
- package/dist/css/components/virtual-list.css +195 -0
- package/dist/css/tokens.css +185 -181
- package/dist/elements/index.d.ts +674 -5
- package/dist/elements/index.js +1361 -8
- package/dist/elements/index.js.map +1 -1
- package/dist/{engine-GDGwQnJE.d.ts → engine-Cn6IhE3z.d.ts} +1 -1
- package/dist/engine-QECQnGz1.d.ts +33 -0
- package/dist/index.css +9104 -189
- package/dist/index.d.ts +89 -5
- package/dist/index.js +243 -9
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +579 -23
- package/dist/react/index.js +2042 -7
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-BmBzKH94.d.ts → reduced-BBUusld6.d.ts} +9 -2
- package/dist/reveal-DEvp6U6T.d.ts +2381 -0
- package/dist/{store-DYb3tTsZ.d.ts → store-DjkTBX7f.d.ts} +2 -2
- package/package.json +1 -1
- package/registry.json +406 -1
- package/src/auto/auto-init.ts +98 -0
- package/src/components/activity-feed/README.md +56 -0
- package/src/components/activity-feed/activity-feed.css +239 -0
- package/src/components/activity-feed/element.ts +36 -0
- package/src/components/activity-feed/engine.ts +351 -0
- package/src/components/activity-feed/react.tsx +32 -0
- package/src/components/attachment/README.md +27 -0
- package/src/components/attachment/attachment.css +265 -0
- package/src/components/attachment/element.ts +50 -0
- package/src/components/attachment/engine.ts +469 -0
- package/src/components/attachment/react.tsx +65 -0
- package/src/components/breadcrumb/README.md +38 -0
- package/src/components/breadcrumb/breadcrumb.css +109 -0
- package/src/components/breadcrumb/element.ts +27 -0
- package/src/components/breadcrumb/engine.ts +249 -0
- package/src/components/breadcrumb/react.tsx +30 -0
- package/src/components/chart/README.md +24 -0
- package/src/components/chart/chart.css +295 -0
- package/src/components/chart/element.ts +41 -0
- package/src/components/chart/engine.ts +811 -0
- package/src/components/chart/react.tsx +50 -0
- package/src/components/chart-tooltip/README.md +53 -0
- package/src/components/chart-tooltip/chart-tooltip.css +167 -0
- package/src/components/chart-tooltip/element.ts +37 -0
- package/src/components/chart-tooltip/engine.ts +442 -0
- package/src/components/chart-tooltip/react.tsx +47 -0
- package/src/components/code-block/README.md +19 -0
- package/src/components/code-block/code-block.css +263 -0
- package/src/components/code-block/element.ts +34 -0
- package/src/components/code-block/engine.ts +254 -0
- package/src/components/code-block/highlight.ts +244 -0
- package/src/components/code-block/react.tsx +94 -0
- package/src/components/color-area/README.md +50 -0
- package/src/components/color-area/color-area.css +81 -0
- package/src/components/color-area/element.ts +26 -0
- package/src/components/color-area/engine.ts +246 -0
- package/src/components/color-area/react.tsx +42 -0
- package/src/components/color-field/README.md +43 -0
- package/src/components/color-field/color-field.css +66 -0
- package/src/components/color-field/element.ts +26 -0
- package/src/components/color-field/engine.ts +174 -0
- package/src/components/color-field/react.tsx +42 -0
- package/src/components/color-picker/README.md +45 -0
- package/src/components/color-picker/color-picker.css +130 -0
- package/src/components/color-picker/element.ts +45 -0
- package/src/components/color-picker/engine.ts +314 -0
- package/src/components/color-picker/react.tsx +47 -0
- package/src/components/color-slider/README.md +55 -0
- package/src/components/color-slider/color-slider.css +112 -0
- package/src/components/color-slider/element.ts +26 -0
- package/src/components/color-slider/engine.ts +234 -0
- package/src/components/color-slider/react.tsx +45 -0
- package/src/components/command-search/README.md +15 -0
- package/src/components/command-search/command-search.css +251 -0
- package/src/components/command-search/element.ts +36 -0
- package/src/components/command-search/engine.ts +356 -0
- package/src/components/command-search/match.ts +53 -0
- package/src/components/command-search/react.tsx +66 -0
- package/src/components/context-menu/README.md +31 -0
- package/src/components/context-menu/context-menu.css +22 -0
- package/src/components/context-menu/element.ts +40 -0
- package/src/components/context-menu/engine.ts +153 -0
- package/src/components/context-menu/react.tsx +57 -0
- package/src/components/credit-card/README.md +20 -0
- package/src/components/credit-card/card.ts +77 -0
- package/src/components/credit-card/credit-card.css +287 -0
- package/src/components/credit-card/element.ts +33 -0
- package/src/components/credit-card/engine.ts +410 -0
- package/src/components/credit-card/react.tsx +36 -0
- package/src/components/data-table/README.md +83 -0
- package/src/components/data-table/data-table.css +590 -0
- package/src/components/data-table/element.ts +54 -0
- package/src/components/data-table/engine.ts +1059 -0
- package/src/components/data-table/react.tsx +94 -0
- package/src/components/date-range-picker/README.md +51 -0
- package/src/components/date-range-picker/date-range-picker.css +167 -0
- package/src/components/date-range-picker/element.ts +52 -0
- package/src/components/date-range-picker/engine.ts +352 -0
- package/src/components/date-range-picker/react.tsx +57 -0
- package/src/components/date-time-picker/README.md +48 -0
- package/src/components/date-time-picker/date-time-picker.css +177 -0
- package/src/components/date-time-picker/element.ts +47 -0
- package/src/components/date-time-picker/engine.ts +350 -0
- package/src/components/date-time-picker/react.tsx +54 -0
- package/src/components/dialog/README.md +103 -102
- package/src/components/dialog/element.ts +123 -118
- package/src/components/dialog/engine.ts +449 -432
- package/src/components/drawer/README.md +28 -0
- package/src/components/drawer/drawer.css +57 -0
- package/src/components/drawer/element.ts +21 -0
- package/src/components/drawer/engine.ts +56 -0
- package/src/components/drawer/react.tsx +85 -0
- package/src/components/editable-table/README.md +67 -0
- package/src/components/editable-table/editable-table.css +200 -0
- package/src/components/editable-table/element.ts +45 -0
- package/src/components/editable-table/engine.ts +775 -0
- package/src/components/editable-table/react.tsx +79 -0
- package/src/components/empty-state/README.md +41 -0
- package/src/components/empty-state/element.ts +23 -0
- package/src/components/empty-state/empty-state.css +60 -0
- package/src/components/empty-state/engine.ts +232 -0
- package/src/components/empty-state/react.tsx +34 -0
- package/src/components/file-explorer/README.md +67 -0
- package/src/components/file-explorer/element.ts +39 -0
- package/src/components/file-explorer/engine.ts +1307 -0
- package/src/components/file-explorer/file-explorer.css +632 -0
- package/src/components/file-explorer/react.tsx +77 -0
- package/src/components/filter-builder/README.md +47 -0
- package/src/components/filter-builder/element.ts +34 -0
- package/src/components/filter-builder/engine.ts +365 -0
- package/src/components/filter-builder/filter-builder.css +231 -0
- package/src/components/filter-builder/react.tsx +36 -0
- package/src/components/hold-confirm/README.md +13 -0
- package/src/components/hold-confirm/element.ts +24 -0
- package/src/components/hold-confirm/engine.ts +216 -0
- package/src/components/hold-confirm/hold-confirm.css +86 -0
- package/src/components/hold-confirm/react.tsx +28 -0
- package/src/components/hover-card/README.md +6 -2
- package/src/components/hover-card/engine.ts +68 -6
- package/src/components/hover-card/hover-card.css +13 -7
- package/src/components/image-preview/README.md +16 -0
- package/src/components/image-preview/element.ts +26 -0
- package/src/components/image-preview/engine.ts +325 -0
- package/src/components/image-preview/image-preview.css +194 -0
- package/src/components/image-preview/react.tsx +43 -0
- package/src/components/json-viewer/README.md +56 -0
- package/src/components/json-viewer/element.ts +37 -0
- package/src/components/json-viewer/engine.ts +440 -0
- package/src/components/json-viewer/json-viewer.css +271 -0
- package/src/components/json-viewer/react.tsx +56 -0
- package/src/components/like-button/README.md +13 -0
- package/src/components/like-button/element.ts +47 -0
- package/src/components/like-button/engine.ts +145 -0
- package/src/components/like-button/like-button.css +77 -0
- package/src/components/like-button/react.tsx +39 -0
- package/src/components/link-preview/README.md +19 -0
- package/src/components/link-preview/element.ts +29 -0
- package/src/components/link-preview/engine.ts +192 -0
- package/src/components/link-preview/link-preview.css +274 -0
- package/src/components/link-preview/react.tsx +101 -0
- package/src/components/loading-button/README.md +45 -0
- package/src/components/loading-button/element.ts +34 -0
- package/src/components/loading-button/engine.ts +201 -0
- package/src/components/loading-button/loading-button.css +76 -0
- package/src/components/loading-button/react.tsx +43 -0
- package/src/components/multi-select/README.md +48 -0
- package/src/components/multi-select/element.ts +28 -0
- package/src/components/multi-select/engine.ts +294 -0
- package/src/components/multi-select/multi-select.css +266 -0
- package/src/components/multi-select/react.tsx +39 -0
- package/src/components/notification-bell/README.md +14 -0
- package/src/components/notification-bell/element.ts +48 -0
- package/src/components/notification-bell/engine.ts +202 -0
- package/src/components/notification-bell/notification-bell.css +126 -0
- package/src/components/notification-bell/react.tsx +57 -0
- package/src/components/password-strength/README.md +17 -0
- package/src/components/password-strength/element.ts +22 -0
- package/src/components/password-strength/engine.ts +212 -0
- package/src/components/password-strength/password-strength.css +182 -0
- package/src/components/password-strength/react.tsx +36 -0
- package/src/components/password-strength/score.ts +62 -0
- package/src/components/popover/README.md +44 -0
- package/src/components/popover/element.ts +31 -0
- package/src/components/popover/engine.ts +114 -0
- package/src/components/popover/popover.css +24 -0
- package/src/components/popover/react.tsx +50 -0
- package/src/components/progress/README.md +17 -0
- package/src/components/progress/element.ts +30 -0
- package/src/components/progress/engine.ts +173 -0
- package/src/components/progress/progress.css +139 -0
- package/src/components/progress/react.tsx +35 -0
- package/src/components/questionnaire/README.md +32 -0
- package/src/components/questionnaire/element.ts +118 -0
- package/src/components/questionnaire/engine.ts +255 -0
- package/src/components/questionnaire/questionnaire.css +216 -0
- package/src/components/questionnaire/react.tsx +140 -0
- package/src/components/range-calendar/README.md +58 -0
- package/src/components/range-calendar/element.ts +42 -0
- package/src/components/range-calendar/engine.ts +390 -0
- package/src/components/range-calendar/range-calendar.css +185 -0
- package/src/components/range-calendar/react.tsx +56 -0
- package/src/components/range-slider/README.md +23 -0
- package/src/components/range-slider/element.ts +53 -0
- package/src/components/range-slider/engine.ts +316 -0
- package/src/components/range-slider/range-slider.css +191 -0
- package/src/components/range-slider/react.tsx +66 -0
- package/src/components/resizable-panel/README.md +61 -0
- package/src/components/resizable-panel/element.ts +36 -0
- package/src/components/resizable-panel/engine.ts +551 -0
- package/src/components/resizable-panel/react.tsx +74 -0
- package/src/components/resizable-panel/resizable-panel.css +242 -0
- package/src/components/rich-text-editor/README.md +57 -0
- package/src/components/rich-text-editor/element.ts +34 -0
- package/src/components/rich-text-editor/engine.ts +673 -0
- package/src/components/rich-text-editor/react.tsx +60 -0
- package/src/components/rich-text-editor/rich-text-editor.css +283 -0
- package/src/components/scratch-card/README.md +15 -0
- package/src/components/scratch-card/element.ts +30 -0
- package/src/components/scratch-card/engine.ts +257 -0
- package/src/components/scratch-card/react.tsx +40 -0
- package/src/components/scratch-card/scratch-card.css +74 -0
- package/src/components/skeleton/README.md +15 -0
- package/src/components/skeleton/element.ts +32 -0
- package/src/components/skeleton/engine.ts +104 -0
- package/src/components/skeleton/react.tsx +41 -0
- package/src/components/skeleton/skeleton.css +107 -0
- package/src/components/split-view/README.md +50 -0
- package/src/components/split-view/element.ts +34 -0
- package/src/components/split-view/engine.ts +114 -0
- package/src/components/split-view/react.tsx +63 -0
- package/src/components/split-view/split-view.css +18 -0
- package/src/components/star-rating/README.md +14 -0
- package/src/components/star-rating/element.ts +26 -0
- package/src/components/star-rating/engine.ts +217 -0
- package/src/components/star-rating/react.tsx +33 -0
- package/src/components/star-rating/star-rating.css +79 -0
- package/src/components/stat-card/README.md +42 -0
- package/src/components/stat-card/element.ts +40 -0
- package/src/components/stat-card/engine.ts +283 -0
- package/src/components/stat-card/react.tsx +36 -0
- package/src/components/stat-card/stat-card.css +163 -0
- package/src/components/swipe-actions/README.md +19 -0
- package/src/components/swipe-actions/element.ts +26 -0
- package/src/components/swipe-actions/engine.ts +260 -0
- package/src/components/swipe-actions/react.tsx +80 -0
- package/src/components/swipe-actions/swipe-actions.css +104 -0
- package/src/components/tag-input/README.md +42 -0
- package/src/components/tag-input/element.ts +39 -0
- package/src/components/tag-input/engine.ts +313 -0
- package/src/components/tag-input/react.tsx +54 -0
- package/src/components/tag-input/tag-input.css +243 -0
- package/src/components/time-field/README.md +54 -0
- package/src/components/time-field/element.ts +38 -0
- package/src/components/time-field/engine.ts +110 -0
- package/src/components/time-field/react.tsx +45 -0
- package/src/components/time-field/time-field.css +87 -0
- package/src/components/timeline/README.md +57 -0
- package/src/components/timeline/element.ts +31 -0
- package/src/components/timeline/engine.ts +300 -0
- package/src/components/timeline/react.tsx +59 -0
- package/src/components/timeline/timeline.css +218 -0
- package/src/components/tooltip/README.md +46 -0
- package/src/components/tooltip/element.ts +22 -0
- package/src/components/tooltip/engine.ts +288 -0
- package/src/components/tooltip/react.tsx +30 -0
- package/src/components/tooltip/tooltip.css +78 -0
- package/src/components/tree-view/README.md +61 -0
- package/src/components/tree-view/element.ts +32 -0
- package/src/components/tree-view/engine.ts +569 -0
- package/src/components/tree-view/react.tsx +78 -0
- package/src/components/tree-view/tree-view.css +209 -0
- package/src/components/virtual-list/README.md +81 -0
- package/src/components/virtual-list/element.ts +42 -0
- package/src/components/virtual-list/engine.ts +678 -0
- package/src/components/virtual-list/react.tsx +101 -0
- package/src/components/virtual-list/virtual-list.css +194 -0
- package/src/core/chips.ts +196 -0
- package/src/core/color.ts +304 -0
- package/src/core/date-range.ts +67 -0
- package/src/core/dates.ts +11 -1
- package/src/core/files.ts +107 -0
- package/src/core/filter.ts +229 -0
- package/src/core/gsap.ts +80 -65
- package/src/core/listbox.ts +332 -0
- package/src/core/mark.ts +29 -0
- package/src/core/position.ts +89 -0
- package/src/core/relative-time.ts +77 -0
- package/src/core/segment-field.ts +373 -0
- package/src/core/segments.ts +153 -0
- package/src/core/tree.ts +116 -0
- package/src/core/virtual.ts +79 -0
- package/src/css/tokens.css +186 -182
- package/src/elements/index.ts +147 -0
- package/src/index.ts +72 -0
- package/src/motion/micro.ts +248 -0
- package/src/motion/sheet.ts +88 -65
- package/src/motion/surface.ts +9 -0
- package/src/react/index.ts +57 -0
- package/dist/chunk-IKQ5FODN.js.map +0 -1
- package/dist/chunk-J6OCFNGU.js +0 -177
- package/dist/chunk-J6OCFNGU.js.map +0 -1
- package/dist/chunk-TFNRIZHU.js +0 -3977
- package/dist/chunk-TFNRIZHU.js.map +0 -1
- package/dist/chunk-XAKC2QY7.js.map +0 -1
- package/dist/reveal-jsX-uwUz.d.ts +0 -625
|
@@ -0,0 +1,1307 @@
|
|
|
1
|
+
import { gsap, EASE, Flip, registerMotion } from '../../core/gsap';
|
|
2
|
+
import { reduced } from '../../core/reduced';
|
|
3
|
+
import { createScope } from '../../core/scope';
|
|
4
|
+
import { TIMING } from '../../core/timing';
|
|
5
|
+
import { inert, type Instance } from '../../core/types';
|
|
6
|
+
import { fromJson, safeFormat } from '../../core/dates';
|
|
7
|
+
import { toTime } from '../../core/relative-time';
|
|
8
|
+
import { cloneEntries, extensionOf, fileFamily, formatSize, isFolder, nameTaken, sortEntries, uniqueName, type FileEntry, type FileSort } from '../../core/files';
|
|
9
|
+
import type { TreeNode } from '../../core/tree';
|
|
10
|
+
import { blurIn, press, ringPulse, shake } from '../../motion/micro';
|
|
11
|
+
import { slideIndicator } from '../../motion/indicator';
|
|
12
|
+
import { createBreadcrumb } from '../breadcrumb/engine';
|
|
13
|
+
import { createTreeView } from '../tree-view/engine';
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
* File explorer: folders and files with the folder tree on the side, the path
|
|
17
|
+
* as a breadcrumb and the folder's content as a list (name, size, date;
|
|
18
|
+
* sortable) or a grid of big icons. Opening a folder pushes the content aside:
|
|
19
|
+
* the old one slides out blurring while the new one slides in from the other
|
|
20
|
+
* side (.34 s; back goes the other way), and the tree and the breadcrumb
|
|
21
|
+
* follow. Switching between list and grid flies every icon to its new place
|
|
22
|
+
* (Flip, .5 s). Items select with a click (Ctrl/⌘ adds, Shift takes a range)
|
|
23
|
+
* on a soft accent; sorting glides them to their new order (layout reflow). A
|
|
24
|
+
* new folder grows in and asks for its name at once; renaming happens in place
|
|
25
|
+
* (a wrong name shakes); deleted items shrink out while the rest close the gap.
|
|
26
|
+
* Dragging items lifts a card with their count that follows the pointer and
|
|
27
|
+
* tilts with its speed; a folder under it (in the content, the tree or the
|
|
28
|
+
* breadcrumb) opens its lid, tree branches open after a moment, and dropping
|
|
29
|
+
* flies the card into it (on touch screens, after a long press). Keyboard:
|
|
30
|
+
* arrows (Shift extends), Ctrl/⌘+A, Enter opens, Backspace goes up, F2
|
|
31
|
+
* renames, Supr deletes, Ctrl/⌘+X and Ctrl/⌘+V move.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export type { FileEntry, FileSort } from '../../core/files';
|
|
35
|
+
export type FileView = 'list' | 'grid';
|
|
36
|
+
|
|
37
|
+
export type FileChange =
|
|
38
|
+
| { type: 'create'; id: string; parent: string }
|
|
39
|
+
| { type: 'rename'; id: string; name: string }
|
|
40
|
+
| { type: 'delete'; ids: string[] }
|
|
41
|
+
| { type: 'move'; ids: string[]; to: string };
|
|
42
|
+
|
|
43
|
+
export interface FileExplorerOptions {
|
|
44
|
+
/** What the top folder holds. */
|
|
45
|
+
files?: FileEntry[];
|
|
46
|
+
/** Name of the top folder (default 'Inicio'). */
|
|
47
|
+
rootLabel?: string;
|
|
48
|
+
/** The folder open at the start (default: the top one, '/'). */
|
|
49
|
+
folder?: string;
|
|
50
|
+
view?: FileView;
|
|
51
|
+
/** The folder tree beside the content (default true; it hides on narrow widths). */
|
|
52
|
+
tree?: boolean;
|
|
53
|
+
/** No new folders, renaming, deleting or moving (default false). */
|
|
54
|
+
readOnly?: boolean;
|
|
55
|
+
locale?: string;
|
|
56
|
+
/** Accessible name (default 'Explorador de archivos'). */
|
|
57
|
+
label?: string;
|
|
58
|
+
/** A file was opened (double click or Enter). */
|
|
59
|
+
onOpen?: (entry: FileEntry) => void;
|
|
60
|
+
onNavigate?: (folder: FileEntry) => void;
|
|
61
|
+
onSelectionChange?: (ids: string[]) => void;
|
|
62
|
+
onViewChange?: (view: FileView) => void;
|
|
63
|
+
/** After every change (new folder, rename, delete, move), with all the files. */
|
|
64
|
+
onChange?: (files: FileEntry[], change: FileChange) => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface FileExplorerInstance extends Instance {
|
|
68
|
+
/** Open a folder, or a file (its folder opens and onOpen is called). */
|
|
69
|
+
open(id: string): void;
|
|
70
|
+
up(): void;
|
|
71
|
+
setView(view: FileView): void;
|
|
72
|
+
select(ids: string[]): void;
|
|
73
|
+
/** A new folder in the open one; its name is asked for in place. Returns its id. */
|
|
74
|
+
createFolder(name?: string): string | null;
|
|
75
|
+
/** With a name, renames at once; without one, asks for it in place. */
|
|
76
|
+
rename(id: string, name?: string): void;
|
|
77
|
+
/** Deletes these entries (default: the selection). */
|
|
78
|
+
remove(ids?: string[]): void;
|
|
79
|
+
move(ids: string[], folder: string): void;
|
|
80
|
+
setFiles(files: FileEntry[]): void;
|
|
81
|
+
readonly files: FileEntry[];
|
|
82
|
+
/** The open folder's id ('/' for the top one). */
|
|
83
|
+
readonly folder: string;
|
|
84
|
+
readonly selection: string[];
|
|
85
|
+
readonly view: FileView;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const ROOT_FOLDER = '/';
|
|
89
|
+
|
|
90
|
+
const SVG = (paths: string) =>
|
|
91
|
+
`<svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
|
|
92
|
+
const ICONS = {
|
|
93
|
+
up: SVG('<path d="M12 19V5M6 11l6-6 6 6"/>'),
|
|
94
|
+
newFolder: SVG('<path d="M3 7.5A2.5 2.5 0 0 1 5.5 5h3.7l2 2h7.3A2.5 2.5 0 0 1 21 9.5v8a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5z"/><path d="M12 11v5M9.5 13.5h5"/>'),
|
|
95
|
+
rename: SVG('<path d="M4 20h4L18.5 9.5a2.1 2.1 0 0 0-3-3L5 17v3"/><path d="M14 7l3 3"/>'),
|
|
96
|
+
remove: SVG('<path d="M5 7h14M10 7V5h4v2M7 7l1 12h8l1-12"/>'),
|
|
97
|
+
list: SVG('<path d="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01"/>'),
|
|
98
|
+
grid: SVG('<rect x="4" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.5"/>'),
|
|
99
|
+
};
|
|
100
|
+
const ARROW = '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M6 11l6-6 6 6"/></svg>';
|
|
101
|
+
const FOLDER =
|
|
102
|
+
'<svg aria-hidden="true" viewBox="0 0 48 40"><path data-mk-fx-folder-back d="M2 6a4 4 0 0 1 4-4h11.6a4 4 0 0 1 2.9 1.2L23.4 6H42a4 4 0 0 1 4 4v24a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4z"/><path data-mk-fx-folder-front d="M2 14a4 4 0 0 1 4-4h36a4 4 0 0 1 4 4v20a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4z"/></svg>';
|
|
103
|
+
const PAGE =
|
|
104
|
+
'<svg aria-hidden="true" viewBox="0 0 40 48"><path data-mk-fx-page d="M7 2h18.5L37 13.5V42a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4z"/><path data-mk-fx-fold d="M25.5 2v8.5a3 3 0 0 0 3 3H37z"/></svg>';
|
|
105
|
+
const SORT_LABEL: Record<FileSort, string> = { name: 'Nombre', size: 'Tamaño', modified: 'Modificado' };
|
|
106
|
+
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
|
107
|
+
let sequence = 0;
|
|
108
|
+
|
|
109
|
+
/** A drawn icon: a folder in the accent, or a page tinted by the file's family with its extension on a tag. */
|
|
110
|
+
function iconOf(entry: FileEntry) {
|
|
111
|
+
const icon = document.createElement('span');
|
|
112
|
+
icon.dataset.mkFxIcon = '';
|
|
113
|
+
icon.dataset.family = fileFamily(entry);
|
|
114
|
+
icon.innerHTML = isFolder(entry) ? FOLDER : PAGE;
|
|
115
|
+
const extension = isFolder(entry) ? '' : extensionOf(entry.name);
|
|
116
|
+
if (extension) {
|
|
117
|
+
const tag = document.createElement('span');
|
|
118
|
+
tag.dataset.mkFxExt = '';
|
|
119
|
+
tag.setAttribute('aria-hidden', 'true');
|
|
120
|
+
tag.textContent = extension.slice(0, 4).toUpperCase();
|
|
121
|
+
icon.append(tag);
|
|
122
|
+
}
|
|
123
|
+
return icon;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface Located {
|
|
127
|
+
entry: FileEntry;
|
|
128
|
+
parent: FileEntry | null;
|
|
129
|
+
/** From the top folder to the entry, both included. */
|
|
130
|
+
path: FileEntry[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface DropTarget {
|
|
134
|
+
id: string;
|
|
135
|
+
where: 'pane' | 'tree' | 'crumbs';
|
|
136
|
+
element: HTMLElement;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface Drag {
|
|
140
|
+
ids: string[];
|
|
141
|
+
origin: HTMLElement;
|
|
142
|
+
pointerId: number;
|
|
143
|
+
startX: number;
|
|
144
|
+
startY: number;
|
|
145
|
+
lastX: number;
|
|
146
|
+
touch: boolean;
|
|
147
|
+
/** Touch: the long press is over, the drag may start. */
|
|
148
|
+
armed: boolean;
|
|
149
|
+
started: boolean;
|
|
150
|
+
ghost: HTMLElement | null;
|
|
151
|
+
target: DropTarget | null;
|
|
152
|
+
follow: { x: (value: number) => void; y: (value: number) => void; tilt: (value: number) => void } | null;
|
|
153
|
+
timers: ReturnType<typeof setTimeout>[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createFileExplorer(root: HTMLElement | null, options: FileExplorerOptions = {}): FileExplorerInstance {
|
|
157
|
+
if (!root) {
|
|
158
|
+
return inert({
|
|
159
|
+
open() {}, up() {}, setView() {}, select() {}, createFolder: () => null, rename() {}, remove() {}, move() {}, setFiles() {},
|
|
160
|
+
files: [], folder: ROOT_FOLDER, selection: [], view: 'list' as FileView,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
registerMotion();
|
|
164
|
+
const scope = createScope();
|
|
165
|
+
const uid = `mk-fx-${++sequence}`;
|
|
166
|
+
const locale = options.locale ?? root.closest('[lang]')?.getAttribute('lang') ?? 'es';
|
|
167
|
+
const readOnly = Boolean(options.readOnly);
|
|
168
|
+
const top: FileEntry = { id: ROOT_FOLDER, name: options.rootLabel ?? 'Inicio', kind: 'folder', children: cloneEntries(fromJson(options.files) ?? []) };
|
|
169
|
+
let located = new Map<string, Located>();
|
|
170
|
+
const reindex = () => {
|
|
171
|
+
located = new Map();
|
|
172
|
+
const visit = (entry: FileEntry, parent: FileEntry | null, path: FileEntry[]) => {
|
|
173
|
+
const here = [...path, entry];
|
|
174
|
+
located.set(entry.id, { entry, parent, path: here });
|
|
175
|
+
for (const child of entry.children ?? []) visit(child, entry, here);
|
|
176
|
+
};
|
|
177
|
+
visit(top, null, []);
|
|
178
|
+
};
|
|
179
|
+
reindex();
|
|
180
|
+
const folderOf = (id: string | null | undefined) => {
|
|
181
|
+
const found = id ? located.get(id) : undefined;
|
|
182
|
+
return found && isFolder(found.entry) ? found.entry : null;
|
|
183
|
+
};
|
|
184
|
+
let current = folderOf(options.folder) ?? top;
|
|
185
|
+
let view: FileView = options.view === 'grid' ? 'grid' : 'list';
|
|
186
|
+
let sort: { key: FileSort; direction: 1 | -1 } = { key: 'name', direction: 1 };
|
|
187
|
+
let selection = new Set<string>();
|
|
188
|
+
let anchor: string | null = null;
|
|
189
|
+
let focusId: string | null = null;
|
|
190
|
+
let cut: string[] = [];
|
|
191
|
+
let renaming: { id: string; finish: (save: boolean) => boolean } | null = null;
|
|
192
|
+
let renders = 0;
|
|
193
|
+
let created = 0;
|
|
194
|
+
const effects = new Set<gsap.core.Animation>();
|
|
195
|
+
const keep = (animation: gsap.core.Animation | null) => {
|
|
196
|
+
if (!animation) return;
|
|
197
|
+
effects.add(animation);
|
|
198
|
+
// Finished ones leave as new ones arrive: the animation's own onComplete stays untouched.
|
|
199
|
+
for (const effect of effects) if (effect.totalProgress() === 1) effects.delete(effect);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// ── The parts ────────────────────────────────────────────────────────────
|
|
203
|
+
const bar = document.createElement('div');
|
|
204
|
+
bar.dataset.mkFxBar = '';
|
|
205
|
+
const crumbsHost = document.createElement('div');
|
|
206
|
+
crumbsHost.className = 'mk-breadcrumb';
|
|
207
|
+
crumbsHost.dataset.mkFxCrumbs = '';
|
|
208
|
+
const tools = document.createElement('div');
|
|
209
|
+
tools.dataset.mkFxTools = '';
|
|
210
|
+
const tool = (name: string, label: string, icon: string, text?: string) => {
|
|
211
|
+
const button = document.createElement('button');
|
|
212
|
+
button.type = 'button';
|
|
213
|
+
button.dataset.mkFxTool = name;
|
|
214
|
+
button.setAttribute('aria-label', label);
|
|
215
|
+
button.title = label;
|
|
216
|
+
button.innerHTML = icon;
|
|
217
|
+
if (text) {
|
|
218
|
+
const span = document.createElement('span');
|
|
219
|
+
span.textContent = text;
|
|
220
|
+
button.append(span);
|
|
221
|
+
}
|
|
222
|
+
return button;
|
|
223
|
+
};
|
|
224
|
+
const upButton = tool('up', 'Subir un nivel', ICONS.up);
|
|
225
|
+
const newButton = tool('new', 'Nueva carpeta', ICONS.newFolder, 'Nueva carpeta');
|
|
226
|
+
const renameButton = tool('rename', 'Renombrar (F2)', ICONS.rename);
|
|
227
|
+
const removeButton = tool('remove', 'Eliminar (Supr)', ICONS.remove);
|
|
228
|
+
const views = document.createElement('div');
|
|
229
|
+
views.dataset.mkFxViews = '';
|
|
230
|
+
views.setAttribute('role', 'radiogroup');
|
|
231
|
+
views.setAttribute('aria-label', 'Vista');
|
|
232
|
+
const indicator = document.createElement('span');
|
|
233
|
+
indicator.dataset.mkFxViewIndicator = '';
|
|
234
|
+
indicator.setAttribute('aria-hidden', 'true');
|
|
235
|
+
const viewButton = (name: FileView, label: string, icon: string) => {
|
|
236
|
+
const button = document.createElement('button');
|
|
237
|
+
button.type = 'button';
|
|
238
|
+
button.dataset.view = name;
|
|
239
|
+
button.setAttribute('role', 'radio');
|
|
240
|
+
button.setAttribute('aria-label', label);
|
|
241
|
+
button.title = label;
|
|
242
|
+
button.innerHTML = icon;
|
|
243
|
+
return button;
|
|
244
|
+
};
|
|
245
|
+
const listButton = viewButton('list', 'Lista', ICONS.list);
|
|
246
|
+
const gridButton = viewButton('grid', 'Cuadrícula', ICONS.grid);
|
|
247
|
+
views.append(indicator, listButton, gridButton);
|
|
248
|
+
tools.append(upButton);
|
|
249
|
+
if (!readOnly) tools.append(newButton, renameButton, removeButton);
|
|
250
|
+
tools.append(views);
|
|
251
|
+
bar.append(crumbsHost, tools);
|
|
252
|
+
|
|
253
|
+
const body = document.createElement('div');
|
|
254
|
+
body.dataset.mkFxBody = '';
|
|
255
|
+
const side = document.createElement('div');
|
|
256
|
+
side.className = 'mk-tree-view';
|
|
257
|
+
side.dataset.mkFxSide = '';
|
|
258
|
+
const main = document.createElement('div');
|
|
259
|
+
main.dataset.mkFxMain = '';
|
|
260
|
+
const head = document.createElement('div');
|
|
261
|
+
head.dataset.mkFxHead = '';
|
|
262
|
+
head.setAttribute('role', 'group');
|
|
263
|
+
head.setAttribute('aria-label', 'Ordenar');
|
|
264
|
+
head.append(document.createElement('span'));
|
|
265
|
+
const sortButtons = (['name', 'size', 'modified'] as FileSort[]).map((key) => {
|
|
266
|
+
const button = document.createElement('button');
|
|
267
|
+
button.type = 'button';
|
|
268
|
+
button.dataset.sort = key;
|
|
269
|
+
const text = document.createElement('span');
|
|
270
|
+
text.textContent = SORT_LABEL[key];
|
|
271
|
+
button.append(text);
|
|
272
|
+
button.insertAdjacentHTML('beforeend', ARROW);
|
|
273
|
+
head.append(button);
|
|
274
|
+
return button;
|
|
275
|
+
});
|
|
276
|
+
const pane = document.createElement('div');
|
|
277
|
+
pane.dataset.mkFxPane = '';
|
|
278
|
+
const list = document.createElement('div');
|
|
279
|
+
list.id = `${uid}-items`;
|
|
280
|
+
list.dataset.mkFxItems = '';
|
|
281
|
+
list.setAttribute('role', 'listbox');
|
|
282
|
+
list.setAttribute('aria-multiselectable', 'true');
|
|
283
|
+
const empty = document.createElement('p');
|
|
284
|
+
empty.dataset.mkFxEmpty = '';
|
|
285
|
+
empty.textContent = 'Esta carpeta está vacía';
|
|
286
|
+
pane.append(list, empty);
|
|
287
|
+
main.append(head, pane);
|
|
288
|
+
if (options.tree !== false) body.append(side);
|
|
289
|
+
body.append(main);
|
|
290
|
+
const footer = document.createElement('div');
|
|
291
|
+
footer.dataset.mkFxStatus = '';
|
|
292
|
+
const count = document.createElement('span');
|
|
293
|
+
count.dataset.mkFxCount = '';
|
|
294
|
+
const note = document.createElement('span');
|
|
295
|
+
note.id = `${uid}-note`;
|
|
296
|
+
note.dataset.mkFxNote = '';
|
|
297
|
+
footer.append(count, note);
|
|
298
|
+
const status = document.createElement('span');
|
|
299
|
+
status.className = 'mk-sr-only';
|
|
300
|
+
status.setAttribute('role', 'status');
|
|
301
|
+
root.append(bar, body, footer, status);
|
|
302
|
+
const leavingCopies = new Set<HTMLElement>();
|
|
303
|
+
scope.add(() => {
|
|
304
|
+
for (const effect of effects) effect.kill();
|
|
305
|
+
effects.clear();
|
|
306
|
+
for (const copy of leavingCopies) copy.remove();
|
|
307
|
+
bar.remove();
|
|
308
|
+
body.remove();
|
|
309
|
+
footer.remove();
|
|
310
|
+
status.remove();
|
|
311
|
+
});
|
|
312
|
+
scope.attr(root, 'data-mk-ready', '');
|
|
313
|
+
scope.attr(root, 'data-mk-view', view);
|
|
314
|
+
scope.attr(root, 'role', 'region');
|
|
315
|
+
scope.attr(root, 'aria-label', options.label ?? 'Explorador de archivos');
|
|
316
|
+
if (options.tree !== false) scope.attr(root, 'data-mk-fx-tree', '');
|
|
317
|
+
if (readOnly) scope.attr(root, 'data-mk-readonly', '');
|
|
318
|
+
|
|
319
|
+
// ── The breadcrumb and the folder tree ───────────────────────────────────
|
|
320
|
+
const pathOf = (folder: FileEntry) => located.get(folder.id)?.path ?? [top];
|
|
321
|
+
const crumbItems = () => pathOf(current).map((folder, i, path) => ({ label: folder.name, href: i < path.length - 1 ? '#' : undefined }));
|
|
322
|
+
const crumbs = createBreadcrumb(crumbsHost, {
|
|
323
|
+
items: crumbItems(),
|
|
324
|
+
label: 'Ruta de la carpeta',
|
|
325
|
+
onNavigate: (_item, index, event) => {
|
|
326
|
+
event.preventDefault();
|
|
327
|
+
const target = pathOf(current)[index];
|
|
328
|
+
if (target) navigate(target.id, { from: pathOf(current)[index + 1]?.id });
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
// Listeners on the explorer hear its own events, not those of its parts.
|
|
332
|
+
scope.on(crumbsHost, 'mk:navigate', (event) => {
|
|
333
|
+
event.preventDefault();
|
|
334
|
+
event.stopPropagation();
|
|
335
|
+
});
|
|
336
|
+
let shownCrumbs = JSON.stringify(crumbItems());
|
|
337
|
+
const paintCrumbs = () => {
|
|
338
|
+
const items = crumbItems();
|
|
339
|
+
const next = JSON.stringify(items);
|
|
340
|
+
if (next === shownCrumbs) return;
|
|
341
|
+
shownCrumbs = next;
|
|
342
|
+
crumbs.setItems(items);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
let collator: Intl.Collator;
|
|
346
|
+
try {
|
|
347
|
+
collator = new Intl.Collator(locale, { numeric: true, sensitivity: 'base' });
|
|
348
|
+
} catch {
|
|
349
|
+
collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
|
350
|
+
}
|
|
351
|
+
const treeNodes = (): TreeNode[] => {
|
|
352
|
+
const toNode = (folder: FileEntry): TreeNode => ({
|
|
353
|
+
id: folder.id,
|
|
354
|
+
label: folder.name,
|
|
355
|
+
children: (folder.children ?? [])
|
|
356
|
+
.filter(isFolder)
|
|
357
|
+
.sort((a, b) => collator.compare(a.name, b.name))
|
|
358
|
+
.map(toNode),
|
|
359
|
+
});
|
|
360
|
+
return [toNode(top)];
|
|
361
|
+
};
|
|
362
|
+
const tree =
|
|
363
|
+
options.tree === false
|
|
364
|
+
? null
|
|
365
|
+
: createTreeView(side, {
|
|
366
|
+
nodes: treeNodes(),
|
|
367
|
+
expanded: pathOf(current).map((folder) => folder.id),
|
|
368
|
+
selected: current.id,
|
|
369
|
+
label: 'Carpetas',
|
|
370
|
+
renderLabel: (node, element) => {
|
|
371
|
+
const name = document.createElement('span');
|
|
372
|
+
name.dataset.mkFxTreeName = '';
|
|
373
|
+
name.textContent = node.label;
|
|
374
|
+
element.append(iconOf({ id: node.id, name: node.label, kind: 'folder' }), name);
|
|
375
|
+
},
|
|
376
|
+
onSelect: (node) => navigate(node.id),
|
|
377
|
+
});
|
|
378
|
+
for (const type of ['mk:select', 'mk:expand', 'mk:check']) scope.on(side, type, (event) => event.stopPropagation());
|
|
379
|
+
scope.add(() => {
|
|
380
|
+
tree?.destroy();
|
|
381
|
+
crumbs.destroy();
|
|
382
|
+
});
|
|
383
|
+
const paintTree = (nodes: boolean) => {
|
|
384
|
+
if (!tree) return;
|
|
385
|
+
if (nodes) tree.setNodes(treeNodes());
|
|
386
|
+
for (const folder of pathOf(current).slice(0, -1)) if (!tree.expanded.includes(folder.id)) tree.expand(folder.id);
|
|
387
|
+
tree.select(current.id);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// ── Drawing the content ──────────────────────────────────────────────────
|
|
391
|
+
const dateFormat = safeFormat(locale, { day: 'numeric', month: 'short', year: 'numeric' });
|
|
392
|
+
const countText = (n: number) => (n ? plural(n, 'elemento', 'elementos') : 'Vacía');
|
|
393
|
+
const items = () => [...list.querySelectorAll<HTMLElement>(':scope > [data-mk-fx-item]')];
|
|
394
|
+
const itemOf = (id: string) => items().find((item) => item.dataset.id === id) ?? null;
|
|
395
|
+
const inCurrent = (id: string) => located.get(id)?.parent === current;
|
|
396
|
+
|
|
397
|
+
const itemElement = (entry: FileEntry) => {
|
|
398
|
+
const folder = isFolder(entry);
|
|
399
|
+
const item = document.createElement('div');
|
|
400
|
+
item.dataset.mkFxItem = '';
|
|
401
|
+
item.dataset.id = entry.id;
|
|
402
|
+
item.dataset.kind = folder ? 'folder' : 'file';
|
|
403
|
+
item.setAttribute('role', 'option');
|
|
404
|
+
item.setAttribute('aria-selected', String(selection.has(entry.id)));
|
|
405
|
+
item.tabIndex = -1;
|
|
406
|
+
if (cut.includes(entry.id)) item.setAttribute('data-mk-fx-cut', '');
|
|
407
|
+
const name = document.createElement('span');
|
|
408
|
+
name.dataset.mkFxName = '';
|
|
409
|
+
name.textContent = entry.name;
|
|
410
|
+
name.title = entry.name;
|
|
411
|
+
const size = document.createElement('span');
|
|
412
|
+
size.dataset.mkFxSize = '';
|
|
413
|
+
size.textContent = folder ? countText(entry.children?.length ?? 0) : entry.size === undefined ? '—' : formatSize(entry.size, locale);
|
|
414
|
+
const date = toTime(entry.modified);
|
|
415
|
+
const modified = document.createElement('span');
|
|
416
|
+
modified.dataset.mkFxDate = '';
|
|
417
|
+
modified.textContent = date ? dateFormat.format(date) : '—';
|
|
418
|
+
item.setAttribute('aria-label', [entry.name, folder ? `carpeta, ${size.textContent.toLowerCase()}` : size.textContent, date ? modified.textContent : ''].filter((part) => part && part !== '—').join(', '));
|
|
419
|
+
item.append(iconOf(entry), name, size, modified);
|
|
420
|
+
return item;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const paintTools = () => {
|
|
424
|
+
upButton.disabled = current === top;
|
|
425
|
+
renameButton.disabled = selection.size !== 1;
|
|
426
|
+
removeButton.disabled = selection.size === 0;
|
|
427
|
+
};
|
|
428
|
+
let error: string | null = null;
|
|
429
|
+
const paintStatus = () => {
|
|
430
|
+
count.textContent = plural(current.children?.length ?? 0, 'elemento', 'elementos');
|
|
431
|
+
note.toggleAttribute('data-mk-fx-error', Boolean(error));
|
|
432
|
+
if (error) {
|
|
433
|
+
note.textContent = error;
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const chosen = [...selection].map((id) => located.get(id)?.entry).filter((entry): entry is FileEntry => Boolean(entry));
|
|
437
|
+
const bytes = chosen.reduce((sum, entry) => sum + (isFolder(entry) ? 0 : entry.size ?? 0), 0);
|
|
438
|
+
note.textContent = chosen.length
|
|
439
|
+
? `${plural(chosen.length, 'seleccionado', 'seleccionados')}${bytes ? ` · ${formatSize(bytes, locale)}` : ''}`
|
|
440
|
+
: cut.length
|
|
441
|
+
? `${plural(cut.length, 'cortado', 'cortados')} · pega con Ctrl+V en otra carpeta`
|
|
442
|
+
: '';
|
|
443
|
+
};
|
|
444
|
+
const paintSort = () => {
|
|
445
|
+
for (const button of sortButtons) {
|
|
446
|
+
const key = button.dataset.sort as FileSort;
|
|
447
|
+
const active = key === sort.key;
|
|
448
|
+
button.toggleAttribute('data-active', active);
|
|
449
|
+
button.dataset.direction = active && sort.direction === -1 ? 'desc' : 'asc';
|
|
450
|
+
button.setAttribute('aria-label', active ? `${SORT_LABEL[key]}, orden ${sort.direction === 1 ? 'ascendente' : 'descendente'}` : `Ordenar por ${SORT_LABEL[key].toLowerCase()}`);
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
/** One item can take the focus (Tab): the focused one, else the first selected, else the first. */
|
|
455
|
+
const roving = () => {
|
|
456
|
+
const all = items();
|
|
457
|
+
const target = (focusId && all.find((item) => item.dataset.id === focusId)) || all.find((item) => selection.has(item.dataset.id!)) || all[0];
|
|
458
|
+
for (const item of all) item.tabIndex = item === target ? 0 : -1;
|
|
459
|
+
// An empty folder still takes the focus, so the keyboard can go up or make a folder.
|
|
460
|
+
list.tabIndex = all.length ? -1 : 0;
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const renderPane = () => {
|
|
464
|
+
renders++;
|
|
465
|
+
renaming?.finish(false);
|
|
466
|
+
const active = document.activeElement;
|
|
467
|
+
const hadFocus = Boolean(active && (list === active || list.contains(active)));
|
|
468
|
+
for (const id of selection) if (!inCurrent(id)) selection.delete(id);
|
|
469
|
+
list.replaceChildren(...sortEntries(current.children ?? [], sort.key, sort.direction, locale).map(itemElement));
|
|
470
|
+
list.setAttribute('aria-label', `Contenido de ${current.name}`);
|
|
471
|
+
empty.hidden = (current.children?.length ?? 0) > 0;
|
|
472
|
+
roving();
|
|
473
|
+
paintStatus();
|
|
474
|
+
paintTools();
|
|
475
|
+
if (hadFocus && !list.contains(document.activeElement)) (items().find((item) => item.tabIndex === 0) ?? list).focus({ preventScroll: true });
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
/** Redraw with `change`: what stays glides to its new place, the `entering` ids grow in. */
|
|
479
|
+
const reflow = (change: () => void, entering: string[] = []) => {
|
|
480
|
+
const before = items();
|
|
481
|
+
for (const item of before) item.dataset.flipId = item.dataset.id;
|
|
482
|
+
const state = reduced() || !before.length || before.length > 240 ? null : Flip.getState(before);
|
|
483
|
+
change();
|
|
484
|
+
const after = items();
|
|
485
|
+
for (const item of after) item.dataset.flipId = item.dataset.id;
|
|
486
|
+
if (!state) {
|
|
487
|
+
if (!reduced()) keep(blurIn(after.filter((item) => entering.includes(item.dataset.id!)), { from: 'pop', stagger: 0.04 }));
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
keep(
|
|
491
|
+
Flip.from(state, {
|
|
492
|
+
targets: after,
|
|
493
|
+
duration: TIMING.layoutTravel,
|
|
494
|
+
ease: EASE.layoutReflow,
|
|
495
|
+
// A beat late: the others make room first.
|
|
496
|
+
onEnter: (arriving) =>
|
|
497
|
+
gsap.fromTo(arriving, { opacity: 0, scale: 0.8, filter: 'blur(8px)' }, { opacity: 1, scale: 1, filter: 'blur(0px)', duration: 0.42, delay: 0.1, stagger: 0.04, ease: EASE.pop, clearProps: 'opacity,transform,filter' }),
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
// ── Selection ────────────────────────────────────────────────────────────
|
|
503
|
+
const sameIds = (a: Iterable<string>, b: Set<string>) => {
|
|
504
|
+
const list = [...a];
|
|
505
|
+
return list.length === b.size && list.every((id) => b.has(id));
|
|
506
|
+
};
|
|
507
|
+
const announceSelection = () => {
|
|
508
|
+
const ids = [...selection];
|
|
509
|
+
options.onSelectionChange?.(ids);
|
|
510
|
+
root.dispatchEvent(new CustomEvent('mk:select', { bubbles: true, detail: { ids } }));
|
|
511
|
+
};
|
|
512
|
+
const setSelection = (ids: Iterable<string>, notify = true) => {
|
|
513
|
+
const next = new Set([...ids].filter(inCurrent));
|
|
514
|
+
const changed = !sameIds(next, selection);
|
|
515
|
+
selection = next;
|
|
516
|
+
for (const item of items()) item.setAttribute('aria-selected', String(selection.has(item.dataset.id!)));
|
|
517
|
+
paintStatus();
|
|
518
|
+
paintTools();
|
|
519
|
+
if (changed && notify) announceSelection();
|
|
520
|
+
};
|
|
521
|
+
const selectRange = (from: string, to: string, add: boolean) => {
|
|
522
|
+
const order = items().map((item) => item.dataset.id!);
|
|
523
|
+
const a = order.indexOf(from);
|
|
524
|
+
const b = order.indexOf(to);
|
|
525
|
+
if (a < 0 || b < 0) return setSelection([to]);
|
|
526
|
+
const range = order.slice(Math.min(a, b), Math.max(a, b) + 1);
|
|
527
|
+
setSelection(add ? [...selection, ...range] : range);
|
|
528
|
+
};
|
|
529
|
+
/** Scroll the content (up and down only: an item still sliding in must not scroll it sideways) to show `item`. */
|
|
530
|
+
const revealItem = (item: HTMLElement | null) => {
|
|
531
|
+
if (!item) return;
|
|
532
|
+
const top = item.offsetTop;
|
|
533
|
+
const bottom = top + item.offsetHeight;
|
|
534
|
+
if (top < pane.scrollTop) pane.scrollTop = Math.max(0, top - 6);
|
|
535
|
+
else if (bottom > pane.scrollTop + pane.clientHeight) pane.scrollTop = bottom - pane.clientHeight + 6;
|
|
536
|
+
};
|
|
537
|
+
const focusItem = (id: string | null) => {
|
|
538
|
+
focusId = id;
|
|
539
|
+
roving();
|
|
540
|
+
const item = id ? itemOf(id) : null;
|
|
541
|
+
item?.focus({ preventScroll: true });
|
|
542
|
+
revealItem(item);
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// ── Going places ─────────────────────────────────────────────────────────
|
|
546
|
+
const navigate = (id: string, { from, focus = false }: { from?: string; focus?: boolean } = {}) => {
|
|
547
|
+
const folder = folderOf(id);
|
|
548
|
+
if (!folder || folder === current) return;
|
|
549
|
+
renaming?.finish(false);
|
|
550
|
+
const forward = pathOf(folder).length >= pathOf(current).length;
|
|
551
|
+
const active = document.activeElement;
|
|
552
|
+
const focusInside = focus || Boolean(active && (list === active || list.contains(active)));
|
|
553
|
+
// The old content leaves as a copy while the new one comes in from the other side.
|
|
554
|
+
let copy: HTMLElement | null = null;
|
|
555
|
+
if (!reduced() && list.childElementCount && list.childElementCount <= 240) {
|
|
556
|
+
copy = list.cloneNode(true) as HTMLElement;
|
|
557
|
+
copy.removeAttribute('id');
|
|
558
|
+
copy.removeAttribute('role');
|
|
559
|
+
copy.setAttribute('aria-hidden', 'true');
|
|
560
|
+
copy.inert = true;
|
|
561
|
+
copy.dataset.mkFxLeaving = '';
|
|
562
|
+
copy.style.top = `${list.offsetTop - pane.scrollTop}px`;
|
|
563
|
+
copy.style.left = `${list.offsetLeft}px`;
|
|
564
|
+
copy.style.width = `${list.offsetWidth}px`;
|
|
565
|
+
}
|
|
566
|
+
current = folder;
|
|
567
|
+
const back = from && inCurrent(from) ? from : null;
|
|
568
|
+
const before = [...selection];
|
|
569
|
+
selection = new Set(back ? [back] : []);
|
|
570
|
+
anchor = back;
|
|
571
|
+
focusId = back;
|
|
572
|
+
cut = cut.filter((cutId) => located.has(cutId));
|
|
573
|
+
error = null;
|
|
574
|
+
renderPane();
|
|
575
|
+
pane.scrollTop = 0;
|
|
576
|
+
if (copy) {
|
|
577
|
+
pane.append(copy);
|
|
578
|
+
leavingCopies.add(copy);
|
|
579
|
+
const gone = copy;
|
|
580
|
+
// The old content fades fast while it slides away; the new one follows a beat later.
|
|
581
|
+
keep(
|
|
582
|
+
gsap
|
|
583
|
+
.timeline({
|
|
584
|
+
onComplete: () => {
|
|
585
|
+
gone.remove();
|
|
586
|
+
leavingCopies.delete(gone);
|
|
587
|
+
},
|
|
588
|
+
})
|
|
589
|
+
.to(gone, { x: forward ? -32 : 32, duration: 0.26, ease: 'power2.in' }, 0)
|
|
590
|
+
.to(gone, { opacity: 0, filter: 'blur(6px)', duration: 0.16, ease: 'power1.out' }, 0),
|
|
591
|
+
);
|
|
592
|
+
keep(gsap.fromTo(list, { x: forward ? 32 : -32, opacity: 0, filter: 'blur(6px)' }, { x: 0, opacity: 1, filter: 'blur(0px)', duration: 0.34, delay: 0.07, ease: EASE.signature, clearProps: 'transform,opacity,filter' }));
|
|
593
|
+
} else if (!reduced()) keep(blurIn(items().slice(0, 24), { from: 'soft', stagger: 0.02, duration: 0.3 }));
|
|
594
|
+
paintCrumbs();
|
|
595
|
+
paintTree(false);
|
|
596
|
+
if (focusInside) focusItem(back ?? items()[0]?.dataset.id ?? null);
|
|
597
|
+
if (focusInside && !items().length) list.focus({ preventScroll: true });
|
|
598
|
+
status.textContent = `${folder.name}: ${countText(folder.children?.length ?? 0).toLowerCase()}`;
|
|
599
|
+
if (back) revealItem(itemOf(back));
|
|
600
|
+
options.onNavigate?.(folder);
|
|
601
|
+
root.dispatchEvent(new CustomEvent('mk:navigate', { bubbles: true, detail: { id: folder.id, folder } }));
|
|
602
|
+
if (!sameIds(before, selection)) announceSelection();
|
|
603
|
+
};
|
|
604
|
+
const goUp = (focus = false) => {
|
|
605
|
+
const parent = located.get(current.id)?.parent;
|
|
606
|
+
if (parent) navigate(parent.id, { from: current.id, focus });
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const openEntry = (id: string, focus = false) => {
|
|
610
|
+
const found = located.get(id);
|
|
611
|
+
if (!found) return;
|
|
612
|
+
if (isFolder(found.entry)) return navigate(id, { focus });
|
|
613
|
+
keep(press(itemOf(id)?.querySelector('[data-mk-fx-icon]')));
|
|
614
|
+
const entry = { ...found.entry };
|
|
615
|
+
options.onOpen?.(entry);
|
|
616
|
+
root.dispatchEvent(new CustomEvent('mk:open', { bubbles: true, detail: { id, entry } }));
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
// ── Changing the files ───────────────────────────────────────────────────
|
|
620
|
+
/** After the data changed: redraw (the `leaving` items shrink out first), update the tree and tell the app. */
|
|
621
|
+
const commit = (change: FileChange, { leaving = [], entering = [] }: { leaving?: string[]; entering?: string[] } = {}) => {
|
|
622
|
+
reindex();
|
|
623
|
+
cut = cut.filter((id) => located.has(id));
|
|
624
|
+
const gone = leaving.map(itemOf).filter((item): item is HTMLElement => Boolean(item));
|
|
625
|
+
const redraw = () => reflow(renderPane, entering);
|
|
626
|
+
if (gone.length && !reduced()) {
|
|
627
|
+
const at = renders;
|
|
628
|
+
for (const item of gone) item.setAttribute('data-mk-fx-gone', '');
|
|
629
|
+
keep(
|
|
630
|
+
gsap.to(gone, {
|
|
631
|
+
opacity: 0,
|
|
632
|
+
scale: 0.85,
|
|
633
|
+
filter: 'blur(6px)',
|
|
634
|
+
duration: 0.22,
|
|
635
|
+
ease: 'power2.in',
|
|
636
|
+
// Unless something else drew the content meanwhile.
|
|
637
|
+
onComplete: () => (renders === at && !scope.disposed ? redraw() : undefined),
|
|
638
|
+
}),
|
|
639
|
+
);
|
|
640
|
+
paintStatus();
|
|
641
|
+
paintTools();
|
|
642
|
+
} else redraw();
|
|
643
|
+
paintCrumbs();
|
|
644
|
+
paintTree(true);
|
|
645
|
+
const files = cloneEntries(top.children ?? []);
|
|
646
|
+
options.onChange?.(files, change);
|
|
647
|
+
root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { files, change } }));
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const createFolder = (name = 'Nueva carpeta') => {
|
|
651
|
+
if (readOnly || scope.disposed) return null;
|
|
652
|
+
renaming?.finish(true);
|
|
653
|
+
const entry: FileEntry = { id: `${uid}-${Date.now().toString(36)}-${++created}`, name: uniqueName(name, current.children ?? []), kind: 'folder', children: [], modified: new Date().toISOString() };
|
|
654
|
+
(current.children ??= []).push(entry);
|
|
655
|
+
reindex();
|
|
656
|
+
selection = new Set([entry.id]);
|
|
657
|
+
anchor = entry.id;
|
|
658
|
+
focusId = entry.id;
|
|
659
|
+
status.textContent = `Carpeta «${entry.name}» creada`;
|
|
660
|
+
commit({ type: 'create', id: entry.id, parent: current.id }, { entering: [entry.id] });
|
|
661
|
+
revealItem(itemOf(entry.id));
|
|
662
|
+
startRename(entry.id);
|
|
663
|
+
return entry.id;
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const problemWith = (name: string, entry: FileEntry) => {
|
|
667
|
+
const parent = located.get(entry.id)?.parent;
|
|
668
|
+
if (!name) return 'Escribe un nombre';
|
|
669
|
+
if (/[\\/]/.test(name)) return 'El nombre no puede llevar / ni \\';
|
|
670
|
+
if (parent && nameTaken(name, parent.children ?? [], entry.id)) return `Ya existe «${name}» en esta carpeta`;
|
|
671
|
+
return null;
|
|
672
|
+
};
|
|
673
|
+
const applyName = (entry: FileEntry, name: string) => {
|
|
674
|
+
entry.name = name;
|
|
675
|
+
status.textContent = `Renombrado a «${name}»`;
|
|
676
|
+
commit({ type: 'rename', id: entry.id, name });
|
|
677
|
+
const renamed = itemOf(entry.id);
|
|
678
|
+
if (renamed && !reduced()) keep(blurIn(renamed.querySelector('[data-mk-fx-name]'), { from: 'soft', duration: 0.35 }));
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const startRename = (id: string) => {
|
|
682
|
+
if (readOnly) return;
|
|
683
|
+
renaming?.finish(true);
|
|
684
|
+
const entry = located.get(id)?.entry;
|
|
685
|
+
const item = itemOf(id);
|
|
686
|
+
if (!entry || !item || entry === top) return;
|
|
687
|
+
const name = item.querySelector<HTMLElement>('[data-mk-fx-name]')!;
|
|
688
|
+
const input = document.createElement('input');
|
|
689
|
+
input.type = 'text';
|
|
690
|
+
input.dataset.mkFxRename = '';
|
|
691
|
+
input.value = entry.name;
|
|
692
|
+
input.spellcheck = false;
|
|
693
|
+
input.setAttribute('aria-label', `Nuevo nombre de ${entry.name}`);
|
|
694
|
+
input.setAttribute('aria-describedby', note.id);
|
|
695
|
+
name.hidden = true;
|
|
696
|
+
name.after(input);
|
|
697
|
+
item.setAttribute('data-mk-fx-renaming', '');
|
|
698
|
+
input.focus();
|
|
699
|
+
// The name without its extension is what people change.
|
|
700
|
+
const dot = isFolder(entry) ? -1 : entry.name.lastIndexOf('.');
|
|
701
|
+
input.setSelectionRange(0, dot > 0 ? dot : entry.name.length);
|
|
702
|
+
const finish = (save: boolean) => {
|
|
703
|
+
if (renaming?.id !== id) return true;
|
|
704
|
+
// Redrawn under it: nothing to keep.
|
|
705
|
+
if (!input.isConnected) {
|
|
706
|
+
renaming = null;
|
|
707
|
+
error = null;
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
const next = input.value.trim();
|
|
711
|
+
if (save && next !== entry.name) {
|
|
712
|
+
const problem = problemWith(next, entry);
|
|
713
|
+
if (problem) {
|
|
714
|
+
error = problem;
|
|
715
|
+
paintStatus();
|
|
716
|
+
status.textContent = problem;
|
|
717
|
+
input.setAttribute('aria-invalid', 'true');
|
|
718
|
+
keep(shake(input));
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
renaming = null;
|
|
723
|
+
error = null;
|
|
724
|
+
input.remove();
|
|
725
|
+
name.hidden = false;
|
|
726
|
+
item.removeAttribute('data-mk-fx-renaming');
|
|
727
|
+
paintStatus();
|
|
728
|
+
if (save && next !== entry.name) applyName(entry, next);
|
|
729
|
+
focusItem(id);
|
|
730
|
+
return true;
|
|
731
|
+
};
|
|
732
|
+
renaming = { id, finish };
|
|
733
|
+
input.addEventListener('keydown', (event) => {
|
|
734
|
+
event.stopPropagation();
|
|
735
|
+
if (event.key === 'Enter') {
|
|
736
|
+
event.preventDefault();
|
|
737
|
+
finish(true);
|
|
738
|
+
} else if (event.key === 'Escape') {
|
|
739
|
+
event.preventDefault();
|
|
740
|
+
finish(false);
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
input.addEventListener('input', () => {
|
|
744
|
+
if (!error) return;
|
|
745
|
+
error = null;
|
|
746
|
+
input.removeAttribute('aria-invalid');
|
|
747
|
+
paintStatus();
|
|
748
|
+
});
|
|
749
|
+
// Leaving the field keeps a good name and drops a wrong one.
|
|
750
|
+
input.addEventListener('blur', () => {
|
|
751
|
+
if (!finish(true)) finish(false);
|
|
752
|
+
});
|
|
753
|
+
for (const type of ['click', 'dblclick', 'pointerdown']) input.addEventListener(type, (event) => event.stopPropagation());
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
const removeEntries = (ids: string[]) => {
|
|
757
|
+
if (readOnly) return;
|
|
758
|
+
renaming?.finish(false);
|
|
759
|
+
const found = [...new Set(ids)].map((id) => located.get(id)).filter((entry): entry is Located => Boolean(entry?.parent));
|
|
760
|
+
if (!found.length) return;
|
|
761
|
+
const order = items().map((item) => item.dataset.id!);
|
|
762
|
+
const firstGone = Math.min(...found.map(({ entry }) => order.indexOf(entry.id)).filter((i) => i >= 0));
|
|
763
|
+
for (const { entry, parent } of found) parent!.children = (parent!.children ?? []).filter((child) => child !== entry);
|
|
764
|
+
const removed = found.map(({ entry }) => entry.id);
|
|
765
|
+
reindex();
|
|
766
|
+
// The open folder went with them: the closest folder left opens.
|
|
767
|
+
if (!located.has(current.id)) {
|
|
768
|
+
const survivor = [...pathOf(current)].reverse().find((folder) => located.has(folder.id)) ?? top;
|
|
769
|
+
current = survivor;
|
|
770
|
+
}
|
|
771
|
+
// The focus goes to what takes the place of the first one deleted.
|
|
772
|
+
const left = order.filter((id) => !removed.includes(id));
|
|
773
|
+
focusId = Number.isFinite(firstGone) ? (left[Math.min(firstGone, left.length - 1)] ?? null) : focusId;
|
|
774
|
+
const hadSelection = selection.size > 0;
|
|
775
|
+
selection = new Set();
|
|
776
|
+
status.textContent = found.length === 1 ? `«${found[0]!.entry.name}» eliminado` : `${found.length} elementos eliminados`;
|
|
777
|
+
commit({ type: 'delete', ids: removed }, { leaving: removed });
|
|
778
|
+
if (hadSelection) announceSelection();
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
/** Can `ids` go into `folderId`? Not into themselves, not into their own insides, not where they are. */
|
|
782
|
+
const canMove = (ids: string[], folderId: string) => {
|
|
783
|
+
if (readOnly || !ids.length) return false;
|
|
784
|
+
const folder = folderOf(folderId);
|
|
785
|
+
if (!folder) return false;
|
|
786
|
+
const path = pathOf(folder).map((entry) => entry.id);
|
|
787
|
+
return ids.every((id) => {
|
|
788
|
+
const found = located.get(id);
|
|
789
|
+
return Boolean(found?.parent) && !path.includes(id) && found!.parent!.id !== folderId;
|
|
790
|
+
});
|
|
791
|
+
};
|
|
792
|
+
const moveEntries = (ids: string[], folderId: string) => {
|
|
793
|
+
if (!canMove(ids, folderId)) return false;
|
|
794
|
+
renaming?.finish(false);
|
|
795
|
+
const folder = folderOf(folderId)!;
|
|
796
|
+
const leaving = ids.filter(inCurrent);
|
|
797
|
+
for (const id of ids) {
|
|
798
|
+
const { entry, parent } = located.get(id)!;
|
|
799
|
+
parent!.children = (parent!.children ?? []).filter((child) => child !== entry);
|
|
800
|
+
entry.name = uniqueName(entry.name, folder.children ?? []);
|
|
801
|
+
(folder.children ??= []).push(entry);
|
|
802
|
+
}
|
|
803
|
+
cut = cut.filter((id) => !ids.includes(id));
|
|
804
|
+
const arriving = folder === current;
|
|
805
|
+
const before = [...selection];
|
|
806
|
+
if (arriving) {
|
|
807
|
+
selection = new Set(ids);
|
|
808
|
+
anchor = ids[0] ?? null;
|
|
809
|
+
focusId = anchor;
|
|
810
|
+
} else selection = new Set([...selection].filter((id) => !ids.includes(id)));
|
|
811
|
+
const names = ids.map((id) => located.get(id)!.entry.name);
|
|
812
|
+
status.textContent = ids.length === 1 ? `«${names[0]}» movido a «${folder.name}»` : `${ids.length} elementos movidos a «${folder.name}»`;
|
|
813
|
+
commit({ type: 'move', ids: [...ids], to: folderId }, { leaving, entering: arriving ? ids : [] });
|
|
814
|
+
if (arriving) focusItem(anchor);
|
|
815
|
+
if (!sameIds(before, selection)) announceSelection();
|
|
816
|
+
return true;
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
// ── Pointer: select, open, drag ──────────────────────────────────────────
|
|
820
|
+
let suppressClick = false;
|
|
821
|
+
scope.on(pane, 'click', (event) => {
|
|
822
|
+
if (suppressClick) return;
|
|
823
|
+
const mouse = event as MouseEvent;
|
|
824
|
+
const item = (mouse.target as Element | null)?.closest<HTMLElement>('[data-mk-fx-item]');
|
|
825
|
+
if (!item || !list.contains(item)) {
|
|
826
|
+
if (selection.size && !mouse.ctrlKey && !mouse.metaKey) setSelection([]);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
const id = item.dataset.id!;
|
|
830
|
+
if (mouse.shiftKey && anchor) selectRange(anchor, id, mouse.ctrlKey || mouse.metaKey);
|
|
831
|
+
else if (mouse.ctrlKey || mouse.metaKey) {
|
|
832
|
+
setSelection(selection.has(id) ? [...selection].filter((chosen) => chosen !== id) : [...selection, id]);
|
|
833
|
+
anchor = id;
|
|
834
|
+
} else {
|
|
835
|
+
const fresh = !selection.has(id);
|
|
836
|
+
setSelection([id]);
|
|
837
|
+
anchor = id;
|
|
838
|
+
if (fresh && !reduced()) keep(gsap.fromTo(item, { scale: 0.97 }, { scale: 1, duration: 0.35, ease: EASE.click, clearProps: 'transform' }));
|
|
839
|
+
}
|
|
840
|
+
focusItem(id);
|
|
841
|
+
});
|
|
842
|
+
scope.on(list, 'dblclick', (event) => {
|
|
843
|
+
const item = (event.target as Element | null)?.closest<HTMLElement>('[data-mk-fx-item]');
|
|
844
|
+
if (item) openEntry(item.dataset.id!, true);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
let drag: Drag | null = null;
|
|
848
|
+
const dropTargetAt = (x: number, y: number, ids: string[]): DropTarget | null => {
|
|
849
|
+
const under = document.elementFromPoint?.(x, y) ?? null;
|
|
850
|
+
if (!under) return null;
|
|
851
|
+
let target: DropTarget | null = null;
|
|
852
|
+
const paneFolder = under.closest<HTMLElement>('[data-mk-fx-item][data-kind="folder"]');
|
|
853
|
+
if (paneFolder && list.contains(paneFolder)) target = { id: paneFolder.dataset.id!, where: 'pane', element: paneFolder };
|
|
854
|
+
const treeItem = !target && tree ? under.closest<HTMLElement>('[role="treeitem"]') : null;
|
|
855
|
+
if (treeItem && side.contains(treeItem)) target = { id: treeItem.dataset.id!, where: 'tree', element: treeItem.querySelector<HTMLElement>(':scope > [data-mk-tree-row]') ?? treeItem };
|
|
856
|
+
const link = !target ? under.closest<HTMLElement>('a[data-mk-crumb-link]') : null;
|
|
857
|
+
if (link && crumbsHost.contains(link)) {
|
|
858
|
+
const folder = pathOf(current)[Number(link.dataset.mkIndex)];
|
|
859
|
+
if (folder) target = { id: folder.id, where: 'crumbs', element: link };
|
|
860
|
+
}
|
|
861
|
+
return target && canMove(ids, target.id) ? target : null;
|
|
862
|
+
};
|
|
863
|
+
/** The element a drop target is drawn by now (the content and the tree are redrawn by a move). */
|
|
864
|
+
const elementFor = (target: DropTarget) => {
|
|
865
|
+
if (target.where === 'pane') return itemOf(target.id);
|
|
866
|
+
if (target.where === 'tree') return [...side.querySelectorAll<HTMLElement>('[role="treeitem"]')].find((item) => item.dataset.id === target.id)?.querySelector<HTMLElement>(':scope > [data-mk-tree-row]') ?? null;
|
|
867
|
+
return target.element.isConnected ? target.element : null;
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
const lift = (x: number, y: number) => {
|
|
871
|
+
if (!drag) return;
|
|
872
|
+
drag.started = true;
|
|
873
|
+
if (!selection.has(drag.ids[0]!) || drag.ids.length === 1) setSelection(drag.ids);
|
|
874
|
+
const first = located.get(drag.ids[0]!)!.entry;
|
|
875
|
+
const ghost = document.createElement('div');
|
|
876
|
+
ghost.dataset.mkFxGhost = '';
|
|
877
|
+
ghost.setAttribute('aria-hidden', 'true');
|
|
878
|
+
const name = document.createElement('span');
|
|
879
|
+
name.textContent = drag.ids.length === 1 ? first.name : `${first.name} y ${plural(drag.ids.length - 1, 'más', 'más')}`;
|
|
880
|
+
ghost.append(iconOf(first), name);
|
|
881
|
+
if (drag.ids.length > 1) {
|
|
882
|
+
const badge = document.createElement('span');
|
|
883
|
+
badge.dataset.mkFxGhostCount = '';
|
|
884
|
+
badge.textContent = String(drag.ids.length);
|
|
885
|
+
ghost.append(badge);
|
|
886
|
+
}
|
|
887
|
+
root.append(ghost);
|
|
888
|
+
// In the top layer when it can be, so no transform or overflow around the explorer moves or clips it.
|
|
889
|
+
if (typeof ghost.showPopover === 'function') {
|
|
890
|
+
ghost.popover = 'manual';
|
|
891
|
+
try {
|
|
892
|
+
ghost.showPopover();
|
|
893
|
+
} catch {
|
|
894
|
+
// Not connected or not supported: it stays a fixed element.
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
drag.ghost = ghost;
|
|
898
|
+
gsap.set(ghost, { x: x + 14, y: y + 12 });
|
|
899
|
+
if (reduced()) drag.follow = { x: (value) => gsap.set(ghost, { x: value }), y: (value) => gsap.set(ghost, { y: value }), tilt: () => {} };
|
|
900
|
+
else {
|
|
901
|
+
keep(gsap.fromTo(ghost, { opacity: 0, scale: 0.85 }, { opacity: 1, scale: 1, duration: 0.18, ease: 'power2.out' }));
|
|
902
|
+
drag.follow = {
|
|
903
|
+
x: gsap.quickTo(ghost, 'x', { duration: 0.28, ease: 'power3.out' }),
|
|
904
|
+
y: gsap.quickTo(ghost, 'y', { duration: 0.28, ease: 'power3.out' }),
|
|
905
|
+
tilt: gsap.quickTo(ghost, 'rotation', { duration: 0.4, ease: 'power2.out' }),
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
for (const id of drag.ids) itemOf(id)?.setAttribute('data-mk-fx-dragging', '');
|
|
909
|
+
root.setAttribute('data-mk-fx-dnd', '');
|
|
910
|
+
status.textContent = `${plural(drag.ids.length, 'elemento', 'elementos')} en arrastre. Suelta sobre una carpeta; Escape cancela.`;
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
const hover = (x: number, y: number) => {
|
|
914
|
+
if (!drag) return;
|
|
915
|
+
const target = dropTargetAt(x, y, drag.ids);
|
|
916
|
+
if (target?.element === drag.target?.element) return;
|
|
917
|
+
drag.target?.element.removeAttribute('data-mk-fx-drop');
|
|
918
|
+
drag.target = target;
|
|
919
|
+
if (!target) return;
|
|
920
|
+
target.element.setAttribute('data-mk-fx-drop', '');
|
|
921
|
+
// A closed branch of the tree opens after a moment under the card.
|
|
922
|
+
const item = target.where === 'tree' ? target.element.closest<HTMLElement>('[role="treeitem"]') : null;
|
|
923
|
+
if (item?.getAttribute('aria-expanded') === 'false') {
|
|
924
|
+
const id = target.id;
|
|
925
|
+
drag.timers.push(setTimeout(() => (drag?.target?.id === id ? tree?.expand(id) : undefined), 700));
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
const autoScroll = (y: number) => {
|
|
930
|
+
const box = pane.getBoundingClientRect();
|
|
931
|
+
if (y < box.top + 36) pane.scrollTop -= 12;
|
|
932
|
+
else if (y > box.bottom - 36) pane.scrollTop += 12;
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
const stopListening = () => {
|
|
936
|
+
window.removeEventListener('pointermove', onMove);
|
|
937
|
+
window.removeEventListener('pointerup', onUp);
|
|
938
|
+
window.removeEventListener('pointercancel', onCancel);
|
|
939
|
+
window.removeEventListener('keydown', onKey, true);
|
|
940
|
+
};
|
|
941
|
+
/** The drag ends: the card flies into its folder, or back where it came from. */
|
|
942
|
+
const land = (dropped: boolean) => {
|
|
943
|
+
const state = drag;
|
|
944
|
+
drag = null;
|
|
945
|
+
stopListening();
|
|
946
|
+
if (!state) return;
|
|
947
|
+
for (const timer of state.timers) clearTimeout(timer);
|
|
948
|
+
if (!state.started) return;
|
|
949
|
+
// The click that follows the release is not a selection.
|
|
950
|
+
suppressClick = true;
|
|
951
|
+
setTimeout(() => (suppressClick = false), 0);
|
|
952
|
+
root.removeAttribute('data-mk-fx-dnd');
|
|
953
|
+
for (const item of items()) item.removeAttribute('data-mk-fx-dragging');
|
|
954
|
+
const target = dropped ? state.target : null;
|
|
955
|
+
target?.element.removeAttribute('data-mk-fx-drop');
|
|
956
|
+
const ghost = state.ghost!;
|
|
957
|
+
const finish = () => ghost.remove();
|
|
958
|
+
if (target && moveEntries(state.ids, target.id)) {
|
|
959
|
+
const element = elementFor(target) ?? target.element;
|
|
960
|
+
const box = element.getBoundingClientRect();
|
|
961
|
+
const card = ghost.getBoundingClientRect();
|
|
962
|
+
if (reduced()) finish();
|
|
963
|
+
else keep(gsap.to(ghost, { x: box.left + box.width / 2 - card.width / 2, y: box.top + box.height / 2 - card.height / 2, scale: 0.2, rotation: 0, opacity: 0, duration: 0.3, ease: 'power2.in', onComplete: finish }));
|
|
964
|
+
if (element.isConnected) {
|
|
965
|
+
keep(ringPulse(element));
|
|
966
|
+
const icon = element.querySelector<HTMLElement>('[data-mk-fx-icon]');
|
|
967
|
+
if (icon && !reduced()) keep(gsap.fromTo(icon, { scale: 1.2 }, { scale: 1, duration: 0.45, delay: 0.2, ease: EASE.pop, clearProps: 'transform' }));
|
|
968
|
+
}
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
status.textContent = 'Movimiento cancelado';
|
|
972
|
+
const home = itemOf(state.ids[0]!) ?? state.origin;
|
|
973
|
+
const box = home.getBoundingClientRect();
|
|
974
|
+
if (reduced() || !home.isConnected) finish();
|
|
975
|
+
else keep(gsap.to(ghost, { x: box.left, y: box.top, rotation: 0, scale: 0.9, opacity: 0, duration: 0.35, ease: EASE.swatch, onComplete: finish }));
|
|
976
|
+
};
|
|
977
|
+
const onMove = (event: PointerEvent) => {
|
|
978
|
+
if (!drag || event.pointerId !== drag.pointerId) return;
|
|
979
|
+
const dx = event.clientX - drag.startX;
|
|
980
|
+
const dy = event.clientY - drag.startY;
|
|
981
|
+
if (!drag.started) {
|
|
982
|
+
// On touch, moving before the long press is a scroll.
|
|
983
|
+
if (drag.touch && !drag.armed) {
|
|
984
|
+
if (Math.hypot(dx, dy) > 8) land(false);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
if (Math.hypot(dx, dy) < 6) return;
|
|
988
|
+
lift(event.clientX, event.clientY);
|
|
989
|
+
}
|
|
990
|
+
event.preventDefault();
|
|
991
|
+
const speed = event.clientX - drag.lastX;
|
|
992
|
+
drag.lastX = event.clientX;
|
|
993
|
+
drag.follow?.x(event.clientX + 14);
|
|
994
|
+
drag.follow?.y(event.clientY + 12);
|
|
995
|
+
drag.follow?.tilt(Math.max(-12, Math.min(12, speed * 0.8)));
|
|
996
|
+
hover(event.clientX, event.clientY);
|
|
997
|
+
autoScroll(event.clientY);
|
|
998
|
+
};
|
|
999
|
+
const onUp = (event: PointerEvent) => {
|
|
1000
|
+
if (drag && event.pointerId === drag.pointerId) land(true);
|
|
1001
|
+
};
|
|
1002
|
+
const onCancel = (event: PointerEvent) => {
|
|
1003
|
+
if (drag && event.pointerId === drag.pointerId) land(false);
|
|
1004
|
+
};
|
|
1005
|
+
const onKey = (event: KeyboardEvent) => {
|
|
1006
|
+
if (event.key !== 'Escape' || !drag?.started) return;
|
|
1007
|
+
event.preventDefault();
|
|
1008
|
+
event.stopPropagation();
|
|
1009
|
+
land(false);
|
|
1010
|
+
};
|
|
1011
|
+
scope.add(() => {
|
|
1012
|
+
stopListening();
|
|
1013
|
+
for (const timer of drag?.timers ?? []) clearTimeout(timer);
|
|
1014
|
+
drag?.ghost?.remove();
|
|
1015
|
+
drag = null;
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
scope.on(list, 'pointerdown', (event) => {
|
|
1019
|
+
const pointer = event as PointerEvent;
|
|
1020
|
+
if (readOnly || pointer.button !== 0 || drag || renaming) return;
|
|
1021
|
+
const item = (pointer.target as Element | null)?.closest<HTMLElement>('[data-mk-fx-item]');
|
|
1022
|
+
if (!item || item.hasAttribute('data-mk-fx-gone')) return;
|
|
1023
|
+
const id = item.dataset.id!;
|
|
1024
|
+
const touch = pointer.pointerType === 'touch';
|
|
1025
|
+
drag = {
|
|
1026
|
+
ids: selection.has(id) ? [...selection] : [id],
|
|
1027
|
+
origin: item,
|
|
1028
|
+
pointerId: pointer.pointerId,
|
|
1029
|
+
startX: pointer.clientX,
|
|
1030
|
+
startY: pointer.clientY,
|
|
1031
|
+
lastX: pointer.clientX,
|
|
1032
|
+
touch,
|
|
1033
|
+
armed: !touch,
|
|
1034
|
+
started: false,
|
|
1035
|
+
ghost: null,
|
|
1036
|
+
target: null,
|
|
1037
|
+
follow: null,
|
|
1038
|
+
timers: [],
|
|
1039
|
+
};
|
|
1040
|
+
if (touch) {
|
|
1041
|
+
const pending = drag;
|
|
1042
|
+
pending.timers.push(
|
|
1043
|
+
setTimeout(() => {
|
|
1044
|
+
if (drag !== pending) return;
|
|
1045
|
+
pending.armed = true;
|
|
1046
|
+
lift(pending.startX, pending.startY);
|
|
1047
|
+
}, 450),
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
window.addEventListener('pointermove', onMove, { passive: false });
|
|
1051
|
+
window.addEventListener('pointerup', onUp);
|
|
1052
|
+
window.addEventListener('pointercancel', onCancel);
|
|
1053
|
+
window.addEventListener('keydown', onKey, true);
|
|
1054
|
+
});
|
|
1055
|
+
// A touch drag keeps the page from scrolling under it, and the long press from opening the system menu.
|
|
1056
|
+
scope.on(pane, 'touchmove', (event) => {
|
|
1057
|
+
if (drag?.started) event.preventDefault();
|
|
1058
|
+
}, { passive: false });
|
|
1059
|
+
scope.on(list, 'contextmenu', (event) => {
|
|
1060
|
+
if (drag?.touch) event.preventDefault();
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
// ── Keyboard ─────────────────────────────────────────────────────────────
|
|
1064
|
+
/** Items in the first row of the grid. */
|
|
1065
|
+
const columns = (all: HTMLElement[]) => {
|
|
1066
|
+
if (view === 'list' || !all.length) return 1;
|
|
1067
|
+
const first = all[0]!.offsetTop;
|
|
1068
|
+
const row = all.findIndex((item) => item.offsetTop !== first);
|
|
1069
|
+
return row < 0 ? all.length : Math.max(1, row);
|
|
1070
|
+
};
|
|
1071
|
+
let typed = '';
|
|
1072
|
+
let typedAt = 0;
|
|
1073
|
+
scope.on(list, 'keydown', (event) => {
|
|
1074
|
+
const key = event as KeyboardEvent;
|
|
1075
|
+
if ((key.target as Element | null)?.closest('[data-mk-fx-rename]')) return;
|
|
1076
|
+
const all = items();
|
|
1077
|
+
const item = (key.target as Element | null)?.closest<HTMLElement>('[data-mk-fx-item]') ?? null;
|
|
1078
|
+
const index = item ? all.indexOf(item) : -1;
|
|
1079
|
+
const id = item?.dataset.id ?? null;
|
|
1080
|
+
const command = key.ctrlKey || key.metaKey;
|
|
1081
|
+
const step = columns(all);
|
|
1082
|
+
const go = (next: number) => {
|
|
1083
|
+
key.preventDefault();
|
|
1084
|
+
const target = all[Math.max(0, Math.min(all.length - 1, next))];
|
|
1085
|
+
if (!target) return;
|
|
1086
|
+
const targetId = target.dataset.id!;
|
|
1087
|
+
if (key.shiftKey) selectRange(anchor ?? id ?? targetId, targetId, command);
|
|
1088
|
+
else if (!command) {
|
|
1089
|
+
setSelection([targetId]);
|
|
1090
|
+
anchor = targetId;
|
|
1091
|
+
}
|
|
1092
|
+
focusItem(targetId);
|
|
1093
|
+
};
|
|
1094
|
+
switch (key.key) {
|
|
1095
|
+
case 'ArrowDown':
|
|
1096
|
+
return go(index < 0 ? 0 : index + step);
|
|
1097
|
+
case 'ArrowUp':
|
|
1098
|
+
if (key.altKey) {
|
|
1099
|
+
key.preventDefault();
|
|
1100
|
+
return goUp(true);
|
|
1101
|
+
}
|
|
1102
|
+
return go(index < 0 ? 0 : index - step);
|
|
1103
|
+
case 'ArrowRight':
|
|
1104
|
+
if (view === 'grid') go(index + 1);
|
|
1105
|
+
return;
|
|
1106
|
+
case 'ArrowLeft':
|
|
1107
|
+
if (view === 'grid') go(index - 1);
|
|
1108
|
+
return;
|
|
1109
|
+
case 'Home':
|
|
1110
|
+
return go(0);
|
|
1111
|
+
case 'End':
|
|
1112
|
+
return go(all.length - 1);
|
|
1113
|
+
case 'Enter':
|
|
1114
|
+
if (id) {
|
|
1115
|
+
key.preventDefault();
|
|
1116
|
+
openEntry(id, true);
|
|
1117
|
+
}
|
|
1118
|
+
return;
|
|
1119
|
+
case 'Backspace':
|
|
1120
|
+
key.preventDefault();
|
|
1121
|
+
return goUp(true);
|
|
1122
|
+
case 'F2':
|
|
1123
|
+
if (id && !readOnly) {
|
|
1124
|
+
key.preventDefault();
|
|
1125
|
+
startRename(id);
|
|
1126
|
+
}
|
|
1127
|
+
return;
|
|
1128
|
+
case 'Delete':
|
|
1129
|
+
if (selection.size && !readOnly) {
|
|
1130
|
+
key.preventDefault();
|
|
1131
|
+
removeEntries([...selection]);
|
|
1132
|
+
}
|
|
1133
|
+
return;
|
|
1134
|
+
case 'Escape':
|
|
1135
|
+
if (cut.length) {
|
|
1136
|
+
cut = [];
|
|
1137
|
+
for (const each of all) each.removeAttribute('data-mk-fx-cut');
|
|
1138
|
+
paintStatus();
|
|
1139
|
+
} else if (selection.size) setSelection([]);
|
|
1140
|
+
return;
|
|
1141
|
+
case ' ':
|
|
1142
|
+
if (!id) return;
|
|
1143
|
+
key.preventDefault();
|
|
1144
|
+
if (command) setSelection(selection.has(id) ? [...selection].filter((chosen) => chosen !== id) : [...selection, id]);
|
|
1145
|
+
else setSelection([id]);
|
|
1146
|
+
anchor = id;
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
const letter = key.key.toLowerCase();
|
|
1150
|
+
if (command && letter === 'a') {
|
|
1151
|
+
key.preventDefault();
|
|
1152
|
+
setSelection(all.map((each) => each.dataset.id!));
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
if (command && letter === 'x' && selection.size && !readOnly) {
|
|
1156
|
+
key.preventDefault();
|
|
1157
|
+
cut = [...selection];
|
|
1158
|
+
for (const each of all) each.toggleAttribute('data-mk-fx-cut', cut.includes(each.dataset.id!));
|
|
1159
|
+
status.textContent = `${plural(cut.length, 'elemento cortado', 'elementos cortados')}. Abre otra carpeta y pega con Ctrl+V.`;
|
|
1160
|
+
paintStatus();
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
if (command && letter === 'v' && cut.length && !readOnly) {
|
|
1164
|
+
key.preventDefault();
|
|
1165
|
+
if (!moveEntries(cut, current.id)) {
|
|
1166
|
+
status.textContent = 'No se puede pegar aquí';
|
|
1167
|
+
keep(shake(list, 3));
|
|
1168
|
+
}
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
// Typing jumps to the next name that starts with what was typed.
|
|
1172
|
+
if (!command && !key.altKey && key.key.length === 1 && /\S/.test(key.key)) {
|
|
1173
|
+
const now = Date.now();
|
|
1174
|
+
typed = now - typedAt > 600 ? letter : typed + letter;
|
|
1175
|
+
typedAt = now;
|
|
1176
|
+
const start = index + (typed.length === 1 ? 1 : 0);
|
|
1177
|
+
const order = [...all.slice(start), ...all.slice(0, start)];
|
|
1178
|
+
const match = order.find((each) => (located.get(each.dataset.id!)?.entry.name ?? '').toLowerCase().startsWith(typed));
|
|
1179
|
+
if (match) go(all.indexOf(match));
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
scope.on(list, 'focusin', (event) => {
|
|
1183
|
+
const item = (event.target as Element | null)?.closest<HTMLElement>('[data-mk-fx-item]');
|
|
1184
|
+
if (!item) return;
|
|
1185
|
+
focusId = item.dataset.id!;
|
|
1186
|
+
roving();
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
// ── The bar ──────────────────────────────────────────────────────────────
|
|
1190
|
+
const paintViews = (animate: boolean) => {
|
|
1191
|
+
root.setAttribute('data-mk-view', view);
|
|
1192
|
+
for (const button of [listButton, gridButton]) {
|
|
1193
|
+
const on = button.dataset.view === view;
|
|
1194
|
+
button.setAttribute('aria-checked', String(on));
|
|
1195
|
+
button.tabIndex = on ? 0 : -1;
|
|
1196
|
+
}
|
|
1197
|
+
head.hidden = view !== 'list';
|
|
1198
|
+
keep(slideIndicator(indicator, view === 'list' ? listButton : gridButton, { immediate: !animate }));
|
|
1199
|
+
};
|
|
1200
|
+
const setView = (next: FileView) => {
|
|
1201
|
+
if (next === view || scope.disposed) return;
|
|
1202
|
+
// Every icon flies from its row to its tile (or back) while the texts fade in again.
|
|
1203
|
+
const icons = [...list.querySelectorAll<HTMLElement>('[data-mk-fx-icon]')];
|
|
1204
|
+
const state = !reduced() && icons.length && icons.length <= 160 ? Flip.getState(icons) : null;
|
|
1205
|
+
view = next;
|
|
1206
|
+
paintViews(true);
|
|
1207
|
+
if (state) {
|
|
1208
|
+
keep(Flip.from(state, { duration: 0.5, ease: EASE.layoutReflow, scale: true }));
|
|
1209
|
+
keep(gsap.fromTo(list.querySelectorAll('[data-mk-fx-name], [data-mk-fx-size], [data-mk-fx-date]'), { opacity: 0 }, { opacity: 1, duration: 0.3, delay: 0.12, ease: 'power1.out', clearProps: 'opacity' }));
|
|
1210
|
+
}
|
|
1211
|
+
status.textContent = next === 'grid' ? 'Vista de cuadrícula' : 'Vista de lista';
|
|
1212
|
+
options.onViewChange?.(next);
|
|
1213
|
+
};
|
|
1214
|
+
scope.on(views, 'click', (event) => {
|
|
1215
|
+
const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-view]');
|
|
1216
|
+
if (button) setView(button.dataset.view as FileView);
|
|
1217
|
+
});
|
|
1218
|
+
scope.on(views, 'keydown', (event) => {
|
|
1219
|
+
const key = (event as KeyboardEvent).key;
|
|
1220
|
+
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(key)) return;
|
|
1221
|
+
event.preventDefault();
|
|
1222
|
+
setView(view === 'list' ? 'grid' : 'list');
|
|
1223
|
+
(view === 'list' ? listButton : gridButton).focus();
|
|
1224
|
+
});
|
|
1225
|
+
scope.on(head, 'click', (event) => {
|
|
1226
|
+
const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-sort]');
|
|
1227
|
+
if (!button) return;
|
|
1228
|
+
const key = button.dataset.sort as FileSort;
|
|
1229
|
+
sort = { key, direction: sort.key === key && sort.direction === 1 ? -1 : 1 };
|
|
1230
|
+
paintSort();
|
|
1231
|
+
reflow(renderPane);
|
|
1232
|
+
status.textContent = `Ordenado por ${SORT_LABEL[key].toLowerCase()}, ${sort.direction === 1 ? 'ascendente' : 'descendente'}`;
|
|
1233
|
+
});
|
|
1234
|
+
scope.on(upButton, 'click', () => goUp(false));
|
|
1235
|
+
scope.on(newButton, 'click', () => {
|
|
1236
|
+
keep(press(newButton));
|
|
1237
|
+
createFolder();
|
|
1238
|
+
});
|
|
1239
|
+
scope.on(renameButton, 'click', () => {
|
|
1240
|
+
const [id] = [...selection];
|
|
1241
|
+
if (id && selection.size === 1) startRename(id);
|
|
1242
|
+
});
|
|
1243
|
+
scope.on(removeButton, 'click', () => {
|
|
1244
|
+
keep(press(removeButton));
|
|
1245
|
+
removeEntries([...selection]);
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
if (typeof ResizeObserver === 'function') scope.observe(new ResizeObserver(() => paintViews(false))).observe(views);
|
|
1249
|
+
|
|
1250
|
+
paintSort();
|
|
1251
|
+
renderPane();
|
|
1252
|
+
paintViews(false);
|
|
1253
|
+
|
|
1254
|
+
return {
|
|
1255
|
+
open(id) {
|
|
1256
|
+
const found = located.get(id);
|
|
1257
|
+
if (!found) return;
|
|
1258
|
+
if (isFolder(found.entry)) return navigate(id);
|
|
1259
|
+
if (found.parent && found.parent !== current) navigate(found.parent.id);
|
|
1260
|
+
setSelection([id], false);
|
|
1261
|
+
anchor = id;
|
|
1262
|
+
focusId = id;
|
|
1263
|
+
roving();
|
|
1264
|
+
openEntry(id);
|
|
1265
|
+
},
|
|
1266
|
+
up: () => goUp(false),
|
|
1267
|
+
setView,
|
|
1268
|
+
select(ids) {
|
|
1269
|
+
setSelection(ids, false);
|
|
1270
|
+
anchor = [...selection][0] ?? null;
|
|
1271
|
+
},
|
|
1272
|
+
createFolder,
|
|
1273
|
+
rename(id, name) {
|
|
1274
|
+
if (name === undefined) return startRename(id);
|
|
1275
|
+
const entry = located.get(id)?.entry;
|
|
1276
|
+
const next = name.trim();
|
|
1277
|
+
if (readOnly || !entry || entry === top || next === entry.name || problemWith(next, entry)) return;
|
|
1278
|
+
applyName(entry, next);
|
|
1279
|
+
},
|
|
1280
|
+
remove: (ids) => removeEntries(ids ?? [...selection]),
|
|
1281
|
+
move: (ids, folder) => void moveEntries(ids, folder),
|
|
1282
|
+
setFiles(files) {
|
|
1283
|
+
if (scope.disposed) return;
|
|
1284
|
+
renaming?.finish(false);
|
|
1285
|
+
top.children = cloneEntries(files);
|
|
1286
|
+
reindex();
|
|
1287
|
+
current = folderOf(current.id) ?? top;
|
|
1288
|
+
cut = cut.filter((id) => located.has(id));
|
|
1289
|
+
renderPane();
|
|
1290
|
+
paintCrumbs();
|
|
1291
|
+
paintTree(true);
|
|
1292
|
+
},
|
|
1293
|
+
get files() {
|
|
1294
|
+
return cloneEntries(top.children ?? []);
|
|
1295
|
+
},
|
|
1296
|
+
get folder() {
|
|
1297
|
+
return current.id;
|
|
1298
|
+
},
|
|
1299
|
+
get selection() {
|
|
1300
|
+
return [...selection];
|
|
1301
|
+
},
|
|
1302
|
+
get view() {
|
|
1303
|
+
return view;
|
|
1304
|
+
},
|
|
1305
|
+
destroy: () => scope.dispose(),
|
|
1306
|
+
};
|
|
1307
|
+
}
|