@jxsuite/studio 0.16.1 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
32
32
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
33
- "@jxsuite/runtime": "^0.15.0",
33
+ "@jxsuite/runtime": "^0.18.0",
34
34
  "@spectrum-web-components/accordion": "^1.12.1",
35
35
  "@spectrum-web-components/action-bar": "1.12.1",
36
36
  "@spectrum-web-components/action-button": "^1.12.1",
@@ -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
- @click=${() => ctx.openFile(f.path)}
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">${f.name}</sp-table-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>
@@ -173,8 +173,8 @@ export function renderCanvas() {
173
173
  return;
174
174
  }
175
175
 
176
- // Settings mode: render element catalog with panzoom surface
177
- if (canvasMode === "settings") {
176
+ // Stylebook mode: render element catalog with panzoom surface
177
+ if (canvasMode === "stylebook") {
178
178
  renderStylebookMode({
179
179
  canvasPanelTemplate,
180
180
  applyTransform,
@@ -170,7 +170,7 @@ export function applyTransform() {
170
170
  view.panzoomWrap.style.transform = `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`;
171
171
  renderZoomIndicator();
172
172
  renderOnly("overlays");
173
- if (_ctx.getCanvasMode() === "settings") _ctx.renderStylebookOverlays();
173
+ if (_ctx.getCanvasMode() === "stylebook") _ctx.renderStylebookOverlays();
174
174
  }
175
175
 
176
176
  /** Calculate zoom + pan to fit all panels within the viewport. */
@@ -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();
@@ -15,9 +15,16 @@ 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, replaceAllTabs } from "../workspace/workspace.js";
18
+ import {
19
+ workspace,
20
+ openTab,
21
+ activateTab,
22
+ replaceAllTabs,
23
+ activeTab,
24
+ } from "../workspace/workspace.js";
19
25
  import { loadMarkdown } from "./file-ops.js";
20
26
  import { view } from "../view.js";
27
+ import { addRecentProject, trackRecentFile } from "../recent-projects.js";
21
28
 
22
29
  // ─── File icon map ────────────────────────────────────────────────────────────
23
30
 
@@ -32,7 +39,7 @@ const fileIconMap = /** @type {Record<string, import("lit-html").TemplateResult>
32
39
 
33
40
  // ─── File management ──────────────────────────────────────────────────────────
34
41
 
35
- async function loadDirectory(/** @type {string} */ dirPath) {
42
+ export async function loadDirectory(/** @type {string} */ dirPath) {
36
43
  if (!projectState) return;
37
44
  try {
38
45
  const platform = getPlatform();
@@ -131,6 +138,7 @@ export async function openProject({ renderActivityBar, renderLeftPanel }) {
131
138
  projectState.projectDirs = foundDirs;
132
139
 
133
140
  view.leftTab = "files";
141
+ addRecentProject(projectState.name, projectState.projectRoot);
134
142
  renderActivityBar();
135
143
  renderLeftPanel();
136
144
  statusMessage(`Opened project: ${projectState.name}`);
@@ -633,6 +641,13 @@ export async function openFileInTab(path) {
633
641
  sourceFormat: path.endsWith(".md") ? "md" : null,
634
642
  });
635
643
  projectState.selectedPath = path;
644
+ trackRecentFile({ path, name: path.split("/").pop() || path });
645
+
646
+ if (path === "project.json") {
647
+ const tab = activeTab.value;
648
+ if (tab) tab.session.ui.canvasMode = "stylebook";
649
+ }
650
+
636
651
  statusMessage(`Opened ${path.split("/").pop()}`);
637
652
  } catch (/** @type {any} */ e) {
638
653
  statusMessage(`Error: ${e.message}`);
@@ -5,6 +5,7 @@ import { activityBar, renderOnly } from "../store.js";
5
5
  import { effect, effectScope } from "../reactivity.js";
6
6
  import { activeTab } from "../workspace/workspace.js";
7
7
  import { view } from "../view.js";
8
+ import { openSettingsModal } from "../settings/settings-modal.js";
8
9
 
9
10
  /** @type {import("@vue/reactivity").EffectScope | null} */
10
11
  let _scope = null;
@@ -113,6 +114,17 @@ export function renderActivityBar() {
113
114
  `,
114
115
  )}
115
116
  </sp-tabs>
117
+ <div style="margin-top:auto;padding:8px 0;display:flex;justify-content:center">
118
+ <sp-action-button
119
+ quiet
120
+ size="m"
121
+ title="Settings"
122
+ aria-label="Settings"
123
+ @click=${() => openSettingsModal()}
124
+ >
125
+ <sp-icon-gears slot="icon"></sp-icon-gears>
126
+ </sp-action-button>
127
+ </div>
116
128
  `;
117
129
  litRender(tpl, /** @type {any} */ (activityBar));
118
130
  }
@@ -131,7 +131,7 @@ function _render() {
131
131
  let content;
132
132
  if (tab === "layers")
133
133
  content =
134
- ctx.getCanvasMode() === "settings"
134
+ ctx.getCanvasMode() === "stylebook"
135
135
  ? renderStylebookLayersTemplate({
136
136
  selectStylebookTag,
137
137
  stylebookMeta,
@@ -197,7 +197,7 @@ function _render() {
197
197
  litRender(html`<div class="panel-body">${content}</div>`, leftPanel);
198
198
 
199
199
  // Post-render side effects
200
- if (tab === "layers" && ctx.getCanvasMode() !== "settings") ctx.registerLayersDnD();
200
+ if (tab === "layers" && ctx.getCanvasMode() !== "stylebook") ctx.registerLayersDnD();
201
201
  else if (tab === "imports") {
202
202
  /* no post-render DnD needed */
203
203
  } else if (tab === "blocks") {
@@ -83,7 +83,7 @@ function _flush() {
83
83
  const { stylebookTab } = tab.session.ui;
84
84
  const canvasMode = _ctx.getCanvasMode();
85
85
 
86
- if (canvasMode !== "design" && canvasMode !== "edit" && canvasMode !== "settings") {
86
+ if (canvasMode !== "design" && canvasMode !== "edit" && canvasMode !== "stylebook") {
87
87
  for (const p of canvasPanels) {
88
88
  litRender(nothing, p.overlay);
89
89
  p.overlayClk.style.pointerEvents = "none";
@@ -95,7 +95,7 @@ function _flush() {
95
95
  return;
96
96
  }
97
97
 
98
- if (canvasMode === "settings") {
98
+ if (canvasMode === "stylebook") {
99
99
  const enable = stylebookTab === "elements";
100
100
  for (const p of canvasPanels) {
101
101
  p.overlayClk.style.pointerEvents = enable ? "" : "none";
@@ -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
+ }
@@ -620,7 +620,7 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
620
620
  export function renderStylePanelTemplate(ctx) {
621
621
  const tab = activeTab.value;
622
622
  if (!tab) return html`<div class="empty-state">No document loaded</div>`;
623
- if (ctx.getCanvasMode() === "settings" && tab.session.ui.stylebookSelection) {
623
+ if (ctx.getCanvasMode() === "stylebook" && tab.session.ui.stylebookSelection) {
624
624
  const node = tab.doc.document;
625
625
  if (!node) return html`<div class="empty-state">No document loaded</div>`;
626
626
  return html`