From 0240287fe23b9a0a9e3bb81fd34ea6ba19656d6f Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Wed, 14 Feb 2024 20:19:30 +0100 Subject: [PATCH 1/9] refactor(core): extend model event with detailed information --- src/core/base/model.change.ts | 39 +++++++++++++++++++++++++++++++++++ src/core/base/model.ts | 26 ++++++++++++----------- src/core/base/root.ts | 5 +++-- src/core/base/snippet.ts | 4 ++-- 4 files changed, 58 insertions(+), 16 deletions(-) create mode 100644 src/core/base/model.change.ts diff --git a/src/core/base/model.change.ts b/src/core/base/model.change.ts new file mode 100644 index 00000000..2315f657 --- /dev/null +++ b/src/core/base/model.change.ts @@ -0,0 +1,39 @@ +import {overrideEvent} from '@exadel/esl/modules/esl-utils/dom'; + +import type {UIPPlugin} from './plugin'; +import type {UIPRoot} from './root'; +import type {UIPStateModel} from './model'; + +export type UIPChangeInfo = { + modifier: UIPPlugin | UIPRoot; + type: 'html' | 'js'; +}; + +export class UIPChangeEvent extends Event { + public readonly target: UIPRoot; + + public constructor( + type: string, + target: UIPRoot, + public readonly changes: UIPChangeInfo[] + ) { + super(type, {bubbles: false, cancelable: false}); + overrideEvent(this, 'target', target); + } + + public get model(): UIPStateModel { + return this.target.model; + } + + public get jsChanges(): UIPChangeInfo[] { + return this.changes.filter((change) => change.type === 'js'); + } + + public get htmlChanges(): UIPChangeInfo[] { + return this.changes.filter((change) => change.type === 'html'); + } + + public isOnlyModifier(modifier: UIPPlugin | UIPRoot): boolean { + return this.changes.every((change) => change.modifier === modifier); + } +} diff --git a/src/core/base/model.ts b/src/core/base/model.ts index 1592f530..b584e1fa 100644 --- a/src/core/base/model.ts +++ b/src/core/base/model.ts @@ -8,6 +8,7 @@ import {UIPSnippetItem} from './snippet'; import type {UIPRoot} from './root'; import type {UIPPlugin} from './plugin'; import type {UIPSnippetTemplate} from './snippet'; +import type {UIPChangeInfo} from './model.change'; /** Type for function to change attribute's current value */ export type TransformSignature = ( @@ -46,8 +47,9 @@ export class UIPStateModel extends SyntheticEventTarget { private _js: string = ''; /** Current markup state */ private _html = new DOMParser().parseFromString('', 'text/html').body; - /** Last {@link UIPPlugin} element which changed markup */ - private _lastModifier: UIPPlugin | UIPRoot; + + /** Last changes history (used to dispatch changes) */ + private _changes: UIPChangeInfo[] = []; /** * Sets current js state to the passed one @@ -58,7 +60,7 @@ export class UIPStateModel extends SyntheticEventTarget { const script = UIPJSNormalizationPreprocessors.preprocess(js); if (this._js === script) return; this._js = script; - this._lastModifier = modifier; + this._changes.push({modifier, type: 'js'}); this.dispatchChange(); } @@ -72,7 +74,7 @@ export class UIPStateModel extends SyntheticEventTarget { const root = new DOMParser().parseFromString(html, 'text/html').body; if (!root || root.innerHTML.trim() !== this.html.trim()) { this._html = root; - this._lastModifier = modifier; + this._changes.push({modifier, type: 'html'}); this.dispatchChange(); } } @@ -87,11 +89,6 @@ export class UIPStateModel extends SyntheticEventTarget { return this._html ? this._html.innerHTML : ''; } - /** Last markup state modifier */ - public get lastModifier(): UIPPlugin | UIPRoot { - return this._lastModifier; - } - /** Snippets template-holders getter */ public get snippets(): UIPSnippetItem[] { return this._snippets; @@ -105,11 +102,12 @@ export class UIPStateModel extends SyntheticEventTarget { }); } - /** Current active {@link SnippetTemplate} getter */ + /** Current active {@link UIPSnippetItem} getter */ public get activeSnippet(): UIPSnippetItem | undefined { return this._snippets.find((snippet) => snippet.active); } + /** Snippet that relates to current anchor */ public get anchorSnippet(): UIPSnippetItem | undefined { const anchor = window.location.hash.slice(1); return this._snippets.find((snippet) => snippet.anchor === anchor); @@ -156,15 +154,19 @@ export class UIPStateModel extends SyntheticEventTarget { attribute, 'transform' in cfg ? cfg.transform : cfg.value ); - this._lastModifier = modifier; + this._changes.push({modifier, type: 'html'}); this.dispatchChange(); } /** Plans microtask to dispatch model change event */ @decorate(microtask) protected dispatchChange(): void { + if (!this._changes.length) return; + const changes = this._changes; + this._changes = []; + this.dispatchEvent( - new CustomEvent('uip:model:change', {bubbles: true, detail: this}) + new CustomEvent('uip:model:change', {bubbles: true, detail: changes}) ); } diff --git a/src/core/base/root.ts b/src/core/base/root.ts index a3789322..1246374f 100644 --- a/src/core/base/root.ts +++ b/src/core/base/root.ts @@ -9,6 +9,7 @@ import { import {UIPStateModel} from './model'; import type {UIPSnippetTemplate} from './snippet'; +import {UIPChangeEvent, UIPChangeInfo} from './model.change'; /** * UI Playground root custom element definition @@ -87,8 +88,8 @@ export class UIPRoot extends ESLBaseElement { } @listen({event: 'uip:model:change', target: ($this: UIPRoot) => $this.model}) - protected onModelChange({detail}: CustomEvent): void { - this.$$fire(this.CHANGE_EVENT, {detail, bubbles: false}); + protected onModelChange({detail}: CustomEvent): void { + this.dispatchEvent(new UIPChangeEvent(this.CHANGE_EVENT, this, detail)); } @listen({ diff --git a/src/core/base/snippet.ts b/src/core/base/snippet.ts index 41196508..ae758386 100644 --- a/src/core/base/snippet.ts +++ b/src/core/base/snippet.ts @@ -9,11 +9,11 @@ export class UIPSnippetItem { @memoize() public get $elementJS(): UIPSnippetTemplate | null { - const $root = this.$element.closest('uip-root') || document.body; + const $root: Element = this.$element.closest('uip-root') || document.body; const selectors = []; if (this.$element.id) selectors.push(`[uip-js-snippet="${this.$element.id}"]`); if (this.label) selectors.push(`[uip-js-snippet][label="${this.label}"]`); - return $root.querySelector(selectors.join(',')) as UIPSnippetTemplate; + return $root.querySelector(selectors.join(','))!; } /** @returns snippet's label */ From a735e2fec465409dbb69345a9e8c32c0befffa55 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Wed, 14 Feb 2024 20:20:53 +0100 Subject: [PATCH 2/9] fix(editor): make editor update only if corresponding change was made --- src/plugins/editor/editor.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/plugins/editor/editor.tsx b/src/plugins/editor/editor.tsx index 900249c8..eb7a9f2b 100644 --- a/src/plugins/editor/editor.tsx +++ b/src/plugins/editor/editor.tsx @@ -15,6 +15,8 @@ import {CopyIcon} from '../copy/copy-button.icon'; import {EditorIcon} from './editor.icon'; +import type {UIPChangeEvent} from '../../core/base/model.change'; + /** * Editor {@link UIPPlugin} custom element definition * Uses Codejar code editor to provide an ability to modify UIP state markup @@ -122,8 +124,9 @@ export class UIPEditor extends UIPPluginPanel { /** Change editor's markup from markup state changes */ @listen({event: 'uip:change', target: ($this: UIPEditor) => $this.$root}) - protected _onRootStateChange(): void { - if (this.model!.lastModifier === this) return; + protected _onRootStateChange(e?: UIPChangeEvent): void { + if (!e || e.isOnlyModifier(this)) return; + if (e && !(this.script ? e.jsChanges.length : e.htmlChanges.length)) return; this.value = this.script ? this.model!.js : this.model!.html; } } From 2d77e970b64d0ce2c14dbf85c0a3bb88323110df Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Wed, 14 Feb 2024 20:24:38 +0100 Subject: [PATCH 3/9] fix(settings): make setting update only if corresponding change was made --- src/plugins/settings/base-setting/base-setting.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/plugins/settings/base-setting/base-setting.ts b/src/plugins/settings/base-setting/base-setting.ts index 75bfa374..14f35f32 100644 --- a/src/plugins/settings/base-setting/base-setting.ts +++ b/src/plugins/settings/base-setting/base-setting.ts @@ -4,6 +4,7 @@ import {getAttr, setAttr} from '@exadel/esl/modules/esl-utils/dom/attr'; import {UIPPlugin} from '../../../core/base/plugin'; import type {UIPStateModel} from '../../../core/base/model'; +import type {UIPChangeEvent} from '../../../core/base/model.change'; /** * Custom element for manipulating with elements attributes @@ -93,9 +94,11 @@ export abstract class UIPSetting extends UIPPlugin { /** Updates {@link UIPSetting} values */ @listen({event: 'uip:change', target: ($this: UIPSetting) => $this.$root}) - protected _onRootStateChange(): void { - this.$$fire('uip:settings:state:change'); + protected _onRootStateChange(e?: UIPChangeEvent): void { + if (e && !e.htmlChanges.length) return; this.updateFrom(this.model!); + // TODO: throw only if real state change + this.$$fire('uip:settings:state:change'); } /** From f2081d55d597a4d42c62c9c04a863681cc3fd5d8 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Wed, 14 Feb 2024 20:34:34 +0100 Subject: [PATCH 4/9] fix(copy): incorrect content to copy from JS editor --- src/plugins/copy/copy-button.shape.ts | 1 + src/plugins/copy/copy-button.ts | 11 ++++++++--- src/plugins/editor/editor.tsx | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/plugins/copy/copy-button.shape.ts b/src/plugins/copy/copy-button.shape.ts index c557f057..df0a5959 100644 --- a/src/plugins/copy/copy-button.shape.ts +++ b/src/plugins/copy/copy-button.shape.ts @@ -2,6 +2,7 @@ import type {ESLBaseElementShape} from '@exadel/esl/modules/esl-base-element/cor import type {UIPCopy} from './copy-button'; export interface UIPCopyShape extends ESLBaseElementShape { + source?: 'js' | 'html'; children?: any; } diff --git a/src/plugins/copy/copy-button.ts b/src/plugins/copy/copy-button.ts index 714ae28d..9f749098 100644 --- a/src/plugins/copy/copy-button.ts +++ b/src/plugins/copy/copy-button.ts @@ -1,15 +1,19 @@ import './copy-button.shape'; +import {attr} from '@exadel/esl/modules/esl-utils/decorators'; import {UIPPluginButton} from '../../core/button/plugin-button'; -import type {AlertActionParams} from '@exadel/esl/modules/esl-alert/core'; +import type {ESLAlertActionParams} from '@exadel/esl/modules/esl-alert/core'; /** Button-plugin to copy snippet to clipboard */ export class UIPCopy extends UIPPluginButton { public static override is = 'uip-copy'; public static override defaultTitle = 'Copy to clipboard'; - public static msgConfig: AlertActionParams = { + /** Source type to copy (html | js) */ + @attr({defaultValue: 'html'}) public source: string; + + public static msgConfig: ESLAlertActionParams = { text: 'Playground content copied to clipboard', cls: 'uip-copy-alert' }; @@ -31,6 +35,7 @@ export class UIPCopy extends UIPPluginButton { /** Copy model content to clipboard */ public copy(): Promise { - return navigator.clipboard.writeText(this.model!.html); + const text = this.source === 'js' ? this.model!.js : this.model!.html; + return navigator.clipboard.writeText(text); } } diff --git a/src/plugins/editor/editor.tsx b/src/plugins/editor/editor.tsx index eb7a9f2b..5c05632a 100644 --- a/src/plugins/editor/editor.tsx +++ b/src/plugins/editor/editor.tsx @@ -43,7 +43,7 @@ export class UIPEditor extends UIPPluginPanel { const type = this.constructor as typeof UIPEditor; return (
- {this.showCopy ? : ''} + {this.showCopy ? : ''}
) as HTMLElement; } From 85fc641fbac3007de9dee3cd67c499c7d33065d4 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Wed, 14 Feb 2024 20:48:06 +0100 Subject: [PATCH 5/9] fix(editor): rework editor type marker - `script` bollean marker replaced with `source` attribute --- site/views/examples/example/form.njk | 2 +- src/plugins/copy/copy-button.shape.ts | 2 +- src/plugins/copy/copy-button.ts | 15 ++++++++++-- src/plugins/editor/editor.tsx | 34 +++++++++++++++++++-------- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/site/views/examples/example/form.njk b/site/views/examples/example/form.njk index 44b40198..a53c5c90 100644 --- a/site/views/examples/example/form.njk +++ b/site/views/examples/example/form.njk @@ -39,5 +39,5 @@ title: Example with form - + diff --git a/src/plugins/copy/copy-button.shape.ts b/src/plugins/copy/copy-button.shape.ts index df0a5959..6f947e46 100644 --- a/src/plugins/copy/copy-button.shape.ts +++ b/src/plugins/copy/copy-button.shape.ts @@ -2,7 +2,7 @@ import type {ESLBaseElementShape} from '@exadel/esl/modules/esl-base-element/cor import type {UIPCopy} from './copy-button'; export interface UIPCopyShape extends ESLBaseElementShape { - source?: 'js' | 'html'; + source?: 'javascript' | 'js' | 'html'; children?: any; } diff --git a/src/plugins/copy/copy-button.ts b/src/plugins/copy/copy-button.ts index 9f749098..5eca7d47 100644 --- a/src/plugins/copy/copy-button.ts +++ b/src/plugins/copy/copy-button.ts @@ -18,6 +18,18 @@ export class UIPCopy extends UIPPluginButton { cls: 'uip-copy-alert' }; + /** Content to copy */ + protected get content(): string | undefined { + switch (this.source) { + case 'js': + case 'javascript': + return this.model?.js; + case 'html': + default: + return this.model?.html; + } + } + protected override connectedCallback(): void { if (!navigator.clipboard) this.hidden = true; super.connectedCallback(); @@ -35,7 +47,6 @@ export class UIPCopy extends UIPPluginButton { /** Copy model content to clipboard */ public copy(): Promise { - const text = this.source === 'js' ? this.model!.js : this.model!.html; - return navigator.clipboard.writeText(text); + return navigator.clipboard.writeText(this.content || ''); } } diff --git a/src/plugins/editor/editor.tsx b/src/plugins/editor/editor.tsx index 5c05632a..5dea4b0f 100644 --- a/src/plugins/editor/editor.tsx +++ b/src/plugins/editor/editor.tsx @@ -8,7 +8,7 @@ import Prism from 'prismjs'; import {CodeJar} from 'codejar'; import {debounce} from '@exadel/esl/modules/esl-utils/async/debounce'; -import {boolAttr, decorate, listen, memoize} from '@exadel/esl/modules/esl-utils/decorators'; +import {attr, boolAttr, decorate, listen, memoize} from '@exadel/esl/modules/esl-utils/decorators'; import {UIPPluginPanel} from '../../core/panel/plugin-panel'; import {CopyIcon} from '../copy/copy-button.icon'; @@ -28,8 +28,8 @@ export class UIPEditor extends UIPPluginPanel { /** Highlight method declaration */ public static highlight = (editor: HTMLElement): void => Prism.highlightElement(editor, false); - /** Marker of JS Editor */ - @boolAttr() public script: boolean; + /** Source for Editor plugin (default: 'html') */ + @attr({defaultValue: 'html'}) public source: 'js' | 'javascript' | 'html'; /** Marker to display copy widget */ @boolAttr({name: 'copy'}) public showCopy: boolean; @@ -43,7 +43,7 @@ export class UIPEditor extends UIPPluginPanel { const type = this.constructor as typeof UIPEditor; return (
- {this.showCopy ? : ''} + {this.showCopy ? : ''}
) as HTMLElement; } @@ -66,7 +66,7 @@ export class UIPEditor extends UIPPluginPanel { @memoize() protected get $code(): HTMLElement { const type = this.constructor as typeof UIPEditor; - const lang = this.script ? 'javascript' : 'html'; + const lang = this.source === 'js' ? 'javascript' : this.source; return (
) as HTMLElement; } @@ -118,15 +118,29 @@ export class UIPEditor extends UIPPluginPanel { /** Callback to call on an editor's content changes */ @decorate(debounce, 2000) protected _onChange(): void { - if (this.script) this.model!.setJS(this.value, this); - else this.model!.setHtml(this.value, this); + switch (this.source) { + case 'js': + case 'javascript': + this.model!.setJS(this.value, this); + break; + case 'html': + this.model!.setHtml(this.value, this); + } } /** Change editor's markup from markup state changes */ @listen({event: 'uip:change', target: ($this: UIPEditor) => $this.$root}) protected _onRootStateChange(e?: UIPChangeEvent): void { - if (!e || e.isOnlyModifier(this)) return; - if (e && !(this.script ? e.jsChanges.length : e.htmlChanges.length)) return; - this.value = this.script ? this.model!.js : this.model!.html; + if (e && e.isOnlyModifier(this)) return; + switch (this.source) { + case 'js': + case 'javascript': + if (e && !e.jsChanges.length) return; + this.value = this.model!.js; + break; + case 'html': + if (e && !e.htmlChanges.length) return; + this.value = this.model!.html; + } } } From df30bfdc4bfc89b5c2d8284f022b2fdddf1d631d Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Wed, 14 Feb 2024 20:48:32 +0100 Subject: [PATCH 6/9] fix(core): a11y of hidden panel --- src/core/panel/plugin-panel.less | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/panel/plugin-panel.less b/src/core/panel/plugin-panel.less index 00f730f6..805e5de6 100644 --- a/src/core/panel/plugin-panel.less +++ b/src/core/panel/plugin-panel.less @@ -20,6 +20,10 @@ display: none; } + &[collapsed] .uip-plugin-inner { + visibility: hidden; + } + --uip-plugin-width: 250px; --uip-plugin-height: 325px; } From 50fdf1597eb2f51d20b6bc7855e0abba528891d9 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Wed, 14 Feb 2024 20:49:59 +0100 Subject: [PATCH 7/9] fix(core): lazy update and alternative re-size control for `uip-preview` --- src/core/preview/preview.tsx | 18 +++++++++++++++--- src/core/processors/templates.ts | 4 +++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/core/preview/preview.tsx b/src/core/preview/preview.tsx index f864c4f0..dedefee1 100644 --- a/src/core/preview/preview.tsx +++ b/src/core/preview/preview.tsx @@ -9,6 +9,7 @@ import {UIPPlugin} from '../base/plugin'; import {UIPRenderingTemplatesService} from '../processors/templates'; import {UIPJSRenderingPreprocessors, UIPHTMLRenderingPreprocessors} from '../processors/rendering'; +import type {UIPChangeEvent} from '../base/model.change'; import type {ESLIntersectionEvent} from '@exadel/esl/modules/esl-event-listener/core'; /** @@ -24,6 +25,8 @@ export class UIPPreview extends UIPPlugin { /** Marker to use iframe isolated rendering */ @attr({parser: parseBoolean, serializer: toBooleanAttribute}) public isolation: boolean; + /** Marker to use iframe isolated rendering */ + @attr({parser: parseBoolean, serializer: toBooleanAttribute}) public forceUpdate: boolean; /** Template to use for isolated rendering */ @attr({defaultValue: 'default'}) public isolationTemplate: string; @@ -73,6 +76,12 @@ export class UIPPreview extends UIPPlugin { } } + protected update(e?: UIPChangeEvent): void { + if (!this.isolation) return this.writeContent(); + if (!e || e.jsChanges.length || this.forceUpdate) this.writeContentIsolated(); + this._onIframeLoad(); + } + /** Writes the content directly to the inner area (non-isolated frame) */ protected writeContent(): void { this.$inner.innerHTML = UIPHTMLRenderingPreprocessors.preprocess(this.model!.html); @@ -97,8 +106,11 @@ export class UIPPreview extends UIPPlugin { if (this._iframeResizeRAF) cancelAnimationFrame(this._iframeResizeRAF); // Addition loop fallback for iframe removal if (this.$iframe.parentElement !== this.$inner) return; + const $document = this.$iframe.contentWindow?.document; + const $root = $document?.querySelector('[uip-content-root]') || $document?.body; + if (!$root) return; // Reflect iframe height with inner content - this.$iframe.style.height = `${this.$iframe.contentWindow?.document.body.scrollHeight}px`; + this.$iframe.style.height = `${$root.scrollHeight}px`; this._iframeResizeRAF = requestAnimationFrame(this.startIframeResizeLoop.bind(this)); } @@ -140,9 +152,9 @@ export class UIPPreview extends UIPPlugin { /** Updates preview content from the model state changes */ @listen({event: 'uip:change', target: ($this: UIPPreview) => $this.$root}) - protected _onRootStateChange(): void { + protected _onRootStateChange(e?: UIPChangeEvent): void { this.$container.style.minHeight = `${this.$inner.offsetHeight}px`; - this.isolation ? this.writeContentIsolated() : this.writeContent(); + this.update(e); afterNextRender(() => this.$container.style.minHeight = '0px'); skipOneRender(() => { diff --git a/src/core/processors/templates.ts b/src/core/processors/templates.ts index ab392f1c..819f558b 100644 --- a/src/core/processors/templates.ts +++ b/src/core/processors/templates.ts @@ -35,6 +35,8 @@ UIPRenderingTemplatesService.add('default', ` - {content} + +
{content}
+ `); From ad9191597c16306c9133b1437337a05fd23ab511 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Wed, 14 Feb 2024 20:57:52 +0100 Subject: [PATCH 8/9] fix(core): fix layout according to marker change --- src/plugins/editor/editor.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/editor/editor.less b/src/plugins/editor/editor.less index 2d5d91c6..c880a104 100644 --- a/src/plugins/editor/editor.less +++ b/src/plugins/editor/editor.less @@ -1,7 +1,7 @@ .uip-editor { grid-area: editor; - &[script] { + &:is([source='js'], [source='javascript']) { grid-area: editor-js; } From 7c02be582dc0969f87c5af9244d272a8c4d4ad6c Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Thu, 15 Feb 2024 17:58:56 +0100 Subject: [PATCH 9/9] refactor: add overflow hidden to hide the scroll --- src/core/processors/templates.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/processors/templates.ts b/src/core/processors/templates.ts index 819f558b..40c01ab6 100644 --- a/src/core/processors/templates.ts +++ b/src/core/processors/templates.ts @@ -35,7 +35,7 @@ UIPRenderingTemplatesService.add('default', ` - +
{content}