@jxsuite/studio 0.32.0 → 0.34.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/iframe-entry.js +6230 -0
- package/dist/iframe-entry.js.map +35 -0
- package/dist/studio.js +22791 -13709
- package/dist/studio.js.map +181 -74
- package/package.json +12 -7
- package/src/browse/browse.ts +11 -4
- package/src/canvas/canvas-helpers.ts +2 -56
- package/src/canvas/canvas-live-render.ts +102 -435
- package/src/canvas/canvas-origin.ts +66 -0
- package/src/canvas/canvas-patcher.ts +63 -403
- package/src/canvas/canvas-render.ts +70 -212
- package/src/canvas/canvas-utils.ts +37 -65
- package/src/canvas/iframe-channel.ts +154 -0
- package/src/canvas/iframe-drop.ts +484 -0
- package/src/canvas/iframe-entry.ts +600 -0
- package/src/canvas/iframe-host.ts +1373 -0
- package/src/canvas/iframe-inline-edit.ts +367 -0
- package/src/canvas/iframe-insert.ts +164 -0
- package/src/canvas/iframe-interaction.ts +176 -0
- package/src/canvas/iframe-keys.ts +85 -0
- package/src/canvas/iframe-overlay.ts +218 -0
- package/src/canvas/iframe-patch.ts +363 -0
- package/src/canvas/iframe-protocol.ts +361 -0
- package/src/canvas/iframe-render.ts +458 -0
- package/src/canvas/iframe-slash.ts +114 -0
- package/src/canvas/iframe-subtree.ts +113 -0
- package/src/canvas/path-mapping.ts +86 -0
- package/src/canvas/serialize-scope.ts +65 -0
- package/src/editor/canvas-context-menu.ts +40 -0
- package/src/editor/canvas-slash-bridge.ts +21 -0
- package/src/editor/context-menu.ts +2 -1
- package/src/editor/inline-edit-apply.ts +183 -0
- package/src/editor/inline-edit.ts +99 -21
- package/src/editor/inline-link.ts +89 -0
- package/src/editor/insert-zone-action.ts +35 -0
- package/src/editor/merge-tags.ts +26 -2
- package/src/editor/repeater-scope.ts +144 -0
- package/src/editor/shortcuts.ts +14 -28
- package/src/editor/slash-menu.ts +73 -42
- package/src/files/files.ts +2 -1
- package/src/page-params.ts +383 -0
- package/src/panels/ai-panel.ts +386 -328
- package/src/panels/block-action-bar.ts +296 -138
- package/src/panels/canvas-dnd-bridge.ts +397 -0
- package/src/panels/component-preview.ts +56 -0
- package/src/panels/dnd.ts +41 -17
- package/src/panels/drag-ghost.ts +62 -0
- package/src/panels/editors.ts +1 -1
- package/src/panels/overlays.ts +10 -125
- package/src/panels/properties-panel.ts +210 -0
- package/src/panels/right-panel.ts +0 -2
- package/src/panels/signals-panel.ts +136 -22
- package/src/panels/stylebook-doc.ts +373 -0
- package/src/panels/stylebook-panel.ts +46 -689
- package/src/panels/tab-bar.ts +159 -13
- package/src/panels/toolbar.ts +3 -2
- package/src/platforms/devserver.ts +18 -47
- package/src/services/ai-settings.ts +107 -0
- package/src/services/ai-system-prompt.ts +617 -0
- package/src/services/ai-tools.ts +854 -0
- package/src/services/context-manager.ts +200 -0
- package/src/services/document-assistant.ts +183 -0
- package/src/services/jx-validate.ts +65 -0
- package/src/services/monaco-setup.ts +12 -0
- package/src/services/render-critic.ts +75 -0
- package/src/services/token-lint.ts +140 -0
- package/src/services/tool-executor.ts +156 -0
- package/src/settings/css-vars-editor.ts +2 -2
- package/src/state.ts +29 -0
- package/src/store.ts +4 -62
- package/src/studio.ts +90 -40
- package/src/tabs/doc-op-apply.ts +89 -0
- package/src/tabs/patch-ops.ts +6 -2
- package/src/tabs/tab.ts +23 -4
- package/src/tabs/transact.ts +33 -69
- package/src/types.ts +16 -24
- package/src/ui/jx-theme.ts +63 -0
- package/src/ui/media-picker.ts +6 -4
- package/src/ui/spectrum.ts +5 -0
- package/src/utils/canvas-media.ts +0 -137
- package/src/utils/edit-display.ts +23 -3
- package/src/utils/geometry.ts +43 -0
- package/src/utils/link-target.ts +93 -0
- package/src/utils/strip-events.ts +54 -0
- package/src/view.ts +0 -23
- package/src/workspace/workspace.ts +14 -1
- package/src/canvas/canvas-subtree-render.ts +0 -113
- package/src/editor/component-inline-edit.ts +0 -349
- package/src/editor/content-inline-edit.ts +0 -207
- package/src/editor/insertion-helper.ts +0 -308
- package/src/panels/canvas-dnd.ts +0 -329
- package/src/panels/panel-events.ts +0 -306
- package/src/panels/preview-render.ts +0 -132
- package/src/panels/pseudo-preview.ts +0 -75
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Cross-frame drag GHOST (Phase 4c, commit 5). A single parent-realm `position:fixed` layer that
|
|
4
|
+
* follows the raw pointer 1:1 during a canvas drag. It lives OUTSIDE the scaled `panzoom-wrap`
|
|
5
|
+
* (D-3) — a sibling of `#canvas-wrap` on `<body>` — so it tracks `clientX`/`clientY` directly and
|
|
6
|
+
* is NEVER subject to the canvas zoom transform (drawing it in the scaled overlay would shrink/grow
|
|
7
|
+
* it with zoom). pragmatic's native drag image is suppressed per source via
|
|
8
|
+
* `disableNativeDragPreview`, so this is the only drag affordance the user sees.
|
|
9
|
+
*
|
|
10
|
+
* One ghost is reused across drags + panels: {@link setDragGhost} shows it with a label and moves
|
|
11
|
+
* it to a cursor; {@link clearDragGhost} hides it. The element is created lazily on first show so
|
|
12
|
+
* the module has no import-time DOM side effect (and stays inert in tests until used).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
let ghostEl: HTMLElement | null = null;
|
|
16
|
+
|
|
17
|
+
/** Lazily create (once) the fixed ghost element appended to `<body>`. */
|
|
18
|
+
function ensureGhost(doc: Document): HTMLElement {
|
|
19
|
+
if (ghostEl?.isConnected) {
|
|
20
|
+
return ghostEl;
|
|
21
|
+
}
|
|
22
|
+
const el = doc.createElement("div");
|
|
23
|
+
el.className = "jx-drag-ghost";
|
|
24
|
+
// Fixed + non-interactive + above the canvas; offset slightly from the cursor so it doesn't sit
|
|
25
|
+
// Under the pointer and block hit-testing visuals. Positioned via left/top on each move.
|
|
26
|
+
el.style.cssText =
|
|
27
|
+
"position:fixed;left:0;top:0;z-index:9999;pointer-events:none;display:none;" +
|
|
28
|
+
"padding:2px 8px;border-radius:4px;font-size:12px;line-height:1.4;white-space:nowrap;" +
|
|
29
|
+
"background:var(--accent,#3b82f6);color:#fff;box-shadow:0 2px 8px rgba(0,0,0,0.25);" +
|
|
30
|
+
"transform:translate(8px,8px)";
|
|
31
|
+
doc.body.append(el);
|
|
32
|
+
ghostEl = el;
|
|
33
|
+
return el;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Show the ghost with `label`, positioned at the raw pointer (`x`,`y` in parent-viewport px). Safe
|
|
38
|
+
* to call every pointermove; reuses the one element.
|
|
39
|
+
*/
|
|
40
|
+
export function setDragGhost(label: string, x: number, y: number, doc: Document = document): void {
|
|
41
|
+
const el = ensureGhost(doc);
|
|
42
|
+
el.textContent = label;
|
|
43
|
+
el.style.left = `${x}px`;
|
|
44
|
+
el.style.top = `${y}px`;
|
|
45
|
+
el.style.display = "block";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Move the ghost to a new pointer position without changing its label (no-op when hidden). */
|
|
49
|
+
export function moveDragGhost(x: number, y: number): void {
|
|
50
|
+
if (!ghostEl || ghostEl.style.display === "none") {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
ghostEl.style.left = `${x}px`;
|
|
54
|
+
ghostEl.style.top = `${y}px`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Hide the ghost (between drags). The element is retained for reuse. */
|
|
58
|
+
export function clearDragGhost(): void {
|
|
59
|
+
if (ghostEl) {
|
|
60
|
+
ghostEl.style.display = "none";
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/panels/editors.ts
CHANGED
|
@@ -102,7 +102,7 @@ export function renderFunctionEditor() {
|
|
|
102
102
|
|
|
103
103
|
view.functionEditor = monaco.editor.create(editorContainer as unknown as HTMLElement, {
|
|
104
104
|
automaticLayout: true,
|
|
105
|
-
fontFamily: "'SF Mono', 'Fira Code', 'Consolas', monospace",
|
|
105
|
+
fontFamily: "'JetBrains Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace",
|
|
106
106
|
fontSize: 12,
|
|
107
107
|
language: "javascript",
|
|
108
108
|
lineNumbers: "on",
|
package/src/panels/overlays.ts
CHANGED
|
@@ -1,29 +1,15 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
2
|
/**
|
|
3
|
-
* Overlays panel —
|
|
4
|
-
*
|
|
3
|
+
* Overlays panel — the iframe canvas owns hit-testing and draws its own hover/selection boxes (from
|
|
4
|
+
* posted rects, inside each host's overlay layer), so this panel only keeps panel-header
|
|
5
|
+
* highlighting in sync and delegates the block-action-bar render on tracked session changes.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import { html, render as litRender, nothing } from "lit-html";
|
|
8
|
-
import { styleMap } from "lit-html/directives/style-map.js";
|
|
9
|
-
import { canvasPanels, pathsEqual } from "../store";
|
|
10
8
|
import { effect, effectScope } from "../reactivity";
|
|
11
9
|
import { activeTab } from "../workspace/workspace";
|
|
12
|
-
import {
|
|
13
|
-
import { effectiveZoom, findCanvasElement, getActivePanel } from "../canvas/canvas-helpers";
|
|
14
|
-
import { layoutElements } from "../canvas/canvas-live-render";
|
|
10
|
+
import { updateActivePanelHeaders } from "../canvas/canvas-utils";
|
|
15
11
|
import type { EffectScope } from "@vue/reactivity";
|
|
16
12
|
|
|
17
|
-
interface OverlayBox {
|
|
18
|
-
cls: string;
|
|
19
|
-
top: string;
|
|
20
|
-
left: string;
|
|
21
|
-
width: string;
|
|
22
|
-
height: string;
|
|
23
|
-
border?: string;
|
|
24
|
-
isLayout?: boolean;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
13
|
interface OverlaysCtx {
|
|
28
14
|
getCanvasMode: () => string;
|
|
29
15
|
isEditing: () => boolean;
|
|
@@ -50,10 +36,12 @@ export function mount(ctx: OverlaysCtx) {
|
|
|
50
36
|
if (!tab) {
|
|
51
37
|
return;
|
|
52
38
|
}
|
|
53
|
-
// Track selection, hover, and
|
|
39
|
+
// Track selection, hover, mode, and the active panel (a hit in another breakpoint panel — or
|
|
40
|
+
// A header click — re-anchors the block action bar even when the selection path is unchanged).
|
|
54
41
|
void tab.session.selection;
|
|
55
42
|
void tab.session.hover;
|
|
56
43
|
void tab.doc.mode;
|
|
44
|
+
void tab.session.ui.activeMedia;
|
|
57
45
|
render();
|
|
58
46
|
});
|
|
59
47
|
});
|
|
@@ -84,113 +72,10 @@ function _flush() {
|
|
|
84
72
|
if (!tab) {
|
|
85
73
|
return;
|
|
86
74
|
}
|
|
87
|
-
const { selection, hover } = tab.session;
|
|
88
|
-
const { stylebookTab } = tab.session.ui;
|
|
89
|
-
const canvasMode = _ctx.getCanvasMode();
|
|
90
|
-
|
|
91
|
-
if (canvasMode !== "design" && canvasMode !== "edit" && canvasMode !== "stylebook") {
|
|
92
|
-
for (const p of canvasPanels) {
|
|
93
|
-
litRender(nothing, p.overlay);
|
|
94
|
-
p.overlayClk.style.pointerEvents = "none";
|
|
95
|
-
}
|
|
96
|
-
if (view.selDragCleanup) {
|
|
97
|
-
view.selDragCleanup();
|
|
98
|
-
view.selDragCleanup = null;
|
|
99
|
-
}
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (canvasMode === "stylebook") {
|
|
104
|
-
const enable = stylebookTab === "elements";
|
|
105
|
-
for (const p of canvasPanels) {
|
|
106
|
-
p.overlayClk.style.pointerEvents = enable ? "" : "none";
|
|
107
|
-
}
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
for (const p of canvasPanels) {
|
|
112
|
-
p.overlayClk.style.pointerEvents = view.componentInlineEdit || _ctx.isEditing() ? "none" : "";
|
|
113
|
-
}
|
|
114
75
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
for (const p of canvasPanels) {
|
|
121
|
-
const boxes: OverlayBox[] = [];
|
|
122
|
-
|
|
123
|
-
// Batch layout reads: read viewport geometry once per panel
|
|
124
|
-
if (!p.viewport) {
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
const vpRect = p.viewport.getBoundingClientRect();
|
|
128
|
-
const { scrollTop } = p.viewport;
|
|
129
|
-
const { scrollLeft } = p.viewport;
|
|
130
|
-
const scale = effectiveZoom();
|
|
131
|
-
|
|
132
|
-
if (hover && !pathsEqual(hover, selection)) {
|
|
133
|
-
const el = findCanvasElement(hover, p.canvas);
|
|
134
|
-
if (el) {
|
|
135
|
-
const elRect = el.getBoundingClientRect();
|
|
136
|
-
const desc: OverlayBox = {
|
|
137
|
-
cls: "overlay-box overlay-hover",
|
|
138
|
-
height: `${elRect.height / scale}px`,
|
|
139
|
-
left: `${(elRect.left - vpRect.left + scrollLeft) / scale}px`,
|
|
140
|
-
top: `${(elRect.top - vpRect.top + scrollTop) / scale}px`,
|
|
141
|
-
width: `${elRect.width / scale}px`,
|
|
142
|
-
};
|
|
143
|
-
if (layoutElements.has(el)) {
|
|
144
|
-
desc.isLayout = true;
|
|
145
|
-
}
|
|
146
|
-
boxes.push(desc);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (selection && p === getActivePanel()) {
|
|
151
|
-
const el = findCanvasElement(selection, p.canvas);
|
|
152
|
-
if (el) {
|
|
153
|
-
const elRect = el.getBoundingClientRect();
|
|
154
|
-
const desc: OverlayBox = {
|
|
155
|
-
cls: "overlay-box overlay-selection",
|
|
156
|
-
height: `${elRect.height / scale}px`,
|
|
157
|
-
left: `${(elRect.left - vpRect.left + scrollLeft) / scale}px`,
|
|
158
|
-
top: `${(elRect.top - vpRect.top + scrollTop) / scale}px`,
|
|
159
|
-
width: `${elRect.width / scale}px`,
|
|
160
|
-
};
|
|
161
|
-
if (view.componentInlineEdit || _ctx.isEditing()) {
|
|
162
|
-
desc.border = "none";
|
|
163
|
-
}
|
|
164
|
-
if (layoutElements.has(el)) {
|
|
165
|
-
desc.isLayout = true;
|
|
166
|
-
}
|
|
167
|
-
boxes.push(desc);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
litRender(
|
|
172
|
-
html`
|
|
173
|
-
${p.dropLine}
|
|
174
|
-
${boxes.map(
|
|
175
|
-
(b) => html`
|
|
176
|
-
<div
|
|
177
|
-
class="${b.cls}${b.isLayout ? " overlay-layout" : ""}"
|
|
178
|
-
style=${styleMap({
|
|
179
|
-
border: b.border,
|
|
180
|
-
height: b.height,
|
|
181
|
-
left: b.left,
|
|
182
|
-
top: b.top,
|
|
183
|
-
width: b.width,
|
|
184
|
-
})}
|
|
185
|
-
>
|
|
186
|
-
${b.isLayout ? html`<span class="overlay-layout-badge">Layout</span>` : nothing}
|
|
187
|
-
</div>
|
|
188
|
-
`,
|
|
189
|
-
)}
|
|
190
|
-
`,
|
|
191
|
-
p.overlay,
|
|
192
|
-
);
|
|
193
|
-
}
|
|
76
|
+
// Header highlighting follows hit-driven activation immediately (it otherwise only refreshes on
|
|
77
|
+
// Full canvas renders).
|
|
78
|
+
updateActivePanelHeaders();
|
|
194
79
|
|
|
195
80
|
_ctx.renderBlockActionBar();
|
|
196
81
|
}
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
inferInputType,
|
|
30
30
|
parseCemType,
|
|
31
31
|
} from "../utils/studio-utils";
|
|
32
|
+
import { classifyHref, composeHref } from "../utils/link-target";
|
|
33
|
+
import type { LinkKind } from "../utils/link-target";
|
|
32
34
|
import { collectCssParts, isCustomElementDoc } from "./signals-panel";
|
|
33
35
|
import { mediaDisplayName } from "./shared";
|
|
34
36
|
import { getCssInitialMap } from "./style-utils";
|
|
@@ -800,6 +802,202 @@ function isPageDocument(documentPath: string | undefined | null) {
|
|
|
800
802
|
return documentPath.startsWith("pages/") || documentPath.startsWith("./pages/");
|
|
801
803
|
}
|
|
802
804
|
|
|
805
|
+
// ─── Page-route enumeration (for the Link-target Internal picker) ─────────────
|
|
806
|
+
|
|
807
|
+
/** @type {string[] | null} — cached list of internal routes derived from the pages/ tree. */
|
|
808
|
+
let pageRouteEntries: string[] | null = null;
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Derive a site route from a page file path relative to `pages/`, following the file-based routing
|
|
812
|
+
* convention: `index.json` → the directory route, `[slug].json` → `:slug`, all others drop their
|
|
813
|
+
* extension. Directory routes get a trailing slash (`/about/`); the root is `/`.
|
|
814
|
+
*
|
|
815
|
+
* @param {string} relPath — path relative to `pages/`, forward-slashed (e.g. "blog/[slug].json").
|
|
816
|
+
* @returns {string}
|
|
817
|
+
*/
|
|
818
|
+
function routeForPagePath(relPath: string): string {
|
|
819
|
+
const withoutExt = relPath.replace(/\.[^./]+$/, "");
|
|
820
|
+
const segments = withoutExt
|
|
821
|
+
.split("/")
|
|
822
|
+
.map((seg) => (seg.startsWith("[") ? `:${seg.slice(1, -1)}` : seg));
|
|
823
|
+
const isIndex = segments.at(-1) === "index";
|
|
824
|
+
if (isIndex) {
|
|
825
|
+
segments.pop();
|
|
826
|
+
}
|
|
827
|
+
const body = segments.join("/");
|
|
828
|
+
if (!body) {
|
|
829
|
+
return "/";
|
|
830
|
+
}
|
|
831
|
+
// Dynamic routes keep no trailing slash; static routes are directory-style (trailing slash).
|
|
832
|
+
return isIndex || body.includes(":") ? `/${body}${isIndex ? "/" : ""}` : `/${body}/`;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Recursively walk the pages/ tree and populate {@link pageRouteEntries} with derived routes. */
|
|
836
|
+
async function loadPageRouteEntries() {
|
|
837
|
+
const platform = getPlatform();
|
|
838
|
+
const routes: string[] = [];
|
|
839
|
+
const docExts = new Set([".json", ".md", ".html"]);
|
|
840
|
+
async function walk(dir: string, rel: string) {
|
|
841
|
+
let listing: DirEntry[];
|
|
842
|
+
try {
|
|
843
|
+
listing = await platform.listDirectory(dir);
|
|
844
|
+
} catch {
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
for (const entry of listing) {
|
|
848
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
849
|
+
if (entry.type === "directory") {
|
|
850
|
+
await walk(entry.path ?? `${dir}/${entry.name}`, childRel);
|
|
851
|
+
} else if (docExts.has(entry.name.slice(entry.name.lastIndexOf(".")))) {
|
|
852
|
+
routes.push(routeForPagePath(childRel));
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
await walk("pages", "");
|
|
857
|
+
pageRouteEntries = [...new Set(routes)].toSorted((a, b) => a.localeCompare(b));
|
|
858
|
+
renderOnly("rightPanel");
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
export function invalidatePageRouteCache() {
|
|
862
|
+
pageRouteEntries = null;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Composite Link-target control for an anchor's `href` — a kind selector (Internal / External /
|
|
867
|
+
* Anchor / Email / Phone) plus the matching input, backed by classifyHref/composeHref so edits
|
|
868
|
+
* round-trip. Internal targets render an sp-picker of page routes enumerated from the pages/ tree.
|
|
869
|
+
*
|
|
870
|
+
* @param {JxMutableNode} node
|
|
871
|
+
* @param {JxPath} path
|
|
872
|
+
*/
|
|
873
|
+
function renderLinkTargetField(node: JxMutableNode, path: JxPath) {
|
|
874
|
+
const raw = typeof node.attributes?.href === "string" ? node.attributes.href : "";
|
|
875
|
+
const { kind, value } = classifyHref(raw);
|
|
876
|
+
|
|
877
|
+
const commit = (nextKind: LinkKind, nextValue: string) => {
|
|
878
|
+
const composed = composeHref(nextKind, nextValue);
|
|
879
|
+
transactDoc(activeTab.value!, (t) =>
|
|
880
|
+
mutateUpdateAttribute(t, path, "href", composed || undefined),
|
|
881
|
+
);
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
const kindOptions: { value: LinkKind; label: string }[] = [
|
|
885
|
+
{ label: "Internal Page", value: "internal" },
|
|
886
|
+
{ label: "External URL", value: "external" },
|
|
887
|
+
{ label: "Anchor", value: "anchor" },
|
|
888
|
+
{ label: "Email", value: "mailto" },
|
|
889
|
+
{ label: "Phone", value: "tel" },
|
|
890
|
+
];
|
|
891
|
+
|
|
892
|
+
const kindSelector = html`
|
|
893
|
+
<sp-picker
|
|
894
|
+
class="link-target-kind"
|
|
895
|
+
size="s"
|
|
896
|
+
value=${kind}
|
|
897
|
+
@change=${(e: Event) => {
|
|
898
|
+
const nextKind = (e.target as HTMLInputElement).value as LinkKind;
|
|
899
|
+
// Switching kind reinterprets the current value under the new kind.
|
|
900
|
+
commit(nextKind, value);
|
|
901
|
+
}}
|
|
902
|
+
>
|
|
903
|
+
${kindOptions.map((o) => html`<sp-menu-item value=${o.value}>${o.label}</sp-menu-item>`)}
|
|
904
|
+
</sp-picker>
|
|
905
|
+
`;
|
|
906
|
+
|
|
907
|
+
let valueInput;
|
|
908
|
+
if (kind === "internal") {
|
|
909
|
+
if (pageRouteEntries === null) {
|
|
910
|
+
void loadPageRouteEntries();
|
|
911
|
+
}
|
|
912
|
+
const routes = pageRouteEntries ?? [];
|
|
913
|
+
const knownValue = value !== "" && !routes.includes(value);
|
|
914
|
+
valueInput = html`
|
|
915
|
+
<sp-picker
|
|
916
|
+
class="link-target-value"
|
|
917
|
+
size="s"
|
|
918
|
+
value=${value}
|
|
919
|
+
@change=${(e: Event) => commit("internal", (e.target as HTMLInputElement).value)}
|
|
920
|
+
>
|
|
921
|
+
${knownValue ? html`<sp-menu-item value=${value}>${value}</sp-menu-item>` : nothing}
|
|
922
|
+
${routes.map((r) => html`<sp-menu-item value=${r}>${r}</sp-menu-item>`)}
|
|
923
|
+
</sp-picker>
|
|
924
|
+
`;
|
|
925
|
+
} else {
|
|
926
|
+
const placeholder =
|
|
927
|
+
kind === "mailto"
|
|
928
|
+
? "name@example.com"
|
|
929
|
+
: kind === "tel"
|
|
930
|
+
? "+15551234567"
|
|
931
|
+
: kind === "anchor"
|
|
932
|
+
? "section-id"
|
|
933
|
+
: "https://example.com";
|
|
934
|
+
valueInput = html`
|
|
935
|
+
<sp-textfield
|
|
936
|
+
class="link-target-value"
|
|
937
|
+
size="s"
|
|
938
|
+
placeholder=${placeholder}
|
|
939
|
+
.value=${live(value)}
|
|
940
|
+
@input=${debouncedStyleCommit("link:href", 400, (e: Event) =>
|
|
941
|
+
commit(kind, (e.target as HTMLInputElement).value),
|
|
942
|
+
)}
|
|
943
|
+
></sp-textfield>
|
|
944
|
+
`;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return renderFieldRow({
|
|
948
|
+
hasValue: raw !== "",
|
|
949
|
+
label: "Link",
|
|
950
|
+
onClear: () => transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, "href")),
|
|
951
|
+
prop: "href",
|
|
952
|
+
widget: html`<div class="link-target-field">${kindSelector}${valueInput}</div>`,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Real enum picker (sp-picker) for the anchor `target` attribute, replacing the generic
|
|
958
|
+
* jx-value-selector so the four browsing-context keywords are offered as a dropdown.
|
|
959
|
+
*
|
|
960
|
+
* @param {JxMutableNode} node
|
|
961
|
+
* @param {JxPath} path
|
|
962
|
+
* @param {HtmlMetaEntry} entry
|
|
963
|
+
*/
|
|
964
|
+
function renderTargetField(node: JxMutableNode, path: JxPath, entry: HtmlMetaEntry) {
|
|
965
|
+
const options = Array.isArray(entry.enum) ? (entry.enum as string[]) : [];
|
|
966
|
+
const current = typeof node.attributes?.target === "string" ? node.attributes.target : "";
|
|
967
|
+
return renderFieldRow({
|
|
968
|
+
hasValue: current !== "",
|
|
969
|
+
label: attrLabel(entry, "target"),
|
|
970
|
+
onClear: () => transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, "target")),
|
|
971
|
+
prop: "target",
|
|
972
|
+
widget: html`
|
|
973
|
+
<sp-picker
|
|
974
|
+
class="link-target-window"
|
|
975
|
+
size="s"
|
|
976
|
+
value=${current}
|
|
977
|
+
@change=${(e: Event) =>
|
|
978
|
+
transactDoc(activeTab.value!, (t) =>
|
|
979
|
+
mutateUpdateAttribute(
|
|
980
|
+
t,
|
|
981
|
+
path,
|
|
982
|
+
"target",
|
|
983
|
+
(e.target as HTMLInputElement).value || undefined,
|
|
984
|
+
),
|
|
985
|
+
)}
|
|
986
|
+
>
|
|
987
|
+
${options.map((o) => html`<sp-menu-item value=${o}>${o}</sp-menu-item>`)}
|
|
988
|
+
</sp-picker>
|
|
989
|
+
`,
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* True when an attribute value is a binding (a `$ref` object or a template string containing
|
|
995
|
+
* `${…}`), so the Link-target special-case must fall back to the raw widget to keep it editable.
|
|
996
|
+
*/
|
|
997
|
+
function isBoundAttrValue(value: unknown): boolean {
|
|
998
|
+
return isRef(value) || (typeof value === "string" && value.includes("${"));
|
|
999
|
+
}
|
|
1000
|
+
|
|
803
1001
|
function renderPageSection(node: JxMutableNode) {
|
|
804
1002
|
const tab = activeTab.value;
|
|
805
1003
|
if (!isPageDocument(tab!.documentPath)) {
|
|
@@ -968,6 +1166,18 @@ export function renderPropertiesPanelTemplate(ctx: {
|
|
|
968
1166
|
const type = inferInputType(entry);
|
|
969
1167
|
const hasVal = value !== undefined && value !== "";
|
|
970
1168
|
|
|
1169
|
+
// Enhanced Link handling: only for anchors (a/area) with a plain (non-binding) value. Bindings
|
|
1170
|
+
// ($ref objects or ${…} template strings) fall through to the raw widget to stay editable.
|
|
1171
|
+
const isAnchor = tagName === "a" || tagName === "area";
|
|
1172
|
+
if (isAnchor && !isBoundAttrValue(value)) {
|
|
1173
|
+
if (attr === "href") {
|
|
1174
|
+
return renderLinkTargetField(node, path);
|
|
1175
|
+
}
|
|
1176
|
+
if (attr === "target") {
|
|
1177
|
+
return renderTargetField(node, path, entry);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
971
1181
|
if (entry.type === "boolean") {
|
|
972
1182
|
return renderFieldRow({
|
|
973
1183
|
hasValue: hasVal,
|
|
@@ -31,7 +31,6 @@ interface RightPanelCtx {
|
|
|
31
31
|
navigateToComponent: (path: string) => void;
|
|
32
32
|
getCanvasMode: () => string;
|
|
33
33
|
renderCanvas: () => void;
|
|
34
|
-
updateForcedPseudoPreview: () => void;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
let _ctx: RightPanelCtx | null = null;
|
|
@@ -223,5 +222,4 @@ function _doRender() {
|
|
|
223
222
|
console.error("right-panel render error:", error);
|
|
224
223
|
}
|
|
225
224
|
requestAnimationFrame(() => mountQuikChat());
|
|
226
|
-
_ctx.updateForcedPseudoPreview();
|
|
227
225
|
}
|