@jxsuite/studio 0.16.0 → 0.17.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/dist/studio.js +466 -126
- package/dist/studio.js.map +19 -14
- package/package.json +1 -1
- package/src/browse/browse.js +24 -3
- package/src/canvas/canvas-live-render.js +1 -27
- package/src/editor/shortcuts.js +7 -0
- package/src/files/files.js +7 -2
- package/src/panels/panel-events.js +24 -19
- package/src/panels/properties-panel.js +14 -14
- package/src/panels/quick-search.js +170 -0
- package/src/panels/toolbar.js +62 -13
- package/src/platforms/devserver.js +12 -0
- package/src/recent-projects.js +57 -0
- package/src/studio.js +58 -9
- package/src/ui/spectrum.js +2 -0
- package/src/workspace/workspace.js +44 -0
package/package.json
CHANGED
package/src/browse/browse.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* button with type-aware entity creation (including content types from project.json).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { html, render as litRender } from "lit-html";
|
|
9
|
+
import { html, render as litRender, nothing } from "lit-html";
|
|
10
10
|
import { getPlatform } from "../platform.js";
|
|
11
11
|
import { projectState } from "../store.js";
|
|
12
12
|
import { yamlDefault } from "../settings/schema-field-ui.js";
|
|
@@ -62,6 +62,22 @@ function extOf(name) {
|
|
|
62
62
|
return dot > 0 ? name.slice(dot).toLowerCase() : "";
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
66
|
+
".jpg",
|
|
67
|
+
".jpeg",
|
|
68
|
+
".png",
|
|
69
|
+
".gif",
|
|
70
|
+
".svg",
|
|
71
|
+
".webp",
|
|
72
|
+
".avif",
|
|
73
|
+
".ico",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
/** @param {string} ext */
|
|
77
|
+
function isImage(ext) {
|
|
78
|
+
return IMAGE_EXTENSIONS.has(ext);
|
|
79
|
+
}
|
|
80
|
+
|
|
65
81
|
/** Map a file path to a display category. Media files override by extension. */
|
|
66
82
|
function categoryFor(/** @type {string} */ dir, /** @type {string} */ ext) {
|
|
67
83
|
if (ext && MEDIA_EXTENSIONS.has(ext)) return "Media";
|
|
@@ -396,9 +412,14 @@ export async function renderBrowse(container, ctx) {
|
|
|
396
412
|
<sp-table-row
|
|
397
413
|
value=${f.path}
|
|
398
414
|
class="browse-row"
|
|
399
|
-
|
|
415
|
+
style=${isImage(f.ext) ? "cursor:default" : ""}
|
|
416
|
+
@click=${isImage(f.ext) ? nothing : () => ctx.openFile(f.path)}
|
|
400
417
|
>
|
|
401
|
-
<sp-table-cell class="browse-name-cell"
|
|
418
|
+
<sp-table-cell class="browse-name-cell"
|
|
419
|
+
>${isImage(f.ext)
|
|
420
|
+
? html`<img class="browse-thumb" src="/${f.path}" />`
|
|
421
|
+
: nothing}${f.name}</sp-table-cell
|
|
422
|
+
>
|
|
402
423
|
<sp-table-cell>${f.category}</sp-table-cell>
|
|
403
424
|
<sp-table-cell>${f.type}</sp-table-cell>
|
|
404
425
|
<sp-table-cell class="browse-path-cell">${f.path}</sp-table-cell>
|
|
@@ -390,38 +390,12 @@ export async function renderCanvasLive(gen, doc, canvasEl) {
|
|
|
390
390
|
canvasEl.removeAttribute("data-content-mode");
|
|
391
391
|
}
|
|
392
392
|
|
|
393
|
-
// Resolve relative asset URLs through the PAL for environments that can't
|
|
394
|
-
// serve project files natively (e.g. ElectroBun's views:// scheme).
|
|
395
|
-
const platform = /** @type {any} */ (globalThis).__jxPlatform;
|
|
396
|
-
if (platform?.resolveAssetUrl && docBase) {
|
|
397
|
-
const mediaEls = el.querySelectorAll("img[src], video[src], source[src], video[poster]");
|
|
398
|
-
for (const node of mediaEls) {
|
|
399
|
-
for (const attr of ["src", "poster"]) {
|
|
400
|
-
const val = node.getAttribute(attr);
|
|
401
|
-
if (
|
|
402
|
-
val &&
|
|
403
|
-
!val.startsWith("data:") &&
|
|
404
|
-
!val.startsWith("blob:") &&
|
|
405
|
-
!val.startsWith("http")
|
|
406
|
-
) {
|
|
407
|
-
try {
|
|
408
|
-
const resolved = new URL(val, docBase).pathname.replace(/^\//, "");
|
|
409
|
-
const dataUrl = await platform.resolveAssetUrl(resolved);
|
|
410
|
-
if (dataUrl) node.setAttribute(attr, dataUrl);
|
|
411
|
-
} catch {}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
393
|
canvasEl.appendChild(el);
|
|
394
|
+
|
|
418
395
|
if (canvasMode === "design" || canvasMode === "edit") {
|
|
419
|
-
// Custom element connectedCallbacks render children asynchronously —
|
|
420
|
-
// sweep again after they've had a chance to run
|
|
421
396
|
requestAnimationFrame(() => {
|
|
422
397
|
const editingEl = getActiveElement();
|
|
423
398
|
for (const child of canvasEl.querySelectorAll("*")) {
|
|
424
|
-
// Preserve pointer-events on the actively-edited element
|
|
425
399
|
if (view.componentInlineEdit && child === view.componentInlineEdit.el) continue;
|
|
426
400
|
if (editingEl && child === editingEl) continue;
|
|
427
401
|
/** @type {any} */ (child).style.pointerEvents = "none";
|
package/src/editor/shortcuts.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "../tabs/transact.js";
|
|
18
18
|
import { isEditing } from "./inline-edit.js";
|
|
19
19
|
import { copyNode, cutNode, pasteNode } from "./context-menu.js";
|
|
20
|
+
import { openQuickSearch } from "../panels/quick-search.js";
|
|
20
21
|
|
|
21
22
|
/** @typedef {import("../state.js").JxPath} JxPath */
|
|
22
23
|
|
|
@@ -44,6 +45,8 @@ export function initShortcuts(getContext) {
|
|
|
44
45
|
const { canvasMode, panX, panY, setPan, applyTransform } = getContext();
|
|
45
46
|
// Edit (content) mode: let the scroll container handle scrolling natively
|
|
46
47
|
if (canvasMode === "edit") return;
|
|
48
|
+
// Manage mode: browse table handles its own scrolling
|
|
49
|
+
if (canvasMode === "manage") return;
|
|
47
50
|
e.preventDefault();
|
|
48
51
|
if (e.ctrlKey || e.metaKey) {
|
|
49
52
|
// Zoom towards cursor
|
|
@@ -164,6 +167,10 @@ export function initShortcuts(getContext) {
|
|
|
164
167
|
e.preventDefault();
|
|
165
168
|
openProject();
|
|
166
169
|
break;
|
|
170
|
+
case "p":
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
openQuickSearch();
|
|
173
|
+
break;
|
|
167
174
|
case "s":
|
|
168
175
|
e.preventDefault();
|
|
169
176
|
saveFile();
|
package/src/files/files.js
CHANGED
|
@@ -15,9 +15,10 @@ import { createState, projectState, setProjectState } from "../store.js";
|
|
|
15
15
|
import { getPlatform } from "../platform.js";
|
|
16
16
|
import { statusMessage } from "../panels/statusbar.js";
|
|
17
17
|
import { loadComponentRegistry } from "./components.js";
|
|
18
|
-
import { workspace, openTab, activateTab } from "../workspace/workspace.js";
|
|
18
|
+
import { workspace, openTab, activateTab, replaceAllTabs } from "../workspace/workspace.js";
|
|
19
19
|
import { loadMarkdown } from "./file-ops.js";
|
|
20
20
|
import { view } from "../view.js";
|
|
21
|
+
import { addRecentProject, trackRecentFile } from "../recent-projects.js";
|
|
21
22
|
|
|
22
23
|
// ─── File icon map ────────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -32,7 +33,7 @@ const fileIconMap = /** @type {Record<string, import("lit-html").TemplateResult>
|
|
|
32
33
|
|
|
33
34
|
// ─── File management ──────────────────────────────────────────────────────────
|
|
34
35
|
|
|
35
|
-
async function loadDirectory(/** @type {string} */ dirPath) {
|
|
36
|
+
export async function loadDirectory(/** @type {string} */ dirPath) {
|
|
36
37
|
if (!projectState) return;
|
|
37
38
|
try {
|
|
38
39
|
const platform = getPlatform();
|
|
@@ -92,6 +93,8 @@ export async function openProject({ renderActivityBar, renderLeftPanel }) {
|
|
|
92
93
|
|
|
93
94
|
const { config, handle } = result;
|
|
94
95
|
|
|
96
|
+
replaceAllTabs({ id: "initial", document: { tagName: "div", children: [] } });
|
|
97
|
+
|
|
95
98
|
setProjectState({
|
|
96
99
|
...projectState,
|
|
97
100
|
projectRoot: handle.root,
|
|
@@ -129,6 +132,7 @@ export async function openProject({ renderActivityBar, renderLeftPanel }) {
|
|
|
129
132
|
projectState.projectDirs = foundDirs;
|
|
130
133
|
|
|
131
134
|
view.leftTab = "files";
|
|
135
|
+
addRecentProject(projectState.name, projectState.projectRoot);
|
|
132
136
|
renderActivityBar();
|
|
133
137
|
renderLeftPanel();
|
|
134
138
|
statusMessage(`Opened project: ${projectState.name}`);
|
|
@@ -631,6 +635,7 @@ export async function openFileInTab(path) {
|
|
|
631
635
|
sourceFormat: path.endsWith(".md") ? "md" : null,
|
|
632
636
|
});
|
|
633
637
|
projectState.selectedPath = path;
|
|
638
|
+
trackRecentFile({ path, name: path.split("/").pop() || path });
|
|
634
639
|
statusMessage(`Opened ${path.split("/").pop()}`);
|
|
635
640
|
} catch (/** @type {any} */ e) {
|
|
636
641
|
statusMessage(`Error: ${e.message}`);
|
|
@@ -81,12 +81,14 @@ export function registerPanelEvents(panel) {
|
|
|
81
81
|
document.elementsFromPoint(e.clientX, e.clientY),
|
|
82
82
|
);
|
|
83
83
|
|
|
84
|
+
if (!tab) return;
|
|
85
|
+
|
|
84
86
|
for (const el of elements) {
|
|
85
87
|
if (canvas.contains(el) && el !== canvas) {
|
|
86
88
|
// Layout element clicked — show layout info instead of selecting in page doc
|
|
87
89
|
if (layoutElements.has(el)) {
|
|
88
90
|
view.layoutSelection = { el, layoutPath: activeLayoutPath };
|
|
89
|
-
|
|
91
|
+
tab.session.selection = null;
|
|
90
92
|
renderOnly("rightPanel");
|
|
91
93
|
return;
|
|
92
94
|
}
|
|
@@ -94,35 +96,35 @@ export function registerPanelEvents(panel) {
|
|
|
94
96
|
|
|
95
97
|
const originalPath = elToPath.get(el);
|
|
96
98
|
if (originalPath) {
|
|
97
|
-
let path = bubbleInlinePath(tab
|
|
99
|
+
let path = bubbleInlinePath(tab.doc.document, originalPath);
|
|
98
100
|
const newMedia = mediaName === "base" ? null : (mediaName ?? null);
|
|
99
101
|
|
|
100
102
|
const resolvedEl = path === originalPath ? el : findCanvasElement(path, canvas) || el;
|
|
101
103
|
|
|
102
104
|
if (
|
|
103
|
-
pathsEqual(path, tab
|
|
105
|
+
pathsEqual(path, tab.session.selection) &&
|
|
104
106
|
isEditableBlock(resolvedEl) &&
|
|
105
|
-
(canvasMode === "edit" || tab
|
|
107
|
+
(canvasMode === "edit" || tab.doc.mode === "content")
|
|
106
108
|
) {
|
|
107
|
-
|
|
109
|
+
tab.session.ui.activeMedia = newMedia;
|
|
108
110
|
_ctx.enterInlineEdit(resolvedEl, path);
|
|
109
111
|
return;
|
|
110
112
|
}
|
|
111
113
|
|
|
112
|
-
if (canvasMode === "design" && tab
|
|
114
|
+
if (canvasMode === "design" && tab.doc.mode !== "content") {
|
|
113
115
|
updateUi("pendingInlineEdit", { path, mediaName });
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
tab.session.ui.activeMedia = newMedia;
|
|
117
|
+
tab.session.selection = path;
|
|
116
118
|
return;
|
|
117
119
|
}
|
|
118
120
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
tab.session.ui.activeMedia = newMedia;
|
|
122
|
+
tab.session.selection = path;
|
|
121
123
|
return;
|
|
122
124
|
}
|
|
123
125
|
}
|
|
124
126
|
}
|
|
125
|
-
|
|
127
|
+
tab.session.selection = null;
|
|
126
128
|
},
|
|
127
129
|
opts,
|
|
128
130
|
);
|
|
@@ -149,16 +151,18 @@ export function registerPanelEvents(panel) {
|
|
|
149
151
|
document.elementsFromPoint(e.clientX, e.clientY),
|
|
150
152
|
);
|
|
151
153
|
|
|
154
|
+
if (!tab) return;
|
|
155
|
+
|
|
152
156
|
for (const el of elements) {
|
|
153
157
|
if (canvas.contains(el) && el !== canvas) {
|
|
154
158
|
const originalPath = elToPath.get(el);
|
|
155
159
|
if (originalPath) {
|
|
156
|
-
const path = bubbleInlinePath(tab
|
|
160
|
+
const path = bubbleInlinePath(tab.doc.document, originalPath);
|
|
157
161
|
const resolvedEl = path === originalPath ? el : findCanvasElement(path, canvas) || el;
|
|
158
162
|
if (isEditableBlock(resolvedEl)) {
|
|
159
163
|
const newMedia = mediaName === "base" ? null : (mediaName ?? null);
|
|
160
|
-
|
|
161
|
-
|
|
164
|
+
tab.session.ui.activeMedia = newMedia;
|
|
165
|
+
tab.session.selection = path;
|
|
162
166
|
_ctx.enterInlineEdit(resolvedEl, path);
|
|
163
167
|
return;
|
|
164
168
|
}
|
|
@@ -217,18 +221,19 @@ export function registerPanelEvents(panel) {
|
|
|
217
221
|
return;
|
|
218
222
|
}
|
|
219
223
|
const tab = activeTab.value;
|
|
224
|
+
if (!tab) return;
|
|
220
225
|
const el = withPanelPointerEvents(() => document.elementFromPoint(e.clientX, e.clientY));
|
|
221
226
|
if (el && canvas.contains(el) && el !== canvas) {
|
|
222
227
|
let path = elToPath.get(el);
|
|
223
228
|
if (path) {
|
|
224
|
-
path = bubbleInlinePath(tab
|
|
225
|
-
if (!pathsEqual(path, tab
|
|
226
|
-
|
|
229
|
+
path = bubbleInlinePath(tab.doc.document, path);
|
|
230
|
+
if (!pathsEqual(path, tab.session.hover)) {
|
|
231
|
+
tab.session.hover = path;
|
|
227
232
|
renderOnly("overlays");
|
|
228
233
|
}
|
|
229
234
|
}
|
|
230
|
-
} else if (tab
|
|
231
|
-
|
|
235
|
+
} else if (tab.session.hover) {
|
|
236
|
+
tab.session.hover = null;
|
|
232
237
|
renderOnly("overlays");
|
|
233
238
|
}
|
|
234
239
|
},
|
|
@@ -222,16 +222,16 @@ function renderFrontmatterOnlyPanel() {
|
|
|
222
222
|
fields.push({ field, entry: fieldSchema, value: fm[field] });
|
|
223
223
|
}
|
|
224
224
|
for (const [field, value] of Object.entries(fm)) {
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
225
|
+
if (schemaProps[field] || field.startsWith("$")) continue;
|
|
226
|
+
fields.push({
|
|
227
|
+
field,
|
|
228
|
+
entry: { type: typeof value === "boolean" ? "boolean" : "string" },
|
|
229
|
+
value,
|
|
230
|
+
});
|
|
232
231
|
}
|
|
233
232
|
} else {
|
|
234
233
|
for (const [field, value] of Object.entries(fm)) {
|
|
234
|
+
if (field.startsWith("$")) continue;
|
|
235
235
|
fields.push({
|
|
236
236
|
field,
|
|
237
237
|
entry: { type: typeof value === "boolean" ? "boolean" : "string" },
|
|
@@ -1656,16 +1656,16 @@ export function renderPropertiesPanelTemplate(ctx) {
|
|
|
1656
1656
|
fields.push({ field, entry: fieldSchema, value: fm[field] });
|
|
1657
1657
|
}
|
|
1658
1658
|
for (const [field, value] of Object.entries(fm)) {
|
|
1659
|
-
if (
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
}
|
|
1659
|
+
if (schemaProps[field] || field.startsWith("$")) continue;
|
|
1660
|
+
fields.push({
|
|
1661
|
+
field,
|
|
1662
|
+
entry: { type: typeof value === "boolean" ? "boolean" : "string" },
|
|
1663
|
+
value,
|
|
1664
|
+
});
|
|
1666
1665
|
}
|
|
1667
1666
|
} else {
|
|
1668
1667
|
for (const [field, value] of Object.entries(fm)) {
|
|
1668
|
+
if (field.startsWith("$")) continue;
|
|
1669
1669
|
fields.push({
|
|
1670
1670
|
field,
|
|
1671
1671
|
entry: { type: typeof value === "boolean" ? "boolean" : "string" },
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { html, render as litRender, nothing } from "lit-html";
|
|
2
|
+
import { getPlatform } from "../platform.js";
|
|
3
|
+
import { openFileInTab } from "../files/files.js";
|
|
4
|
+
import { getRecentFiles, trackRecentFile } from "../recent-projects.js";
|
|
5
|
+
|
|
6
|
+
let _open = false;
|
|
7
|
+
let _query = "";
|
|
8
|
+
/** @type {any[]} */
|
|
9
|
+
let _results = [];
|
|
10
|
+
let _selectedIndex = 0;
|
|
11
|
+
let _debounceTimer = 0;
|
|
12
|
+
|
|
13
|
+
/** @type {HTMLElement | null} */
|
|
14
|
+
let _container = null;
|
|
15
|
+
|
|
16
|
+
export function initQuickSearch() {
|
|
17
|
+
_container = document.createElement("div");
|
|
18
|
+
_container.style.display = "contents";
|
|
19
|
+
(document.querySelector("sp-theme") || document.body).appendChild(_container);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function openQuickSearch() {
|
|
23
|
+
_open = true;
|
|
24
|
+
_query = "";
|
|
25
|
+
_results = [];
|
|
26
|
+
_selectedIndex = 0;
|
|
27
|
+
renderOverlay();
|
|
28
|
+
requestAnimationFrame(() => {
|
|
29
|
+
const input = _container?.querySelector(".quick-search-input");
|
|
30
|
+
if (input) /** @type {HTMLInputElement} */ (input).focus();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function closeQuickSearch() {
|
|
35
|
+
_open = false;
|
|
36
|
+
renderOverlay();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function doSearch(/** @type {string} */ query) {
|
|
40
|
+
if (!query.trim()) {
|
|
41
|
+
_results = [];
|
|
42
|
+
_selectedIndex = 0;
|
|
43
|
+
renderOverlay();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const platform = getPlatform();
|
|
48
|
+
_results = await platform.searchFiles(query.trim().toLowerCase());
|
|
49
|
+
_selectedIndex = 0;
|
|
50
|
+
renderOverlay();
|
|
51
|
+
} catch {
|
|
52
|
+
_results = [];
|
|
53
|
+
renderOverlay();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function onInput(/** @type {Event} */ e) {
|
|
58
|
+
_query = /** @type {HTMLInputElement} */ (e.target).value;
|
|
59
|
+
clearTimeout(_debounceTimer);
|
|
60
|
+
_debounceTimer = /** @type {any} */ (setTimeout(() => doSearch(_query), 150));
|
|
61
|
+
renderOverlay();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function onKeydown(/** @type {KeyboardEvent} */ e) {
|
|
65
|
+
const items = _query.trim() ? _results : getRecentFiles();
|
|
66
|
+
switch (e.key) {
|
|
67
|
+
case "ArrowDown":
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
_selectedIndex = Math.min(_selectedIndex + 1, items.length - 1);
|
|
70
|
+
renderOverlay();
|
|
71
|
+
break;
|
|
72
|
+
case "ArrowUp":
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
_selectedIndex = Math.max(_selectedIndex - 1, 0);
|
|
75
|
+
renderOverlay();
|
|
76
|
+
break;
|
|
77
|
+
case "Enter":
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
if (items[_selectedIndex]) selectItem(items[_selectedIndex]);
|
|
80
|
+
break;
|
|
81
|
+
case "Escape":
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
closeQuickSearch();
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function selectItem(/** @type {any} */ item) {
|
|
89
|
+
closeQuickSearch();
|
|
90
|
+
const path = item.path;
|
|
91
|
+
trackRecentFile({ path, name: path.split("/").pop() });
|
|
92
|
+
openFileInTab(path);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function fileIcon(/** @type {string} */ name) {
|
|
96
|
+
const ext = name.split(".").pop()?.toLowerCase();
|
|
97
|
+
switch (ext) {
|
|
98
|
+
case "json":
|
|
99
|
+
return html`<sp-icon-file-code size="s"></sp-icon-file-code>`;
|
|
100
|
+
case "md":
|
|
101
|
+
return html`<sp-icon-file-txt size="s"></sp-icon-file-txt>`;
|
|
102
|
+
default:
|
|
103
|
+
return html`<sp-icon-document size="s"></sp-icon-document>`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function dirPart(/** @type {string} */ path) {
|
|
108
|
+
const parts = path.split("/");
|
|
109
|
+
parts.pop();
|
|
110
|
+
return parts.length ? parts.join("/") : "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderOverlay() {
|
|
114
|
+
if (!_container) return;
|
|
115
|
+
if (!_open) {
|
|
116
|
+
litRender(nothing, _container);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const recentFiles = getRecentFiles();
|
|
121
|
+
const showRecent = !_query.trim();
|
|
122
|
+
const items = showRecent ? recentFiles : _results;
|
|
123
|
+
|
|
124
|
+
const tpl = html`
|
|
125
|
+
<div class="quick-search-overlay" @click=${closeQuickSearch}>
|
|
126
|
+
<div class="quick-search-panel" @click=${(/** @type {Event} */ e) => e.stopPropagation()}>
|
|
127
|
+
<input
|
|
128
|
+
class="quick-search-input"
|
|
129
|
+
type="text"
|
|
130
|
+
placeholder="Search project files…"
|
|
131
|
+
.value=${_query}
|
|
132
|
+
@input=${onInput}
|
|
133
|
+
@keydown=${onKeydown}
|
|
134
|
+
/>
|
|
135
|
+
<div class="quick-search-results">
|
|
136
|
+
${items.length === 0 && _query.trim()
|
|
137
|
+
? html`<div class="quick-search-empty">No results</div>`
|
|
138
|
+
: nothing}
|
|
139
|
+
${items.length === 0 && !_query.trim() && recentFiles.length === 0
|
|
140
|
+
? html`<div class="quick-search-empty">Type to search project files</div>`
|
|
141
|
+
: nothing}
|
|
142
|
+
${showRecent && recentFiles.length
|
|
143
|
+
? html`<div class="quick-search-section-label">Recently opened</div>`
|
|
144
|
+
: nothing}
|
|
145
|
+
${items.map(
|
|
146
|
+
(item, i) => html`
|
|
147
|
+
<div
|
|
148
|
+
class="quick-search-item ${i === _selectedIndex ? "selected" : ""}"
|
|
149
|
+
@click=${() => selectItem(item)}
|
|
150
|
+
@mouseenter=${() => {
|
|
151
|
+
_selectedIndex = i;
|
|
152
|
+
renderOverlay();
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
<span class="quick-search-icon"
|
|
156
|
+
>${fileIcon(item.name || item.path.split("/").pop())}</span
|
|
157
|
+
>
|
|
158
|
+
<span class="quick-search-name">${item.name || item.path.split("/").pop()}</span>
|
|
159
|
+
<span class="quick-search-path">${dirPart(item.path)}</span>
|
|
160
|
+
${showRecent ? html`<span class="quick-search-badge">recent</span>` : nothing}
|
|
161
|
+
</div>
|
|
162
|
+
`,
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
`;
|
|
168
|
+
|
|
169
|
+
litRender(tpl, _container);
|
|
170
|
+
}
|
package/src/panels/toolbar.js
CHANGED
|
@@ -11,6 +11,8 @@ import { activeTab } from "../workspace/workspace.js";
|
|
|
11
11
|
import { getEffectiveMedia } from "../site-context.js";
|
|
12
12
|
import { mediaDisplayName } from "./shared.js";
|
|
13
13
|
import { view } from "../view.js";
|
|
14
|
+
import { getRecentProjects } from "../recent-projects.js";
|
|
15
|
+
import { openQuickSearch } from "./quick-search.js";
|
|
14
16
|
|
|
15
17
|
/** @type {HTMLElement | null} */
|
|
16
18
|
let _rootEl = null;
|
|
@@ -62,6 +64,9 @@ function tbBtnTpl(label, onClick, iconTag) {
|
|
|
62
64
|
export function mount(rootEl, ctx) {
|
|
63
65
|
_rootEl = rootEl;
|
|
64
66
|
_ctx = ctx;
|
|
67
|
+
if (/** @type {any} */ (globalThis).__jxPlatform?.windowControls) {
|
|
68
|
+
rootEl.classList.add("electrobun-webkit-app-region-drag");
|
|
69
|
+
}
|
|
65
70
|
_scope = effectScope();
|
|
66
71
|
_scope.run(() => {
|
|
67
72
|
effect(() => {
|
|
@@ -226,10 +231,60 @@ function toolbarTemplate() {
|
|
|
226
231
|
</sp-action-group>
|
|
227
232
|
`;
|
|
228
233
|
|
|
234
|
+
const windowControls = /** @type {any} */ (globalThis).__jxPlatform?.windowControls;
|
|
235
|
+
const csdTpl = windowControls
|
|
236
|
+
? html`
|
|
237
|
+
<sp-action-group class="window-controls" size="s">
|
|
238
|
+
<sp-action-button
|
|
239
|
+
quiet
|
|
240
|
+
size="s"
|
|
241
|
+
title="Minimize"
|
|
242
|
+
@click=${() => windowControls.minimize()}
|
|
243
|
+
>
|
|
244
|
+
<sp-icon-remove slot="icon"></sp-icon-remove>
|
|
245
|
+
</sp-action-button>
|
|
246
|
+
<sp-action-button
|
|
247
|
+
quiet
|
|
248
|
+
size="s"
|
|
249
|
+
title="Maximize"
|
|
250
|
+
@click=${() => windowControls.maximize()}
|
|
251
|
+
>
|
|
252
|
+
<sp-icon-full-screen slot="icon"></sp-icon-full-screen>
|
|
253
|
+
</sp-action-button>
|
|
254
|
+
<sp-action-button
|
|
255
|
+
quiet
|
|
256
|
+
size="s"
|
|
257
|
+
title="Close"
|
|
258
|
+
class="csd-close"
|
|
259
|
+
@click=${() => windowControls.close()}
|
|
260
|
+
>
|
|
261
|
+
<sp-icon-close slot="icon"></sp-icon-close>
|
|
262
|
+
</sp-action-button>
|
|
263
|
+
</sp-action-group>
|
|
264
|
+
`
|
|
265
|
+
: nothing;
|
|
266
|
+
|
|
267
|
+
const recentProjects = getRecentProjects();
|
|
268
|
+
const recentProjectsTpl = recentProjects.length
|
|
269
|
+
? html`
|
|
270
|
+
<overlay-trigger placement="bottom-start">
|
|
271
|
+
<sp-action-button size="s" slot="trigger" title="Recent projects">
|
|
272
|
+
<sp-icon-chevron-down slot="icon"></sp-icon-chevron-down>
|
|
273
|
+
</sp-action-button>
|
|
274
|
+
<sp-popover slot="click-content" tip>
|
|
275
|
+
<sp-menu @change=${(/** @type {any} */ e) => _ctx.openRecentProject(e.target.value)}>
|
|
276
|
+
${recentProjects.map(
|
|
277
|
+
(p) => html`<sp-menu-item value=${p.root}>${p.name}</sp-menu-item>`,
|
|
278
|
+
)}
|
|
279
|
+
</sp-menu>
|
|
280
|
+
</sp-popover>
|
|
281
|
+
</overlay-trigger>
|
|
282
|
+
`
|
|
283
|
+
: nothing;
|
|
284
|
+
|
|
229
285
|
return html`
|
|
230
286
|
<sp-action-group compact size="s">
|
|
231
|
-
${tbBtnTpl("Open Project", _ctx.openProject, "sp-icon-folder-open")}
|
|
232
|
-
${tbBtnTpl("Open File", _ctx.openFile, "sp-icon-document")}
|
|
287
|
+
${tbBtnTpl("Open Project", _ctx.openProject, "sp-icon-folder-open")} ${recentProjectsTpl}
|
|
233
288
|
${tbBtnTpl("Save", _ctx.saveFile, "sp-icon-save-floppy")}
|
|
234
289
|
</sp-action-group>
|
|
235
290
|
<sp-action-group compact size="s">
|
|
@@ -237,17 +292,11 @@ function toolbarTemplate() {
|
|
|
237
292
|
${tbBtnTpl("Redo", () => tabRedo(activeTab.value), "sp-icon-redo")}
|
|
238
293
|
</sp-action-group>
|
|
239
294
|
<div class="tb-spacer"></div>
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
: S.fileHandle
|
|
245
|
-
? html`<span class="tb-file-title"
|
|
246
|
-
>${S.fileHandle.name}${S.dirty ? html`<span class="tb-dirty">●</span>` : nothing}</span
|
|
247
|
-
>`
|
|
248
|
-
: nothing}
|
|
249
|
-
${breadcrumbTpl}
|
|
295
|
+
<sp-action-button class="tb-search-trigger" size="s" quiet @click=${openQuickSearch}>
|
|
296
|
+
<sp-icon-search slot="icon"></sp-icon-search>
|
|
297
|
+
<span class="tb-search-label">Search files… <kbd>⌘P</kbd></span>
|
|
298
|
+
</sp-action-button>
|
|
250
299
|
<div class="tb-spacer"></div>
|
|
251
|
-
${togglesTpl} ${modeSwitcherTpl}
|
|
300
|
+
${breadcrumbTpl} ${togglesTpl} ${modeSwitcherTpl} ${csdTpl}
|
|
252
301
|
`;
|
|
253
302
|
}
|
|
@@ -315,6 +315,18 @@ export function createDevServerPlatform() {
|
|
|
315
315
|
return null;
|
|
316
316
|
},
|
|
317
317
|
|
|
318
|
+
/** @param {string} query */
|
|
319
|
+
async searchFiles(query) {
|
|
320
|
+
const glob = `**/*${query}*.{json,md}`;
|
|
321
|
+
const res = await fetch(
|
|
322
|
+
`/__studio/files?dir=${encodeURIComponent(serverPath("."))}&glob=${encodeURIComponent(glob)}`,
|
|
323
|
+
);
|
|
324
|
+
if (!res.ok) return [];
|
|
325
|
+
const entries = await res.json();
|
|
326
|
+
for (const e of entries) e.path = stripRoot(e.path);
|
|
327
|
+
return entries;
|
|
328
|
+
},
|
|
329
|
+
|
|
318
330
|
// ─── Plugin schema ────────────────────────────────────────────────────
|
|
319
331
|
|
|
320
332
|
/**
|