@jxsuite/studio 2.4.0 → 2.4.2
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/chunks/{content-source-jrvn5vwy.js → content-source-k9724vcv.js} +8 -8
- package/dist/chunks/{convert-to-repeater-109dwcj7.js → convert-to-repeater-k10dvcc7.js} +7 -7
- package/dist/chunks/{extension-sections-2zjyty05.js → extension-sections-0ada0xcw.js} +11 -11
- package/dist/chunks/{files-jdbtammm.js → files-yew27sn9.js} +12 -12
- package/dist/chunks/{format-host-kq6963gs.js → format-host-wewba4nq.js} +2 -2
- package/dist/chunks/{git-panel-2stf3ahg.js → git-panel-2dh1aw50.js} +7 -7
- package/dist/chunks/{layers-panel-a8wjsdjj.js → layers-panel-qnsghwwd.js} +10 -10
- package/dist/chunks/{publish-panel-2we6smjv.js → publish-panel-a0pdft5w.js} +5 -5
- package/dist/chunks/{quick-search-hmpwdgys.js → quick-search-9avyc4pc.js} +13 -13
- package/dist/chunks/{right-panel-hn3y3j0j.js → right-panel-kcnhtc5j.js} +9 -9
- package/dist/chunks/{studio-0evba9z5.js → studio-0b6fs05q.js} +5 -5
- package/dist/chunks/{studio-wxthr2x1.js → studio-26sv44br.js} +8 -8
- package/dist/chunks/{studio-eqyybj2e.js → studio-28xc8ws4.js} +4 -4
- package/dist/chunks/{studio-53dn6e6t.js → studio-aweyr4v1.js} +2 -2
- package/dist/chunks/{studio-102jzb8r.js → studio-cz2j1c0g.js} +4 -4
- package/dist/chunks/{studio-tqr02xaa.js → studio-dgj7gq44.js} +3 -3
- package/dist/chunks/{studio-ky68mp85.js → studio-ef8t53vc.js} +3 -3
- package/dist/chunks/{studio-8x4399kc.js → studio-f5yyvqxv.js} +7 -7
- package/dist/chunks/{studio-rdsvb808.js → studio-hb7c46jy.js} +5 -5
- package/dist/chunks/{studio-yz06mkfj.js → studio-hyn3vhwr.js} +5 -5
- package/dist/chunks/{studio-qd4k0h80.js → studio-jnakhg87.js} +4 -4
- package/dist/chunks/{studio-q07vj7p9.js → studio-ke244pam.js} +6 -6
- package/dist/chunks/{studio-yk7037sg.js → studio-pyrw8h0w.js} +60 -19
- package/dist/chunks/{studio-ddgrc5hs.js → studio-ra7ar50f.js} +3 -3
- package/dist/chunks/{studio-6gcrk1bq.js → studio-rqemfhz6.js} +2 -2
- package/dist/chunks/{studio-kwavhmdr.js → studio-sccge2wf.js} +2 -2
- package/dist/chunks/{studio-ng5gbxkj.js → studio-v1va0vhd.js} +8 -8
- package/dist/chunks/{studio-1dgqagzz.js → studio-v2c5pmjj.js} +10 -10
- package/dist/chunks/{studio-377bbhtz.js → studio-ws6ef6js.js} +8 -8
- package/dist/iframe-entry.js +99 -7
- package/dist/studio.js +31 -31
- package/package.json +4 -4
- package/src/canvas/canvas-origin.ts +14 -2
- package/src/canvas/editable-actions.ts +37 -1
- package/src/canvas/iframe-editable-root.ts +27 -0
- package/src/canvas/iframe-host.ts +118 -7
- package/src/canvas/iframe-inline-edit.ts +122 -6
- package/src/canvas/iframe-protocol.ts +7 -0
- package/src/canvas/iframe-render.ts +38 -1
- package/src/editor/inline-edit-apply.ts +10 -4
- package/src/editor/inline-edit.ts +62 -4
- package/src/types.ts +3 -1
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
tabOfContainer,
|
|
39
39
|
} from "./canvas-surface";
|
|
40
40
|
import { cloneSelection, primarySelection, toggleSelected } from "../tabs/selection";
|
|
41
|
+
import { getNodeAtPath } from "../state";
|
|
41
42
|
import type { JxPath } from "../state";
|
|
42
43
|
import { setLayoutSelection, shell } from "../shell";
|
|
43
44
|
import { formatEditableVerdicts } from "../format/constraints";
|
|
@@ -198,7 +199,13 @@ interface HostState {
|
|
|
198
199
|
* render acks (`renderComplete`) at a bumped one — both satisfy `gen >= minGen`. An immediate
|
|
199
200
|
* `enterEdit` would race the escalated ASYNC render and silently fail to find the element.
|
|
200
201
|
*/
|
|
201
|
-
pendingEnterEdit: {
|
|
202
|
+
pendingEnterEdit: {
|
|
203
|
+
path: (string | number)[];
|
|
204
|
+
minGen: number;
|
|
205
|
+
offset?: number;
|
|
206
|
+
/** Set when the deferred re-entry is a PROP host rather than a block (see deferEnterEdit). */
|
|
207
|
+
prop?: string;
|
|
208
|
+
} | null;
|
|
202
209
|
/**
|
|
203
210
|
* Stylebook capability (set by {@link mountStylebookCanvas}, cleared by page mounts). The specimen
|
|
204
211
|
* doc's paths decode to TAGS, not tab-document paths: hits route to the injected stylebook
|
|
@@ -1246,6 +1253,66 @@ function withEchoSuppressed(host: HostState, paths: (string | number)[][], fn: (
|
|
|
1246
1253
|
}
|
|
1247
1254
|
|
|
1248
1255
|
/** The tab whose document `state`'s iframe currently renders (null when unknown or closed). */
|
|
1256
|
+
/**
|
|
1257
|
+
* Serialized path of an in-place prop commit whose patch was echo-suppressed, or null.
|
|
1258
|
+
*
|
|
1259
|
+
* One value rather than a set: a plain session edits exactly one prop of one instance at a time,
|
|
1260
|
+
* and it is cleared on the release that follows.
|
|
1261
|
+
*/
|
|
1262
|
+
let propEchoPending: string | null = null;
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Serialized instance path whose release commit just transacted, so a `$props` patch rebuilding it
|
|
1266
|
+
* is in flight. Read by the very next `editStart` and cleared there — the two messages arrive back
|
|
1267
|
+
* to back when the user clicks from one prop slot to another in the same component.
|
|
1268
|
+
*/
|
|
1269
|
+
let propRebuildAt: string | null = null;
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Whether a release commit has to re-render the instance itself.
|
|
1273
|
+
*
|
|
1274
|
+
* Exported for its own test: the posting side ({@link postPatchToHosts}) needs a resolvable stage
|
|
1275
|
+
* and is covered elsewhere, but the STATE MACHINE here is the part that was wrong, and it is three
|
|
1276
|
+
* conditions that have to agree — the release must have declined to transact, an in-place commit
|
|
1277
|
+
* must have been suppressed, and it must have been for this same instance.
|
|
1278
|
+
*
|
|
1279
|
+
* @param {boolean} transacted - Whether the release commit wrote to the document.
|
|
1280
|
+
* @param {string | null} pending - Serialized path of a suppressed in-place commit, if any.
|
|
1281
|
+
* @param {string} path - Serialized path of the instance being released.
|
|
1282
|
+
* @returns {boolean}
|
|
1283
|
+
*/
|
|
1284
|
+
export function needsReleaseReconcile(
|
|
1285
|
+
transacted: boolean,
|
|
1286
|
+
pending: string | null,
|
|
1287
|
+
path: string,
|
|
1288
|
+
): boolean {
|
|
1289
|
+
return !transacted && pending === path;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Re-render a component instance from the document, WITHOUT transacting.
|
|
1294
|
+
*
|
|
1295
|
+
* Used when a release commit legitimately no-ops but the DOM is still showing what the suppressed
|
|
1296
|
+
* in-place commits rendered. The op is the same `set-key $props` the patcher would have produced,
|
|
1297
|
+
* so it takes the ordinary `replaceSubtree` path — this adds no rendering mechanism, it re-sends
|
|
1298
|
+
* the one that was deliberately dropped while the caret needed protecting.
|
|
1299
|
+
*
|
|
1300
|
+
* @param {HostState} state - The host whose tab owns the document.
|
|
1301
|
+
* @param {JxPath} path - The component instance's document path.
|
|
1302
|
+
* @returns {void}
|
|
1303
|
+
*/
|
|
1304
|
+
function reconcileInstance(state: HostState, path: JxPath): void {
|
|
1305
|
+
const tab = hostTab(state);
|
|
1306
|
+
if (!tab) {
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
const node = getNodeAtPath(tab.doc.document, path);
|
|
1310
|
+
if (!node) {
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
postPatchToHosts([{ key: "$props", op: "set-key", path, value: node.$props }], tab.id);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1249
1316
|
function hostTab(state: HostState): Tab | null {
|
|
1250
1317
|
return state.tabId ? (workspace.tabs.get(state.tabId) ?? null) : null;
|
|
1251
1318
|
}
|
|
@@ -2251,6 +2318,13 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
2251
2318
|
activeEditHost.snapshot = null;
|
|
2252
2319
|
}
|
|
2253
2320
|
activeEditHost = state;
|
|
2321
|
+
/* The session that just opened is inside an instance a release commit is about to rebuild —
|
|
2322
|
+
defer a re-entry so it survives the patch. Only for the SAME instance: moving to a slot in
|
|
2323
|
+
a different component is not disturbed by this rebuild. */
|
|
2324
|
+
if (msg.prop !== undefined && propRebuildAt === serializeJxPath(msg.path)) {
|
|
2325
|
+
deferEnterEdit(state, msg.path, undefined, msg.prop);
|
|
2326
|
+
}
|
|
2327
|
+
propRebuildAt = null;
|
|
2254
2328
|
// Each visit to a block is one undoable edit. Bumping the run id here breaks the history
|
|
2255
2329
|
// Coalescing run, so returning to the same paragraph later is a separate ⌘Z step.
|
|
2256
2330
|
editRunSeq += 1;
|
|
@@ -2314,9 +2388,34 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
2314
2388
|
const applyProp = () => applyInlinePropCommit(hostTab(state), msg.path, msg.prop, msg.value);
|
|
2315
2389
|
if (msg.inPlace) {
|
|
2316
2390
|
withEchoSuppressed(state, [msg.path], applyProp);
|
|
2317
|
-
|
|
2318
|
-
|
|
2391
|
+
propEchoPending = serializeJxPath(msg.path);
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
const transacted = applyProp();
|
|
2395
|
+
/*
|
|
2396
|
+
* "The instance re-renders for real on release" was only true when the release actually
|
|
2397
|
+
* transacted. After an idle tick the release posts the SAME string, the apply no-ops, and no
|
|
2398
|
+
* patch is ever generated — so the suppressed in-place render was the last word and the
|
|
2399
|
+
* canvas kept showing pre-edit output. Emptying a heading left it visibly empty while the
|
|
2400
|
+
* document had dropped the prop entirely, and any SECOND place the component renders that
|
|
2401
|
+
* value kept the old one.
|
|
2402
|
+
*
|
|
2403
|
+
* Reconciling here rather than lifting the no-op: that guard is load-bearing (it is what
|
|
2404
|
+
* stops a commit → patch → disturb → re-commit loop, and keeps undo clean), so the fix is to
|
|
2405
|
+
* re-render without transacting.
|
|
2406
|
+
*/
|
|
2407
|
+
if (needsReleaseReconcile(transacted, propEchoPending, serializeJxPath(msg.path))) {
|
|
2408
|
+
reconcileInstance(state, msg.path);
|
|
2319
2409
|
}
|
|
2410
|
+
propEchoPending = null;
|
|
2411
|
+
/*
|
|
2412
|
+
* A release that TRANSACTED rebuilds the whole instance — `set-key $props` at the instance
|
|
2413
|
+
* path becomes a `replaceSubtree`. If the user got here by clicking a SECOND prop slot in
|
|
2414
|
+
* that same instance, the frame has already adopted a marker inside the subtree about to be
|
|
2415
|
+
* replaced, so the session it just opened is dead on arrival: the click did nothing and they
|
|
2416
|
+
* had to click again. The next `editStart` tells us whether that is what happened.
|
|
2417
|
+
*/
|
|
2418
|
+
propRebuildAt = transacted ? serializeJxPath(msg.path) : null;
|
|
2320
2419
|
return;
|
|
2321
2420
|
}
|
|
2322
2421
|
case "editMerge": {
|
|
@@ -2434,7 +2533,7 @@ function onDomUpdated(state: HostState, gen: number): void {
|
|
|
2434
2533
|
requestPresence(state);
|
|
2435
2534
|
hideInsertZoneNow(state);
|
|
2436
2535
|
if (state.pendingEnterEdit && gen >= state.pendingEnterEdit.minGen) {
|
|
2437
|
-
const { offset, path } = state.pendingEnterEdit;
|
|
2536
|
+
const { offset, path, prop } = state.pendingEnterEdit;
|
|
2438
2537
|
state.pendingEnterEdit = null;
|
|
2439
2538
|
/* Re-enter only when this host STILL SHOWS THE TAB IT OWES THE CARET TO — which is a question
|
|
2440
2539
|
about this host's pane, not about the focus. A background tab's iframe renders a different
|
|
@@ -2444,7 +2543,7 @@ function onDomUpdated(state: HostState, gen: number): void {
|
|
|
2444
2543
|
there is one stage. With two, the side pane could be displaying its tab, holding a caret it
|
|
2445
2544
|
owed, and drop it because the PRIMARY had focus. */
|
|
2446
2545
|
if (state.tabId !== null && state.tabId === tabOfContainer(state.canvasEl)?.id) {
|
|
2447
|
-
reenterEdit(state, path, offset);
|
|
2546
|
+
reenterEdit(state, path, offset, prop);
|
|
2448
2547
|
}
|
|
2449
2548
|
}
|
|
2450
2549
|
}
|
|
@@ -2455,20 +2554,32 @@ function onDomUpdated(state: HostState, gen: number): void {
|
|
|
2455
2554
|
* that same gen; an escalated full render acks at a bumped one — both satisfy `gen >= minGen`,
|
|
2456
2555
|
* while a stale ack cannot. Latest-wins on overwrite.
|
|
2457
2556
|
*/
|
|
2458
|
-
function deferEnterEdit(
|
|
2557
|
+
function deferEnterEdit(
|
|
2558
|
+
state: HostState,
|
|
2559
|
+
path: (string | number)[],
|
|
2560
|
+
offset?: number,
|
|
2561
|
+
prop?: string,
|
|
2562
|
+
): void {
|
|
2459
2563
|
state.pendingEnterEdit = {
|
|
2460
2564
|
minGen: state.lastRenderedGen,
|
|
2461
2565
|
path: [...path],
|
|
2462
2566
|
...(offset === undefined ? {} : { offset }),
|
|
2567
|
+
...(prop === undefined ? {} : { prop }),
|
|
2463
2568
|
};
|
|
2464
2569
|
}
|
|
2465
2570
|
|
|
2466
2571
|
/** Ask the host's iframe to (re-)enter inline editing on `path` (a plain copy crosses the bridge). */
|
|
2467
|
-
function reenterEdit(
|
|
2572
|
+
function reenterEdit(
|
|
2573
|
+
state: HostState,
|
|
2574
|
+
path: (string | number)[],
|
|
2575
|
+
offset?: number,
|
|
2576
|
+
prop?: string,
|
|
2577
|
+
): void {
|
|
2468
2578
|
state.channel.post({
|
|
2469
2579
|
kind: "enterEdit",
|
|
2470
2580
|
path: [...path],
|
|
2471
2581
|
...(offset === undefined ? {} : { offset }),
|
|
2582
|
+
...(prop === undefined ? {} : { prop }),
|
|
2472
2583
|
});
|
|
2473
2584
|
}
|
|
2474
2585
|
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
handleSlashTrigger,
|
|
26
26
|
isEditableBlock,
|
|
27
27
|
isEditing,
|
|
28
|
+
isPlainSession,
|
|
28
29
|
isSlashActive,
|
|
29
30
|
openSlashMenu,
|
|
30
31
|
refreshSlashMenu,
|
|
@@ -37,6 +38,7 @@ import { adjacentBlock, blocksInOrder } from "./iframe-position";
|
|
|
37
38
|
import { isTagActiveInSelection, toggleInlineFormat } from "../editor/inline-format";
|
|
38
39
|
import { applyLink, insertTemplateToken, linkStateForSelection } from "../editor/inline-link";
|
|
39
40
|
import { restoreTemplateExpressions } from "../utils/edit-display";
|
|
41
|
+
import { RESERVED_KEYS, camelToKebab } from "@jxsuite/runtime";
|
|
40
42
|
import { rectOfRange } from "../utils/geometry";
|
|
41
43
|
import { getNodeAtPath } from "../state";
|
|
42
44
|
import { parseJxPath, serializeJxPath } from "./path-mapping";
|
|
@@ -85,6 +87,77 @@ function ownerInstanceOf(el: HTMLElement): HTMLElement | null {
|
|
|
85
87
|
return null;
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Whether the instance node delivers `prop` by some route OTHER than `$props` — in which case
|
|
92
|
+
* "absent from `$props`" does not mean "unset", and a `$props` write would add a SECOND source for
|
|
93
|
+
* one rendered value.
|
|
94
|
+
*
|
|
95
|
+
* The routes are the property bridge's own, in the order `connectedCallback` reads them
|
|
96
|
+
* (`compiler.md` §4.4) — a `data-jx-props` payload, literal `props.*` attributes, then JS
|
|
97
|
+
* properties set before connection — plus the one that reaches that third source without going
|
|
98
|
+
* through `$props`. Each was reproduced against the real runtime:
|
|
99
|
+
*
|
|
100
|
+
* 1. `attributes: { "data-jx-props": "{…}" }` — the compiler's pre-rendered payload, parsed into state
|
|
101
|
+
* before anything else. Studio edits source documents rather than build output, so this is the
|
|
102
|
+
* rare one; it is here because the bridge reads it.
|
|
103
|
+
* 2. The JSON shorthand `attributes: { "props.text": "…" }`. `connectedCallback` collects `props.*`
|
|
104
|
+
* attributes into state, so the marker renders that text while `$props` is empty. This repo's
|
|
105
|
+
* own site carries 95 of them.
|
|
106
|
+
* 3. A prop whose name collides with a REFLECTED HTMLElement property — `title`, `role`, `id`, `lang`,
|
|
107
|
+
* `dir`, `slot`, `hidden`. `setAttribute("title", …)` writes the JS property as a side effect,
|
|
108
|
+
* and the merge `key in this && this[key] !== undefined` picks it up. 41 shipped starter
|
|
109
|
+
* components declare `title` or `role` as state. Matched the way `instanceSupplies` matches:
|
|
110
|
+
* attribute names are case-insensitive, and an ARIA-style reflection carries a kebab attribute
|
|
111
|
+
* for a camelCase property (`ariaLabel` → `aria-label`).
|
|
112
|
+
* 4. A TOP-LEVEL key on the instance node — `{ "tagName": "x-card", "heading": "Local" }`. This is the
|
|
113
|
+
* property-first interface written straight onto the node: `applyProperties` sets every
|
|
114
|
+
* non-reserved key as a JS property, which is exactly what the merge then reads back off `this`.
|
|
115
|
+
* Reserved keys (`name`, `items`, `children`, …) are skipped there and so are skipped here —
|
|
116
|
+
* refusing on one would block a prop that the key never delivered.
|
|
117
|
+
*
|
|
118
|
+
* Editing any of them writes `$props[prop]` and leaves the other value standing. For case 3 the
|
|
119
|
+
* attribute WINS (applyAttributes runs after the $props assignment) so the edit is simply
|
|
120
|
+
* invisible; for the rest `$props` wins and the stale value waits — clearing the prop later
|
|
121
|
+
* resurrects it. Refusing keeps the properties panel, which edits the real source, as the one way
|
|
122
|
+
* in.
|
|
123
|
+
*/
|
|
124
|
+
function deliveredOutsideProps(node: JxMutableNode | undefined, prop: string): boolean {
|
|
125
|
+
if (!node) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
const attrs = node.attributes as Record<string, unknown> | undefined;
|
|
129
|
+
if (attrs) {
|
|
130
|
+
if (attrs[`props.${prop}`] !== undefined) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
const reflected = new Set([prop.toLowerCase(), camelToKebab(prop).toLowerCase()]);
|
|
134
|
+
for (const [name, val] of Object.entries(attrs)) {
|
|
135
|
+
if (val !== undefined && reflected.has(name.toLowerCase())) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const payload = attrs["data-jx-props"];
|
|
140
|
+
if (typeof payload === "string" && payloadNames(payload, prop)) {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// `$`-prefixed keys are scope bindings, never DOM properties — `applyProperties` skips them too.
|
|
145
|
+
if (RESERVED_KEYS.has(prop) || prop.startsWith("$")) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
return (node as Record<string, unknown>)[prop] !== undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Whether a `data-jx-props` JSON payload carries `prop`. Unparseable payloads deliver nothing. */
|
|
152
|
+
function payloadNames(payload: string, prop: string): boolean {
|
|
153
|
+
try {
|
|
154
|
+
const parsed: unknown = JSON.parse(payload);
|
|
155
|
+
return typeof parsed === "object" && parsed !== null && prop in parsed;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
88
161
|
/** Locate the rendered element for a document path via its stamped `data-jx-path`. */
|
|
89
162
|
function elementForPath(container: HTMLElement, path: JxPath): HTMLElement | null {
|
|
90
163
|
const serialized = serializeJxPath(path);
|
|
@@ -266,10 +339,12 @@ export function startIframeInlineEdit(
|
|
|
266
339
|
};
|
|
267
340
|
|
|
268
341
|
/**
|
|
269
|
-
* Whether the instance's
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
342
|
+
* Whether the instance's prop (read from the pre-edit-display shadow doc) is plain-text editable
|
|
343
|
+
* AND `$props` is the place it lives — a value the instance delivers by another route belongs to
|
|
344
|
+
* that route, and `deliveredOutsideProps` refuses it. Template-valued (`"${...}"`) and
|
|
345
|
+
* `$ref`-valued props render display sugar, not the value — committing typed text would silently
|
|
346
|
+
* destroy the binding, so they're blocked. An absent getShadowDoc is permissive (tests, same
|
|
347
|
+
* contract as getMode).
|
|
273
348
|
*/
|
|
274
349
|
const propEditableAt = (hostPath: JxPath, prop: string): boolean => {
|
|
275
350
|
const shadowDoc = opts?.getShadowDoc?.();
|
|
@@ -277,14 +352,24 @@ export function startIframeInlineEdit(
|
|
|
277
352
|
return true;
|
|
278
353
|
}
|
|
279
354
|
const node = getNodeAtPath(shadowDoc, hostPath) as JxMutableNode | undefined;
|
|
355
|
+
if (deliveredOutsideProps(node, prop)) {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
280
358
|
const raw = (node?.$props as Record<string, unknown> | undefined)?.[prop];
|
|
281
359
|
if (raw == null) {
|
|
282
360
|
return true; // Unset — editing ADDS the prop, overriding the definition default.
|
|
283
361
|
}
|
|
284
|
-
|
|
362
|
+
/*
|
|
363
|
+
* Only a STRING is text. A number or a boolean renders as text and reads as editable, but this
|
|
364
|
+
* session commits `textContent` — so editing `count: 3` wrote the string `"3"` and silently
|
|
365
|
+
* retyped the prop, after which `${count * 2}` is `"33"` and `${enabled ? …}` is always truthy.
|
|
366
|
+
* Objects were already excluded; the scalar cases were not, and are the ones a component
|
|
367
|
+
* actually declares. They stay editable in the properties panel, which knows their type.
|
|
368
|
+
*/
|
|
369
|
+
if (typeof raw !== "string") {
|
|
285
370
|
return false;
|
|
286
371
|
}
|
|
287
|
-
return !
|
|
372
|
+
return !raw.includes("${");
|
|
288
373
|
};
|
|
289
374
|
|
|
290
375
|
/**
|
|
@@ -309,6 +394,13 @@ export function startIframeInlineEdit(
|
|
|
309
394
|
onEnd: () => {
|
|
310
395
|
clearHighlight();
|
|
311
396
|
lastNonEmptyRange = null;
|
|
397
|
+
/*
|
|
398
|
+
* Enter and Escape end the session inside the engine, which knows nothing about the
|
|
399
|
+
* editing host that adopted this marker. Without this the host kept pointing at the dead
|
|
400
|
+
* session, so the very next click on the same text was swallowed by the
|
|
401
|
+
* `el !== activeEl` re-entry guard — you had to click something else and come back.
|
|
402
|
+
*/
|
|
403
|
+
root.forget();
|
|
312
404
|
channel.post({ kind: "editEnd" });
|
|
313
405
|
},
|
|
314
406
|
// Prop values are single plain strings — no split, no slash-insert.
|
|
@@ -325,6 +417,15 @@ export function startIframeInlineEdit(
|
|
|
325
417
|
if (!el) {
|
|
326
418
|
return; // Session not active → no-op.
|
|
327
419
|
}
|
|
420
|
+
/*
|
|
421
|
+
* A prop value carries no formatting — "formatting is off" is the documented rule for this
|
|
422
|
+
* session. The chord is inertified inside the frame, but a caret-scoped ⌘B is ALSO forwarded to
|
|
423
|
+
* the parent, matched by the shortcut registry, and posted straight back as an intent that
|
|
424
|
+
* lands here. This is the one chokepoint both routes pass through.
|
|
425
|
+
*/
|
|
426
|
+
if (isPlainSession()) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
328
429
|
// Restore the cached range ONLY if it's still usable (the DOM may have re-rendered).
|
|
329
430
|
if (
|
|
330
431
|
lastNonEmptyRange &&
|
|
@@ -511,6 +612,21 @@ export function startIframeInlineEdit(
|
|
|
511
612
|
if (!editingAllowed()) {
|
|
512
613
|
return;
|
|
513
614
|
}
|
|
615
|
+
if (msg.prop !== undefined) {
|
|
616
|
+
/*
|
|
617
|
+
* A prop host cannot be re-entered by placing a caret: the marker has no path, and the
|
|
618
|
+
* `$props` patch that prompted this REPLACED the instance element, so the node the old
|
|
619
|
+
* session held is detached. Find the marker again inside the fresh instance and open it.
|
|
620
|
+
*/
|
|
621
|
+
const instance = elementForPath(container, msg.path);
|
|
622
|
+
const marker = instance?.querySelector<HTMLElement>(
|
|
623
|
+
`[data-jx-bound-prop="${CSS.escape(msg.prop)}"]`,
|
|
624
|
+
);
|
|
625
|
+
if (marker) {
|
|
626
|
+
activateProp(marker);
|
|
627
|
+
}
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
514
630
|
// Follow-the-caret after a split or slash-insert: the parent re-renders and then names the path
|
|
515
631
|
// The caret belongs in. Placing the caret is the whole job — activation follows from where the
|
|
516
632
|
// Caret is, so there is nothing else to "enter".
|
|
@@ -190,6 +190,13 @@ export type ParentToIframe =
|
|
|
190
190
|
path: (string | number)[];
|
|
191
191
|
/** Character offset for the caret; defaults to the block's start. */
|
|
192
192
|
offset?: number;
|
|
193
|
+
/**
|
|
194
|
+
* Re-enter the PROP-BOUND host for this prop of the instance at `path`, rather than placing a
|
|
195
|
+
* caret in a block. A prop host is identified by (instance, prop) — the marker element
|
|
196
|
+
* carries no path of its own — and it is re-entered rather than caret-placed because a
|
|
197
|
+
* `$props` patch rebuilds the whole instance, replacing the element the session was on.
|
|
198
|
+
*/
|
|
199
|
+
prop?: string;
|
|
193
200
|
}
|
|
194
201
|
// Commit and end the inline-edit session if one is live (a no-op otherwise). Posted by the parent
|
|
195
202
|
// When focus/intent leaves the edit surface in the PARENT realm (tab switch, layers-panel click,
|
|
@@ -480,6 +480,17 @@ export function injectHead(doc: JxDocument): void {
|
|
|
480
480
|
* + `data-jx-layout-file` (layout-originated nodes) on rendered nodes.
|
|
481
481
|
*/
|
|
482
482
|
export function makeStamper(ctx: PathMapCtx) {
|
|
483
|
+
/*
|
|
484
|
+
* Paths of the component instances frozen below. Nodes arrive parent-first and `onNodeCreated`
|
|
485
|
+
* fires straight after `createElement`, BEFORE the node is attached — so there is no ancestor to
|
|
486
|
+
* walk at stamp time, and the path is the only way to know a node landed inside an island.
|
|
487
|
+
*/
|
|
488
|
+
const islands: (string | number)[][] = [];
|
|
489
|
+
const insideIsland = (path: (string | number)[]): boolean =>
|
|
490
|
+
islands.some(
|
|
491
|
+
(island) => path.length > island.length && island.every((seg, i) => path[i] === seg),
|
|
492
|
+
);
|
|
493
|
+
|
|
483
494
|
return (created: Node, path: (string | number)[], def: unknown) => {
|
|
484
495
|
if (!(created instanceof HTMLElement)) {
|
|
485
496
|
return;
|
|
@@ -503,8 +514,11 @@ export function makeStamper(ctx: PathMapCtx) {
|
|
|
503
514
|
//
|
|
504
515
|
// The opened document's own root is excluded: when a component definition is the file being
|
|
505
516
|
// Edited, its subtree IS the document and must stay editable.
|
|
506
|
-
|
|
517
|
+
const isIsland =
|
|
518
|
+
!isDefinitionRoot && created.tagName.includes("-") && isEditableMode(ctx.canvasMode);
|
|
519
|
+
if (isIsland) {
|
|
507
520
|
created.contentEditable = "false";
|
|
521
|
+
islands.push(path);
|
|
508
522
|
}
|
|
509
523
|
const classified = classifyRenderNode(path, def, ctx);
|
|
510
524
|
if (classified.kind === "layout") {
|
|
@@ -530,6 +544,29 @@ export function makeStamper(ctx: PathMapCtx) {
|
|
|
530
544
|
return;
|
|
531
545
|
}
|
|
532
546
|
created.dataset.jxPath = serializeJxPath(classified.path);
|
|
547
|
+
/*
|
|
548
|
+
* SLOTTED PAGE CONTENT IS NOT COMPONENT INTERNALS.
|
|
549
|
+
*
|
|
550
|
+
* The island above exists to keep the caret out of what a component renders for itself. But a
|
|
551
|
+
* component's CHILDREN are the author's own document — in jx-markdown,
|
|
552
|
+
*
|
|
553
|
+
* :::eer-intro
|
|
554
|
+
* If you need reliable rental equipment fast, request a quote today!
|
|
555
|
+
* :::
|
|
556
|
+
*
|
|
557
|
+
* is a paragraph the author typed, stamped here with its own `data-jx-path`. Inheriting the
|
|
558
|
+
* island's `contenteditable="false"` made every such paragraph uneditable: on a page written
|
|
559
|
+
* this way — which is most component-using pages — the caret could not be placed anywhere at
|
|
560
|
+
* all, and the only route to the text was the properties sidebar.
|
|
561
|
+
*
|
|
562
|
+
* Re-opening on the STAMPED node alone is what keeps the distinction. Component internals are
|
|
563
|
+
* created by the component's own `connectedCallback` in another document and never pass through
|
|
564
|
+
* this stamper, so they stay frozen; prop-bound internals keep their nested-host activation
|
|
565
|
+
* (see iframe-editable-root's `onPointerDownCapture`).
|
|
566
|
+
*/
|
|
567
|
+
if (!isIsland && isEditableMode(ctx.canvasMode) && insideIsland(path)) {
|
|
568
|
+
created.contentEditable = "true";
|
|
569
|
+
}
|
|
533
570
|
};
|
|
534
571
|
}
|
|
535
572
|
|
|
@@ -107,27 +107,33 @@ export function applyInlineCommit(
|
|
|
107
107
|
* disturb→re-commit cycle loops (and pollutes undo history). An empty value deletes the prop
|
|
108
108
|
* (mutateUpdateProp), reverting the instance to the definition default — same as clearing the
|
|
109
109
|
* sidebar field.
|
|
110
|
+
*
|
|
111
|
+
* @returns Whether it transacted. The caller needs to know: an in-place commit's patch is
|
|
112
|
+
* echo-suppressed so the caret survives, and the release that follows is expected to re-render
|
|
113
|
+
* the instance for real. When the release posts the same value it no-ops here, nothing
|
|
114
|
+
* re-renders, and the canvas keeps showing pre-edit output — see the reconcile in iframe-host.
|
|
110
115
|
*/
|
|
111
116
|
export function applyInlinePropCommit(
|
|
112
117
|
tab: Tab | null,
|
|
113
118
|
path: JxPath,
|
|
114
119
|
prop: string,
|
|
115
120
|
value: string,
|
|
116
|
-
):
|
|
121
|
+
): boolean {
|
|
117
122
|
if (!tab) {
|
|
118
|
-
return;
|
|
123
|
+
return false;
|
|
119
124
|
}
|
|
120
125
|
const node = getNodeAtPath(tab.doc.document, path);
|
|
121
126
|
if (!node) {
|
|
122
|
-
return;
|
|
127
|
+
return false;
|
|
123
128
|
}
|
|
124
129
|
const current = (node.$props as Record<string, unknown> | undefined)?.[prop];
|
|
125
130
|
if (`${current ?? ""}` === value) {
|
|
126
|
-
return;
|
|
131
|
+
return false;
|
|
127
132
|
}
|
|
128
133
|
transactDoc(tab, (t) => {
|
|
129
134
|
mutateUpdateProp(t, path, prop, value);
|
|
130
135
|
});
|
|
136
|
+
return true;
|
|
131
137
|
}
|
|
132
138
|
|
|
133
139
|
/**
|
|
@@ -182,11 +182,19 @@ let endFn: (() => void) | null = null; // Function() called when editing stops
|
|
|
182
182
|
/**
|
|
183
183
|
* Plain (plaintext-only) session state — used for prop-bound text, where the committed value is a
|
|
184
184
|
* plain string (a directive attribute), so rich formatting, Enter-split, and the slash menu are all
|
|
185
|
-
* disabled: Enter commits, Escape restores `_plainOriginal
|
|
186
|
-
*
|
|
185
|
+
* disabled: Enter commits, Escape restores `_plainOriginal`.
|
|
186
|
+
*
|
|
187
|
+
* Escape is a genuine cancel, and `_plainPosted` is what makes it one. Restoring the text is not
|
|
188
|
+
* enough on its own: if an idle tick already wrote during the session, the document holds the typed
|
|
189
|
+
* value and the restored text must be written back over it. Conversely a session that never posted
|
|
190
|
+
* and ends matching its original must write NOTHING — the host's "unchanged value" guard cannot be
|
|
191
|
+
* relied on for that, because it compares against the STORED prop and an unset prop's stored value
|
|
192
|
+
* is `undefined` while the marker renders the definition's default.
|
|
187
193
|
*/
|
|
188
194
|
let _plainMode = false;
|
|
189
195
|
let _plainOriginal = "";
|
|
196
|
+
/** Whether this plain session has posted a commit — see the note above on Escape. */
|
|
197
|
+
let _plainPosted = false;
|
|
190
198
|
|
|
191
199
|
/**
|
|
192
200
|
* The live document's format overrides for which tags hold a caret. Injected per render (the same
|
|
@@ -286,6 +294,7 @@ export function startEditing(
|
|
|
286
294
|
endFn = callbacks.onEnd;
|
|
287
295
|
_plainMode = opts?.plainText === true;
|
|
288
296
|
_plainOriginal = _plainMode ? (el.textContent ?? "") : "";
|
|
297
|
+
_plainPosted = false;
|
|
289
298
|
|
|
290
299
|
// Mark the caret's block so the empty-block slash hint can target it. The editing host itself is
|
|
291
300
|
// The canvas container (see syncEditableRoot) — activating a block does NOT make it a separate
|
|
@@ -345,6 +354,7 @@ export function stopEditing(silent = false) {
|
|
|
345
354
|
insertFn = null;
|
|
346
355
|
_plainMode = false;
|
|
347
356
|
_plainOriginal = "";
|
|
357
|
+
_plainPosted = false;
|
|
348
358
|
|
|
349
359
|
if (silent) {
|
|
350
360
|
endFn = null;
|
|
@@ -370,6 +380,19 @@ export function commitActiveBlock(): void {
|
|
|
370
380
|
commitChanges(true);
|
|
371
381
|
}
|
|
372
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Whether the live session is PLAIN — a prop value rather than a block of the document.
|
|
385
|
+
*
|
|
386
|
+
* Exported because the format bridge is in another module and `_plainMode` is file-local, so
|
|
387
|
+
* `applyFormatIntent` could not even ask: a ⌘B forwarded out to the parent came back as an
|
|
388
|
+
* `applyFormat` intent and ran `toggleInlineFormat` on a `plaintext-only` host, wrapping the prop's
|
|
389
|
+
* text in a `<strong>` that its next commit then flattened away — losing nothing visible, but
|
|
390
|
+
* mutating the DOM under a session whose whole contract is that it holds one plain string.
|
|
391
|
+
*/
|
|
392
|
+
export function isPlainSession(): boolean {
|
|
393
|
+
return _plainMode && activeEl !== null;
|
|
394
|
+
}
|
|
395
|
+
|
|
373
396
|
/**
|
|
374
397
|
* Whether inline editing is currently active.
|
|
375
398
|
*
|
|
@@ -416,7 +439,9 @@ function handleKeydown(e: KeyboardEvent) {
|
|
|
416
439
|
e.preventDefault();
|
|
417
440
|
e.stopPropagation();
|
|
418
441
|
if (activeEl) {
|
|
419
|
-
|
|
442
|
+
/* Cancel: restore the original text. `commitChanges` then posts it ONLY if this session had
|
|
443
|
+
already written (an idle tick), which is what makes Escape undo the tick instead of
|
|
444
|
+
leaving it standing — and what stops a bare Escape from writing anything at all. */
|
|
420
445
|
activeEl.textContent = _plainOriginal;
|
|
421
446
|
}
|
|
422
447
|
stopEditing();
|
|
@@ -532,6 +557,7 @@ function releaseWithoutCommit(): void {
|
|
|
532
557
|
insertFn = null;
|
|
533
558
|
_plainMode = false;
|
|
534
559
|
_plainOriginal = "";
|
|
560
|
+
_plainPosted = false;
|
|
535
561
|
|
|
536
562
|
if (endFn) {
|
|
537
563
|
const fn = endFn;
|
|
@@ -557,7 +583,26 @@ function commitChanges(inPlace: boolean) {
|
|
|
557
583
|
if (_plainMode) {
|
|
558
584
|
// A prop value is a plain single-line string (a directive attribute) — flatten any newline
|
|
559
585
|
// That survived plaintext editing and skip the rich DOM→Jx serialization entirely.
|
|
560
|
-
|
|
586
|
+
const text = (activeEl.textContent ?? "").replaceAll("\n", " ");
|
|
587
|
+
/*
|
|
588
|
+
* A SESSION THAT CHANGED NOTHING MUST WRITE NOTHING.
|
|
589
|
+
*
|
|
590
|
+
* This branch used to post unconditionally, and the only guard was the host's
|
|
591
|
+
* "same value, do nothing" check against the STORED prop — which does not fire when the prop is
|
|
592
|
+
* unset, because the marker renders the DEFINITION's default and the stored value is
|
|
593
|
+
* `undefined`. So merely clicking a component's text and clicking away wrote
|
|
594
|
+
* `$props.<name> = "<the default>"`: the tab went dirty, an undo entry appeared, and that
|
|
595
|
+
* instance was silently detached from its definition forever. Escape did the same thing, which
|
|
596
|
+
* made "cancel" a way to modify the document.
|
|
597
|
+
*
|
|
598
|
+
* `_plainPosted` is what keeps Escape honest AFTER an idle tick has already written: the
|
|
599
|
+
* restored text equals the original, but something WAS written, so it must be written back.
|
|
600
|
+
*/
|
|
601
|
+
if (text === _plainOriginal.replaceAll("\n", " ") && !_plainPosted) {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
_plainPosted = true;
|
|
605
|
+
commitFn(activePath, null, text, inPlace);
|
|
561
606
|
return;
|
|
562
607
|
}
|
|
563
608
|
|
|
@@ -775,6 +820,19 @@ export function openSlashMenu(opts?: { anchored?: boolean }): void {
|
|
|
775
820
|
if (!activeEl || !insertFn || !activePath) {
|
|
776
821
|
return;
|
|
777
822
|
}
|
|
823
|
+
/*
|
|
824
|
+
* Never in a plain session. A prop value is one string — there is nowhere to insert a block, and
|
|
825
|
+
* `enterPropEditAt` says so by passing `onInsert: () => {}`. That no-op is TRUTHY, so the
|
|
826
|
+
* precondition above let the menu open anyway, and choosing an item then ran the rich select
|
|
827
|
+
* path: it deleted the typed "/…" run and called `releaseWithoutCommit`, so the edit was
|
|
828
|
+
* discarded and no block was inserted either. The menu also captures Enter and Escape
|
|
829
|
+
* document-wide, so it took the two keys the docs promise for this session.
|
|
830
|
+
*
|
|
831
|
+
* Guarded here rather than at the trigger so a programmatic `openSlashMenu()` is covered too.
|
|
832
|
+
*/
|
|
833
|
+
if (_plainMode) {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
778
836
|
|
|
779
837
|
const sel = window.getSelection();
|
|
780
838
|
if (!sel || !sel.rangeCount) {
|
package/src/types.ts
CHANGED
|
@@ -195,7 +195,9 @@ export interface StudioPlatform {
|
|
|
195
195
|
* every `$ref` fetch landed on the SPA fallback — which answers HTML at **200**, so `res.ok`
|
|
196
196
|
* passed and the parse died on `Unexpected token '<'`. Images failed the same way in silence.
|
|
197
197
|
*
|
|
198
|
-
*
|
|
198
|
+
* May be absolute or root-relative — a root-relative value is resolved against the canvas origin,
|
|
199
|
+
* because the canvas uses this as `new URL(path, base)` and a relative BASE throws. A missing
|
|
200
|
+
* trailing `/` is added: without it `new URL` drops the last segment.
|
|
199
201
|
*/
|
|
200
202
|
documentBaseUrl?: string;
|
|
201
203
|
activate: (root?: string) => Promise<void>;
|