@jxsuite/studio 1.0.0 → 1.2.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 +760 -65
- package/dist/iframe-entry.js.map +16 -12
- package/dist/studio.css +1333 -0
- package/dist/studio.js +71573 -50074
- package/dist/studio.js.map +123 -88
- package/package.json +11 -9
- package/src/canvas/canvas-live-render.ts +21 -2
- package/src/canvas/canvas-render.ts +35 -16
- package/src/canvas/canvas-utils.ts +118 -72
- package/src/canvas/iframe-entry.ts +37 -1
- package/src/canvas/iframe-eval.ts +155 -0
- package/src/canvas/iframe-host.ts +193 -6
- package/src/canvas/iframe-inline-edit.ts +107 -1
- package/src/canvas/iframe-protocol.ts +56 -3
- package/src/canvas/iframe-render.ts +53 -14
- package/src/canvas/site-style-css.ts +121 -0
- package/src/collab/collab-session.ts +23 -8
- package/src/collab/collab-state.ts +6 -3
- package/src/component-props.ts +104 -0
- package/src/editor/inline-edit-apply.ts +36 -1
- package/src/editor/inline-edit.ts +58 -1
- package/src/editor/shortcuts.ts +41 -12
- package/src/files/file-ops.ts +14 -0
- package/src/files/files.ts +53 -1
- package/src/grid/cell-editors.ts +288 -0
- package/src/grid/cell-popovers.ts +108 -0
- package/src/grid/csv-codec.ts +122 -0
- package/src/grid/edit-buffer.ts +490 -0
- package/src/grid/grid-controller.ts +417 -0
- package/src/grid/grid-layout.ts +83 -0
- package/src/grid/grid-open.ts +167 -0
- package/src/grid/grid-panel.ts +302 -0
- package/src/grid/grid-source.ts +210 -0
- package/src/grid/grid-view.ts +367 -0
- package/src/grid/schema-columns.ts +261 -0
- package/src/grid/sources/connector-source.ts +217 -0
- package/src/grid/sources/content-source.ts +542 -0
- package/src/grid/sources/csv-file-source.ts +247 -0
- package/src/grid/tabulator-tables.d.ts +120 -0
- package/src/panels/block-action-bar.ts +8 -3
- package/src/panels/chat-panel.ts +98 -0
- package/src/panels/data-grid.ts +9 -387
- package/src/panels/editors.ts +43 -17
- package/src/panels/events-panel.ts +137 -44
- package/src/panels/formula-workspace.ts +379 -0
- package/src/panels/frontmatter-fields.ts +231 -0
- package/src/panels/frontmatter-panel.ts +132 -0
- package/src/panels/git-panel.ts +1 -1
- package/src/panels/head-panel.ts +11 -175
- package/src/panels/properties-panel.ts +154 -144
- package/src/panels/right-panel.ts +9 -47
- package/src/panels/signals-panel.ts +115 -23
- package/src/panels/statement-editor.ts +710 -0
- package/src/panels/style-panel.ts +76 -22
- package/src/panels/stylebook-panel.ts +0 -2
- package/src/panels/tab-bar.ts +214 -10
- package/src/panels/tab-strip.ts +62 -6
- package/src/panels/toolbar.ts +37 -3
- package/src/services/ai-project-tools.ts +541 -0
- package/src/services/ai-session-store.ts +28 -0
- package/src/services/ai-system-prompt.ts +194 -24
- package/src/services/ai-tools.ts +88 -21
- package/src/services/automation.ts +16 -0
- package/src/services/document-assistant.ts +81 -11
- package/src/services/gated-registry.ts +72 -0
- package/src/services/live-preview.ts +0 -0
- package/src/services/preview-eval.ts +68 -0
- package/src/services/project-adoption.ts +31 -0
- package/src/settings/css-vars-editor.ts +122 -4
- package/src/site-context.ts +2 -0
- package/src/store.ts +4 -0
- package/src/studio.ts +90 -52
- package/src/tabs/patch-ops.ts +25 -0
- package/src/tabs/tab.ts +46 -1
- package/src/tabs/transact.ts +60 -13
- package/src/types.ts +12 -0
- package/src/ui/dynamic-slot.ts +272 -0
- package/src/ui/expression-editor.ts +423 -125
- package/src/ui/field-row.ts +5 -0
- package/src/ui/formula-catalog.ts +557 -0
- package/src/ui/formula-chips.ts +216 -0
- package/src/ui/formula-palette.ts +211 -0
- package/src/ui/layers.ts +40 -0
- package/src/ui/media-picker.ts +1 -1
- package/src/ui/panel-resize.ts +15 -1
- package/src/utils/canvas-media.ts +23 -0
- package/src/utils/preview-format.ts +26 -0
- package/src/view.ts +4 -4
- package/src/workspace/workspace.ts +14 -0
|
@@ -22,7 +22,8 @@ import {
|
|
|
22
22
|
} from "../tabs/transact";
|
|
23
23
|
import { inferInputType, propLabel } from "../utils/studio-utils";
|
|
24
24
|
import { renderFieldRow } from "../ui/field-row";
|
|
25
|
-
import {
|
|
25
|
+
import { renderDynamicSlot } from "../ui/dynamic-slot";
|
|
26
|
+
import { parseMediaEntries, schemeOfQuery } from "../utils/canvas-media";
|
|
26
27
|
import { getEffectiveMedia, getEffectiveStyle } from "../site-context";
|
|
27
28
|
import { computeInheritedStyle } from "../utils/inherited-style";
|
|
28
29
|
import { mediaDisplayName } from "./shared";
|
|
@@ -41,6 +42,7 @@ import { widgetForType } from "./style-inputs";
|
|
|
41
42
|
|
|
42
43
|
import type { Tab } from "../tabs/tab";
|
|
43
44
|
import type { JxPath } from "../state";
|
|
45
|
+
import type { JsonValue } from "../types";
|
|
44
46
|
import type { JxMutableNode, JxStyle } from "@jxsuite/schema/types";
|
|
45
47
|
|
|
46
48
|
interface CssLonghand {
|
|
@@ -96,17 +98,29 @@ function renderStyleRow(
|
|
|
96
98
|
isWarning: boolean,
|
|
97
99
|
gridMode: boolean,
|
|
98
100
|
inheritedValue: string | undefined,
|
|
101
|
+
templateSignals: string[] = [],
|
|
102
|
+
fieldKey: string = prop,
|
|
99
103
|
) {
|
|
100
104
|
const type = inferInputType(entry);
|
|
101
105
|
const hasVal = value !== undefined && value !== "";
|
|
102
106
|
const placeholder = !hasVal && inheritedValue ? String(inheritedValue) : "";
|
|
103
107
|
const spanVal = gridMode && (entry as Record<string, unknown>).$span === 2 ? 2 : undefined;
|
|
108
|
+
// Style values are schema-legal at two rungs: literal and ${} template (no $ref in JxStyle).
|
|
109
|
+
const slot = renderDynamicSlot({
|
|
110
|
+
caps: ["literal", "template"],
|
|
111
|
+
fieldKey,
|
|
112
|
+
onChange: (v?: JsonValue) => onCommit(v === undefined || v === "" ? undefined : String(v)),
|
|
113
|
+
staticWidget: widgetForType(type, entry, prop, value, onCommit, { placeholder }),
|
|
114
|
+
stateDefs: templateSignals,
|
|
115
|
+
value,
|
|
116
|
+
});
|
|
104
117
|
return renderFieldRow({
|
|
105
118
|
prop,
|
|
106
119
|
label: propLabel(entry, prop),
|
|
107
120
|
hasValue: hasVal,
|
|
108
121
|
onClear: onDelete,
|
|
109
|
-
widget:
|
|
122
|
+
widget: slot.widget,
|
|
123
|
+
labelExtra: slot.modeButton,
|
|
110
124
|
...(spanVal != null && { span: spanVal }),
|
|
111
125
|
warning: isWarning,
|
|
112
126
|
});
|
|
@@ -287,10 +301,32 @@ function styleSidebarTemplate(
|
|
|
287
301
|
const tab = activeTab.value!;
|
|
288
302
|
const sel = tab.session.selection as JxPath;
|
|
289
303
|
const style = effectiveStyle || node.style || {};
|
|
304
|
+
// Signals seeding the ${} template default when a style value escalates (no $ref in JxStyle).
|
|
305
|
+
const templateSignals = Object.entries(tab.doc.document.state || {})
|
|
306
|
+
.filter(
|
|
307
|
+
([, d]) =>
|
|
308
|
+
!(d as Record<string, unknown>)?.$handler &&
|
|
309
|
+
(d as Record<string, unknown>)?.$prototype !== "Function",
|
|
310
|
+
)
|
|
311
|
+
.map(([defName]) => defName);
|
|
290
312
|
const { sizeBreakpoints } = parseMediaEntries(getEffectiveMedia(tab.doc.document.$media));
|
|
291
313
|
const mediaNames = sizeBreakpoints.map((bp) => bp.name);
|
|
292
314
|
const mediaTab = activeMediaTab || null;
|
|
293
315
|
|
|
316
|
+
// ── Scheme-layer routing (spec §9.5) ────────────────────────────────────────
|
|
317
|
+
// With the tab-bar scheme control forcing a scheme that has a matching declared scheme query,
|
|
318
|
+
// Base-context edits target that scheme's `@--name` block — no extra sidebar tabs. Breakpoint
|
|
319
|
+
// Tabs stay breakpoint-scoped (scheme × breakpoint compound blocks are unsupported).
|
|
320
|
+
const forcedScheme = tab.session.ui.previewColorScheme;
|
|
321
|
+
const schemeLayer =
|
|
322
|
+
mediaTab === null && (forcedScheme === "light" || forcedScheme === "dark")
|
|
323
|
+
? (Object.entries(getEffectiveMedia(tab.doc.document.$media) ?? {}).find(
|
|
324
|
+
([, q]) => schemeOfQuery(String(q)) === forcedScheme,
|
|
325
|
+
)?.[0] ?? null)
|
|
326
|
+
: null;
|
|
327
|
+
// The media context edits actually target: a valid breakpoint tab, else the scheme layer.
|
|
328
|
+
const editMedia = mediaTab && mediaNames.length > 0 ? mediaTab : schemeLayer;
|
|
329
|
+
|
|
294
330
|
// ── Media tabs template ──────────────────────────────────────────────────
|
|
295
331
|
const mediaTabsT =
|
|
296
332
|
mediaNames.length > 0
|
|
@@ -315,7 +351,9 @@ function styleSidebarTemplate(
|
|
|
315
351
|
: nothing;
|
|
316
352
|
|
|
317
353
|
// ── Selector dropdown ──────────────────────────────────────────────────────
|
|
318
|
-
const contextStyle =
|
|
354
|
+
const contextStyle = editMedia
|
|
355
|
+
? (style[`@${editMedia}`] as Record<string, unknown>) || {}
|
|
356
|
+
: style;
|
|
319
357
|
const existingSelectors = Object.keys(contextStyle).filter((s) => isNestedSelector(s));
|
|
320
358
|
const existingSet = new Set(existingSelectors);
|
|
321
359
|
const commonSet = new Set(COMMON_SELECTORS);
|
|
@@ -391,10 +429,21 @@ function styleSidebarTemplate(
|
|
|
391
429
|
</sp-picker>
|
|
392
430
|
`;
|
|
393
431
|
|
|
394
|
-
// ── Combined toolbar (media tabs + selector)
|
|
432
|
+
// ── Combined toolbar (media tabs + scheme badge + selector) ────────────────
|
|
433
|
+
// No scheme tabs — the tab-bar control is the one switch; the badge shows where edits land.
|
|
434
|
+
const schemeBadgeT = schemeLayer
|
|
435
|
+
? html`
|
|
436
|
+
<span
|
|
437
|
+
class="style-scheme-badge"
|
|
438
|
+
title="Edits target the @${schemeLayer} variant — set the tab-bar scheme control to Auto to edit base styles"
|
|
439
|
+
>
|
|
440
|
+
${mediaDisplayName(schemeLayer)} variant
|
|
441
|
+
</span>
|
|
442
|
+
`
|
|
443
|
+
: nothing;
|
|
395
444
|
const toolbarT = html`
|
|
396
445
|
<div class="style-toolbar">
|
|
397
|
-
<div class="style-toolbar-tabs">${mediaTabsT}</div>
|
|
446
|
+
<div class="style-toolbar-tabs">${mediaTabsT} ${schemeBadgeT}</div>
|
|
398
447
|
${selectorT}
|
|
399
448
|
</div>
|
|
400
449
|
`;
|
|
@@ -428,15 +477,15 @@ function styleSidebarTemplate(
|
|
|
428
477
|
// ── Determine the active style object ──────────────────────────────────────
|
|
429
478
|
let activeStyle: JxStyle;
|
|
430
479
|
let commitMutate: StyleMutateFn;
|
|
431
|
-
if (activeSelector && isTagPath(activeSelector) &&
|
|
432
|
-
const mediaObj = getNestedStyle(style, `@${
|
|
480
|
+
if (activeSelector && isTagPath(activeSelector) && editMedia) {
|
|
481
|
+
const mediaObj = getNestedStyle(style, `@${editMedia}`) ?? {};
|
|
433
482
|
activeStyle = resolveNestedTagStyle(mediaObj, activeSelector);
|
|
434
483
|
const stylePath = activeSelector.split(" ");
|
|
435
484
|
commitMutate = (t: Tab, prop: string, val: string | Record<string, unknown> | undefined) =>
|
|
436
485
|
mutateUpdateMediaNestedStylePath(
|
|
437
486
|
t,
|
|
438
487
|
sel,
|
|
439
|
-
|
|
488
|
+
editMedia,
|
|
440
489
|
stylePath,
|
|
441
490
|
prop,
|
|
442
491
|
val as string | undefined,
|
|
@@ -446,14 +495,14 @@ function styleSidebarTemplate(
|
|
|
446
495
|
const stylePath = activeSelector.split(" ");
|
|
447
496
|
commitMutate = (t: Tab, prop: string, val: string | Record<string, unknown> | undefined) =>
|
|
448
497
|
mutateUpdateNestedStylePath(t, sel, stylePath, prop, val as string | undefined);
|
|
449
|
-
} else if (activeSelector &&
|
|
450
|
-
const mediaObj = getNestedStyle(style, `@${
|
|
498
|
+
} else if (activeSelector && editMedia) {
|
|
499
|
+
const mediaObj = getNestedStyle(style, `@${editMedia}`) ?? {};
|
|
451
500
|
activeStyle = getNestedStyle(mediaObj, activeSelector) ?? {};
|
|
452
501
|
commitMutate = (t: Tab, prop: string, val: string | Record<string, unknown> | undefined) =>
|
|
453
502
|
mutateUpdateMediaNestedStyle(
|
|
454
503
|
t,
|
|
455
504
|
sel,
|
|
456
|
-
|
|
505
|
+
editMedia,
|
|
457
506
|
activeSelector,
|
|
458
507
|
prop,
|
|
459
508
|
val as string | undefined,
|
|
@@ -464,29 +513,32 @@ function styleSidebarTemplate(
|
|
|
464
513
|
mutateUpdateNestedStyle(t, sel, activeSelector, prop, val as string | undefined);
|
|
465
514
|
} else {
|
|
466
515
|
activeStyle = {};
|
|
467
|
-
const
|
|
468
|
-
const flatSource =
|
|
516
|
+
const inMediaCtx = editMedia !== null;
|
|
517
|
+
const flatSource = inMediaCtx ? (getNestedStyle(style, `@${editMedia}`) ?? {}) : style;
|
|
469
518
|
for (const [p, v] of Object.entries(flatSource)) {
|
|
470
519
|
if (typeof v !== "object") {
|
|
471
520
|
activeStyle[p] = v;
|
|
472
521
|
}
|
|
473
522
|
}
|
|
474
|
-
commitMutate =
|
|
523
|
+
commitMutate = inMediaCtx
|
|
475
524
|
? (t: Tab, prop: string, val: string | Record<string, unknown> | undefined) =>
|
|
476
|
-
mutateUpdateMediaStyle(t, sel,
|
|
525
|
+
mutateUpdateMediaStyle(t, sel, editMedia, prop, val as string | undefined)
|
|
477
526
|
: (t: Tab, prop: string, val: string | Record<string, unknown> | undefined) =>
|
|
478
527
|
mutateUpdateStyle(t, sel, prop, val as string | undefined);
|
|
479
528
|
}
|
|
480
529
|
const commitStyle = (prop: string, val?: string | Record<string, unknown> | undefined) =>
|
|
481
530
|
transactDoc(activeTab.value, (t) => commitMutate(t, prop, val));
|
|
482
531
|
|
|
483
|
-
// ── Compute inherited style
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
532
|
+
// ── Compute inherited style ────────────────────────────────────────────────
|
|
533
|
+
// Scheme layer: the base styles show through as inherited (placeholders); breakpoint tabs
|
|
534
|
+
// Inherit from higher breakpoints as before.
|
|
535
|
+
const inheritedStyle: Record<string, string | number> = schemeLayer
|
|
536
|
+
? (Object.fromEntries(
|
|
537
|
+
Object.entries(
|
|
538
|
+
activeSelector ? (getNestedStyle(style, activeSelector) ?? {}) : style,
|
|
539
|
+
).filter(([, v]) => typeof v !== "object"),
|
|
540
|
+
) as Record<string, string | number>)
|
|
541
|
+
: computeInheritedStyle(style, mediaNames, mediaTab, activeSelector);
|
|
490
542
|
|
|
491
543
|
// Auto-open sections that have properties
|
|
492
544
|
const newSections = autoOpenSections({ style: activeStyle }, tab.session.ui.styleSections);
|
|
@@ -660,6 +712,8 @@ function styleSidebarTemplate(
|
|
|
660
712
|
isWarning,
|
|
661
713
|
sec.$layout === "grid",
|
|
662
714
|
inheritedStyle[prop] as string | undefined,
|
|
715
|
+
templateSignals,
|
|
716
|
+
`style|${sel.join("/")}|${editMedia ?? ""}|${activeSelector ?? ""}|${prop}`,
|
|
663
717
|
),
|
|
664
718
|
);
|
|
665
719
|
}
|
|
@@ -43,7 +43,6 @@ interface StylebookCtx {
|
|
|
43
43
|
) => { tpl: TemplateResult; panel: CanvasPanel };
|
|
44
44
|
applyTransform: () => void;
|
|
45
45
|
observeCenterUntilStable: () => void;
|
|
46
|
-
renderZoomIndicator: () => void;
|
|
47
46
|
updateActivePanelHeaders: () => void;
|
|
48
47
|
}
|
|
49
48
|
|
|
@@ -182,7 +181,6 @@ export function renderStylebookMode(ctx: StylebookCtx) {
|
|
|
182
181
|
|
|
183
182
|
ctx.applyTransform();
|
|
184
183
|
ctx.observeCenterUntilStable();
|
|
185
|
-
ctx.renderZoomIndicator();
|
|
186
184
|
}
|
|
187
185
|
|
|
188
186
|
/**
|
package/src/panels/tab-bar.ts
CHANGED
|
@@ -13,12 +13,15 @@ import { html, render as litRender, nothing } from "lit-html";
|
|
|
13
13
|
import { projectState, updateUi } from "../store";
|
|
14
14
|
import { effect, effectScope } from "../reactivity";
|
|
15
15
|
import { activeTab } from "../workspace/workspace";
|
|
16
|
+
import { applyTransform, fitToScreen, resetZoom, setEditZoom } from "../canvas/canvas-utils";
|
|
16
17
|
import { getEffectiveLayoutPath, getEffectiveMedia } from "../site-context";
|
|
18
|
+
import { isSchemeQuery } from "../utils/canvas-media";
|
|
17
19
|
import { dynamicRouteParams, loadParamValues, pagePathsDef } from "../page-params";
|
|
20
|
+
import { componentPropEntries, isComponentDoc } from "../component-props";
|
|
18
21
|
import { mediaDisplayName } from "./shared";
|
|
19
22
|
import type { ParamValues } from "../page-params";
|
|
20
23
|
import type { Tab } from "../tabs/tab";
|
|
21
|
-
import type { DocumentStackEntry, FunctionEditDef } from "../types";
|
|
24
|
+
import type { DocumentStackEntry, FormulaEditDef, FunctionEditDef, JsonValue } from "../types";
|
|
22
25
|
import type { EffectScope } from "@vue/reactivity";
|
|
23
26
|
import type { TemplateResult } from "lit-html";
|
|
24
27
|
|
|
@@ -26,6 +29,7 @@ interface TabBarCtx {
|
|
|
26
29
|
navigateBack: () => void;
|
|
27
30
|
navigateToLevel: (level: number) => void;
|
|
28
31
|
closeFunctionEditor: () => void;
|
|
32
|
+
closeFormulaWorkspace: () => void;
|
|
29
33
|
exportFile: () => void;
|
|
30
34
|
getCanvasMode: () => string;
|
|
31
35
|
parseMediaEntries: (media: Record<string, string> | null | undefined) => {
|
|
@@ -67,11 +71,16 @@ export function mount(host: HTMLElement, ctx: TabBarCtx) {
|
|
|
67
71
|
void tab.documentPath;
|
|
68
72
|
void tab.session.documentStack.length;
|
|
69
73
|
void tab.session.ui.canvasMode;
|
|
74
|
+
void tab.session.ui.editZoom;
|
|
75
|
+
void tab.session.ui.editingFormula;
|
|
70
76
|
void tab.session.ui.editingFunction;
|
|
71
77
|
void tab.session.ui.featureToggles;
|
|
72
78
|
void tab.session.ui.preview;
|
|
79
|
+
void tab.session.ui.previewColorScheme;
|
|
73
80
|
void tab.session.ui.previewParams;
|
|
81
|
+
void tab.session.ui.previewProps;
|
|
74
82
|
void tab.session.ui.showLayout;
|
|
83
|
+
void tab.session.ui.zoom;
|
|
75
84
|
}
|
|
76
85
|
render();
|
|
77
86
|
});
|
|
@@ -110,9 +119,13 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
|
|
|
110
119
|
};
|
|
111
120
|
const canvasMode = ctx.getCanvasMode();
|
|
112
121
|
const editing = S.ui.editingFunction as FunctionEditDef | null;
|
|
122
|
+
const formulaEditing = S.ui.editingFormula as FormulaEditDef | null;
|
|
123
|
+
// Any full-screen canvas takeover (function editor or formula workspace) suppresses the zoom
|
|
124
|
+
// Widget, the view-settings cluster, and the Export action below.
|
|
125
|
+
const takeover = Boolean(editing) || Boolean(formulaEditing);
|
|
113
126
|
const hasStack = S.documentStack && S.documentStack.length > 0;
|
|
114
127
|
|
|
115
|
-
// ── Left region: navigation context (function editor
|
|
128
|
+
// ── Left region: navigation context (function editor > formula workspace > document stack) ──
|
|
116
129
|
let navTpl: TemplateResult | typeof nothing = nothing;
|
|
117
130
|
if (editing) {
|
|
118
131
|
const docName = S.documentPath?.split("/").pop() || S.document?.tagName || "document";
|
|
@@ -128,6 +141,27 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
|
|
|
128
141
|
<span class="breadcrumb-item current">${funcLabel}</span>
|
|
129
142
|
</div>
|
|
130
143
|
`;
|
|
144
|
+
} else if (formulaEditing) {
|
|
145
|
+
const docName = S.documentPath?.split("/").pop() || S.document?.tagName || "document";
|
|
146
|
+
const formulaLabel =
|
|
147
|
+
formulaEditing.type === "def"
|
|
148
|
+
? `fx ${formulaEditing.defName}`
|
|
149
|
+
: `fx ${formulaEditing.eventKey}`;
|
|
150
|
+
navTpl = html`
|
|
151
|
+
<div class="breadcrumb">
|
|
152
|
+
<sp-action-button
|
|
153
|
+
size="s"
|
|
154
|
+
title="Close formula workspace"
|
|
155
|
+
@click=${ctx.closeFormulaWorkspace}
|
|
156
|
+
>
|
|
157
|
+
<sp-icon-back slot="icon"></sp-icon-back>
|
|
158
|
+
Back
|
|
159
|
+
</sp-action-button>
|
|
160
|
+
<span class="breadcrumb-item">${docName}</span>
|
|
161
|
+
<span class="breadcrumb-sep"> › </span>
|
|
162
|
+
<span class="breadcrumb-item current">${formulaLabel}</span>
|
|
163
|
+
</div>
|
|
164
|
+
`;
|
|
131
165
|
} else if (hasStack) {
|
|
132
166
|
navTpl = html`
|
|
133
167
|
<div class="breadcrumb">
|
|
@@ -150,6 +184,82 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
|
|
|
150
184
|
`;
|
|
151
185
|
}
|
|
152
186
|
|
|
187
|
+
// ── Left region: zoom widget (every mode with a zoom concept) ──
|
|
188
|
+
// Gates on the EFFECTIVE mode: edit drives the content-reflow `editZoom`, while design /
|
|
189
|
+
// Stylebook / git-diff / preview all render on the panzoom surface and drive `ui.zoom` (with the
|
|
190
|
+
// Panzoom-only fit-to-screen action).
|
|
191
|
+
let zoomTpl: TemplateResult | typeof nothing = nothing;
|
|
192
|
+
if (!takeover && canvasMode === "edit") {
|
|
193
|
+
const editZoom = S.ui.editZoom ?? 1;
|
|
194
|
+
zoomTpl = html`
|
|
195
|
+
<sp-action-group compact size="s" class="tb-zoom">
|
|
196
|
+
<sp-action-button
|
|
197
|
+
size="s"
|
|
198
|
+
title="Zoom out (Ctrl+-)"
|
|
199
|
+
@click=${() => setEditZoom((tab.session.ui.editZoom ?? 1) / 1.2)}
|
|
200
|
+
>
|
|
201
|
+
−
|
|
202
|
+
</sp-action-button>
|
|
203
|
+
<sp-action-button
|
|
204
|
+
size="s"
|
|
205
|
+
class="tb-zoom-label"
|
|
206
|
+
title="Reset to 100% (Ctrl+0)"
|
|
207
|
+
@click=${() => setEditZoom(1)}
|
|
208
|
+
>
|
|
209
|
+
${Math.round(editZoom * 100)}%
|
|
210
|
+
</sp-action-button>
|
|
211
|
+
<sp-action-button
|
|
212
|
+
size="s"
|
|
213
|
+
title="Zoom in (Ctrl+=)"
|
|
214
|
+
@click=${() => setEditZoom((tab.session.ui.editZoom ?? 1) * 1.2)}
|
|
215
|
+
>
|
|
216
|
+
+
|
|
217
|
+
</sp-action-button>
|
|
218
|
+
</sp-action-group>
|
|
219
|
+
`;
|
|
220
|
+
} else if (
|
|
221
|
+
!takeover &&
|
|
222
|
+
(canvasMode === "design" ||
|
|
223
|
+
canvasMode === "stylebook" ||
|
|
224
|
+
canvasMode === "git-diff" ||
|
|
225
|
+
canvasMode === "preview")
|
|
226
|
+
) {
|
|
227
|
+
const zoom = S.ui.zoom ?? 1;
|
|
228
|
+
const setPanZoom = (next: number) => {
|
|
229
|
+
tab.session.ui.zoom = Math.min(5, Math.max(0.05, next));
|
|
230
|
+
applyTransform();
|
|
231
|
+
};
|
|
232
|
+
zoomTpl = html`
|
|
233
|
+
<sp-action-group compact size="s" class="tb-zoom">
|
|
234
|
+
<sp-action-button
|
|
235
|
+
size="s"
|
|
236
|
+
title="Zoom out (Ctrl+-)"
|
|
237
|
+
@click=${() => setPanZoom((tab.session.ui.zoom ?? 1) / 1.2)}
|
|
238
|
+
>
|
|
239
|
+
−
|
|
240
|
+
</sp-action-button>
|
|
241
|
+
<sp-action-button
|
|
242
|
+
size="s"
|
|
243
|
+
class="tb-zoom-label"
|
|
244
|
+
title="Reset to 100% (Ctrl+0)"
|
|
245
|
+
@click=${() => resetZoom()}
|
|
246
|
+
>
|
|
247
|
+
${Math.round(zoom * 100)}%
|
|
248
|
+
</sp-action-button>
|
|
249
|
+
<sp-action-button
|
|
250
|
+
size="s"
|
|
251
|
+
title="Zoom in (Ctrl+=)"
|
|
252
|
+
@click=${() => setPanZoom((tab.session.ui.zoom ?? 1) * 1.2)}
|
|
253
|
+
>
|
|
254
|
+
+
|
|
255
|
+
</sp-action-button>
|
|
256
|
+
<sp-action-button size="s" title="Fit to screen" @click=${() => fitToScreen()}>
|
|
257
|
+
Fit
|
|
258
|
+
</sp-action-button>
|
|
259
|
+
</sp-action-group>
|
|
260
|
+
`;
|
|
261
|
+
}
|
|
262
|
+
|
|
153
263
|
// ── Right region: view settings cluster (edit/design base modes only) ──
|
|
154
264
|
// Gates on the BASE mode (not the effective mode): the cluster stays visible while the preview
|
|
155
265
|
// Toggle is on so it can be toggled back off.
|
|
@@ -160,10 +270,11 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
|
|
|
160
270
|
(S.documentPath.startsWith("pages/") || S.documentPath.startsWith("./pages/")),
|
|
161
271
|
);
|
|
162
272
|
let settingsTpl: TemplateResult | typeof nothing = nothing;
|
|
163
|
-
if (!
|
|
273
|
+
if (!takeover && (baseMode === "edit" || baseMode === "design")) {
|
|
164
274
|
const canPreview = tab.capabilities.modes.includes("preview");
|
|
165
275
|
const hasLayout = isPage && Boolean(getEffectiveLayoutPath(S.document?.$layout));
|
|
166
|
-
|
|
276
|
+
// Pages get route-param pickers; component docs get test-prop fields (M6) — same slot.
|
|
277
|
+
const pickersTpl = isPage ? paramPickersTpl(tab) : propFieldsTpl(tab);
|
|
167
278
|
const previewTpl = canPreview
|
|
168
279
|
? html`
|
|
169
280
|
<sp-action-button
|
|
@@ -199,13 +310,46 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
|
|
|
199
310
|
}
|
|
200
311
|
}
|
|
201
312
|
|
|
202
|
-
// ── Right region:
|
|
313
|
+
// ── Right region: color-scheme preview (projects that declare a scheme query, spec §9.5) ──
|
|
314
|
+
// One switch per tab: drives the canvas data-color-scheme attribute and (in the style
|
|
315
|
+
// Sidebar) which scheme layer edits target. Ungated — works in every canvas mode.
|
|
203
316
|
const { featureQueries } = ctx.parseMediaEntries(getEffectiveMedia(S.document?.$media));
|
|
317
|
+
const schemeQueries = featureQueries.filter(({ query }) => isSchemeQuery(query));
|
|
318
|
+
const plainQueries = featureQueries.filter(({ query }) => !isSchemeQuery(query));
|
|
319
|
+
const scheme = S.ui.previewColorScheme ?? "auto";
|
|
320
|
+
const schemeTpl =
|
|
321
|
+
schemeQueries.length > 0
|
|
322
|
+
? html`
|
|
323
|
+
<sp-action-group compact size="s" class="tb-scheme">
|
|
324
|
+
${(
|
|
325
|
+
[
|
|
326
|
+
["auto", "Auto", "Follow the OS color scheme"],
|
|
327
|
+
["light", "Light", "Force the light scheme"],
|
|
328
|
+
["dark", "Dark", "Force the dark scheme"],
|
|
329
|
+
] as const
|
|
330
|
+
).map(
|
|
331
|
+
([value, label, title]) => html`
|
|
332
|
+
<sp-action-button
|
|
333
|
+
toggles
|
|
334
|
+
size="s"
|
|
335
|
+
title=${title}
|
|
336
|
+
?selected=${scheme === value}
|
|
337
|
+
@click=${() => updateUi("previewColorScheme", value)}
|
|
338
|
+
>
|
|
339
|
+
${label}
|
|
340
|
+
</sp-action-button>
|
|
341
|
+
`,
|
|
342
|
+
)}
|
|
343
|
+
</sp-action-group>
|
|
344
|
+
`
|
|
345
|
+
: nothing;
|
|
346
|
+
|
|
347
|
+
// ── Right region: media feature toggles (non-scheme queries) ──
|
|
204
348
|
const togglesTpl =
|
|
205
|
-
|
|
349
|
+
plainQueries.length > 0
|
|
206
350
|
? html`
|
|
207
351
|
<sp-action-group compact size="s">
|
|
208
|
-
${
|
|
352
|
+
${plainQueries.map(
|
|
209
353
|
({ name, query }: { name: string; query: string }) => html`
|
|
210
354
|
<sp-action-button
|
|
211
355
|
toggles
|
|
@@ -230,7 +374,7 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
|
|
|
230
374
|
|
|
231
375
|
// ── Right region: mode actions (Code-mode Export) ──
|
|
232
376
|
const exportTpl =
|
|
233
|
-
!
|
|
377
|
+
!takeover && canvasMode === "source"
|
|
234
378
|
? html`
|
|
235
379
|
<sp-action-button size="s" @click=${ctx.exportFile}>
|
|
236
380
|
<sp-icon-export slot="icon"></sp-icon-export>
|
|
@@ -241,9 +385,9 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
|
|
|
241
385
|
|
|
242
386
|
return html`
|
|
243
387
|
<div class="tab-bar">
|
|
244
|
-
${navTpl}
|
|
388
|
+
${navTpl} ${zoomTpl}
|
|
245
389
|
<div class="tb-spacer"></div>
|
|
246
|
-
${settingsTpl} ${togglesTpl} ${exportTpl}
|
|
390
|
+
${settingsTpl} ${schemeTpl} ${togglesTpl} ${exportTpl}
|
|
247
391
|
</div>
|
|
248
392
|
`;
|
|
249
393
|
}
|
|
@@ -342,3 +486,63 @@ function paramPickersTpl(tab: Tab): TemplateResult | typeof nothing {
|
|
|
342
486
|
)}
|
|
343
487
|
`;
|
|
344
488
|
}
|
|
489
|
+
|
|
490
|
+
// ── Component test-prop fields (M6) ──────────────────────────────────────────
|
|
491
|
+
// The previewParams mirror for component docs: one small field per prop entry (the doc's
|
|
492
|
+
// Plain-data state entries), committed on change so typing never re-renders the bar mid-edit. A
|
|
493
|
+
// Value parses as JSON when it can (numbers, booleans, arrays) and falls back to the raw string;
|
|
494
|
+
// Clearing a field removes the override so the prop returns to its authored default.
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* @param {string} raw
|
|
498
|
+
* @returns {JsonValue}
|
|
499
|
+
*/
|
|
500
|
+
function parsePropValue(raw: string): JsonValue {
|
|
501
|
+
try {
|
|
502
|
+
return JSON.parse(raw) as JsonValue;
|
|
503
|
+
} catch {
|
|
504
|
+
return raw;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* @param {Tab} tab
|
|
510
|
+
* @returns {TemplateResult | typeof nothing}
|
|
511
|
+
*/
|
|
512
|
+
function propFieldsTpl(tab: Tab): TemplateResult | typeof nothing {
|
|
513
|
+
const doc = tab.doc.document;
|
|
514
|
+
if (!isComponentDoc(doc)) {
|
|
515
|
+
return nothing;
|
|
516
|
+
}
|
|
517
|
+
const entries = componentPropEntries(doc);
|
|
518
|
+
if (entries.length === 0) {
|
|
519
|
+
return nothing;
|
|
520
|
+
}
|
|
521
|
+
const { previewProps } = tab.session.ui;
|
|
522
|
+
const display = (v: JsonValue | undefined) =>
|
|
523
|
+
v === undefined ? "" : typeof v === "string" ? v : JSON.stringify(v);
|
|
524
|
+
return html`
|
|
525
|
+
${entries.map(
|
|
526
|
+
({ name }) => html`
|
|
527
|
+
<sp-textfield
|
|
528
|
+
size="s"
|
|
529
|
+
quiet
|
|
530
|
+
class="tab-bar-prop"
|
|
531
|
+
placeholder=${name}
|
|
532
|
+
title=${`Test value for ${name}`}
|
|
533
|
+
.value=${display(previewProps?.[name])}
|
|
534
|
+
@change=${(e: Event) => {
|
|
535
|
+
const raw = (e.target as HTMLInputElement).value;
|
|
536
|
+
const next = { ...tab.session.ui.previewProps };
|
|
537
|
+
if (raw === "") {
|
|
538
|
+
delete next[name];
|
|
539
|
+
} else {
|
|
540
|
+
next[name] = parsePropValue(raw);
|
|
541
|
+
}
|
|
542
|
+
updateUi("previewProps", Object.keys(next).length > 0 ? next : null);
|
|
543
|
+
}}
|
|
544
|
+
></sp-textfield>
|
|
545
|
+
`,
|
|
546
|
+
)}
|
|
547
|
+
`;
|
|
548
|
+
}
|
package/src/panels/tab-strip.ts
CHANGED
|
@@ -11,14 +11,18 @@ import { classMap } from "lit-html/directives/class-map.js";
|
|
|
11
11
|
import { repeat } from "lit-html/directives/repeat.js";
|
|
12
12
|
import { effect, effectScope } from "../reactivity";
|
|
13
13
|
import { activateTab, closeTab, workspace } from "../workspace/workspace";
|
|
14
|
+
import { gridTabLabel } from "../grid/grid-source";
|
|
14
15
|
import type { Tab } from "../tabs/tab";
|
|
15
16
|
import { showConfirmDialog } from "../ui/layers";
|
|
17
|
+
import { collabState } from "../collab/collab-state";
|
|
16
18
|
import type { EffectScope } from "@vue/reactivity";
|
|
17
19
|
|
|
18
20
|
let _host: HTMLElement | null = null;
|
|
19
21
|
|
|
20
22
|
let _scope: EffectScope | null = null;
|
|
21
23
|
|
|
24
|
+
let _lastActiveId: string | null = null;
|
|
25
|
+
|
|
22
26
|
/**
|
|
23
27
|
* Mount the tab strip into the given host element.
|
|
24
28
|
*
|
|
@@ -26,6 +30,7 @@ let _scope: EffectScope | null = null;
|
|
|
26
30
|
*/
|
|
27
31
|
export function mount(host: HTMLElement) {
|
|
28
32
|
_host = host;
|
|
33
|
+
_lastActiveId = null;
|
|
29
34
|
_scope = effectScope();
|
|
30
35
|
_scope.run(() => {
|
|
31
36
|
effect(() => {
|
|
@@ -52,13 +57,14 @@ function render() {
|
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
if (workspace.tabOrder.length === 0) {
|
|
60
|
+
_lastActiveId = null;
|
|
55
61
|
litRender(nothing, _host);
|
|
56
62
|
return;
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
litRender(
|
|
60
66
|
html`
|
|
61
|
-
<div class="tab-strip">
|
|
67
|
+
<div class="tab-strip" @wheel=${onWheel}>
|
|
62
68
|
${repeat(
|
|
63
69
|
workspace.tabOrder,
|
|
64
70
|
(id) => id,
|
|
@@ -80,7 +86,7 @@ function render() {
|
|
|
80
86
|
void requestClose(id);
|
|
81
87
|
}
|
|
82
88
|
}}
|
|
83
|
-
title=${tab.documentPath || "Untitled"}
|
|
89
|
+
title=${tab.documentPath || gridTabLabel(tab.id) || "Untitled"}
|
|
84
90
|
>
|
|
85
91
|
<span class="tab-strip-label">${label}</span>
|
|
86
92
|
${isDirty ? html`<span class="tab-strip-dirty">●</span>` : nothing}
|
|
@@ -101,10 +107,40 @@ function render() {
|
|
|
101
107
|
`,
|
|
102
108
|
_host,
|
|
103
109
|
);
|
|
110
|
+
|
|
111
|
+
if (workspace.activeTabId !== _lastActiveId) {
|
|
112
|
+
_lastActiveId = workspace.activeTabId;
|
|
113
|
+
_host
|
|
114
|
+
.querySelector(".tab-strip-tab.active")
|
|
115
|
+
?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Scroll the strip horizontally from wheel motion so overflowed tabs stay reachable — a plain mouse
|
|
121
|
+
* wheel only emits deltaY, which does nothing in an overflow-x container. The scrollbar is hidden
|
|
122
|
+
* by design, so the wheel is the primary way to reach off-screen tabs.
|
|
123
|
+
*
|
|
124
|
+
* @param {WheelEvent} e
|
|
125
|
+
*/
|
|
126
|
+
function onWheel(e: WheelEvent) {
|
|
127
|
+
if (e.ctrlKey) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const el = e.currentTarget as HTMLElement;
|
|
131
|
+
if (el.scrollWidth <= el.clientWidth) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
|
135
|
+
if (!delta) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
el.scrollLeft += delta;
|
|
104
140
|
}
|
|
105
141
|
|
|
106
142
|
/**
|
|
107
|
-
* Derive a short label from the tab's documentPath.
|
|
143
|
+
* Derive a short label from the tab's documentPath (virtual grid tabs label from their id).
|
|
108
144
|
*
|
|
109
145
|
* @param {Tab} tab
|
|
110
146
|
* @returns {string}
|
|
@@ -112,14 +148,34 @@ function render() {
|
|
|
112
148
|
function tabLabel(tab: Tab) {
|
|
113
149
|
const path = tab.documentPath;
|
|
114
150
|
if (!path) {
|
|
115
|
-
return "Untitled";
|
|
151
|
+
return gridTabLabel(tab.id) ?? "Untitled";
|
|
116
152
|
}
|
|
117
153
|
const parts = path.split("/");
|
|
118
154
|
return parts.at(-1);
|
|
119
155
|
}
|
|
120
156
|
|
|
121
157
|
/**
|
|
122
|
-
*
|
|
158
|
+
* True when closing this tab would lose unsaved work the user must be warned about: the tab is
|
|
159
|
+
* dirty AND either it is not co-edited, or this client is the last active collaborator on the doc
|
|
160
|
+
* (no other peer is focused on its path). When peers remain, the shared session lives on and the
|
|
161
|
+
* edits are still on the server, so closing is safe.
|
|
162
|
+
*
|
|
163
|
+
* @param {Tab} tab
|
|
164
|
+
*/
|
|
165
|
+
export function shouldWarnOnClose(tab: Tab): boolean {
|
|
166
|
+
if (!tab.doc.dirty) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
const state = collabState(tab);
|
|
170
|
+
if (!state.active) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
const peersHere = state.peers.filter((p) => p.state?.focusedPath === tab.documentPath);
|
|
174
|
+
return peersHere.length === 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Close a tab, prompting if closing would lose unsaved work.
|
|
123
179
|
*
|
|
124
180
|
* @param {string} id
|
|
125
181
|
*/
|
|
@@ -128,7 +184,7 @@ async function requestClose(id: string) {
|
|
|
128
184
|
if (!tab) {
|
|
129
185
|
return;
|
|
130
186
|
}
|
|
131
|
-
if (tab
|
|
187
|
+
if (shouldWarnOnClose(tab)) {
|
|
132
188
|
const confirmed = await showConfirmDialog(
|
|
133
189
|
"Unsaved Changes",
|
|
134
190
|
`"${tabLabel(tab)}" has unsaved changes. Close without saving?`,
|