From 2be37580fa6b18b815fa7f74c4a20d169caf483c Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Fri, 28 Aug 2026 13:41:59 +0200 Subject: [PATCH 1/8] Move the playground front-end to js-toolkit v4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped shell called `createApp()`, which v4 removed. Any consumer already on v4 — @studiometa/ui's documentation site, which embeds this playground — got `TypeError: createApp is not a function` from dist/front/js/create-playground.js before a single example rendered. What each v3 API became: - `createApp(Playground)` -> `registerComponent(Playground)`, and `pages/index.twig` declares `data-component="Playground"` on ``. An instance exists because its element is in the document and its class is registered; there is no application object, so `createPlayground()` returns nothing. - `$children.X` -> `$watchChildren(X)`. v4 guarantees no mount ordering, so this is not a rename. `Playground` used to read the editor visibility out of `$children` in `mounted()`, which only worked because a v3 parent mounted after its children; it now owns that state and pushes it onto every `EditorVisibility` through the `added` callback, so an editor that mounts later gets the same treatment as one that was already there. `Resizable` watches its `ResizableSync` children the same way. - `getClosestParent(instance, Class)` -> `$closest(name)`, and `getInstanceFromElement(el, Class)` -> `getInstance(el, name)`. Resolving `Resizable` by name also keeps its class out of `LayoutReactive`'s import graph, so the lazy chunk stays lazy. - `config.emits` -> `$emits` in the props type. A payload is now one object, so `$emit('switch', value)` is `$emit('switch', { value })`. - v3 emitted every hook call as an event for free. v4 does not, so `ResizableCursor.dragged()` republishes the drag props itself — which is what `Resizable.onResizableCursorDragged()` waits on. - Delegated child handlers receive `{ event, target, payload }`, not `{ event, index, args, target }`. `Playground.onResizableDragged()` was typed as if it received the drag props directly, so `props.mode` was always undefined and neither of its branches ever ran. - `destroyed()` -> the cleanup `mounted()` returns. Editors now dispose their Monaco instance and drop their theme subscription; v3 leaked both. - `domScheduler` -> `this.$read` / `this.$write` in components, cancelled on unmount, and `defaultScheduler` in the stores, which have no instance. `nextTick()` -> `wait()`, `isArray()` -> `Array.isArray()`, `nextFrame` moved to the package root. - `Object` option defaults must be factories, so `importMap: Object` is `{ type: Object, default: () => ({}) }`. - `HtmlEditor`, `ScriptEditor` and `StyleEditor` declare their own `config.name`. They inherited `Editor`'s, which v4 would have registered under the wrong `data-component` token; config now merges along the prototype chain, so nothing else is respelled. `ResizableSync.set()` also writes to `style.width`/`style.height` rather than to element properties that do not exist. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R --- package-lock.json | 50 ++--- packages/playground/package.json | 2 +- .../src/front/js/components/Editor.ts | 20 +- .../front/js/components/EditorVisibility.ts | 17 +- .../src/front/js/components/Editors.ts | 5 +- .../src/front/js/components/HeaderSwitcher.ts | 9 +- .../src/front/js/components/HtmlEditor.ts | 17 +- .../src/front/js/components/Iframe.ts | 49 +++-- .../src/front/js/components/LayoutReactive.ts | 26 +-- .../src/front/js/components/LayoutSwitcher.ts | 19 +- .../src/front/js/components/Playground.ts | 187 ++++++++++-------- .../src/front/js/components/Resizable.ts | 96 ++++----- .../front/js/components/ResizableCursor.ts | 22 ++- .../src/front/js/components/ResizableSync.ts | 20 +- .../src/front/js/components/ScriptEditor.ts | 14 +- .../src/front/js/components/StyleEditor.ts | 16 +- .../src/front/js/components/Switcher.ts | 18 +- .../src/front/js/components/ThemeSwitcher.ts | 17 +- .../src/front/js/create-playground.ts | 13 +- .../playground/src/front/js/store/header.ts | 4 +- .../playground/src/front/js/store/layout.ts | 4 +- .../playground/src/front/js/store/theme.ts | 4 +- .../src/front/templates/pages/index.twig | 1 + 23 files changed, 359 insertions(+), 271 deletions(-) diff --git a/package-lock.json b/package-lock.json index 273974e..be900ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2218,33 +2218,6 @@ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", "license": "MIT" }, - "node_modules/@motionone/easing": { - "version": "10.18.0", - "resolved": "https://registry.npmjs.org/@motionone/easing/-/easing-10.18.0.tgz", - "integrity": "sha512-VcjByo7XpdLS4o9T8t99JtgxkdMcNWD3yHU/n6CLEz3bkmKDRZyYQ/wmSf6daum8ZXqfUAgFeCZSpJZIMxaCzg==", - "license": "MIT", - "dependencies": { - "@motionone/utils": "^10.18.0", - "tslib": "^2.3.1" - } - }, - "node_modules/@motionone/types": { - "version": "10.17.1", - "resolved": "https://registry.npmjs.org/@motionone/types/-/types-10.17.1.tgz", - "integrity": "sha512-KaC4kgiODDz8hswCrS0btrVrzyU2CSQKO7Ps90ibBVSQmjkrt2teqta6/sOG59v7+dPnKMAg13jyqtMKV2yJ7A==", - "license": "MIT" - }, - "node_modules/@motionone/utils": { - "version": "10.18.0", - "resolved": "https://registry.npmjs.org/@motionone/utils/-/utils-10.18.0.tgz", - "integrity": "sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw==", - "license": "MIT", - "dependencies": { - "@motionone/types": "^10.17.1", - "hey-listen": "^1.0.8", - "tslib": "^2.3.1" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -3655,13 +3628,12 @@ "license": "MIT" }, "node_modules/@studiometa/js-toolkit": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/@studiometa/js-toolkit/-/js-toolkit-3.4.3.tgz", - "integrity": "sha512-CF7+Q5JgK8d8730pnC+CfdtsCT1ayABkzsSLoihKtcx36uOJv1tDNGRMl8rsUFTUk4HTmROQAHrsmaqTH8pfUg==", + "version": "4.0.0-alpha.1", + "resolved": "https://registry.npmjs.org/@studiometa/js-toolkit/-/js-toolkit-4.0.0-alpha.1.tgz", + "integrity": "sha512-Ei3OITjFTx3D/sWBKegDww1ybw8TopwFSlRqS6KnpRA5fDAl0/jDr1ANyiPwJDEj7oXCD1a5sMIHxmA0stdOTw==", "license": "MIT", "dependencies": { - "@motionone/easing": "^10.18.0", - "deepmerge": "^4.3.1" + "morphdom": "^2.7.8" } }, "node_modules/@studiometa/oxlint-config": { @@ -8446,12 +8418,6 @@ "he": "bin/he" } }, - "node_modules/hey-listen": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", - "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==", - "license": "MIT" - }, "node_modules/hookable": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.0.1.tgz", @@ -11215,6 +11181,12 @@ "webpack": "^4.37.0 || ^5.0.0" } }, + "node_modules/morphdom": { + "version": "2.7.8", + "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.8.tgz", + "integrity": "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg==", + "license": "MIT" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -16904,7 +16876,7 @@ "version": "0.3.14", "license": "MIT", "dependencies": { - "@studiometa/js-toolkit": "^3.4.3", + "@studiometa/js-toolkit": "^4.0.0-alpha.1", "@studiometa/webpack-config": "^6.3.6", "@studiometa/webpack-config-preset-prototyping": "^6.3.6", "@studiometa/webpack-config-preset-tailwindcss-4": "6.3.6", diff --git a/packages/playground/package.json b/packages/playground/package.json index 3ca77ae..8ef34dc 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -56,7 +56,7 @@ "build:types": "tsc --build tsconfig.build.json" }, "dependencies": { - "@studiometa/js-toolkit": "^3.4.3", + "@studiometa/js-toolkit": "^4.0.0-alpha.1", "@studiometa/webpack-config": "^6.3.6", "@studiometa/webpack-config-preset-prototyping": "^6.3.6", "@studiometa/webpack-config-preset-tailwindcss-4": "6.3.6", diff --git a/packages/playground/src/front/js/components/Editor.ts b/packages/playground/src/front/js/components/Editor.ts index 56f1c55..21a7290 100644 --- a/packages/playground/src/front/js/components/Editor.ts +++ b/packages/playground/src/front/js/components/Editor.ts @@ -6,7 +6,11 @@ import { getMonaco, registerLspOptions, DARK_THEME, LIGHT_THEME } from '../utils import type { MonacoNamespace } from '../utils/monaco.js'; import { themeIsDark, watchTheme } from '../store/index.js'; -export type EditorProps = BaseProps; +export type EditorProps = BaseProps & { + $emits: { + 'content-change': { value: string }; + }; +}; type MonacoEditor = ReturnType; @@ -19,7 +23,6 @@ export default class Editor extends Base { */ static config: BaseConfig = { name: 'Editor', - emits: ['content-change'], }; /** @@ -97,15 +100,24 @@ export default class Editor extends Base { addJsAutocompletion(this.#monaco.languages); - watchTheme(async () => { + const unwatchTheme = watchTheme(async () => { this.#monaco.editor.setTheme((await themeIsDark()) ? DARK_THEME : LIGHT_THEME); }); this.editor.onDidChangeModelContent( debounce(() => { - this.$emit('content-change', this.editor.getValue()); + this.$emit('content-change', { value: this.editor.getValue() }); }, 500), ); + + // v4 replaces `destroyed()` with the cleanup `mounted()` returns. The theme + // subscription and the Monaco instance both belong to this mount cycle. + return () => { + unwatchTheme(); + this.editor?.dispose(); + this.editor = undefined; + model.dispose(); + }; } async getInitialValue() { diff --git a/packages/playground/src/front/js/components/EditorVisibility.ts b/packages/playground/src/front/js/components/EditorVisibility.ts index 19398d1..e6ca747 100644 --- a/packages/playground/src/front/js/components/EditorVisibility.ts +++ b/packages/playground/src/front/js/components/EditorVisibility.ts @@ -1,6 +1,5 @@ import { Base } from '@studiometa/js-toolkit'; import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; -import { domScheduler } from '@studiometa/js-toolkit/utils'; export type EditorVisibilityProps = BaseProps; @@ -15,14 +14,24 @@ export default class EditorVisibility extends Base { name: 'EditorVisibility', }; + /** + * The content type this editor edits, read from the markup. + * + * The coordinator uses it to tell the three editors apart. It is a DOM fact, + * so it answers before anything mounts. + */ + get lang(): string { + return this.$el.dataset.lang ?? ''; + } + show() { - domScheduler.write(() => { + this.$write(() => { this.$el.style.display = ''; }); } hide() { - domScheduler.write(() => { + this.$write(() => { this.$el.style.display = 'none'; }); } @@ -38,7 +47,7 @@ export default class EditorVisibility extends Base { return; } - domScheduler.read(() => { + this.$read(() => { if (this.$el.style.display === 'none') { this.show(); } else { diff --git a/packages/playground/src/front/js/components/Editors.ts b/packages/playground/src/front/js/components/Editors.ts index 787c97a..dd90e07 100644 --- a/packages/playground/src/front/js/components/Editors.ts +++ b/packages/playground/src/front/js/components/Editors.ts @@ -1,6 +1,5 @@ import { Base } from '@studiometa/js-toolkit'; import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; -import { domScheduler } from '@studiometa/js-toolkit/utils'; export type EditorsProps = BaseProps; @@ -16,13 +15,13 @@ export default class Editors extends Base { }; hide() { - domScheduler.write(() => { + this.$write(() => { this.$el.classList.add('hidden'); }); } show() { - domScheduler.write(() => { + this.$write(() => { this.$el.classList.remove('hidden'); }); } diff --git a/packages/playground/src/front/js/components/HeaderSwitcher.ts b/packages/playground/src/front/js/components/HeaderSwitcher.ts index 6431b7c..55b7e79 100644 --- a/packages/playground/src/front/js/components/HeaderSwitcher.ts +++ b/packages/playground/src/front/js/components/HeaderSwitcher.ts @@ -1,14 +1,13 @@ import { Base } from '@studiometa/js-toolkit'; import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; -import { domScheduler } from '@studiometa/js-toolkit/utils'; import { setHeaderVisibility, headerIsVisible } from '../store/header.js'; -export interface HeaderSwitcherProps extends BaseProps { +export type HeaderSwitcherProps = BaseProps & { $refs: { show: HTMLButtonElement; hide: HTMLButtonElement; }; -} +}; /** * HeaderSwitcher class. @@ -46,8 +45,8 @@ export default class HeaderSwitcher extends Base { this.$refs.show.focus(); } - update(isVisible) { - domScheduler.write(() => { + update(isVisible: boolean) { + this.$write(() => { this.$refs.hide.classList.toggle('flex', isVisible); this.$refs.show.classList.toggle('flex', !isVisible); this.$refs.hide.classList.toggle('hidden', !isVisible); diff --git a/packages/playground/src/front/js/components/HtmlEditor.ts b/packages/playground/src/front/js/components/HtmlEditor.ts index 8666c58..da49a46 100644 --- a/packages/playground/src/front/js/components/HtmlEditor.ts +++ b/packages/playground/src/front/js/components/HtmlEditor.ts @@ -1,3 +1,4 @@ +import type { BaseConfig } from '@studiometa/js-toolkit'; import { getHtml, setHtml } from '../store/index.js'; import { registerLang, registerHtmlLspAlias } from '../utils/monaco.js'; import Editor from './Editor.js'; @@ -20,9 +21,13 @@ const LANG_EXTENSIONS: Record = { export default class HtmlEditor extends Editor { /** * Config. + * + * v4 merges config along the prototype chain, so only what this class adds + * is declared here — and the name has to be its own, because it is the + * `data-component` token the registry answers for. */ - static config = { - ...Editor.config, + static config: BaseConfig = { + name: 'HtmlEditor', options: { /** * The language ID for the editor (e.g. 'html', 'twig', 'liquid'). @@ -60,20 +65,22 @@ export default class HtmlEditor extends Editor { registerHtmlLspAlias(lang); } - await super.mounted(); + const cleanup = await super.mounted(); // Register language-specific snippets after Monaco is ready. if (lang === 'twig') { const { addTwigAutocompletion } = await import('../utils/twig/index.js'); addTwigAutocompletion(this.monaco.languages); } + + return cleanup; } async getInitialValue() { return getHtml(); } - onContentChange({ args: [value] }) { - setHtml(value); + onContentChange(event: CustomEvent<{ value: string }>) { + setHtml(event.detail.value); } } diff --git a/packages/playground/src/front/js/components/Iframe.ts b/packages/playground/src/front/js/components/Iframe.ts index 2293983..3180944 100644 --- a/packages/playground/src/front/js/components/Iframe.ts +++ b/packages/playground/src/front/js/components/Iframe.ts @@ -1,6 +1,6 @@ -import { Base } from '@studiometa/js-toolkit'; -import type { BaseProps } from '@studiometa/js-toolkit'; -import { nextTick, isArray, nextFrame } from '@studiometa/js-toolkit/utils'; +import { Base, nextFrame } from '@studiometa/js-toolkit'; +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { wait } from '@studiometa/js-toolkit/utils'; import { themeIsDark, watchTheme, @@ -20,15 +20,16 @@ type esbuildType = typeof import('esbuild-wasm'); */ declare const __ESBUILD_WASM_VERSION__: string; -export interface IframeProps extends BaseProps { +export type IframeProps = BaseProps & { $refs: { iframe: HTMLIFrameElement; }; $options: { tailwindcss: boolean; + syncColorScheme: boolean; importMap: Record; }; -} +}; /** * Iframe class. @@ -37,13 +38,13 @@ export default class Iframe extends Base { /** * Config. */ - static config = { + static config: BaseConfig = { name: 'Iframe', refs: ['iframe'], options: { tailwindcss: Boolean, syncColorScheme: Boolean, - importMap: Object, + importMap: { type: Object, default: () => ({}) }, }, }; @@ -63,10 +64,18 @@ export default class Iframe extends Base { static esbuildPromise: PromiseWithResolvers; /** - * The style element inside the iframe used to inject the style editor's content. + * The style element inside the iframe used to inject the script editor's content. */ style: HTMLStyleElement; + /** + * Unsubscribe the theme watcher installed by the current iframe document. + * + * `initIframe()` runs again on every reload, so the previous subscription + * has to go before a new one replaces it. + */ + #unwatchTheme?: () => void; + get window() { return this.$refs.iframe.contentWindow; } @@ -78,6 +87,11 @@ export default class Iframe extends Base { async mounted() { await nextFrame(); await this.initIframe(); + + return () => { + this.#unwatchTheme?.(); + this.#unwatchTheme = undefined; + }; } async initIframe() { @@ -109,7 +123,8 @@ ${html} } if (this.$options.syncColorScheme) { this.doc.documentElement.classList.toggle('dark', await themeIsDark()); - watchTheme((theme) => { + this.#unwatchTheme?.(); + this.#unwatchTheme = watchTheme((theme) => { this.doc.documentElement.classList.toggle('dark', theme === 'dark'); }); } @@ -130,7 +145,7 @@ ${html} this.script.id = 'script'; this.doc.head.append(this.script); - await nextTick(); + await wait(); await this.updateStyle(); await this.updateScript(false); } @@ -194,22 +209,22 @@ ${html} async updateHtml() { console.log('updating html...'); - await nextTick(); + await wait(); const html = await getHtml(); if (html !== this.doc.body.innerHTML) { this.doc.body.innerHTML = html; } - await nextTick(); + await wait(); console.log('html updated!'); await this.updateScript(false); } async updateStyle() { console.log('updating style...'); - await nextTick(); + await wait(); const style = await getStyle(); this.style.textContent = style; - await nextTick(); + await wait(); console.log('style updated!'); } @@ -217,9 +232,9 @@ ${html} console.log('updating script...'); if (resetHtml) { this.doc.body.replaceWith(this.doc.body.cloneNode(true)); - await nextTick(); + await wait(); } - await nextTick(); + await wait(); const newScriptContent = await getScript(); const newScript = `${newScriptContent}\ndocument.dispatchEvent(new Event("readystatechange"))`; @@ -239,7 +254,7 @@ ${html} console.log('script updated!'); } catch (err) { console.log('script not updated due to some errors:'); - if (isArray(err.errors)) { + if (Array.isArray(err.errors)) { for (const error of err.errors) { console.error(`${error.text} (${error.location.line}:${error.location.column})`); } diff --git a/packages/playground/src/front/js/components/LayoutReactive.ts b/packages/playground/src/front/js/components/LayoutReactive.ts index f635950..52bddd3 100644 --- a/packages/playground/src/front/js/components/LayoutReactive.ts +++ b/packages/playground/src/front/js/components/LayoutReactive.ts @@ -1,18 +1,17 @@ -import { Base, getInstanceFromElement } from '@studiometa/js-toolkit'; +import { Base, getInstance } from '@studiometa/js-toolkit'; import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; -import { domScheduler } from '@studiometa/js-toolkit/utils'; import { watchLayout, getLayout } from '../store/index.js'; import type { Layouts } from '../store/index.js'; -import Resizable from './Resizable.js'; +import type Resizable from './Resizable.js'; -export interface LayoutReactiveProps extends BaseProps { +export type LayoutReactiveProps = BaseProps & { $options: { top: string; right: string; bottom: string; left: string; }; -} +}; const layouts = ['top', 'right', 'bottom', 'left']; @@ -35,14 +34,14 @@ export default class LayoutReactive extends Base { async mounted() { this.switch(await getLayout()); - watchLayout((value: Layouts) => { + + return watchLayout((value: Layouts) => { this.switch(value); }); } switch(value: Layouts) { - console.log('switch', value); - domScheduler.read(() => { + this.$read(() => { let toAdd = ''; let toRemove = ''; @@ -58,19 +57,22 @@ export default class LayoutReactive extends Base { toRemove = toRemove.trim(); if (toRemove.length) { - domScheduler.write(() => { + this.$write(() => { this.$el.classList.remove(...toRemove.split(' ')); }); } if (toAdd.length) { - domScheduler.write(() => { + this.$write(() => { this.$el.classList.add(...toAdd.split(' ')); }); } - const maybeResizable = getInstanceFromElement(this.$el, Resizable); - if (maybeResizable) { + // `Resizable` is lazy, and it shares this element when it is there. + // Resolving it by name keeps the class out of this module's import + // graph, so the lazy chunk stays lazy. + const maybeResizable = getInstance(this.$el, 'Resizable'); + if (maybeResizable?.$isMounted) { maybeResizable.reset(); } }); diff --git a/packages/playground/src/front/js/components/LayoutSwitcher.ts b/packages/playground/src/front/js/components/LayoutSwitcher.ts index 3a95f10..81198b9 100644 --- a/packages/playground/src/front/js/components/LayoutSwitcher.ts +++ b/packages/playground/src/front/js/components/LayoutSwitcher.ts @@ -1,4 +1,3 @@ -import { domScheduler } from '@studiometa/js-toolkit/utils'; import type { BaseConfig } from '@studiometa/js-toolkit'; import Switcher from './Switcher.js'; import { setLayout, getLayout } from '../store/index.js'; @@ -9,17 +8,15 @@ export default class LayoutSwitcher extends Switcher { name: 'LayoutSwitcher', }; - mounted() { - domScheduler.read(async () => { - const value = await getLayout(); - const input = this.$refs.inputs.find((i) => i.value === value); + async mounted() { + const value = await getLayout(); + const input = this.$refs.inputs.find((i) => i.value === value); - if (input) { - domScheduler.write(() => { - input.checked = true; - }); - } - }); + if (input) { + this.$write(() => { + input.checked = true; + }); + } } switch(value: Layouts) { diff --git a/packages/playground/src/front/js/components/Playground.ts b/packages/playground/src/front/js/components/Playground.ts index 0165263..11a44c7 100644 --- a/packages/playground/src/front/js/components/Playground.ts +++ b/packages/playground/src/front/js/components/Playground.ts @@ -1,17 +1,14 @@ import { Base } from '@studiometa/js-toolkit'; -import type { BaseConfig, BaseProps, DragServiceProps } from '@studiometa/js-toolkit'; -import { domScheduler, wait } from '@studiometa/js-toolkit/utils'; +import type { BaseConfig, BaseProps, DelegatedEvent } from '@studiometa/js-toolkit'; +import { wait } from '@studiometa/js-toolkit/utils'; import HeaderSwitcher from './HeaderSwitcher.js'; import LayoutReactive from './LayoutReactive.js'; import LayoutSwitcher from './LayoutSwitcher.js'; import ThemeSwitcher from './ThemeSwitcher.js'; import EditorVisibility from './EditorVisibility.js'; import Editors from './Editors.js'; -import type HtmlEditor from './HtmlEditor.js'; import Iframe from './Iframe.js'; import type Resizable from './Resizable.js'; -import type ScriptEditor from './ScriptEditor.js'; -import type StyleEditor from './StyleEditor.js'; import { layoutUpdateDOM, themeUpdateDOM, headerUpdateDOM } from '../store/index.js'; import { urlStore } from '../utils/storage/index.js'; import { setDefaults } from '../store/config.js'; @@ -20,19 +17,7 @@ layoutUpdateDOM(); themeUpdateDOM(); headerUpdateDOM(); -export interface PlaygroundProps extends BaseProps { - $children: { - LayoutSwitcher: LayoutSwitcher[]; - LayoutReactive: LayoutReactive[]; - HeaderSwitcher: HeaderSwitcher[]; - Editors: Editors[]; - Iframe: Iframe[]; - EditorVisibility: EditorVisibility[]; - Resizable: Promise[]; - HtmlEditor: Promise[]; - ScriptEditor: Promise[]; - StyleEditor: Promise[]; - }; +export type PlaygroundProps = BaseProps & { $refs: { htmlVisibility: HTMLInputElement; scriptVisibility: HTMLInputElement; @@ -43,7 +28,18 @@ export interface PlaygroundProps extends BaseProps { style: string; script: string; }; -} +}; + +/** + * The `data-lang` value each visibility checkbox owns. + */ +const LANGS = { + html: 'text/html', + style: 'text/css', + script: 'text/javascript', +} as const; + +type EditorKind = keyof typeof LANGS; export class Playground extends Base { static config: BaseConfig = { @@ -70,36 +66,41 @@ export class Playground extends Base { }, }; - get iframe() { - return this.$children.Iframe[0]; - } + /** + * Which editors are visible, by `data-lang`. + * + * v3 kept this in the DOM and read it back through `$children`, which was + * only safe because a parent mounted after its children. v4 gives no such + * ordering, so the coordinator owns the state and pushes it onto every + * `EditorVisibility` it sees — the ones already there, and the ones that + * arrive later. + */ + #visibility = new Map([ + [LANGS.html, true], + [LANGS.style, true], + [LANGS.script, true], + ]); + + #editorVisibilities = this.$watchChildren(EditorVisibility, { + added: (instance) => { + this.#applyVisibility(instance); + this.maybeToggleEditorsContainer(); + }, + removed: () => this.maybeToggleEditorsContainer(), + }); - get editors() { - return this.$children.Editors[0]; - } + #editors = this.$watchChildren(Editors, { + added: () => this.maybeToggleEditorsContainer(), + }); - get htmlEditorVisibility() { - for (const editor of this.$children.EditorVisibility) { - if (editor.$el.dataset.lang === 'text/html') { - return editor; - } - } - } + #iframes = this.$watchChildren(Iframe); - get scriptEditorVisibility() { - for (const editor of this.$children.EditorVisibility) { - if (editor.$el.dataset.lang === 'text/javascript') { - return editor; - } - } + get iframe(): Iframe | undefined { + return this.#iframes.items[0]; } - get styleEditorVisibility() { - for (const editor of this.$children.EditorVisibility) { - if (editor.$el.dataset.lang === 'text/css') { - return editor; - } - } + get editors(): Editors | undefined { + return this.#editors.items[0]; } async mounted() { @@ -109,80 +110,112 @@ export class Playground extends Base { style: this.$options.style, }); - this.$refs.htmlVisibility.checked = - !(await urlStore.has('html-editor')) || (await urlStore.get('html-editor')) === 'true'; - this.$refs.styleVisibility.checked = - !(await urlStore.has('style-editor')) || (await urlStore.get('style-editor')) === 'true'; - this.$refs.scriptVisibility.checked = - !(await urlStore.has('script-editor')) || (await urlStore.get('script-editor')) === 'true'; + const [html, style, script] = await Promise.all([ + this.#readStoredVisibility('html-editor'), + this.#readStoredVisibility('style-editor'), + this.#readStoredVisibility('script-editor'), + ]); - this.htmlEditorVisibility.toggle(this.$refs.htmlVisibility.checked); - this.scriptEditorVisibility.toggle(this.$refs.scriptVisibility.checked); - this.styleEditorVisibility.toggle(this.$refs.styleVisibility.checked); - this.maybeToggleEditorsContainer(); + this.$refs.htmlVisibility.checked = html; + this.$refs.styleVisibility.checked = style; + this.$refs.scriptVisibility.checked = script; + + this.#setVisibility('html', html); + this.#setVisibility('style', style); + this.#setVisibility('script', script); } onHtmlVisibilityInput({ target: { checked } }) { - this.htmlEditorVisibility.toggle(checked); + this.#setVisibility('html', checked); urlStore.set('html-editor', checked); - this.maybeToggleEditorsContainer(); } onStyleVisibilityInput({ target: { checked } }) { - this.styleEditorVisibility.toggle(this.$refs.styleVisibility.checked); + this.#setVisibility('style', checked); urlStore.set('style-editor', checked); - this.maybeToggleEditorsContainer(); } onScriptVisibilityInput({ target: { checked } }) { - this.scriptEditorVisibility.toggle(this.$refs.scriptVisibility.checked); + this.#setVisibility('script', checked); urlStore.set('script-editor', checked); - this.maybeToggleEditorsContainer(); } maybeToggleEditorsContainer() { const { editors } = this; - if ( - !this.$refs.htmlVisibility.checked && - !this.$refs.scriptVisibility.checked && - !this.$refs.styleVisibility.checked - ) { - editors.hide(); - } else { + + if (!editors) { + return; + } + + if ([...this.#visibility.values()].some(Boolean)) { editors.show(); + } else { + editors.hide(); } } onHtmlEditorContentChange() { - this.iframe.updateHtml(); + this.iframe?.updateHtml(); } onStyleEditorContentChange() { - this.iframe.updateStyle(); + this.iframe?.updateStyle(); } onScriptEditorContentChange() { - this.iframe.updateScript(); + this.iframe?.updateScript(); } - onResizableDragged(props: DragServiceProps) { + onResizableDragged({ payload }: DelegatedEvent) { const { iframe } = this; - if (props.mode === 'start') { - domScheduler.write(() => { + + if (!iframe) { + return; + } + + if (payload.mode === 'start') { + this.$write(() => { document.body.classList.add('select-none'); iframe.$el.parentElement.classList.add('pointer-events-none'); }); } - if (props.mode === 'drop') { - domScheduler.write(() => { + if (payload.mode === 'drop') { + this.$write(() => { document.body.classList.remove('select-none'); iframe.$el.parentElement.classList.remove('pointer-events-none'); }); } } - async onIframeReloaderClick() { - this.iframe.initIframe(); + onIframeReloaderClick() { + this.iframe?.initIframe(); + } + + /** + * Read one editor's stored visibility. Absent means visible. + */ + async #readStoredVisibility(key: string): Promise { + return !(await urlStore.has(key)) || (await urlStore.get(key)) === 'true'; + } + + #setVisibility(kind: EditorKind, isVisible: boolean) { + this.#visibility.set(LANGS[kind], isVisible); + + for (const instance of this.#editorVisibilities) { + if (instance.lang === LANGS[kind]) { + instance.toggle(isVisible); + } + } + + this.maybeToggleEditorsContainer(); + } + + #applyVisibility(instance: EditorVisibility) { + const isVisible = this.#visibility.get(instance.lang); + + if (typeof isVisible === 'boolean') { + instance.toggle(isVisible); + } } } diff --git a/packages/playground/src/front/js/components/Resizable.ts b/packages/playground/src/front/js/components/Resizable.ts index 64e6276..58d6a9a 100644 --- a/packages/playground/src/front/js/components/Resizable.ts +++ b/packages/playground/src/front/js/components/Resizable.ts @@ -1,16 +1,15 @@ -import { Base, getClosestParent, getInstanceFromElement } from '@studiometa/js-toolkit'; -import type { BaseProps, BaseConfig, DragServiceProps } from '@studiometa/js-toolkit'; -import { domScheduler, clamp } from '@studiometa/js-toolkit/utils'; +import { Base, getInstance } from '@studiometa/js-toolkit'; +import type { BaseProps, BaseConfig, DelegatedEvent, DragProps } from '@studiometa/js-toolkit'; +import { clamp } from '@studiometa/js-toolkit/utils'; import { layoutIsVertical, layoutIs } from '../store/index.js'; import ResizableCursor from './ResizableCursor.js'; import ResizableSync from './ResizableSync.js'; -export interface ResizableProps extends BaseProps { - $children: { - ResizableCursor: ResizableCursor[]; - ResizableSync: ResizableSync[]; +export type ResizableProps = BaseProps & { + $emits: { + dragged: DragProps; }; -} +}; export default class Resizable extends Base { static config: BaseConfig = { @@ -19,90 +18,90 @@ export default class Resizable extends Base { ResizableCursor, ResizableSync, }, - emits: ['dragged'], }; previousSize = 0; + /** + * Live, DOM-ordered views over the children. + * + * v3 read `$children`, which was a snapshot the parent owned and which only + * existed once the parent had mounted. These are watched from the field + * initializer, so a child that mounts first — v4 guarantees no ordering — + * joins the collection when it arrives instead of being missed. + */ + #syncs = this.$watchChildren(ResizableSync); + get visibleResizeSync() { - return this.$children.ResizableSync.filter( - (resizableSync) => resizableSync.$el.offsetParent !== null, - ); + return this.#syncs.items.filter((resizableSync) => resizableSync.$el.offsetParent !== null); } - async onResizableCursorDragged({ - target, - args: [props], - }: { - target: ResizableCursor; - args: [DragServiceProps]; - }) { - const { axis } = target.$options; - let method = 'resize'; - + async onResizableCursorDragged({ target, payload }: DelegatedEvent) { + const axis = target.$options.axis; const isVertical = await layoutIsVertical(); + const distance = axis === 'x' ? payload.distanceX : payload.distanceY; if ((isVertical && axis === 'y') || (!isVertical && axis === 'x')) { - method = 'resizeSync'; + this.resizeSync(payload.mode, axis, distance, target); + } else { + await this.resize(payload.mode, axis, distance); } - this[method](props.mode, target.$options.axis, props.distance, target); - this.$emit('dragged', props); + this.$emit('dragged', payload); } - async resize( - mode: DragServiceProps['mode'], - axis: 'x' | 'y', - distance: DragServiceProps['distance'], - ) { + async resize(mode: DragProps['mode'], axis: 'x' | 'y', distance: number) { + let value = distance; + if ((await layoutIs('right')) || (await layoutIs('bottom'))) { - distance.x *= -1; - distance.y *= -1; + value *= -1; } if (mode === 'start') { - domScheduler.read(() => { + this.$read(() => { const size = axis === 'x' ? 'offsetWidth' : 'offsetHeight'; this.previousSize = this.$el[size]; }); } else if (mode === 'drag') { - domScheduler.write(() => { + this.$write(() => { const size = axis === 'x' ? 'width' : 'height'; const minSize = 8; const maxSize = axis === 'x' ? window.innerWidth : window.innerHeight - 48; - const newSize = clamp(distance[axis] + this.previousSize, minSize, maxSize); + const newSize = clamp(value + this.previousSize, minSize, maxSize); this.$el.style[size] = `${newSize}px`; }); } } resizeSync( - mode: DragServiceProps['mode'], + mode: DragProps['mode'], axis: 'x' | 'y', - distance: DragServiceProps['distance'], + distance: number, resizableCursor: ResizableCursor, ) { const { visibleResizeSync } = this; if (visibleResizeSync.length === 2) { - visibleResizeSync[0]?.sync(mode, axis, distance[axis]); - visibleResizeSync[1]?.sync(mode, axis, distance[axis] * -1); + visibleResizeSync[0]?.sync(mode, axis, distance); + visibleResizeSync[1]?.sync(mode, axis, distance * -1); return; } - const parent = getClosestParent(resizableCursor, ResizableSync); + // `$closest()` walks the DOM for a mounted ancestor, which is what + // `getClosestParent(instance, Class)` did over the v3 parent/child graph. + const parent = resizableCursor.$closest('ResizableSync'); const next = parent - ? getInstanceFromElement(parent.$el.nextElementSibling as HTMLElement, ResizableSync) - : null; + ? getInstance(parent.$el.nextElementSibling, 'ResizableSync') + : undefined; if (!next) { return; } - parent.sync(mode, axis, distance[axis]); - next.sync(mode, axis, distance[axis] * -1); + parent.sync(mode, axis, distance); + next.sync(mode, axis, distance * -1); - for (const resizableSync of this.$children.ResizableSync) { + for (const resizableSync of this.#syncs) { if (resizableSync !== next && resizableSync !== parent) { resizableSync.set(axis); } @@ -110,10 +109,13 @@ export default class Resizable extends Base { } reset() { - domScheduler.write(() => { + this.$write(() => { this.$el.style.width = ''; this.$el.style.height = ''; }); - this.$children.ResizableSync.forEach((resizable) => resizable.reset()); + + for (const resizableSync of this.#syncs) { + resizableSync.reset(); + } } } diff --git a/packages/playground/src/front/js/components/ResizableCursor.ts b/packages/playground/src/front/js/components/ResizableCursor.ts index 5cd17ab..cd4bbe7 100644 --- a/packages/playground/src/front/js/components/ResizableCursor.ts +++ b/packages/playground/src/front/js/components/ResizableCursor.ts @@ -1,15 +1,17 @@ import { Base, withDrag } from '@studiometa/js-toolkit'; -import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import type { BaseConfig, BaseProps, DragProps } from '@studiometa/js-toolkit'; -export interface ResizableCursorProps extends BaseProps { +export type ResizableCursorProps = BaseProps & { $options: { axis: 'x' | 'y'; - targets: string[]; }; -} + $emits: { + dragged: DragProps; + }; +}; /** - * ResizableCursorX class. + * ResizableCursor class. */ export default class ResizableCursor extends withDrag(Base) { /** @@ -25,6 +27,16 @@ export default class ResizableCursor extends withDrag(Base) { previousSize = 0; - sync(mode: DragServiceProps['mode'], axis: 'x' | 'y', distance: number) { + sync(mode: DragProps['mode'], axis: 'x' | 'y', distance: number) { if (mode === 'start') { - domScheduler.read(() => { + this.$read(() => { const size = axis === 'x' ? 'offsetWidth' : 'offsetHeight'; this.previousSize = this.$el[size]; }); } else if (mode === 'drag') { - domScheduler.read(() => { + this.$read(() => { const minSize = 0; const maxSize = axis === 'x' ? window.innerWidth : window.innerHeight; - domScheduler.write(() => { + this.$write(() => { const size = axis === 'x' ? 'width' : 'height'; const newSize = clamp(distance + this.previousSize, minSize, maxSize); this.$el.style[size] = `${newSize}px`; @@ -37,18 +37,18 @@ export default class ResizableSync extends Base { } set(axis: 'x' | 'y') { - domScheduler.read(() => { + this.$read(() => { const size = axis === 'x' ? this.$el.offsetWidth : this.$el.offsetHeight; - domScheduler.write(() => { + this.$write(() => { const prop = axis === 'x' ? 'width' : 'height'; - this.$el[prop] = `${size}px`; + this.$el.style[prop] = `${size}px`; }); }); } reset() { - domScheduler.write(() => { + this.$write(() => { this.$el.style.width = ''; this.$el.style.height = ''; }); diff --git a/packages/playground/src/front/js/components/ScriptEditor.ts b/packages/playground/src/front/js/components/ScriptEditor.ts index f1445bb..6bd5508 100644 --- a/packages/playground/src/front/js/components/ScriptEditor.ts +++ b/packages/playground/src/front/js/components/ScriptEditor.ts @@ -1,3 +1,4 @@ +import type { BaseConfig } from '@studiometa/js-toolkit'; import type { InitOptions } from 'modern-monaco'; import { getScript, setScript } from '../store/index.js'; import { resolveImportMapUrls } from '../utils/resolve-import-map-urls.js'; @@ -6,11 +7,14 @@ import Editor from './Editor.js'; export default class ScriptEditor extends Editor { /** * Config. + * + * An `Object` option default must be a factory in v4, so each instance gets + * its own value. */ - static config = { - ...Editor.config, + static config: BaseConfig = { + name: 'ScriptEditor', options: { - importMap: Object, + importMap: { type: Object, default: () => ({}) }, }, }; @@ -47,7 +51,7 @@ export default class ScriptEditor extends Editor { return getScript(); } - onContentChange({ args: [value] }) { - setScript(value); + onContentChange(event: CustomEvent<{ value: string }>) { + setScript(event.detail.value); } } diff --git a/packages/playground/src/front/js/components/StyleEditor.ts b/packages/playground/src/front/js/components/StyleEditor.ts index 030eee8..3aab7b3 100644 --- a/packages/playground/src/front/js/components/StyleEditor.ts +++ b/packages/playground/src/front/js/components/StyleEditor.ts @@ -1,7 +1,19 @@ +import type { BaseConfig } from '@studiometa/js-toolkit'; import { getStyle, setStyle } from '../store/index.js'; import Editor from './Editor.js'; export default class StyleEditor extends Editor { + /** + * Config. + * + * v3 let this class inherit `Editor`'s config, name included. In v4 the name + * is the registry key, so a subclass that mounts on its own + * `data-component` token has to declare it. + */ + static config: BaseConfig = { + name: 'StyleEditor', + }; + get language(): string { return 'css'; } @@ -14,7 +26,7 @@ export default class StyleEditor extends Editor { return getStyle(); } - onContentChange({ args: [value] }) { - setStyle(value); + onContentChange(event: CustomEvent<{ value: string }>) { + setStyle(event.detail.value); } } diff --git a/packages/playground/src/front/js/components/Switcher.ts b/packages/playground/src/front/js/components/Switcher.ts index 81f978e..8997d04 100644 --- a/packages/playground/src/front/js/components/Switcher.ts +++ b/packages/playground/src/front/js/components/Switcher.ts @@ -1,11 +1,14 @@ import { Base } from '@studiometa/js-toolkit'; import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; -export interface SwitcherProps extends BaseProps { +export type SwitcherProps = BaseProps & { $refs: { inputs: HTMLInputElement[]; }; -} + $emits: { + switch: { value: string }; + }; +}; /** * Switcher class. @@ -17,7 +20,6 @@ export default class Switcher extends Base { static config: BaseConfig = { name: 'Switcher', refs: ['inputs[]'], - emits: ['switch'], }; get value() { @@ -25,8 +27,14 @@ export default class Switcher extends Base { } onInputsInput() { - this.switch(this.value); - this.$emit('switch', this.value); + const { value } = this; + + if (typeof value === 'undefined') { + return; + } + + this.switch(value); + this.$emit('switch', { value }); } switch(_value: string) { diff --git a/packages/playground/src/front/js/components/ThemeSwitcher.ts b/packages/playground/src/front/js/components/ThemeSwitcher.ts index 83d4811..18b0d1e 100644 --- a/packages/playground/src/front/js/components/ThemeSwitcher.ts +++ b/packages/playground/src/front/js/components/ThemeSwitcher.ts @@ -1,4 +1,3 @@ -import { domScheduler } from '@studiometa/js-toolkit/utils'; import type { BaseConfig } from '@studiometa/js-toolkit'; import Switcher from './Switcher.js'; import { setTheme, getTheme } from '../store/index.js'; @@ -9,17 +8,15 @@ export default class ThemeSwitcher extends Switcher { name: 'ThemeSwitcher', }; - mounted() { - domScheduler.read(async () => { - const value = await getTheme(); - const input = this.$refs.inputs.find((i) => i.value === value); + async mounted() { + const value = await getTheme(); + const input = this.$refs.inputs.find((i) => i.value === value); - domScheduler.write(() => { - if (input) { - input.checked = true; - } + if (input) { + this.$write(() => { + input.checked = true; }); - }); + } } switch(value: Themes) { diff --git a/packages/playground/src/front/js/create-playground.ts b/packages/playground/src/front/js/create-playground.ts index ec4aeac..e4503c5 100644 --- a/packages/playground/src/front/js/create-playground.ts +++ b/packages/playground/src/front/js/create-playground.ts @@ -1,10 +1,17 @@ -import { createApp } from '@studiometa/js-toolkit'; +import { registerComponent } from '@studiometa/js-toolkit'; import { Playground } from './components/Playground.js'; import type { PartialPlaygroundConfig } from './store/config.js'; import { setConfig } from './store/config.js'; -export function createPlayground(config?: PartialPlaygroundConfig) { +/** + * Configure the playground and register its root component. + * + * There is no application object in js-toolkit v4: an instance exists because + * its element declares `data-component="Playground"` and its class is + * registered. The `` element carries that declaration. + */ +export function createPlayground(config?: PartialPlaygroundConfig): void { setConfig(config); - return createApp(Playground); + registerComponent(Playground); } diff --git a/packages/playground/src/front/js/store/header.ts b/packages/playground/src/front/js/store/header.ts index cc35505..fbb8ce1 100644 --- a/packages/playground/src/front/js/store/header.ts +++ b/packages/playground/src/front/js/store/header.ts @@ -1,4 +1,4 @@ -import { domScheduler } from '@studiometa/js-toolkit/utils'; +import { defaultScheduler } from '@studiometa/js-toolkit'; import { fallbackStore as store } from '../utils/storage/index.js'; export type HeaderVisibility = 'visible' | 'hidden'; @@ -25,7 +25,7 @@ export async function headerIsHidden() { export async function headerUpdateDOM(value?: HeaderVisibility) { const header = value ?? (await getHeaderVisibility()); - domScheduler.write(() => { + defaultScheduler.write(() => { document.documentElement.classList.toggle('has-header', header === 'visible'); }); } diff --git a/packages/playground/src/front/js/store/layout.ts b/packages/playground/src/front/js/store/layout.ts index 12c3682..66e353c 100644 --- a/packages/playground/src/front/js/store/layout.ts +++ b/packages/playground/src/front/js/store/layout.ts @@ -1,4 +1,4 @@ -import { domScheduler } from '@studiometa/js-toolkit/utils'; +import { defaultScheduler } from '@studiometa/js-toolkit'; import { fallbackStore as store } from '../utils/storage/index.js'; export type Layouts = 'top' | 'right' | 'bottom' | 'left'; @@ -27,7 +27,7 @@ export async function layoutIsHoritontal() { export async function layoutUpdateDOM(value?: Layouts) { const layout = value ?? (await getLayout()); - domScheduler.write(() => { + defaultScheduler.write(() => { document.documentElement.classList.toggle('is-top', layout === 'top'); document.documentElement.classList.toggle('is-right', layout === 'right'); document.documentElement.classList.toggle('is-bottom', layout === 'bottom'); diff --git a/packages/playground/src/front/js/store/theme.ts b/packages/playground/src/front/js/store/theme.ts index 7891b20..18e735a 100644 --- a/packages/playground/src/front/js/store/theme.ts +++ b/packages/playground/src/front/js/store/theme.ts @@ -1,4 +1,4 @@ -import { domScheduler } from '@studiometa/js-toolkit/utils'; +import { defaultScheduler } from '@studiometa/js-toolkit'; import { fallbackStore as store } from '../utils/storage/index.js'; export type Themes = 'dark' | 'light'; @@ -21,7 +21,7 @@ export async function themeIsLight() { export async function themeUpdateDOM(value?: Themes) { const theme = value ?? (await getTheme()); - domScheduler.write(() => { + defaultScheduler.write(() => { document.documentElement.classList.toggle('dark', theme === 'dark'); }); } diff --git a/packages/playground/src/front/templates/pages/index.twig b/packages/playground/src/front/templates/pages/index.twig index ede63b4..8e92e1c 100644 --- a/packages/playground/src/front/templates/pages/index.twig +++ b/packages/playground/src/front/templates/pages/index.twig @@ -18,6 +18,7 @@ {}, { class: 'fixed inset-0 group dark:bg-zinc-900 dark:text-white', + data_component: 'Playground', data_option_html: defaults.html ?? '', data_option_style: defaults.style ?? '', data_option_script: defaults.script ?? '' From c9a875cd5a4d2fe701ecaccf9628a354ab0d4d56 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Fri, 28 Aug 2026 13:42:07 +0200 Subject: [PATCH 2/8] Clear the resizing state from the drag service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is-resizing` was raised on `pointerdown` and cleared on `pointerup`, both bound to the handle element. A mouse released anywhere else — which is what happens on any drag long enough to matter — never delivered the `pointerup`, so the class stayed on `` and `is-resizing:pointer-events-none` left the preview iframe unclickable until the next drag happened to end on a handle. The drag service reports the end of the gesture wherever the pointer is, so `dragged()` owns both halves. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R --- .../front/js/components/ResizableCursor.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/playground/src/front/js/components/ResizableCursor.ts b/packages/playground/src/front/js/components/ResizableCursor.ts index cd4bbe7..aa27626 100644 --- a/packages/playground/src/front/js/components/ResizableCursor.ts +++ b/packages/playground/src/front/js/components/ResizableCursor.ts @@ -1,4 +1,4 @@ -import { Base, withDrag } from '@studiometa/js-toolkit'; +import { Base, DRAG_MODES, withDrag } from '@studiometa/js-toolkit'; import type { BaseConfig, BaseProps, DragProps } from '@studiometa/js-toolkit'; export type ResizableCursorProps = BaseProps & { @@ -34,14 +34,17 @@ export default class ResizableCursor extends withDrag(Base) Date: Fri, 28 Aug 2026 13:42:15 +0200 Subject: [PATCH 3/8] Register the preview import map once per document `initIframe()` rewrites `documentElement.innerHTML`, which removes the `