@kupcad/files 0.0.0-stage → 0.1.4
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/LICENSE +661 -0
- package/README.md +49 -2
- package/package.json +40 -3
- package/src/ContextMenu.svelte +155 -0
- package/src/FileTree.svelte +412 -0
- package/src/index.js +32 -0
- package/src/menu.js +41 -0
- package/src/scripts.svelte.js +226 -0
- package/src/tree.js +197 -0
package/README.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @kupcad/files
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A KupCAD project's files as a tree, for any host of them: the desktop app's folder on the disk,
|
|
4
|
+
the browser IDE's in its private file system (OPFS). Svelte components and plain functions; the
|
|
5
|
+
host brings the file system.
|
|
6
|
+
|
|
7
|
+
```svelte
|
|
8
|
+
<script>
|
|
9
|
+
import { ContextMenu, FileTree, listFolder, rowMenu } from '@kupcad/files';
|
|
10
|
+
import '@kupcad/ui/tokens.css';
|
|
11
|
+
|
|
12
|
+
let entries = $state(await listFolder('/', { readDir }));
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<FileTree
|
|
16
|
+
{entries}
|
|
17
|
+
collapsed={new Set()}
|
|
18
|
+
selected={null}
|
|
19
|
+
unsaved={new Set()}
|
|
20
|
+
root="/"
|
|
21
|
+
onopen={(entry) => open(entry.path)}
|
|
22
|
+
ontoggle={(folder, opening) => toggle(folder.path, opening)}
|
|
23
|
+
onnew={(kind) => startNew(kind)}
|
|
24
|
+
/>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
* **The tree** (`FileTree`): folders, scripts and the files they read, each under its folder, a
|
|
28
|
+
folder opened and closed by a click or the arrow keys, the open script marked, and one with
|
|
29
|
+
unsaved changes. Its bar has its title and the buttons that make a script and a folder
|
|
30
|
+
(`onnew`; `keys` shows the host's shortcuts in their tooltips). A row's menu, and the tree's,
|
|
31
|
+
are the host's (`onmenu`, with where to show it); F2 renames a row, Delete or ⌘⌫ removes it. A
|
|
32
|
+
name being given (`editing`) is a field in the tree, what is wrong with it said under it
|
|
33
|
+
(`problem`). It fills its box, its rows scrolling under the bar, in `@kupcad/ui`'s `--kc-*`
|
|
34
|
+
tokens.
|
|
35
|
+
* **The rows' menu** (`rowMenu`): what a row, or the tree, offers, as data: New Script, New
|
|
36
|
+
Folder, Rename…, and removing (`Delete…`, or the host's `Move to Trash`), with Upload Files…
|
|
37
|
+
and a file's Download where the host gives them (a browser's). A desktop app draws it as a
|
|
38
|
+
native menu; a page draws it with `ContextMenu`, a popover at the pointer that the arrow keys
|
|
39
|
+
move through and Escape, a click elsewhere or the focus leaving close.
|
|
40
|
+
* **The folder** (`listFolder(root, { readDir })`): the tree of `root`, each folder's folders
|
|
41
|
+
first, hidden folders, `node_modules` and `target` left out, and a folder holding files but
|
|
42
|
+
no script or file a script reads (`kindOf`), at most `MAX_ENTRIES`. The names a new entry may
|
|
43
|
+
take (`nameProblem`, `scriptName`), and paths in it (`childPath`, `parentOf`, `within`) with
|
|
44
|
+
the host's separator, `/` or `\`.
|
|
45
|
+
* **The scripts read** (`Scripts`, over the host's `read`, `write` and `exists`): each one's text
|
|
46
|
+
now and as the disk had it, for what is unsaved; a save that finds the disk changed since
|
|
47
|
+
holds the disk's text as a conflict for the person to choose; each script's editor snapshot,
|
|
48
|
+
its undo history, kept while another is shown.
|
|
49
|
+
|
|
50
|
+
`pnpm test` runs its tests under jsdom (the popover is stood in for, `popover.test-helper.js`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kupcad/files",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "A KupCAD project's files as a tree, for any host of them (the desktop app's disk, a browser's private file system): the Svelte tree with its bar, its rows' menu, the folder model and the scripts read with their unsaved changes.",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/kupcad/kupcad.git",
|
|
9
|
+
"directory": "packages/kupcad-files"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"!src/**/*.test.js",
|
|
14
|
+
"!src/**/*.test-helper.*"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"svelte": "./src/index.js",
|
|
20
|
+
"default": "./src/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
|
28
|
+
"@testing-library/svelte": "^5.4.2",
|
|
29
|
+
"@types/node": "^26.6.5",
|
|
30
|
+
"jsdom": "^30.1.3",
|
|
31
|
+
"svelte": "^5.57.2",
|
|
32
|
+
"svelte-check": "^4.7.7",
|
|
33
|
+
"vite": "^8.3.5",
|
|
34
|
+
"vitest": "^5.0.5"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"svelte": "^5.57.2"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"check": "svelte-check --tsconfig ./jsconfig.json --fail-on-warnings",
|
|
41
|
+
"test": "vitest run"
|
|
42
|
+
}
|
|
6
43
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
A menu at a point of the page (a tree row's, as `rowMenu` gives it), for a host with no native
|
|
4
|
+
one: in the page's top layer (an auto popover), moved in to stay in the window. It takes the
|
|
5
|
+
focus, on its first item; the arrow keys, Home and End move between its items. Choosing one
|
|
6
|
+
closes it and does what it says; Escape, a click elsewhere or the focus leaving close it with
|
|
7
|
+
nothing done. Closed either way, it says so (`onclose`, for the host to drop it), the focus
|
|
8
|
+
back where it was unless it went elsewhere.
|
|
9
|
+
-->
|
|
10
|
+
<script>
|
|
11
|
+
import { onMount } from 'svelte';
|
|
12
|
+
|
|
13
|
+
/** @import { MenuItem } from './menu.js' */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} Props
|
|
17
|
+
* @property {readonly MenuItem[]} items
|
|
18
|
+
* @property {{ x: number, y: number }} at its top left corner, in the window's pixels (read
|
|
19
|
+
* as it opens)
|
|
20
|
+
* @property {() => void} onclose
|
|
21
|
+
* @property {string} [label] what it is a menu of, for a screen reader
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** @type {Props} */
|
|
25
|
+
let { items, at, onclose, label = 'Menu' } = $props();
|
|
26
|
+
|
|
27
|
+
/** @type {HTMLDivElement} */
|
|
28
|
+
let bound;
|
|
29
|
+
/** The menu, kept once shown: the browser's events of it may come after it is unmounted. */
|
|
30
|
+
let menu = /** @type {HTMLDivElement | null} */ (null);
|
|
31
|
+
/** What had the focus before it opened. */
|
|
32
|
+
let before = /** @type {Element | null} */ (null);
|
|
33
|
+
let open = false;
|
|
34
|
+
|
|
35
|
+
onMount(() => {
|
|
36
|
+
menu = bound;
|
|
37
|
+
before = document.activeElement;
|
|
38
|
+
menu.showPopover();
|
|
39
|
+
open = true;
|
|
40
|
+
const { width, height } = menu.getBoundingClientRect();
|
|
41
|
+
menu.style.left = `${Math.max(0, Math.min(at.x, innerWidth - width))}px`;
|
|
42
|
+
menu.style.top = `${Math.max(0, Math.min(at.y, innerHeight - height))}px`;
|
|
43
|
+
itemsOf()[0]?.focus();
|
|
44
|
+
return close;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const itemsOf = () => [...(menu?.querySelectorAll('button') ?? [])];
|
|
48
|
+
|
|
49
|
+
function close() {
|
|
50
|
+
if (!open) return;
|
|
51
|
+
open = false;
|
|
52
|
+
menu?.hidePopover();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @param {Extract<MenuItem, object>} item */
|
|
56
|
+
function choose(item) {
|
|
57
|
+
close();
|
|
58
|
+
item.action();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Closed, by a choice or the browser (Escape, a click elsewhere). @param {Event} event */
|
|
62
|
+
function toggled(event) {
|
|
63
|
+
if (/** @type {ToggleEvent} */ (event).newState !== 'closed') return;
|
|
64
|
+
open = false;
|
|
65
|
+
const focused = document.activeElement;
|
|
66
|
+
const lost = !focused || focused === document.body || !!menu?.contains(focused);
|
|
67
|
+
if (lost && before instanceof HTMLElement && before.isConnected) before.focus();
|
|
68
|
+
onclose();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @param {KeyboardEvent} event */
|
|
72
|
+
function keydown(event) {
|
|
73
|
+
const all = itemsOf();
|
|
74
|
+
const focused = all.indexOf(/** @type {HTMLButtonElement} */ (document.activeElement));
|
|
75
|
+
const next = {
|
|
76
|
+
ArrowDown: (focused + 1) % all.length,
|
|
77
|
+
ArrowUp: (focused - 1 + all.length) % all.length,
|
|
78
|
+
Home: 0,
|
|
79
|
+
End: all.length - 1,
|
|
80
|
+
}[event.key];
|
|
81
|
+
if (next === undefined) return;
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
all[next]?.focus();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The focus left it (Tab, a click on something that takes it): it closes. @param {FocusEvent} event */
|
|
87
|
+
function focusout(event) {
|
|
88
|
+
if (!menu?.contains(/** @type {Node | null} */ (event.relatedTarget))) close();
|
|
89
|
+
}
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
<div
|
|
93
|
+
bind:this={bound}
|
|
94
|
+
class="kc-menu"
|
|
95
|
+
popover="auto"
|
|
96
|
+
role="menu"
|
|
97
|
+
tabindex="-1"
|
|
98
|
+
aria-label={label}
|
|
99
|
+
ontoggle={toggled}
|
|
100
|
+
onkeydown={keydown}
|
|
101
|
+
onfocusout={focusout}
|
|
102
|
+
>
|
|
103
|
+
{#each items as item, i (i)}
|
|
104
|
+
{#if item === '-'}
|
|
105
|
+
<div class="separator" role="separator"></div>
|
|
106
|
+
{:else}
|
|
107
|
+
<button type="button" role="menuitem" tabindex="-1" onclick={() => choose(item)}>
|
|
108
|
+
{item.text}
|
|
109
|
+
</button>
|
|
110
|
+
{/if}
|
|
111
|
+
{/each}
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<style>
|
|
115
|
+
.kc-menu {
|
|
116
|
+
/* At the point it opens, not the middle of the window where a popover goes */
|
|
117
|
+
position: fixed;
|
|
118
|
+
inset: auto;
|
|
119
|
+
margin: 0;
|
|
120
|
+
min-width: 12em;
|
|
121
|
+
padding: 4px;
|
|
122
|
+
font: inherit;
|
|
123
|
+
color: var(--kc-fg);
|
|
124
|
+
background: var(--kc-bg);
|
|
125
|
+
border: 1px solid var(--kc-border);
|
|
126
|
+
border-radius: var(--kc-radius);
|
|
127
|
+
box-shadow: 0 6px 20px rgb(0 0 0 / 0.18);
|
|
128
|
+
}
|
|
129
|
+
button {
|
|
130
|
+
display: block;
|
|
131
|
+
width: 100%;
|
|
132
|
+
padding: 4px 12px;
|
|
133
|
+
font: inherit;
|
|
134
|
+
text-align: left;
|
|
135
|
+
color: inherit;
|
|
136
|
+
background: none;
|
|
137
|
+
border: none;
|
|
138
|
+
border-radius: var(--kc-radius);
|
|
139
|
+
cursor: pointer;
|
|
140
|
+
}
|
|
141
|
+
button:hover,
|
|
142
|
+
button:focus {
|
|
143
|
+
outline: none;
|
|
144
|
+
background: var(--kc-hover);
|
|
145
|
+
}
|
|
146
|
+
button:focus-visible {
|
|
147
|
+
outline: 2px solid var(--kc-focus);
|
|
148
|
+
outline-offset: -2px;
|
|
149
|
+
}
|
|
150
|
+
.separator {
|
|
151
|
+
height: 1px;
|
|
152
|
+
margin: 4px 0;
|
|
153
|
+
background: var(--kc-border);
|
|
154
|
+
}
|
|
155
|
+
</style>
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
A project's tree: its folders, scripts and the files they read, each under its folder, under a
|
|
4
|
+
bar with its title and what can be made in it (`onnew`: a script, a folder). A folder,
|
|
5
|
+
clicked, opens or closes, nothing under a closed one shown; a script, clicked, is opened; the
|
|
6
|
+
open one is marked, and one with unsaved changes too. The arrow keys move through it as
|
|
7
|
+
through any tree: up and down from row to row, right into a folder (opening it first), left
|
|
8
|
+
out of it to its folder (closing an open one first). A row's menu (a right click, the menu key
|
|
9
|
+
or Shift+F10) and the tree's are the host's (`onmenu`, with where to show it); F2 renames a
|
|
10
|
+
row, Delete or ⌘⌫ (Ctrl+Backspace) removes it. A name being given (`editing`: a row renamed,
|
|
11
|
+
an entry made in a folder) is a field in the tree, Enter giving it and Escape leaving it, what
|
|
12
|
+
is wrong with it said under it (`problem`). It fills its box, the rows scrolling under the bar.
|
|
13
|
+
-->
|
|
14
|
+
<script>
|
|
15
|
+
import { foldersAbove, shownEntries } from './tree.js';
|
|
16
|
+
|
|
17
|
+
/** @import { Entry } from './tree.js' */
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} Props
|
|
21
|
+
* @property {Entry[]} entries
|
|
22
|
+
* @property {ReadonlySet<string>} collapsed the closed folders' paths
|
|
23
|
+
* @property {string | null} selected the open script's path
|
|
24
|
+
* @property {ReadonlySet<string>} unsaved the paths of the scripts with unsaved changes
|
|
25
|
+
* @property {(entry: Entry) => void} onopen
|
|
26
|
+
* @property {(folder: Entry, open: boolean) => void} ontoggle opens or closes a folder
|
|
27
|
+
* @property {string} [root] the folder's own path: where an entry made in no folder of it goes
|
|
28
|
+
* @property {Naming | null} [editing] the name being given, if one is
|
|
29
|
+
* @property {string | null} [problem] what is wrong with the name given last
|
|
30
|
+
* @property {(name: string) => void} [oncommit] the name is given
|
|
31
|
+
* @property {() => void} [oncancel] it isn't
|
|
32
|
+
* @property {(entry: Entry | null, at: Point) => void} [onmenu] a row's menu, or the tree's
|
|
33
|
+
* (null), to show at `at`: where the pointer is, or under the row for the keys
|
|
34
|
+
* @property {(entry: Entry) => void} [onrename]
|
|
35
|
+
* @property {(entry: Entry) => void} [ontrash] removes it
|
|
36
|
+
* @property {(kind: 'script' | 'folder') => void} [onnew] makes one: the bar's buttons
|
|
37
|
+
* @property {string} [title] the bar's
|
|
38
|
+
* @property {{ script?: string, folder?: string }} [keys] the host's shortcuts for the bar's
|
|
39
|
+
* buttons, shown in their tooltips (`⌘N`)
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** A point in the window, in its pixels. @typedef {{ x: number, y: number }} Point */
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A name being given: a row's new one, or a new entry's in the folder `in`.
|
|
46
|
+
* @typedef {{ path: string } | { in: string, kind: 'script' | 'folder' }} Naming
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** @type {Props} */
|
|
50
|
+
let {
|
|
51
|
+
entries,
|
|
52
|
+
collapsed,
|
|
53
|
+
selected,
|
|
54
|
+
unsaved,
|
|
55
|
+
onopen,
|
|
56
|
+
ontoggle,
|
|
57
|
+
root = '',
|
|
58
|
+
editing = null,
|
|
59
|
+
problem = null,
|
|
60
|
+
oncommit,
|
|
61
|
+
oncancel,
|
|
62
|
+
onmenu,
|
|
63
|
+
onrename,
|
|
64
|
+
ontrash,
|
|
65
|
+
onnew,
|
|
66
|
+
title = 'Files',
|
|
67
|
+
keys = {},
|
|
68
|
+
} = $props();
|
|
69
|
+
|
|
70
|
+
const id = $props.id();
|
|
71
|
+
|
|
72
|
+
const shown = $derived(shownEntries(entries, collapsed));
|
|
73
|
+
let list = $state(/** @type {HTMLUListElement | null} */ (null));
|
|
74
|
+
|
|
75
|
+
/** The row of the entry at `path`, when it has one to focus (data files have none). */
|
|
76
|
+
const rowOf = (/** @type {string | undefined} */ path) =>
|
|
77
|
+
[...(list?.querySelectorAll('button') ?? [])].find((button) => button.dataset.path === path);
|
|
78
|
+
|
|
79
|
+
/** @param {KeyboardEvent} event */
|
|
80
|
+
function keydown(event) {
|
|
81
|
+
const rows = [...(list?.querySelectorAll('button') ?? [])];
|
|
82
|
+
const row = rows.indexOf(/** @type {HTMLButtonElement} */ (document.activeElement));
|
|
83
|
+
const at = shown.findIndex((entry) => entry.path === rows[row]?.dataset.path);
|
|
84
|
+
if (at < 0) return;
|
|
85
|
+
const entry = shown[at];
|
|
86
|
+
const closed = entry.kind === 'folder' && collapsed.has(entry.path);
|
|
87
|
+
if (event.key === 'F2') onrename?.(entry);
|
|
88
|
+
else if (
|
|
89
|
+
event.key === 'Delete' ||
|
|
90
|
+
(event.key === 'Backspace' && (event.metaKey || event.ctrlKey))
|
|
91
|
+
) {
|
|
92
|
+
ontrash?.(entry);
|
|
93
|
+
} else if (event.key === 'ContextMenu' || (event.key === 'F10' && event.shiftKey)) {
|
|
94
|
+
const { left, bottom } = rows[row].getBoundingClientRect();
|
|
95
|
+
onmenu?.(entry, { x: left, y: bottom });
|
|
96
|
+
} else if (event.key === 'ArrowDown') rows[row + 1]?.focus();
|
|
97
|
+
else if (event.key === 'ArrowUp') rows[row - 1]?.focus();
|
|
98
|
+
else if (event.key === 'ArrowRight' && entry.kind === 'folder') {
|
|
99
|
+
if (closed) ontoggle(entry, true);
|
|
100
|
+
else if (shown[at + 1]?.depth > entry.depth) rowOf(shown[at + 1].path)?.focus();
|
|
101
|
+
} else if (event.key === 'ArrowLeft') {
|
|
102
|
+
if (entry.kind === 'folder' && !closed) ontoggle(entry, false);
|
|
103
|
+
else rowOf(foldersAbove(entries, entry.path).at(-1))?.focus();
|
|
104
|
+
} else return;
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The menu of `entry`'s row, or of the tree itself, in place of the page's.
|
|
110
|
+
* @param {MouseEvent} event
|
|
111
|
+
* @param {Entry | null} entry
|
|
112
|
+
*/
|
|
113
|
+
function menu(event, entry) {
|
|
114
|
+
if (!onmenu) return;
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
event.stopPropagation();
|
|
117
|
+
onmenu(entry, { x: event.clientX, y: event.clientY });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The field a name is given in: focused once there, its name without its extension chosen.
|
|
122
|
+
* @param {HTMLInputElement} input
|
|
123
|
+
*/
|
|
124
|
+
function naming(input) {
|
|
125
|
+
input.focus();
|
|
126
|
+
const dot = input.value.lastIndexOf('.');
|
|
127
|
+
input.setSelectionRange(0, dot > 0 ? dot : input.value.length);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The name in the field given (Enter, or the field left), or none where it is empty or the
|
|
132
|
+
* row's own. A field left with Escape gives nothing on the blur that follows; the host takes a
|
|
133
|
+
* name given again while it makes one, or once there is none to give, for nothing.
|
|
134
|
+
* @param {HTMLInputElement} input
|
|
135
|
+
* @param {string} given what the field started with
|
|
136
|
+
*/
|
|
137
|
+
function settle(input, given) {
|
|
138
|
+
if (input.dataset.left) return;
|
|
139
|
+
const name = input.value;
|
|
140
|
+
if (name.trim() !== '' && name !== given) {
|
|
141
|
+
oncommit?.(name);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
input.dataset.left = 'true';
|
|
145
|
+
oncancel?.();
|
|
146
|
+
}
|
|
147
|
+
</script>
|
|
148
|
+
|
|
149
|
+
{#snippet field(
|
|
150
|
+
/** @type {string} */ given,
|
|
151
|
+
/** @type {string} */ label,
|
|
152
|
+
/** @type {number} */ depth,
|
|
153
|
+
)}
|
|
154
|
+
<li class="naming" style:padding-left="{depth * 12 + 8}px">
|
|
155
|
+
<span class="twisty" aria-hidden="true"></span>
|
|
156
|
+
<span class="field">
|
|
157
|
+
<input
|
|
158
|
+
type="text"
|
|
159
|
+
class="kc-control"
|
|
160
|
+
value={given}
|
|
161
|
+
aria-label={label}
|
|
162
|
+
aria-invalid={problem ? 'true' : undefined}
|
|
163
|
+
aria-describedby={problem ? `${id}-problem` : undefined}
|
|
164
|
+
spellcheck="false"
|
|
165
|
+
autocomplete="off"
|
|
166
|
+
{@attach naming}
|
|
167
|
+
onkeydown={(event) => {
|
|
168
|
+
if (event.key === 'Enter') settle(event.currentTarget, given);
|
|
169
|
+
else if (event.key === 'Escape') {
|
|
170
|
+
event.currentTarget.dataset.left = 'true';
|
|
171
|
+
oncancel?.();
|
|
172
|
+
} else return;
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
event.stopPropagation();
|
|
175
|
+
}}
|
|
176
|
+
onblur={(event) => settle(event.currentTarget, given)}
|
|
177
|
+
/>
|
|
178
|
+
{#if problem}<span class="problem" id="{id}-problem">{problem}</span>{/if}
|
|
179
|
+
</span>
|
|
180
|
+
</li>
|
|
181
|
+
{/snippet}
|
|
182
|
+
|
|
183
|
+
{#snippet made(/** @type {number} */ depth)}
|
|
184
|
+
{#if editing && 'in' in editing}
|
|
185
|
+
{@render field('', editing.kind === 'script' ? 'New script name' : 'New folder name', depth)}
|
|
186
|
+
{/if}
|
|
187
|
+
{/snippet}
|
|
188
|
+
|
|
189
|
+
<div class="kc-files">
|
|
190
|
+
<div class="bar">
|
|
191
|
+
<span class="title">{title}</span>
|
|
192
|
+
{#if onnew}
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
class="action"
|
|
196
|
+
aria-label="New script"
|
|
197
|
+
title={keys.script ? `New script (${keys.script})` : 'New script'}
|
|
198
|
+
onclick={() => onnew('script')}
|
|
199
|
+
>
|
|
200
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
201
|
+
<path d="M7 3.5h7l4 4v13H7z" />
|
|
202
|
+
<path d="M14 3.5v4h4M12.5 11v6M9.5 14h6" />
|
|
203
|
+
</svg>
|
|
204
|
+
</button>
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
class="action"
|
|
208
|
+
aria-label="New folder"
|
|
209
|
+
title={keys.folder ? `New folder (${keys.folder})` : 'New folder'}
|
|
210
|
+
onclick={() => onnew('folder')}
|
|
211
|
+
>
|
|
212
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
213
|
+
<path d="M3.5 6.5h6l2 2h9v11h-17z" />
|
|
214
|
+
<path d="M12 11v6M9 14h6" />
|
|
215
|
+
</svg>
|
|
216
|
+
</button>
|
|
217
|
+
{/if}
|
|
218
|
+
</div>
|
|
219
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions: its rows are the buttons the keys move between, and their menu is the keys' too -->
|
|
220
|
+
<ul
|
|
221
|
+
class="tree"
|
|
222
|
+
bind:this={list}
|
|
223
|
+
onkeydown={keydown}
|
|
224
|
+
oncontextmenu={(event) => menu(event, null)}
|
|
225
|
+
>
|
|
226
|
+
{#if editing && 'in' in editing && editing.in === root}{@render made(0)}{/if}
|
|
227
|
+
{#each shown as entry (entry.path)}
|
|
228
|
+
{#if editing && 'path' in editing && editing.path === entry.path}
|
|
229
|
+
{@render field(entry.name, `Rename ${entry.name}`, entry.depth)}
|
|
230
|
+
{:else}
|
|
231
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions: a row's menu is its keys' too -->
|
|
232
|
+
<li
|
|
233
|
+
class={entry.kind}
|
|
234
|
+
style:padding-left="{entry.depth * 12 + 8}px"
|
|
235
|
+
oncontextmenu={(event) => menu(event, entry)}
|
|
236
|
+
>
|
|
237
|
+
{#if entry.kind === 'folder'}
|
|
238
|
+
{@const open = !collapsed.has(entry.path)}
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
data-path={entry.path}
|
|
242
|
+
aria-expanded={open}
|
|
243
|
+
title={entry.path}
|
|
244
|
+
onclick={() => ontoggle(entry, !open)}
|
|
245
|
+
>
|
|
246
|
+
<span class="twisty" aria-hidden="true">{open ? '▾' : '▸'}</span>{entry.name}
|
|
247
|
+
</button>
|
|
248
|
+
{:else if entry.kind === 'script'}
|
|
249
|
+
<span class="twisty" aria-hidden="true"></span>
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
data-path={entry.path}
|
|
253
|
+
class:selected={entry.path === selected}
|
|
254
|
+
aria-current={entry.path === selected ? 'true' : undefined}
|
|
255
|
+
aria-label={unsaved.has(entry.path) ? `${entry.name}, unsaved` : entry.name}
|
|
256
|
+
title={entry.path}
|
|
257
|
+
onclick={() => onopen(entry)}
|
|
258
|
+
>
|
|
259
|
+
{entry.name}{#if unsaved.has(entry.path)}<span class="unsaved" aria-hidden="true"
|
|
260
|
+
>●</span
|
|
261
|
+
>{/if}
|
|
262
|
+
</button>
|
|
263
|
+
{:else}
|
|
264
|
+
<span class="twisty" aria-hidden="true"></span>
|
|
265
|
+
<span class="name" title={entry.path}>{entry.name}</span>
|
|
266
|
+
{/if}
|
|
267
|
+
</li>
|
|
268
|
+
{/if}
|
|
269
|
+
{#if editing && 'in' in editing && editing.in === entry.path}{@render made(
|
|
270
|
+
entry.depth + 1,
|
|
271
|
+
)}{/if}
|
|
272
|
+
{:else}
|
|
273
|
+
<li class="empty">No scripts here.</li>
|
|
274
|
+
{/each}
|
|
275
|
+
</ul>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
<style>
|
|
279
|
+
.kc-files {
|
|
280
|
+
display: flex;
|
|
281
|
+
flex-direction: column;
|
|
282
|
+
height: 100%;
|
|
283
|
+
min-height: 0;
|
|
284
|
+
}
|
|
285
|
+
/* The tree's own bar: its title, and what can be made in it, each a plain icon in the text's
|
|
286
|
+
colour */
|
|
287
|
+
.bar {
|
|
288
|
+
flex: none;
|
|
289
|
+
display: flex;
|
|
290
|
+
align-items: center;
|
|
291
|
+
gap: 2px;
|
|
292
|
+
padding: 3px 4px 3px 12px;
|
|
293
|
+
border-bottom: 1px solid var(--kc-border);
|
|
294
|
+
}
|
|
295
|
+
.title {
|
|
296
|
+
flex: 1;
|
|
297
|
+
font-size: 11px;
|
|
298
|
+
font-weight: 600;
|
|
299
|
+
letter-spacing: 0.04em;
|
|
300
|
+
text-transform: uppercase;
|
|
301
|
+
color: var(--kc-muted);
|
|
302
|
+
}
|
|
303
|
+
.action {
|
|
304
|
+
flex: none;
|
|
305
|
+
display: grid;
|
|
306
|
+
place-items: center;
|
|
307
|
+
width: 26px;
|
|
308
|
+
height: 26px;
|
|
309
|
+
padding: 0;
|
|
310
|
+
color: var(--kc-fg);
|
|
311
|
+
}
|
|
312
|
+
.action:focus-visible {
|
|
313
|
+
outline: 2px solid var(--kc-focus);
|
|
314
|
+
outline-offset: -2px;
|
|
315
|
+
}
|
|
316
|
+
.action svg {
|
|
317
|
+
width: 18px;
|
|
318
|
+
height: 18px;
|
|
319
|
+
fill: none;
|
|
320
|
+
stroke: currentColor;
|
|
321
|
+
stroke-width: 1.5;
|
|
322
|
+
stroke-linecap: round;
|
|
323
|
+
stroke-linejoin: round;
|
|
324
|
+
}
|
|
325
|
+
.tree {
|
|
326
|
+
flex: 1;
|
|
327
|
+
min-height: 0;
|
|
328
|
+
overflow: auto;
|
|
329
|
+
margin: 0;
|
|
330
|
+
padding: 4px 0;
|
|
331
|
+
list-style: none;
|
|
332
|
+
}
|
|
333
|
+
li {
|
|
334
|
+
display: flex;
|
|
335
|
+
min-height: 22px;
|
|
336
|
+
align-items: center;
|
|
337
|
+
padding-right: 8px;
|
|
338
|
+
white-space: nowrap;
|
|
339
|
+
}
|
|
340
|
+
.folder {
|
|
341
|
+
color: var(--kc-muted);
|
|
342
|
+
font-weight: 600;
|
|
343
|
+
}
|
|
344
|
+
.data,
|
|
345
|
+
.empty {
|
|
346
|
+
color: var(--kc-muted);
|
|
347
|
+
}
|
|
348
|
+
.empty {
|
|
349
|
+
padding-left: 8px;
|
|
350
|
+
}
|
|
351
|
+
/* The folders' arrow, and its room in the other rows, so names at one depth line up */
|
|
352
|
+
.twisty {
|
|
353
|
+
flex: none;
|
|
354
|
+
display: inline-block;
|
|
355
|
+
width: 12px;
|
|
356
|
+
text-align: center;
|
|
357
|
+
}
|
|
358
|
+
.name {
|
|
359
|
+
padding: 2px 4px;
|
|
360
|
+
}
|
|
361
|
+
button {
|
|
362
|
+
flex: 1;
|
|
363
|
+
min-width: 0;
|
|
364
|
+
padding: 2px 4px;
|
|
365
|
+
overflow: hidden;
|
|
366
|
+
text-overflow: ellipsis;
|
|
367
|
+
text-align: left;
|
|
368
|
+
font: inherit;
|
|
369
|
+
color: var(--kc-fg);
|
|
370
|
+
background: none;
|
|
371
|
+
border: none;
|
|
372
|
+
border-radius: var(--kc-radius);
|
|
373
|
+
cursor: pointer;
|
|
374
|
+
}
|
|
375
|
+
/* A folder's arrow is inside its button, in the room the padding takes in the others */
|
|
376
|
+
.folder button {
|
|
377
|
+
padding-left: 0;
|
|
378
|
+
color: inherit;
|
|
379
|
+
}
|
|
380
|
+
.folder .twisty {
|
|
381
|
+
margin-right: 4px;
|
|
382
|
+
}
|
|
383
|
+
button:hover {
|
|
384
|
+
background: var(--kc-hover);
|
|
385
|
+
}
|
|
386
|
+
/* Tinted, its text as any row's: a full accent row outshouts the editor beside it */
|
|
387
|
+
button.selected {
|
|
388
|
+
background: var(--kc-accent-subtle);
|
|
389
|
+
}
|
|
390
|
+
.unsaved {
|
|
391
|
+
margin-left: 4px;
|
|
392
|
+
}
|
|
393
|
+
/* A name being given: the field the row's width, what is wrong with it under it */
|
|
394
|
+
.field {
|
|
395
|
+
display: flex;
|
|
396
|
+
flex: 1;
|
|
397
|
+
flex-direction: column;
|
|
398
|
+
gap: 2px;
|
|
399
|
+
min-width: 0;
|
|
400
|
+
padding: 1px 0;
|
|
401
|
+
}
|
|
402
|
+
.field input {
|
|
403
|
+
width: 100%;
|
|
404
|
+
box-sizing: border-box;
|
|
405
|
+
padding: 1px 4px;
|
|
406
|
+
font: inherit;
|
|
407
|
+
}
|
|
408
|
+
.problem {
|
|
409
|
+
color: var(--kc-error);
|
|
410
|
+
white-space: normal;
|
|
411
|
+
}
|
|
412
|
+
</style>
|
package/src/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @kupcad/files: a project's files as a tree, for any host of them (the desktop app's on the
|
|
2
|
+
// disk, the browser IDE's in its private file system): the tree (FileTree), its rows' menu
|
|
3
|
+
// (rowMenu, drawn by a host's native menu or by ContextMenu), the folder listed through the
|
|
4
|
+
// host's `readDir` and the paths and names in it (tree.js), and the scripts read, their unsaved
|
|
5
|
+
// changes and undo histories (Scripts).
|
|
6
|
+
|
|
7
|
+
export { default as ContextMenu } from './ContextMenu.svelte';
|
|
8
|
+
export { default as FileTree } from './FileTree.svelte';
|
|
9
|
+
export { rowMenu } from './menu.js';
|
|
10
|
+
export { Scripts } from './scripts.svelte.js';
|
|
11
|
+
export {
|
|
12
|
+
childPath,
|
|
13
|
+
DATA_EXTENSIONS,
|
|
14
|
+
foldersAbove,
|
|
15
|
+
kindOf,
|
|
16
|
+
listFolder,
|
|
17
|
+
MAX_ENTRIES,
|
|
18
|
+
nameProblem,
|
|
19
|
+
parentOf,
|
|
20
|
+
SCRIPT_EXTENSION,
|
|
21
|
+
scriptName,
|
|
22
|
+
shownEntries,
|
|
23
|
+
SKIPPED_FOLDER,
|
|
24
|
+
within,
|
|
25
|
+
} from './tree.js';
|
|
26
|
+
|
|
27
|
+
/** @typedef {import('./tree.js').Entry} Entry */
|
|
28
|
+
/** @typedef {import('./tree.js').DirEntry} DirEntry */
|
|
29
|
+
/** @typedef {import('./tree.js').DirReader} DirReader */
|
|
30
|
+
/** @typedef {import('./menu.js').MenuItem} MenuItem */
|
|
31
|
+
/** @typedef {import('./menu.js').RowActions} RowActions */
|
|
32
|
+
/** @typedef {import('./scripts.svelte.js').ScriptFiles} ScriptFiles */
|