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 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
24 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
7924410
Merge branch 'main' into feat/store-state
ala-n Jan 21, 2025
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
79 changes: 79 additions & 0 deletions src/core/base/model.storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type {UIPStateModel} from './model';

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

private static instances = new Map<string, UIPStateStorage>();

protected constructor(protected storeKey: string, protected model: UIPStateModel) {}

public static for(storeKey: string, model: UIPStateModel): UIPStateStorage {
fshovchko marked this conversation as resolved.
Show resolved Hide resolved
const instance = this.instances.get(storeKey);
if (instance) return instance;

const newInstance = new UIPStateStorage(storeKey, model);
this.instances.set(storeKey, newInstance);
return newInstance;
}

protected loadEntry(key: string): string | null {
const entry = (this.lsGet()[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.lsSet(Object.assign(this.lsGet(), {[key]: {ts: Date.now(), snippets: value}}));
}

protected removeEntry(key: string): void {
const data = this.lsGet();
delete data[key];
this.lsSet(data);
}

protected lsGet(): Record<string, any> {
return JSON.parse(localStorage.getItem(UIPStateStorage.STORAGE_KEY) || '{}');
}

protected lsSet(value: Record<string, any>): void {
localStorage.setItem(UIPStateStorage.STORAGE_KEY, JSON.stringify(value));
}

protected getStateKey(): string | null {
const {activeSnippet} = this.model;
if (!activeSnippet) return null;
return JSON.stringify({key: this.storeKey, snippet: activeSnippet.html});
}

public loadState(): UIPStateModelSnippets | undefined {
const stateKey = this.getStateKey();
const state = stateKey && this.loadEntry(stateKey);
if (state) return JSON.parse(state);
}

public saveState(): void {
const {js, html, note} = this.model;
const stateKey = this.getStateKey();
stateKey && this.saveEntry(stateKey, JSON.stringify({js, html, note}));
}

public resetState(): void {
const stateKey = this.getStateKey();
stateKey && this.removeEntry(stateKey);
}
}
79 changes: 63 additions & 16 deletions src/core/base/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import {
UIPNoteNormalizationPreprocessors
} from '../processors/normalization';

import {UIPStateStorage} from './model.storage';
import {UIPSnippetItem} from './snippet';

import type {UIPRoot} from './root';
import type {UIPPlugin} from './plugin';
import {UIPRoot} from './root';
fshovchko marked this conversation as resolved.
Show resolved Hide resolved
import {UIPPlugin} from './plugin';
fshovchko marked this conversation as resolved.
Show resolved Hide resolved
import type {UIPSnippetTemplate} from './snippet';
import type {UIPChangeInfo} from './model.change';

Expand Down Expand Up @@ -55,6 +56,8 @@ export class UIPStateModel extends SyntheticEventTarget {
/** Current markup state */
private _html = new DOMParser().parseFromString('', 'text/html').body;

public storage: UIPStateStorage | undefined;

/** Last changes history (used to dispatch changes) */
private _changes: UIPChangeInfo[] = [];

Expand All @@ -64,13 +67,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 +98,53 @@ 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 resetSnippet(source: 'js' | 'javascript' | 'html', modifier: UIPPlugin | UIPRoot): void {
source === 'html' ? this.resetHTML(modifier) : this.resetJS(modifier);
}

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

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


/** Current js state getter */
public get js(): string {
return this._js;
Expand Down Expand Up @@ -147,16 +184,25 @@ export class UIPStateModel extends SyntheticEventTarget {
return this._snippets.find((snippet) => snippet.anchor === anchor);
}

protected getStorageKey(modifier: UIPPlugin | UIPRoot): string {
return modifier instanceof UIPRoot ? modifier.storeKey : modifier.$root?.storeKey || '';
fshovchko marked this conversation as resolved.
Show resolved Hide resolved
}

/** Changes current active snippet */
public applySnippet(
snippet: UIPSnippetItem,
modifier: UIPPlugin | UIPRoot
): void {
if (!snippet) return;
this._snippets.forEach((s) => (s.active = s === snippet));
this.setHtml(snippet.html, modifier, true);
this.setJS(snippet.js, modifier);
this.setNote(snippet.note, modifier);

const storeKey = this.getStorageKey(modifier);
if (storeKey) this.storage = UIPStateStorage.for(storeKey, this);

const {js, html, note} = this.storage?.loadState() || snippet;
this.setHtml(html, modifier, true);
this.setJS(js, modifier);
this.setNote(note, modifier);
this.dispatchEvent(
new CustomEvent('uip:model:snippet:change', {detail: this})
);
Expand Down Expand Up @@ -199,6 +245,7 @@ export class UIPStateModel extends SyntheticEventTarget {
if (!this._changes.length) return;
const detail = this._changes;
this._changes = [];
this.storage?.saveState();
this.dispatchEvent(
new CustomEvent('uip:model:change', {detail})
);
Expand Down
11 changes: 7 additions & 4 deletions src/core/base/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import {
memoize,
boolAttr,
listen,
prop
prop,
attr
} from '@exadel/esl/modules/esl-utils/decorators';

import {UIPStateModel} from './model';
import {UIPChangeEvent} from './model.change';

import type {UIPSnippetTemplate} from './snippet';
import type {UIPChangeInfo} from './model.change';
import type {UIPSnippetTemplate} from './snippet';

/**
* UI Playground root custom element definition
Expand All @@ -36,6 +37,8 @@ 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;

/** Indicates ready state of the uip-root */
@boolAttr({readonly: true}) public ready: boolean;
Expand All @@ -51,7 +54,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);
Expand All @@ -65,7 +68,7 @@ export class UIPRoot extends ESLBaseElement {
this.$$fire(this.READY_EVENT, {bubbles: false});

if (this.model.anchorSnippet) {
this.delyedScrollIntoView();
this.delayedScrollIntoView();
}
}

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
3 changes: 2 additions & 1 deletion src/plugins/editor/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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';
Expand Down Expand Up @@ -45,6 +45,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
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
7 changes: 7 additions & 0 deletions src/plugins/reset/reset-button.icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React from 'jsx-dom';

export const ResetIcon = (): SVGElement => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 12 12">
<path d="M4 0C1.8 0 0 1.8 0 4s1.8 4 4 4c1.1 0 2.12-.43 2.84-1.16l-.72-.72c-.54.54-1.29.88-2.13.88-1.66 0-3-1.34-3-3s1.34-3 3-3c.83 0 1.55.36 2.09.91L4.99 3h3V0L6.8 1.19C6.08.47 5.09 0 3.99 0z"/>
</svg>
) as SVGElement;
14 changes: 14 additions & 0 deletions src/plugins/reset/reset-button.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.uip-reset {
display: inline-flex;
cursor: pointer;

> svg {
fill: currentColor;
width: 100%;
height: 100%;
}

&-hidden {
fshovchko marked this conversation as resolved.
Show resolved Hide resolved
display: none;
}
}
15 changes: 15 additions & 0 deletions src/plugins/reset/reset-button.shape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type {ESLBaseElementShape} from '@exadel/esl/modules/esl-base-element/core';
import type {UIPReset} from './reset-button';

export interface UIPResetShape extends ESLBaseElementShape<UIPReset> {
source?: 'javascript' | 'js' | 'html';
children?: any;
}

declare global {
namespace JSX {
interface IntrinsicElements {
'uip-reset': UIPResetShape;
}
}
}
35 changes: 35 additions & 0 deletions src/plugins/reset/reset-button.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import './reset-button.shape';

import {listen, attr} from '@exadel/esl/modules/esl-utils/decorators';

import {UIPPluginButton} from '../../core/button/plugin-button';
import {UIPRoot} from '../../core/base/root';

/** Button-plugin to reset snippet to default settings */
export class UIPReset extends UIPPluginButton {
public static override is = 'uip-reset';

/** Source type to copy (html | js) */
@attr({defaultValue: 'html'}) public source: 'js' | 'javascript' | 'html';

protected override connectedCallback(): void {
super.connectedCallback();
}

public override onAction(): void {
if (this.$root) this.model?.resetSnippet(this.source, this.$root);
fshovchko marked this conversation as resolved.
Show resolved Hide resolved
}

@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.source === 'javascript')
this.toggleButton(!this.model.isJSChanged());
fshovchko marked this conversation as resolved.
Show resolved Hide resolved
else if (this.source === 'html')
this.toggleButton(!this.model.isHTMLChanged());
fshovchko marked this conversation as resolved.
Show resolved Hide resolved
}

protected toggleButton(state?: boolean): void {
this.$$cls('uip-reset-hidden', state);
}
fshovchko marked this conversation as resolved.
Show resolved Hide resolved
}
Loading