Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(uip-editor): store editor state #780

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7c8dc7e
feat(uip-editor): store editor state
fshovchko Jun 27, 2024
06b246c
chore(uip-editor): code refactoring
fshovchko Jul 3, 2024
ab1ae15
chore(uip-editor): code refactoring
fshovchko Jul 3, 2024
ce6f1c7
Merge branch 'main' into feat/store-state
ala-n Nov 8, 2024
77cd6d1
chore(uip-editor): add reset button
fshovchko Nov 12, 2024
55a5d7e
chore(uip-editor): hide reset button if snippet is unmodified
fshovchko Nov 19, 2024
8b444ee
Merge branch 'main' into feat/store-state
ala-n Nov 27, 2024
7ff2cd8
chore(uip-editor): remove duplicate code
fshovchko Dec 3, 2024
63f85ed
Merge branch 'feat/store-state' of github.com:exadel-inc/ui-playgroun…
fshovchko Dec 3, 2024
c83116c
Merge branch 'main' into feat/store-state
ala-n Dec 3, 2024
81df5e6
chore(uip-editor): move logic to uip-model
fshovchko Dec 5, 2024
3467d7d
Merge branch 'feat/store-state' of github.com:exadel-inc/ui-playgroun…
fshovchko Dec 5, 2024
2f6bd92
chore(uip-editor): get rid of cyclic dependency
fshovchko Dec 10, 2024
d4f6f11
chore(uip-editor): code refactoring
fshovchko Dec 10, 2024
e3aa4a3
chore(uip-editor): code refactoring
fshovchko Dec 11, 2024
7d922fb
chore(uip-editor): code refactoring
fshovchko Dec 11, 2024
559f537
chore(uip-editor): code refactoring
fshovchko Dec 13, 2024
03cf84c
chore(uip-editor): code refactoring
fshovchko Dec 13, 2024
0ec11cb
Merge branch 'main' into feat/store-state
ala-n Dec 16, 2024
13920ba
chore(uip-editor): code refactoring
fshovchko Dec 18, 2024
d33cd14
Merge branch 'feat/store-state' of github.com:exadel-inc/ui-playgroun…
fshovchko Dec 18, 2024
d02fc15
chore(uip-editor): code refactoring
fshovchko Dec 18, 2024
af67d3e
chore(uip-editor): code refactoring
fshovchko Dec 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/core/base/model.change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ 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';
type: UIPSource;
force?: boolean;
};

Expand Down
54 changes: 43 additions & 11 deletions src/core/base/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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 Down Expand Up @@ -64,13 +65,17 @@ export class UIPStateModel extends SyntheticEventTarget {
* @param modifier - plugin, that initiates the change
*/
public setJS(js: string, modifier: UIPPlugin | UIPRoot): void {
const script = UIPJSNormalizationPreprocessors.preprocess(js);
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
Expand All @@ -91,23 +96,50 @@ export class UIPStateModel extends SyntheticEventTarget {
* @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);
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: UIPPlugin | UIPRoot): void {
if (source === 'html') this.resetHTML(modifier);
if (source === 'js') this.resetJS(modifier);
}

protected resetJS(modifier: UIPPlugin | UIPRoot): void {
if (this.activeSnippet) this.setJS(this.activeSnippet.js, modifier);
}

protected resetHTML(modifier: UIPPlugin | UIPRoot): void {
if (this.activeSnippet) this.setHtml(this.activeSnippet.html, modifier);
}


/** Current js state getter */
public get js(): string {
return this._js;
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);

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';
95 changes: 95 additions & 0 deletions src/core/base/state.storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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 {UIPRoot} from './root';
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

protected model: UIPStateModel;

public constructor(protected storeKey: string, protected root: UIPRoot) {
fshovchko marked this conversation as resolved.
Show resolved Hide resolved
this.model = root.model;
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.root, true);
this.model.setJS(stateobj.js, this.root);
this.model.setNote(stateobj.note, this.root);
fshovchko marked this conversation as resolved.
Show resolved Hide resolved
}

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.root);
fshovchko marked this conversation as resolved.
Show resolved Hide resolved
}

@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
31 changes: 11 additions & 20 deletions src/plugins/editor/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -45,6 +46,7 @@ export class UIPEditor extends UIPPluginPanel {
return (
<div className={type.is + '-toolbar uip-plugin-header-toolbar'}>
{this.showCopy ? <uip-copy class={type.is + '-header-copy'} source={this.source}><CopyIcon/></uip-copy> : ''}
{this.$root?.storeKey ? <uip-reset class={type.is + '-header-reset'} source={this.source}><ResetIcon/></uip-reset> : ''}
</div>
) as HTMLElement;
}
Expand Down Expand Up @@ -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 */
Expand Down
1 change: 1 addition & 0 deletions src/plugins/registration.less
Original file line number Diff line number Diff line change
Expand Up @@ -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';
4 changes: 4 additions & 0 deletions src/plugins/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -34,6 +37,7 @@ export const registerSettings = (): void => {

export const registerPlugins = (): void => {
UIPCopy.register();
UIPReset.register();
UIPDirSwitcher.register();
UIPThemeSwitcher.register();

Expand Down
Loading
Loading