Skip to content

Commit

Permalink
Merge pull request #780 from exadel-inc/feat/store-state
Browse files Browse the repository at this point in the history
feat(uip-editor): store editor state
  • Loading branch information
yadamskaya authored Jan 31, 2025
2 parents fca33f7 + 7924410 commit 72089b9
Show file tree
Hide file tree
Showing 15 changed files with 246 additions and 57 deletions.
8 changes: 4 additions & 4 deletions src/core/base/model.change.ts
Original file line number Diff line number Diff line change
@@ -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;
};

Expand Down Expand Up @@ -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);
}
}
68 changes: 49 additions & 19 deletions src/core/base/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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));
Expand All @@ -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);
}
Expand Down
14 changes: 11 additions & 3 deletions src/core/base/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -51,21 +57,23 @@ 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);
}

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();
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/core/base/source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type UIPEditableSource = 'js' | 'html';

export type UIPSource = UIPEditableSource | 'note';
91 changes: 91 additions & 0 deletions src/core/base/state.storage.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> {
return JSON.parse(localStorage.getItem(UIPStateStorage.STORAGE_KEY) || '{}');
}

protected set _lsState(value: Record<string, any>) {
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()
}
}
3 changes: 2 additions & 1 deletion src/plugins/copy/copy-button.shape.ts
Original file line number Diff line number Diff line change
@@ -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<UIPCopy> {
source?: 'javascript' | 'js' | 'html';
source?: UIPEditableSource;
children?: any;
}

Expand Down
12 changes: 3 additions & 9 deletions src/plugins/copy/copy-button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ 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 {
public static override is = 'uip-copy';
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',
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/editor/editor.less
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
padding: 1em;
}

&-header-copy {
&-header-copy, &-header-reset {
position: relative;
width: 25px;
height: 25px;
Expand Down
Loading

0 comments on commit 72089b9

Please # to comment.