diff --git a/src/core/base/model.change.ts b/src/core/base/model.change.ts index 5393b601..e8ee76de 100644 --- a/src/core/base/model.change.ts +++ b/src/core/base/model.change.ts @@ -1,12 +1,12 @@ import {overrideEvent} from '@exadel/esl/modules/esl-utils/dom'; -import type {UIPPlugin} from './plugin'; import type {UIPRoot} from './root'; import type {UIPStateModel} from './model'; +import type {UIPSource} from './source'; export type UIPChangeInfo = { - modifier: UIPPlugin | UIPRoot; - type: 'html' | 'js' | 'note'; + modifier: object; + type: UIPSource; force?: boolean; }; @@ -38,7 +38,7 @@ export class UIPChangeEvent extends Event { return this.changes.filter((change) => change.type === 'html'); } - public isOnlyModifier(modifier: UIPPlugin | UIPRoot): boolean { + public isOnlyModifier(modifier: object): boolean { return this.changes.every((change) => change.modifier === modifier); } } diff --git a/src/core/base/model.ts b/src/core/base/model.ts index 7161b656..1501edaf 100644 --- a/src/core/base/model.ts +++ b/src/core/base/model.ts @@ -10,10 +10,9 @@ import { 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'; +import type {UIPEditableSource} from './source'; /** Type for function to change attribute's current value */ export type TransformSignature = ( @@ -27,7 +26,7 @@ export type ChangeAttrConfig = { /** Attribute to change */ attribute: string; /** Changes initiator */ - modifier: UIPPlugin | UIPRoot; + modifier: object; } & ({ /** New {@link attribute} value */ value: string | boolean; @@ -63,20 +62,24 @@ export class UIPStateModel extends SyntheticEventTarget { * @param js - new state * @param modifier - plugin, that initiates the change */ - public setJS(js: string, modifier: UIPPlugin | UIPRoot): void { - const script = UIPJSNormalizationPreprocessors.preprocess(js); + public setJS(js: string, modifier: object): void { + const script = this.normalizeJS(js); if (this._js === script) return; this._js = script; this._changes.push({modifier, type: 'js', force: true}); this.dispatchChange(); } + protected normalizeJS(snippet: string): string { + return UIPJSNormalizationPreprocessors.preprocess(snippet); + } + /** * Sets current note state to the passed one * @param text - new state * @param modifier - plugin, that initiates the change */ - public setNote(text: string, modifier: UIPPlugin | UIPRoot): void { + public setNote(text: string, modifier: object): void { const note = UIPNoteNormalizationPreprocessors.preprocess(text); if (this._note === note) return; this._note = note; @@ -90,24 +93,51 @@ export class UIPStateModel extends SyntheticEventTarget { * @param modifier - plugin, that initiates the change * @param force - marker, that indicates if html changes require iframe rerender */ - public setHtml(markup: string, modifier: UIPPlugin | UIPRoot, force: boolean = false): void { - const html = UIPHTMLNormalizationPreprocessors.preprocess(markup); + public setHtml(markup: string, modifier: object, force: boolean = false): void { + const root = this.normalizeHTML(markup); + if (root.innerHTML.trim() === this.html.trim()) return; + this._html = root; + this._changes.push({modifier, type: 'html', force}); + this.dispatchChange(); + } + + protected normalizeHTML(snippet: string): HTMLElement { + const html = UIPHTMLNormalizationPreprocessors.preprocess(snippet); const {head, body: root} = new DOMParser().parseFromString(html, 'text/html'); Array.from(head.children).reverse().forEach((el) => { - if (el.tagName === 'STYLE') { - root.innerHTML = '\n' + root.innerHTML; - root.insertBefore(el, root.firstChild); - } + if (el.tagName !== 'STYLE') return; + root.innerHTML = '\n' + root.innerHTML; + root.insertBefore(el, root.firstChild); }); - if (root.innerHTML.trim() !== this.html.trim()) { - this._html = root; - this._changes.push({modifier, type: 'html', force}); - this.dispatchChange(); - } + return root; + } + + public isHTMLChanged(): boolean { + if (!this.activeSnippet) return false; + return this.normalizeHTML(this.activeSnippet.html).innerHTML.trim() !== this.html.trim(); } + public isJSChanged(): boolean { + if (!this.activeSnippet) return false; + return this.normalizeJS(this.activeSnippet.js) !== this.js; + } + + public reset(source: UIPEditableSource, modifier: object): void { + if (source === 'html') this.resetHTML(modifier); + if (source === 'js') this.resetJS(modifier); + } + + protected resetJS(modifier: object): void { + if (this.activeSnippet) this.setJS(this.activeSnippet.js, modifier); + } + + protected resetHTML(modifier: object): void { + if (this.activeSnippet) this.setHtml(this.activeSnippet.html, modifier); + } + + /** Current js state getter */ public get js(): string { return this._js; @@ -150,7 +180,7 @@ export class UIPStateModel extends SyntheticEventTarget { /** Changes current active snippet */ public applySnippet( snippet: UIPSnippetItem, - modifier: UIPPlugin | UIPRoot + modifier: object ): void { if (!snippet) return; this._snippets.forEach((s) => (s.active = s === snippet)); @@ -162,7 +192,7 @@ export class UIPStateModel extends SyntheticEventTarget { ); } /** Applies an active snippet from DOM */ - public applyCurrentSnippet(modifier: UIPPlugin | UIPRoot): void { + public applyCurrentSnippet(modifier: object): void { const activeSnippet = this.anchorSnippet || this.activeSnippet || this.snippets[0]; this.applySnippet(activeSnippet, modifier); } diff --git a/src/core/base/root.ts b/src/core/base/root.ts index da0da2fd..8c353a7b 100644 --- a/src/core/base/root.ts +++ b/src/core/base/root.ts @@ -3,11 +3,13 @@ import { memoize, boolAttr, listen, - prop + prop, + attr } from '@exadel/esl/modules/esl-utils/decorators'; import {UIPStateModel} from './model'; import {UIPChangeEvent} from './model.change'; +import {UIPStateStorage} from './state.storage'; import type {UIPSnippetTemplate} from './snippet'; import type {UIPChangeInfo} from './model.change'; @@ -36,6 +38,10 @@ export class UIPRoot extends ESLBaseElement { /** Indicates that the UIP components' theme is dark */ @boolAttr() public darkTheme: boolean; + /** Key to store UIP state in the local storage */ + @attr({defaultValue: ''}) public storeKey: string; + /** State storage based on `storeKey` */ + public storage: UIPStateStorage | undefined; /** Indicates ready state of the uip-root */ @boolAttr({readonly: true}) public ready: boolean; @@ -51,7 +57,7 @@ export class UIPRoot extends ESLBaseElement { return Array.from(this.querySelectorAll(UIPRoot.SNIPPET_SEL)); } - protected delyedScrollIntoView(): void { + protected delayedScrollIntoView(): void { setTimeout(() => { this.scrollIntoView({behavior: 'smooth', block: 'start'}); }, 100); @@ -59,13 +65,15 @@ export class UIPRoot extends ESLBaseElement { protected override connectedCallback(): void { super.connectedCallback(); + if (this.storeKey) this.storage = new UIPStateStorage(this.storeKey, this.model); + this.model.snippets = this.$snippets; this.model.applyCurrentSnippet(this); this.$$attr('ready', true); this.$$fire(this.READY_EVENT, {bubbles: false}); if (this.model.anchorSnippet) { - this.delyedScrollIntoView(); + this.delayedScrollIntoView(); } } diff --git a/src/core/base/source.ts b/src/core/base/source.ts new file mode 100644 index 00000000..313ae571 --- /dev/null +++ b/src/core/base/source.ts @@ -0,0 +1,3 @@ +export type UIPEditableSource = 'js' | 'html'; + +export type UIPSource = UIPEditableSource | 'note'; diff --git a/src/core/base/state.storage.ts b/src/core/base/state.storage.ts new file mode 100644 index 00000000..245492be --- /dev/null +++ b/src/core/base/state.storage.ts @@ -0,0 +1,91 @@ +import {ESLEventUtils} from '@exadel/esl/modules/esl-utils/dom'; +import {listen} from '@exadel/esl/modules/esl-utils/decorators'; + +import type {UIPStateModel} from './model'; +import type {UIPEditableSource} from './source'; + +interface UIPStateStorageEntry { + ts: string; + snippets: string; +} + +interface UIPStateModelSnippets { + js: string; + html: string; + note: string; +} + +export class UIPStateStorage { + public static readonly STORAGE_KEY = 'uip-editor-storage'; + + protected static readonly EXPIRATION_TIME = 3600000 * 12; // 12 hours + + public constructor(protected storeKey: string, protected model: UIPStateModel) { + ESLEventUtils.subscribe(this); + } + + protected loadEntry(key: string): string | null { + const entry = (this._lsState[key] || {}) as UIPStateStorageEntry; + if (parseInt(entry?.ts, 10) + UIPStateStorage.EXPIRATION_TIME > Date.now()) return entry.snippets || null; + this.removeEntry(key); + return null; + } + + protected saveEntry(key: string, value: string): void { + this._lsState = Object.assign(this._lsState, {[key]: {ts: Date.now(), snippets: value}}); + } + + protected removeEntry(key: string): void { + const data = this._lsState; + delete this._lsState[key]; + this._lsState = data; + } + + protected get _lsState(): Record { + return JSON.parse(localStorage.getItem(UIPStateStorage.STORAGE_KEY) || '{}'); + } + + protected set _lsState(value: Record) { + localStorage.setItem(UIPStateStorage.STORAGE_KEY, JSON.stringify(value)); + } + + protected getStateKey(): string | null { + const {activeSnippet} = this.model; + if (!activeSnippet || !this.storeKey) return null; + return JSON.stringify({key: this.storeKey, snippet: activeSnippet.html}); + } + + public loadState(): void { + const stateKey = this.getStateKey(); + const state = stateKey && this.loadEntry(stateKey); + if (!state) return; + + const stateobj = JSON.parse(state) as UIPStateModelSnippets; + this.model.setHtml(stateobj.html, this, true); + this.model.setJS(stateobj.js, this); + this.model.setNote(stateobj.note, this); + } + + public saveState(): void { + const stateKey = this.getStateKey(); + const {js, html, note} = this.model; + stateKey && this.saveEntry(stateKey, JSON.stringify({js, html, note})); + } + + public resetState(source: UIPEditableSource): void { + const stateKey = this.getStateKey(); + stateKey && this.removeEntry(stateKey); + + this.model.reset(source, this); + } + + @listen({event: 'uip:model:change', target: ($this: UIPStateStorage) => $this.model}) + protected _onModelChange(): void { + this.saveState() + } + + @listen({event: 'uip:model:snippet:change', target: ($this: UIPStateStorage) => $this.model}) + protected _onSnippetChange(): void { + this.loadState() + } +} diff --git a/src/plugins/copy/copy-button.shape.ts b/src/plugins/copy/copy-button.shape.ts index 6f947e46..55648d9f 100644 --- a/src/plugins/copy/copy-button.shape.ts +++ b/src/plugins/copy/copy-button.shape.ts @@ -1,8 +1,9 @@ import type {ESLBaseElementShape} from '@exadel/esl/modules/esl-base-element/core'; import type {UIPCopy} from './copy-button'; +import type {UIPEditableSource} from '../../core/base/source'; export interface UIPCopyShape extends ESLBaseElementShape { - source?: 'javascript' | 'js' | 'html'; + source?: UIPEditableSource; children?: any; } diff --git a/src/plugins/copy/copy-button.ts b/src/plugins/copy/copy-button.ts index 5eca7d47..7de06749 100644 --- a/src/plugins/copy/copy-button.ts +++ b/src/plugins/copy/copy-button.ts @@ -4,6 +4,7 @@ import {attr} from '@exadel/esl/modules/esl-utils/decorators'; import {UIPPluginButton} from '../../core/button/plugin-button'; import type {ESLAlertActionParams} from '@exadel/esl/modules/esl-alert/core'; +import type {UIPEditableSource} from '../../core/base/source'; /** Button-plugin to copy snippet to clipboard */ export class UIPCopy extends UIPPluginButton { @@ -11,7 +12,7 @@ export class UIPCopy extends UIPPluginButton { public static override defaultTitle = 'Copy to clipboard'; /** Source type to copy (html | js) */ - @attr({defaultValue: 'html'}) public source: string; + @attr({defaultValue: 'html'}) public source: UIPEditableSource; public static msgConfig: ESLAlertActionParams = { text: 'Playground content copied to clipboard', @@ -20,14 +21,7 @@ export class UIPCopy extends UIPPluginButton { /** 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; - } + if (this.source === 'js' || this.source === 'html') return this.model?.[this.source]; } protected override connectedCallback(): void { diff --git a/src/plugins/editor/editor.less b/src/plugins/editor/editor.less index 8a31acfe..890baf04 100644 --- a/src/plugins/editor/editor.less +++ b/src/plugins/editor/editor.less @@ -9,7 +9,7 @@ padding: 1em; } - &-header-copy { + &-header-copy, &-header-reset { position: relative; width: 25px; height: 25px; diff --git a/src/plugins/editor/editor.tsx b/src/plugins/editor/editor.tsx index 49e19396..4a31144f 100644 --- a/src/plugins/editor/editor.tsx +++ b/src/plugins/editor/editor.tsx @@ -12,11 +12,12 @@ import {attr, boolAttr, decorate, listen, memoize} from '@exadel/esl/modules/esl import {UIPPluginPanel} from '../../core/panel/plugin-panel'; import {CopyIcon} from '../copy/copy-button.icon'; - +import {ResetIcon} from '../reset/reset-button.icon'; import {EditorIcon} from './editor.icon'; import type {UIPSnippetsList} from '../snippets-list/snippets-list'; import type {UIPChangeEvent} from '../../core/base/model.change'; +import type {UIPEditableSource} from '../../core/base/source'; /** * Editor {@link UIPPlugin} custom element definition @@ -30,7 +31,7 @@ export class UIPEditor extends UIPPluginPanel { public static highlight = (editor: HTMLElement): void => Prism.highlightElement(editor, false); /** Source for Editor plugin (default: 'html') */ - @attr({defaultValue: 'html'}) public source: 'js' | 'javascript' | 'html'; + @attr({defaultValue: 'html'}) public source: UIPEditableSource; /** Marker to display copy widget */ @boolAttr({name: 'copy'}) public showCopy: boolean; @@ -45,6 +46,7 @@ export class UIPEditor extends UIPPluginPanel { return (
{this.showCopy ? : ''} + {this.$root?.storeKey ? : ''}
) as HTMLElement; } @@ -142,30 +144,19 @@ export class UIPEditor extends UIPPluginPanel { @decorate(debounce, 2000) protected _onChange(): void { if (!this.editable) return; - switch (this.source) { - case 'js': - case 'javascript': - this.model!.setJS(this.value, this); - break; - case 'html': - this.model!.setHtml(this.value, this); - } + if (this.source === 'js') this.model!.setJS(this.value, this); + if (this.source === '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; - 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; - } + + if ( + (this.source === 'js' && (!e || e.jsChanges.length)) || + (this.source === 'html' && (!e || e.htmlChanges.length)) + ) this.value = this.model![this.source]; } /** Handles snippet change to set readonly value */ diff --git a/src/plugins/registration.less b/src/plugins/registration.less index d085d27a..10de06e4 100644 --- a/src/plugins/registration.less +++ b/src/plugins/registration.less @@ -8,5 +8,6 @@ @import './settings/settings.less'; @import './copy/copy-button.less'; +@import './reset/reset-button.less'; @import './theme/theme-toggle.less'; @import './direction/dir-toggle.less'; diff --git a/src/plugins/registration.ts b/src/plugins/registration.ts index 09045cfd..ee02c6f8 100644 --- a/src/plugins/registration.ts +++ b/src/plugins/registration.ts @@ -17,6 +17,9 @@ export {UIPSetting, UIPSettings, UIPTextSetting, UIPBoolSetting, UIPSelectSettin import {UIPCopy} from './copy/copy-button'; export {UIPCopy}; +import {UIPReset} from './reset/reset-button'; +export {UIPReset}; + import {UIPNote} from './note/note'; export {UIPNote}; @@ -34,6 +37,7 @@ export const registerSettings = (): void => { export const registerPlugins = (): void => { UIPCopy.register(); + UIPReset.register(); UIPDirSwitcher.register(); UIPThemeSwitcher.register(); diff --git a/src/plugins/reset/reset-button.icon.tsx b/src/plugins/reset/reset-button.icon.tsx new file mode 100644 index 00000000..b467cbe7 --- /dev/null +++ b/src/plugins/reset/reset-button.icon.tsx @@ -0,0 +1,7 @@ +import React from 'jsx-dom'; + +export const ResetIcon = (): SVGElement => ( + + + +) as SVGElement; diff --git a/src/plugins/reset/reset-button.less b/src/plugins/reset/reset-button.less new file mode 100644 index 00000000..fd9b6f94 --- /dev/null +++ b/src/plugins/reset/reset-button.less @@ -0,0 +1,14 @@ +.uip-reset { + display: inline-flex; + cursor: pointer; + + > svg { + fill: currentColor; + width: 100%; + height: 100%; + } + + &[disabled] { + display: none; + } +} diff --git a/src/plugins/reset/reset-button.shape.ts b/src/plugins/reset/reset-button.shape.ts new file mode 100644 index 00000000..ac487996 --- /dev/null +++ b/src/plugins/reset/reset-button.shape.ts @@ -0,0 +1,16 @@ +import type {ESLBaseElementShape} from '@exadel/esl/modules/esl-base-element/core'; +import type {UIPReset} from './reset-button'; +import type {UIPEditableSource} from '../../core/base/source'; + +export interface UIPResetShape extends ESLBaseElementShape { + source?: UIPEditableSource; + children?: any; +} + +declare global { + namespace JSX { + interface IntrinsicElements { + 'uip-reset': UIPResetShape; + } + } +} diff --git a/src/plugins/reset/reset-button.ts b/src/plugins/reset/reset-button.ts new file mode 100644 index 00000000..d3c00cc7 --- /dev/null +++ b/src/plugins/reset/reset-button.ts @@ -0,0 +1,29 @@ +import './reset-button.shape'; + +import {listen, attr, boolAttr} from '@exadel/esl/modules/esl-utils/decorators'; + +import {UIPPluginButton} from '../../core/button/plugin-button'; +import {UIPRoot} from '../../core/base/root'; + +import type {UIPEditableSource} from '../../core/base/source'; + +/** Button-plugin to reset snippet to default settings */ +export class UIPReset extends UIPPluginButton { + public static override is = 'uip-reset'; + + @boolAttr() public disabled: boolean; + + /** Source type to copy (html | js) */ + @attr({defaultValue: 'html'}) public source: UIPEditableSource; + + public override onAction(): void { + this.$root?.storage!.resetState(this.source); + } + + @listen({event: 'uip:model:change', target: ($this: UIPRoot) => $this.model}) + protected _onModelChange(): void { + if (!this.model || !this.model.activeSnippet) return; + if (this.source === 'js') this.disabled = !this.model.isJSChanged(); + if (this.source === 'html') this.disabled = !this.model.isHTMLChanged(); + } +}