diff --git a/studio/src/app/components/editor/actions/element/app-actions-element/app-actions-element.tsx b/studio/src/app/components/editor/actions/element/app-actions-element/app-actions-element.tsx index 5161eaf0a..b9e17cada 100644 --- a/studio/src/app/components/editor/actions/element/app-actions-element/app-actions-element.tsx +++ b/studio/src/app/components/editor/actions/element/app-actions-element/app-actions-element.tsx @@ -6,6 +6,7 @@ import {isSlide} from '@deckdeckgo/deck-utils'; import store from '../../../../../stores/busy.store'; import i18n from '../../../../../stores/i18n.store'; +import undoRedoStore from '../../../../../stores/undo-redo.store'; import {ImageHelper} from '../../../../../helpers/editor/image.helper'; import {ShapeHelper} from '../../../../../helpers/editor/shape.helper'; @@ -67,6 +68,26 @@ export class AppActionsElement { @Event() private resetted: EventEmitter; + private observeElementMutations = () => { + if (undoRedoStore.state.elementInnerHTML === undefined) { + return; + } + + if (undoRedoStore.state.elementInnerHTML === this.selectedElement.element.innerHTML) { + return; + } + + if (undoRedoStore.state.undo === undefined) { + undoRedoStore.state.undo = []; + } + + undoRedoStore.state.undo.push({type: 'input', target: this.selectedElement.element, data: {innerHTML: undoRedoStore.state.elementInnerHTML}}); + + undoRedoStore.state.elementInnerHTML = this.selectedElement.element.innerHTML; + }; + + private observer: MutationObserver = new MutationObserver(this.observeElementMutations); + constructor() { this.debounceResizeSlideContent = debounce(async () => { await this.resizeSlideContent(); @@ -133,17 +154,27 @@ export class AppActionsElement { } @Method() - touch(element: HTMLElement, autoOpen: boolean = true): Promise { - return new Promise(async (resolve) => { - await this.unSelect(); - await this.select(element, autoOpen); + async touch(element: HTMLElement | undefined, autoOpen: boolean = true) { + await this.unSelect(); + await this.select(element, autoOpen); - if (element) { - element.focus(); - } + if (!element) { + return; + } - resolve(); - }); + element.focus(); + + if (this.selectedElement?.type === 'element') { + this.observer.takeRecords(); + this.observer.observe(this.selectedElement.element, {attributes: true, childList: true, subtree: true}); + + undoRedoStore.state.elementInnerHTML = this.selectedElement.element.innerHTML; + return; + } + + this.observer.disconnect(); + + undoRedoStore.state.elementInnerHTML = undefined; } @Method() diff --git a/studio/src/app/components/editor/actions/footer/app-actions-editor/app-actions-editor.tsx b/studio/src/app/components/editor/actions/footer/app-actions-editor/app-actions-editor.tsx index 7d3ed6230..3d427d56b 100644 --- a/studio/src/app/components/editor/actions/footer/app-actions-editor/app-actions-editor.tsx +++ b/studio/src/app/components/editor/actions/footer/app-actions-editor/app-actions-editor.tsx @@ -3,6 +3,7 @@ import {Component, Element, Event, Watch, EventEmitter, Fragment, h, Host, JSX, import {isSlide} from '@deckdeckgo/deck-utils'; import editorStore from '../../../../../stores/editor.store'; +import undoRedoStore from '../../../../../stores/undo-redo.store'; import {BreadcrumbsStep} from '../../../../../types/editor/breadcrumbs-step'; @@ -57,6 +58,27 @@ export class AppActionsEditor { private actionsElementRef!: HTMLAppActionsElementElement; + private destroyUndoRedoListener; + + componentDidLoad() { + this.destroyUndoRedoListener = undoRedoStore.onChange('elementInnerHTML', (elementInnerHTML: string | undefined) => { + if (elementInnerHTML === undefined) { + this.el.removeEventListener('click', this.resetElementInnerHTML, false); + return; + } + + this.el.addEventListener('click', this.resetElementInnerHTML, {once: true}); + }); + } + + disconnectedCallback() { + this.destroyUndoRedoListener?.(); + } + + private resetElementInnerHTML = () => { + undoRedoStore.state.elementInnerHTML = undefined; + }; + @Watch('fullscreen') onFullscreenChange() { this.hideBottomSheet = true; diff --git a/studio/src/app/components/editor/styles/app-color-text-background/app-color-text-background.tsx b/studio/src/app/components/editor/styles/app-color-text-background/app-color-text-background.tsx index 7322e3f2a..db287748f 100644 --- a/studio/src/app/components/editor/styles/app-color-text-background/app-color-text-background.tsx +++ b/studio/src/app/components/editor/styles/app-color-text-background/app-color-text-background.tsx @@ -5,6 +5,7 @@ import i18n from '../../../../stores/i18n.store'; import {ColorUtils, InitStyleColor} from '../../../../utils/editor/color.utils'; import {SettingsUtils} from '../../../../utils/core/settings.utils'; +import {setStyle} from '../../../../utils/editor/undo-redo.utils'; import {Expanded} from '../../../../types/core/settings'; @@ -26,6 +27,8 @@ export class AppColorTextBackground { @Prop() colorType: 'text' | 'background' = 'text'; + private colorRef!: HTMLAppColorElement; + @Event() colorChange: EventEmitter; private initBackground = async (): Promise => { @@ -85,11 +88,11 @@ export class AppColorTextBackground { return; } - if (this.deck || this.slide) { - this.selectedElement.style.setProperty('--color', selectedColor); - } else { - this.selectedElement.style.color = selectedColor; - } + setStyle(this.selectedElement, { + properties: [{property: this.deck || this.slide ? '--color' : 'color', value: selectedColor}], + type: this.deck ? 'deck' : this.slide ? 'slide' : 'element', + updateUI: async (_value: string) => await this.colorRef.loadColor(), + }); this.colorChange.emit(); } @@ -99,11 +102,11 @@ export class AppColorTextBackground { return; } - if (this.deck || this.slide) { - this.selectedElement.style.setProperty('--background', selectedColor); - } else { - this.selectedElement.style.background = selectedColor; - } + setStyle(this.selectedElement, { + properties: [{value: selectedColor, property: this.deck || this.slide ? '--background' : 'background'}], + type: this.deck ? 'deck' : this.slide ? 'slide' : 'element', + updateUI: async (_value: string) => await this.colorRef.loadColor(), + }); this.colorChange.emit(); } @@ -116,6 +119,7 @@ export class AppColorTextBackground { {i18n.state.editor.color} (this.colorRef = el as HTMLAppColorElement)} initColor={this.colorType === 'background' ? this.initBackground : this.initColor} onResetColor={() => this.resetColor()} defaultColor={this.colorType === 'background' ? '#fff' : '#000'} diff --git a/studio/src/app/components/editor/styles/app-color/app-color.tsx b/studio/src/app/components/editor/styles/app-color/app-color.tsx index fb9a63657..fe5ee6960 100644 --- a/studio/src/app/components/editor/styles/app-color/app-color.tsx +++ b/studio/src/app/components/editor/styles/app-color/app-color.tsx @@ -33,6 +33,8 @@ export class AppColor { @State() private colorCSS: string; + private skipNextColorCSSEmit: boolean = false; + @Event() colorDidChange: EventEmitter; @@ -79,6 +81,8 @@ export class AppColor { this.opacity = opacity ? opacity : 100; + this.skipNextColorCSSEmit = true; + await this.initColorCSS(); } @@ -127,6 +131,8 @@ export class AppColor { $event.stopPropagation(); + this.skipNextColorCSSEmit = true; + await this.initColorStateRgb(color.rgb); await this.initColorCSS(); @@ -218,6 +224,11 @@ export class AppColor { } private async updateColorCSS() { + if (this.skipNextColorCSSEmit) { + this.skipNextColorCSSEmit = false; + return; + } + this.colorDidChange.emit(this.colorCSS); } diff --git a/studio/src/app/components/editor/styles/deck/app-deck-fonts/app-deck-fonts.tsx b/studio/src/app/components/editor/styles/deck/app-deck-fonts/app-deck-fonts.tsx index 6af84df1e..38debb2bb 100644 --- a/studio/src/app/components/editor/styles/deck/app-deck-fonts/app-deck-fonts.tsx +++ b/studio/src/app/components/editor/styles/deck/app-deck-fonts/app-deck-fonts.tsx @@ -4,6 +4,8 @@ import i18n from '../../../../../stores/i18n.store'; import {FontsService} from '../../../../../services/editor/fonts/fonts.service'; +import {setStyle} from '../../../../../utils/editor/undo-redo.utils'; + @Component({ tag: 'app-deck-fonts', styleUrl: 'app-deck-fonts.scss', @@ -51,15 +53,13 @@ export class AppDeckFonts { return; } - if (!font) { - this.deckElement.style.removeProperty('font-family'); - } else { - this.deckElement.style.setProperty('font-family', font.family); - } + setStyle(this.deckElement, { + properties: [{value: !font ? null : font.family, property: 'font-family'}], + type: 'deck', + updateUI: async () => await this.initSelectedFont(), + }); this.fontsChange.emit(); - - await this.initSelectedFont(); } render() { diff --git a/studio/src/app/components/editor/styles/deck/app-deck-transition/app-deck-transition.tsx b/studio/src/app/components/editor/styles/deck/app-deck-transition/app-deck-transition.tsx index f4ac4f936..e5fdd095c 100644 --- a/studio/src/app/components/editor/styles/deck/app-deck-transition/app-deck-transition.tsx +++ b/studio/src/app/components/editor/styles/deck/app-deck-transition/app-deck-transition.tsx @@ -5,6 +5,8 @@ import i18n from '../../../../../stores/i18n.store'; import {DeckAction} from '../../../../../types/editor/deck-action'; +import {setAttribute} from '../../../../../utils/editor/undo-redo.utils'; + @Component({ tag: 'app-deck-transition', styleUrl: 'app-deck-transition.scss', @@ -141,9 +143,16 @@ export class AppDeckTransition { await this.goToFirstSlide(); - this.deckElement.setAttribute(this.device === 'mobile' ? 'direction-mobile' : 'direction', direction); + const resetDevice: 'desktop' | 'mobile' = this.device; - this.selectedDirection = direction; + setAttribute(this.deckElement, { + attribute: this.device === 'mobile' ? 'direction-mobile' : 'direction', + value: direction, + updateUI: (value: string) => { + this.device = resetDevice; + this.selectedDirection = value as 'horizontal' | 'vertical' | 'papyrus'; + }, + }); this.transitionChange.emit(); @@ -171,9 +180,11 @@ export class AppDeckTransition { return; } - this.deckElement.setAttribute('animation', animation); - - this.selectedAnimation = animation; + setAttribute(this.deckElement, { + attribute: 'animation', + value: animation, + updateUI: (value: string) => (this.selectedAnimation = value as 'slide' | 'fade' | 'none'), + }); this.transitionChange.emit(); } diff --git a/studio/src/app/components/editor/styles/element/app-block/app-block.tsx b/studio/src/app/components/editor/styles/element/app-block/app-block.tsx index 425268a91..5c221049c 100644 --- a/studio/src/app/components/editor/styles/element/app-block/app-block.tsx +++ b/studio/src/app/components/editor/styles/element/app-block/app-block.tsx @@ -5,16 +5,18 @@ import {RangeChangeEventDetail} from '@ionic/core'; import settingsStore from '../../../../../stores/settings.store'; import i18n from '../../../../../stores/i18n.store'; -import {SettingsUtils} from '../../../../../utils/core/settings.utils'; - import {EditMode, Expanded} from '../../../../../types/core/settings'; +import { SelectedElement } from "../../../../../types/editor/selected-element"; + +import {SettingsUtils} from '../../../../../utils/core/settings.utils'; +import { setStyle } from "../../../../../utils/editor/undo-redo.utils"; @Component({ tag: 'app-block', }) export class AppBlock { @Prop() - selectedElement: HTMLElement; + selectedElement: SelectedElement; @State() private width: number = 100; @@ -38,6 +40,8 @@ export class AppBlock { private destroyListener; + private ignoreUpdateStyle: boolean = false; + async componentWillLoad() { await this.initWidth(); await this.initWidthCSS(); @@ -69,33 +73,33 @@ export class AppBlock { } private async initPadding() { - const css: CSSStyleDeclaration = window.getComputedStyle(this.selectedElement); + const css: CSSStyleDeclaration = window.getComputedStyle(this.selectedElement?.element); const padding: number = parseInt(css.paddingTop); this.padding = isNaN(padding) ? 0 : padding; } private async initPaddingCSS() { - const css: CSSStyleDeclaration = window.getComputedStyle(this.selectedElement); + const css: CSSStyleDeclaration = window.getComputedStyle(this.selectedElement?.element); this.paddingCSS = css.padding; } private async initWidth() { - const width: number = parseInt(this.selectedElement?.style.width); + const width: number = parseInt(this.selectedElement?.element?.style.width); this.width = isNaN(width) ? 100 : width; } private async initWidthCSS() { - this.widthCSS = this.selectedElement?.style.width; + this.widthCSS = this.selectedElement?.element?.style.width; } private async initRotate() { - const matches: RegExpMatchArray | null = this.selectedElement?.style.transform.match(/(\d+)/); + const matches: RegExpMatchArray | null = this.selectedElement?.element?.style.transform.match(/(\d+)/); const rotate: number = parseInt(matches?.[0]); this.rotate = isNaN(rotate) ? 0 : rotate; } private async initTransformCSS() { - this.transformCSS = this.selectedElement?.style.transform; + this.transformCSS = this.selectedElement?.element?.style.transform; } private async updateWidth($event: CustomEvent) { @@ -107,7 +111,7 @@ export class AppBlock { this.width = $event.detail.value as number; - this.selectedElement.style.width = `${this.width}%`; + this.updateStyle({property: 'width', value: `${this.width}%`}); this.blockChange.emit(); } @@ -121,7 +125,7 @@ export class AppBlock { return; } - this.selectedElement.style.width = this.widthCSS; + this.updateStyle({property: 'width', value: this.widthCSS}); this.blockChange.emit(); } @@ -135,7 +139,7 @@ export class AppBlock { this.padding = $event.detail.value as number; - this.selectedElement.style.padding = `${this.padding}px`; + this.updateStyle({property: 'padding', value: `${this.padding}px`}); this.blockChange.emit(); } @@ -149,7 +153,7 @@ export class AppBlock { return; } - this.selectedElement.style.padding = this.paddingCSS; + this.updateStyle({property: 'padding', value: this.paddingCSS}); this.blockChange.emit(); } @@ -163,7 +167,7 @@ export class AppBlock { this.rotate = $event.detail.value as number; - this.selectedElement.style.transform = `rotate(${this.rotate}deg)`; + this.updateStyle({property: 'transform', value: `rotate(${this.rotate}deg)`}); this.blockChange.emit(); } @@ -177,11 +181,53 @@ export class AppBlock { return; } - this.selectedElement.style.transform = this.transformCSS; + this.updateStyle({property: 'transform', value: this.transformCSS}); this.blockChange.emit(); } + private updateStyle({property, value}: {property: 'transform' | 'width' | 'padding'; value: string}) { + if (this.ignoreUpdateStyle) { + this.ignoreUpdateStyle = false; + return; + } + + setStyle(this.selectedElement.element, { + properties: [{property, value}], + type: this.selectedElement.type, + updateUI: async () => { + // ion-change triggers the event each time its value changes, because we re-render, it triggers it again + this.ignoreUpdateStyle = true; + + if (settingsStore.state.editMode === 'css') { + switch (property) { + case 'transform': + await this.initTransformCSS(); + break; + case 'width': + await this.initWidthCSS(); + break; + case 'padding': + await this.initPaddingCSS(); + } + + return; + } + + switch (property) { + case 'transform': + await this.initRotate(); + break; + case 'width': + await this.initWidth(); + break; + case 'padding': + await this.initPadding(); + } + }, + }); + } + render() { return ( = new Map([ - ['General', 0], - ['TopLeft', 0], - ['TopRight', 0], - ['BottomLeft', 0], - ['BottomRight', 0], + ['general', 0], + ['top-left', 0], + ['top-right', 0], + ['bottom-left', 0], + ['bottom-right', 0], ]); @State() @@ -37,6 +39,8 @@ export class AppBorderRadius { private destroyListener; + private ignoreUpdateStyle: boolean = false; + async componentWillLoad() { await this.initBorderRadius(); await this.initCornersExpanded(); @@ -61,35 +65,35 @@ export class AppBorderRadius { } private async initBorderRadiusCSS() { - this.borderRadiusCSS = this.selectedElement?.style.borderRadius; + this.borderRadiusCSS = this.selectedElement?.element?.style.borderRadius; } private async initBorderRadius() { - if (!this.selectedElement || !window) { + if (!this.selectedElement || !this.selectedElement.element || !window) { return; } - const style: CSSStyleDeclaration = window.getComputedStyle(this.selectedElement); + const style: CSSStyleDeclaration = window.getComputedStyle(this.selectedElement.element); if (!style) { return; } - this.borderRadiuses.set('TopLeft', parseInt(style.borderTopLeftRadius)); - this.borderRadiuses.set('TopRight', parseInt(style.borderTopRightRadius)); - this.borderRadiuses.set('BottomRight', parseInt(style.borderBottomRightRadius)); - this.borderRadiuses.set('BottomLeft', parseInt(style.borderBottomLeftRadius)); + this.borderRadiuses.set('top-left', parseInt(style.borderTopLeftRadius)); + this.borderRadiuses.set('top-right', parseInt(style.borderTopRightRadius)); + this.borderRadiuses.set('bottom-right', parseInt(style.borderBottomRightRadius)); + this.borderRadiuses.set('bottom-left', parseInt(style.borderBottomLeftRadius)); } private async initCornersExpanded() { this.cornersExpanded = !( - this.borderRadiuses.get('TopLeft') === this.borderRadiuses.get('TopRight') && - this.borderRadiuses.get('TopLeft') === this.borderRadiuses.get('BottomRight') && - this.borderRadiuses.get('TopLeft') === this.borderRadiuses.get('BottomLeft') + this.borderRadiuses.get('top-left') === this.borderRadiuses.get('top-right') && + this.borderRadiuses.get('top-left') === this.borderRadiuses.get('bottom-right') && + this.borderRadiuses.get('top-left') === this.borderRadiuses.get('bottom-left') ); if (!this.cornersExpanded) { - this.borderRadiuses.set('General', this.borderRadiuses.get('TopLeft')); + this.borderRadiuses.set('general', this.borderRadiuses.get('top-left')); } } @@ -101,20 +105,28 @@ export class AppBorderRadius { if (!this.selectedElement || !$event || !$event.detail) { return; } - if (corner === 'General') { + if (corner === 'general') { this.borderRadiuses.forEach((_, key) => { this.borderRadiuses.set(key, $event.detail.value); }); - this.selectedElement.style.borderRadius = `${$event.detail.value}px`; + + this.updateStyle({property: 'border-radius', value: `${$event.detail.value}px`}); } else { this.borderRadiuses.set(corner, $event.detail.value); - this.selectedElement.style[`border${corner}Radius`] = `${$event.detail.value}px`; + + this.updateStyle({property: `border-${corner}-radius`, value: `${$event.detail.value}px`}); } - this.borderRadiuses = new Map(this.borderRadiuses); + + this.updateBorderRadiuses(); this.emitBorderRadiusChange(); } + // To apply a re-render + private updateBorderRadiuses() { + this.borderRadiuses = new Map(this.borderRadiuses); + } + private selectCornersToShow($event: CustomEvent) { if (!$event || !$event.detail) { return; @@ -127,11 +139,38 @@ export class AppBorderRadius { } private async updateBorderRadiusCSS() { - this.selectedElement.style.borderRadius = this.borderRadiusCSS; + this.selectedElement.element.style.borderRadius = this.borderRadiusCSS; this.emitBorderRadiusChange(); } + private updateStyle({property, value}: {property: string; value: string}) { + if (this.ignoreUpdateStyle) { + this.ignoreUpdateStyle = false; + return; + } + + setStyle(this.selectedElement.element, { + properties: [{property, value}], + type: this.selectedElement.type, + updateUI: async () => { + // ion-change triggers the event each time its value changes, because we re-render, it triggers it again + this.ignoreUpdateStyle = true; + + if (settingsStore.state.editMode === 'css') { + await this.initBorderRadiusCSS(); + + return; + } + + await this.initBorderRadius(); + await this.initCornersExpanded(); + + this.updateBorderRadiuses(); + }, + }); + } + render() { return ( {i18n.state.editor.individual_corners} - {!this.cornersExpanded ? this.renderOption('General', 'Every corner') : undefined} + {!this.cornersExpanded ? this.renderOption('general', 'Every corner') : undefined} {this.cornersExpanded && ( - {this.renderOption('TopLeft', i18n.state.editor.top_left)} - {this.renderOption('TopRight', i18n.state.editor.top_right)} - {this.renderOption('BottomRight', i18n.state.editor.bottom_right)} - {this.renderOption('BottomLeft', i18n.state.editor.bottom_left)} + {this.renderOption('top-left', i18n.state.editor.top_left)} + {this.renderOption('top-right', i18n.state.editor.top_right)} + {this.renderOption('bottom-right', i18n.state.editor.bottom_right)} + {this.renderOption('bottom-left', i18n.state.editor.bottom_left)} )} @@ -175,7 +214,7 @@ export class AppBorderRadius { ); } - private renderOption(option: 'General' | 'TopLeft' | 'TopRight' | 'BottomRight' | 'BottomLeft', text: string) { + private renderOption(option: 'general' | 'top-left' | 'top-right' | 'bottom-right' | 'bottom-left', text: string) { const borderRadius: number = this.borderRadiuses.get(option); return [ diff --git a/studio/src/app/components/editor/styles/element/app-box-shadow/app-box-shadow.tsx b/studio/src/app/components/editor/styles/element/app-box-shadow/app-box-shadow.tsx index 3aabd0226..1d40b8e50 100644 --- a/studio/src/app/components/editor/styles/element/app-box-shadow/app-box-shadow.tsx +++ b/studio/src/app/components/editor/styles/element/app-box-shadow/app-box-shadow.tsx @@ -9,13 +9,15 @@ import {ColorUtils, InitStyleColor} from '../../../../../utils/editor/color.util import {SettingsUtils} from '../../../../../utils/core/settings.utils'; import {EditMode, Expanded} from '../../../../../types/core/settings'; +import {setStyle} from '../../../../../utils/editor/undo-redo.utils'; +import {SelectedElement} from '../../../../../types/editor/selected-element'; @Component({ tag: 'app-box-shadow', }) export class AppBoxShadow { @Prop() - selectedElement: HTMLElement; + selectedElement: SelectedElement; private readonly defaultBoxShadowProperties = new Map([ ['hLength', 0], @@ -44,6 +46,10 @@ export class AppBoxShadow { private destroyListener; + private ignoreUpdateStyle: boolean = false; + + private colorRef!: HTMLAppColorElement; + async componentWillLoad() { await this.init(); await this.initCSS(); @@ -65,15 +71,15 @@ export class AppBoxShadow { } private async initCSS() { - this.boxShadowCSS = this.selectedElement?.style.boxShadow; + this.boxShadowCSS = this.selectedElement?.element?.style.boxShadow; } private async init() { - if (!this.selectedElement) { + if (!this.selectedElement || !this.selectedElement.element) { return; } - const style: CSSStyleDeclaration = window.getComputedStyle(this.selectedElement); + const style: CSSStyleDeclaration = window.getComputedStyle(this.selectedElement.element); if (!style) { return; @@ -110,14 +116,14 @@ export class AppBoxShadow { } private initColor = async (): Promise => { - if (!this.selectedElement) { + if (!this.selectedElement || !this.selectedElement.element) { return { rgb: null, opacity: null, }; } - const style: CSSStyleDeclaration = window.getComputedStyle(this.selectedElement); + const style: CSSStyleDeclaration = window.getComputedStyle(this.selectedElement.element); if (!style) { return { @@ -182,19 +188,47 @@ export class AppBoxShadow { } private async updateBoxShadow() { - this.selectedElement.style.boxShadow = `${this.boxShadowProperties.get('hLength')}px ${this.boxShadowProperties.get( - 'vLength' - )}px ${this.boxShadowProperties.get('blurRadius')}px ${this.boxShadowProperties.get('spreadRadius')}px ${this.color}`; + this.updateStyle( + `${this.boxShadowProperties.get('hLength')}px ${this.boxShadowProperties.get('vLength')}px ${this.boxShadowProperties.get( + 'blurRadius' + )}px ${this.boxShadowProperties.get('spreadRadius')}px ${this.color}` + ); this.emitBoxShadowChange(); } + private updateStyle(value: string) { + if (this.ignoreUpdateStyle) { + this.ignoreUpdateStyle = false; + return; + } + + setStyle(this.selectedElement.element, { + properties: [{property: 'box-shadow', value}], + type: this.selectedElement.type, + updateUI: async () => { + // ion-change triggers the event each time its value changes, because we re-render, it triggers it again + this.ignoreUpdateStyle = true; + + await this.colorRef.loadColor(); + + if (settingsStore.state.editMode === 'css') { + await this.initCSS(); + + return; + } + + await this.init(); + }, + }); + } + private async resetBoxShadow() { if (!this.selectedElement) { return; } - this.selectedElement.style.boxShadow = ''; + this.updateStyle(''); this.boxShadowProperties = new Map(this.defaultBoxShadowProperties); @@ -212,7 +246,7 @@ export class AppBoxShadow { } private async updateLetterSpacingCSS() { - this.selectedElement.style.boxShadow = this.boxShadowCSS; + this.updateStyle(this.boxShadowCSS); this.emitBoxShadowChange(); } @@ -224,7 +258,7 @@ export class AppBoxShadow { onExpansion={($event: CustomEvent) => SettingsUtils.update({boxShadow: $event.detail})}> {i18n.state.editor.box_shadow} - this.colorRef = el as HTMLAppColorElement} class="ion-margin-top properties" initColor={this.initColor} onResetColor={() => this.resetBoxShadow()} diff --git a/studio/src/app/components/editor/styles/element/app-color-code/app-color-code.tsx b/studio/src/app/components/editor/styles/element/app-color-code/app-color-code.tsx index 887d33293..05cca966c 100644 --- a/studio/src/app/components/editor/styles/element/app-color-code/app-color-code.tsx +++ b/studio/src/app/components/editor/styles/element/app-color-code/app-color-code.tsx @@ -5,6 +5,7 @@ import i18n from '../../../../../stores/i18n.store'; import {DeckdeckgoHighlightCodeCarbonTheme, DeckdeckgoHighlightCodeTerminal} from '@deckdeckgo/highlight-code'; import {ColorUtils, InitStyleColor} from '../../../../../utils/editor/color.utils'; +import { setStyle } from "../../../../../utils/editor/undo-redo.utils"; enum CodeColorType { COMMENTS, @@ -100,7 +101,7 @@ export class AppColorCode { this.selectedElement.style.setProperty(this.getStyle(), $event.detail); - this.emitCodeChange(); + this.updateStyle($event.detail); } private async toggleColorType($event: CustomEvent) { @@ -143,7 +144,21 @@ export class AppColorCode { return; } - this.selectedElement.style.removeProperty(this.getStyle()); + this.updateStyle(null); + } + + private updateStyle(value: string | null) { + const redoType: CodeColorType = this.codeColorType; + + setStyle(this.selectedElement, { + properties: [{property: this.getStyle(), value}], + type: 'element', + updateUI: async (_value: string) => { + await this.colorCodeRef.loadColor(); + + this.codeColorType = redoType; + } + }); this.emitCodeChange(); } diff --git a/studio/src/app/components/editor/styles/element/app-color-word-cloud/app-color-word-cloud.tsx b/studio/src/app/components/editor/styles/element/app-color-word-cloud/app-color-word-cloud.tsx index 4aa897a5b..35a28fef5 100644 --- a/studio/src/app/components/editor/styles/element/app-color-word-cloud/app-color-word-cloud.tsx +++ b/studio/src/app/components/editor/styles/element/app-color-word-cloud/app-color-word-cloud.tsx @@ -3,6 +3,7 @@ import {Component, Event, EventEmitter, h, Prop, State} from '@stencil/core'; import i18n from '../../../../../stores/i18n.store'; import {ColorUtils, InitStyleColor} from '../../../../../utils/editor/color.utils'; +import { setStyle } from "../../../../../utils/editor/undo-redo.utils"; @Component({ tag: 'app-color-word-cloud', @@ -25,9 +26,7 @@ export class AppColorWordCloud { return; } - this.selectedElement.style.setProperty(this.getStyle(), $event.detail); - - this.emitChange(); + this.updateStyle($event.detail); } private getStyle(): string { @@ -54,7 +53,21 @@ export class AppColorWordCloud { return; } - this.selectedElement.style.removeProperty(this.getStyle()); + this.updateStyle(null); + } + + private updateStyle(value: string | null) { + const redoIndex: number = this.colorIndex; + + setStyle(this.selectedElement, { + properties: [{property: this.getStyle(), value}], + type: 'element', + updateUI: async (_value: string) => { + await this.colorRef.loadColor(); + + this.colorIndex = redoIndex; + } + }); this.emitChange(); } @@ -93,7 +106,7 @@ export class AppColorWordCloud { - (this.colorRef = el as HTMLAppColorElement)} initColor={this.initColor} onResetColor={() => this.resetColor()} diff --git a/studio/src/app/components/editor/styles/element/app-image-style/app-image-style.tsx b/studio/src/app/components/editor/styles/element/app-image-style/app-image-style.tsx index b66f763a9..e4571499a 100644 --- a/studio/src/app/components/editor/styles/element/app-image-style/app-image-style.tsx +++ b/studio/src/app/components/editor/styles/element/app-image-style/app-image-style.tsx @@ -8,6 +8,7 @@ import i18n from '../../../../../stores/i18n.store'; import {EditMode, Expanded} from '../../../../../types/core/settings'; import {SettingsUtils} from '../../../../../utils/core/settings.utils'; +import {setStyle} from '../../../../../utils/editor/undo-redo.utils'; enum ImageSize { SMALL = '25%', @@ -47,9 +48,10 @@ export class AppImageStyle { private destroyListener; + private ignoreUpdateStyle: boolean = false; + async componentWillLoad() { - this.currentImageSize = await this.initImageSize(); - this.currentImageAlignment = await this.initImageAlignment(); + await this.init(); this.destroyListener = settingsStore.onChange('editMode', async (edit: EditMode) => { if (edit === 'css') { @@ -57,8 +59,7 @@ export class AppImageStyle { return; } - this.currentImageSize = await this.initImageSize(); - this.currentImageAlignment = await this.initImageAlignment(); + await this.init(); }); } @@ -68,6 +69,12 @@ export class AppImageStyle { } } + private async init() { + this.currentImageSize = await this.initImageSize(); + this.currentImageAlignment = await this.initImageAlignment(); + + } + private async initCSS() { this.imageHeightCSS = this.selectedElement.style.getPropertyValue('--deckgo-lazy-img-height'); this.imageJustifyContentCSS = this.selectedElement.style.getPropertyValue('justify-content'); @@ -132,53 +139,69 @@ export class AppImageStyle { }); } - private toggleImageSize($event: CustomEvent): Promise { - return new Promise(async (resolve) => { - if (!$event || !$event.detail) { - resolve(); - return; - } + private toggleImageSize($event: CustomEvent) { + if (!$event || !$event.detail) { + return; + } - this.currentImageSize = $event.detail.value; + this.currentImageSize = $event.detail.value; - if (!this.selectedElement) { - resolve(); - return; - } + if (!this.selectedElement) { + return; + } - if (this.currentImageSize === ImageSize.ORIGINAL) { - this.selectedElement.style.removeProperty('--deckgo-lazy-img-height'); - } else { - this.selectedElement.style.setProperty('--deckgo-lazy-img-height', this.currentImageSize); - } + this.updateStyle([{ + property: '--deckgo-lazy-img-height', + value: this.currentImageSize === ImageSize.ORIGINAL ? null : this.currentImageSize + }]); + } - this.imgDidChange.emit(this.selectedElement); + private toggleImageAlignment($event: CustomEvent) { + if (!$event || !$event.detail) { + return; + } - resolve(); - }); - } + this.currentImageAlignment = $event.detail.value; - private toggleImageAlignment($event: CustomEvent): Promise { - return new Promise(async (resolve) => { - if (!$event || !$event.detail) { - resolve(); - return; - } + if (!this.selectedElement) { + return; + } - this.currentImageAlignment = $event.detail.value; + this.updateStyle([ + { + property: 'display', + value: 'inline-flex', + }, + { + property: 'justify-content', + value: this.currentImageAlignment.toString(), + }, + ]); + } - if (!this.selectedElement) { - resolve(); - return; - } + private updateStyle(properties: {property: string; value: string | null}[]) { + if (this.ignoreUpdateStyle) { + this.ignoreUpdateStyle = false; + return; + } - this.selectedElement.style.setProperty('display', 'inline-flex'); - this.selectedElement.style.setProperty('justify-content', this.currentImageAlignment); + setStyle(this.selectedElement, { + properties, + type: 'element', + updateUI: async (_value: string) => { + // ion-change triggers the event each time its value changes, because we re-render, it triggers it again + this.ignoreUpdateStyle = true; - this.imgDidChange.emit(this.selectedElement); + if (settingsStore.state.editMode === 'css') { + await this.initCSS(); + return; + } - resolve(); + await this.init(); + }, }); + + this.imgDidChange.emit(this.selectedElement); } private handleImageHeightInput($event: CustomEvent) { @@ -232,7 +255,7 @@ export class AppImageStyle { this.toggleImageSize(e)} + onIonChange={($event: CustomEvent) => this.toggleImageSize($event)} interface="popover" mode="md" class="ion-padding-start ion-padding-end"> @@ -266,7 +289,7 @@ export class AppImageStyle { this.toggleImageAlignment(e)} + onIonChange={($event: CustomEvent) => this.toggleImageAlignment($event)} interface="popover" mode="md" class="ion-padding-start ion-padding-end"> diff --git a/studio/src/app/components/editor/styles/element/app-list/app-list.tsx b/studio/src/app/components/editor/styles/element/app-list/app-list.tsx index afce92f9d..891d12c2a 100644 --- a/studio/src/app/components/editor/styles/element/app-list/app-list.tsx +++ b/studio/src/app/components/editor/styles/element/app-list/app-list.tsx @@ -2,6 +2,7 @@ import {Component, Event, EventEmitter, h, Prop, State} from '@stencil/core'; import settingsStore from '../../../../../stores/settings.store'; import i18n from '../../../../../stores/i18n.store'; +import undoRedoStore from "../../../../../stores/undo-redo.store"; import {SlotType} from '../../../../../types/editor/slot-type'; import {ListStyle} from '../../../../../types/editor/list-style'; @@ -10,6 +11,7 @@ import {EditMode, Expanded} from '../../../../../types/core/settings'; import {ListUtils} from '../../../../../utils/editor/list.utils'; import {SlotUtils} from '../../../../../utils/editor/slot.utils'; import {SettingsUtils} from '../../../../../utils/core/settings.utils'; +import {setStyle} from '../../../../../utils/editor/undo-redo.utils'; @Component({ tag: 'app-list', @@ -34,11 +36,12 @@ export class AppList { private destroyListener; + private ignoreUpdateStyle: boolean = false; + async componentWillLoad() { this.listType = ListUtils.isElementList(this.selectedElement); await this.initListStyle(); - await this.initListStyleCSS(); this.destroyListener = settingsStore.onChange('editMode', async (edit: EditMode) => { @@ -76,7 +79,15 @@ export class AppList { this.listType = $event.detail.value; - await this.removeStyle(); + // Remove style with undo redo as we are going to replace the element in the dom + if (SlotUtils.isNodeRevealList(this.selectedElement)) { + this.selectedElement.style['--reveal-list-style'] = ''; + } else { + this.selectedElement.style.listStyleType = ''; + } + + // We have to clear the history undo redo too + undoRedoStore.reset(); this.toggleList.emit(this.listType); } @@ -86,29 +97,20 @@ export class AppList { return; } - await this.updateStyle($event.detail.value); + await this.applyStyle($event.detail.value); } - private async updateStyle(style: ListStyle) { + private async applyStyle(style: ListStyle) { this.selectedStyle = style; - if (SlotUtils.isNodeRevealList(this.selectedElement)) { - this.selectedElement.style['--reveal-list-style'] = this.selectedStyle; - } else { - this.selectedElement.style.listStyleType = this.selectedStyle; - } + this.updateStyle({ + property: SlotUtils.isNodeRevealList(this.selectedElement) ? '--reveal-list-style' : 'list-style-type', + value: this.selectedStyle, + }); this.listStyleChanged.emit(); } - private async removeStyle() { - if (SlotUtils.isNodeRevealList(this.selectedElement)) { - this.selectedElement.style['--reveal-list-style'] = ''; - } else { - this.selectedElement.style.listStyleType = ''; - } - } - private handleInput($event: CustomEvent) { this.listStyleCSS = ($event.target as InputTargetEvent).value; } @@ -121,6 +123,36 @@ export class AppList { } this.listStyleChanged.emit(); + + this.updateStyle({ + property: SlotUtils.isNodeRevealList(this.selectedElement) ? '--reveal-list-style' : 'list-style-type', + value: this.listStyleCSS, + }); + + this.listStyleChanged.emit(); + } + + private updateStyle(property: {property: string; value: string | null}) { + if (this.ignoreUpdateStyle) { + this.ignoreUpdateStyle = false; + return; + } + + setStyle(this.selectedElement, { + properties: [property], + type: 'element', + updateUI: async (_value: string) => { + // ion-change triggers the event each time its value changes, because we re-render, it triggers it again + this.ignoreUpdateStyle = true; + + if (settingsStore.state.editMode === 'css') { + await this.initListStyleCSS(); + return; + } + + await this.initListStyle(); + }, + }); } render() { @@ -167,7 +199,7 @@ export class AppList { placeholder="list-style-type" debounce={500} onIonInput={(e: CustomEvent) => this.handleInput(e)} - onIonChange={async () => await this.updateLetterSpacingCSS()}> + onIonChange={() => this.updateLetterSpacingCSS()}> diff --git a/studio/src/app/components/editor/styles/element/app-text/app-text.tsx b/studio/src/app/components/editor/styles/element/app-text/app-text.tsx index a544a9311..c2866d83d 100644 --- a/studio/src/app/components/editor/styles/element/app-text/app-text.tsx +++ b/studio/src/app/components/editor/styles/element/app-text/app-text.tsx @@ -6,10 +6,12 @@ import i18n from '../../../../../stores/i18n.store'; import {SettingsUtils} from '../../../../../utils/core/settings.utils'; import {EditMode, Expanded} from '../../../../../types/core/settings'; -import { FontSize } from "../../../../../types/editor/font-size"; +import {FontSize} from '../../../../../types/editor/font-size'; +import {SelectedElement} from '../../../../../types/editor/selected-element'; import {AlignUtils, TextAlign} from '../../../../../utils/editor/align.utils'; -import { initFontSize, toggleFontSize } from "../../../../../utils/editor/font-size.utils"; +import {initFontSize, toggleFontSize} from '../../../../../utils/editor/font-size.utils'; +import {setStyle} from '../../../../../utils/editor/undo-redo.utils'; enum LetterSpacing { TIGHTER, @@ -26,7 +28,7 @@ enum LetterSpacing { }) export class AppText { @Prop() - selectedElement: HTMLElement; + selectedElement: SelectedElement; @State() private align: TextAlign | undefined; @@ -50,6 +52,9 @@ export class AppText { private destroyListener; + // When we update states on undo / redo it triggers a rerender which triggers the onChange events of Ionic components + private ignoreUpdateStyle: boolean = false; + async componentWillLoad() { await this.init(); @@ -73,16 +78,16 @@ export class AppText { private async init() { this.letterSpacing = await this.initLetterSpacing(); - this.align = await AlignUtils.getAlignment(this.selectedElement); - this.fontSize = await initFontSize(this.selectedElement); + this.align = await AlignUtils.getAlignment(this.selectedElement?.element); + this.fontSize = await initFontSize(this.selectedElement?.element); } - + private async initLetterSpacing(): Promise { - if (!this.selectedElement) { + if (!this.selectedElement || !this.selectedElement.element) { return LetterSpacing.NORMAL; } - const spacing: string = this.selectedElement.style.letterSpacing; + const spacing: string = this.selectedElement.element.style.letterSpacing; if (!spacing || spacing === '') { return LetterSpacing.NORMAL; @@ -105,12 +110,8 @@ export class AppText { return LetterSpacing.NORMAL; } - private emitLetterSpacingChange() { - this.textDidChange.emit(); - } - private async updateLetterSpacing($event: CustomEvent): Promise { - if (!this.selectedElement || !$event || !$event.detail) { + if (!this.selectedElement || !this.selectedElement.element || !$event || !$event.detail) { return; } @@ -137,49 +138,90 @@ export class AppText { default: letterSpacingConverted = 'normal'; } + this.letterSpacing = $event.detail.value; - this.selectedElement.style.letterSpacing = letterSpacingConverted; - this.emitLetterSpacingChange(); + this.updateLetterSpacingCSS(letterSpacingConverted); } private async initCSS() { - this.letterSpacingCSS = this.selectedElement?.style.letterSpacing; - this.alignCSS = this.selectedElement?.style.textAlign; - this.fontSizeCSS = this.selectedElement?.style.fontSize; + this.letterSpacingCSS = this.selectedElement?.element?.style.letterSpacing; + this.alignCSS = this.selectedElement?.element?.style.textAlign; + this.fontSizeCSS = this.selectedElement?.element?.style.fontSize; } private handleLetterSpacingInput($event: CustomEvent) { this.letterSpacingCSS = ($event.target as InputTargetEvent).value; } - private async updateLetterSpacingCSS() { - this.selectedElement.style.letterSpacing = this.letterSpacingCSS; + private updateLetterSpacingCSS(letterSpacingCSS: string) { + this.updateStyle({property: 'letter-spacing', value: letterSpacingCSS}); - this.emitLetterSpacingChange(); + this.textDidChange.emit(); + } + + private updateStyle({property, value}: {property: 'letter-spacing' | 'text-align' | 'font-size'; value: string}) { + if (this.ignoreUpdateStyle) { + this.ignoreUpdateStyle = false; + return; + } + + setStyle(this.selectedElement.element, { + properties: [{property, value}], + type: this.selectedElement.type, + updateUI: async () => { + // ion-change triggers the event each time its value changes, because we re-render, it triggers it again + this.ignoreUpdateStyle = true; + + if (settingsStore.state.editMode === 'css') { + switch (property) { + case 'letter-spacing': + this.letterSpacingCSS = this.selectedElement?.element?.style.letterSpacing; + break; + case 'font-size': + this.fontSizeCSS = this.selectedElement?.element?.style.fontSize; + break; + case 'text-align': + this.alignCSS = this.selectedElement?.element?.style.textAlign; + } + + return; + } + + switch (property) { + case 'letter-spacing': + this.letterSpacing = await this.initLetterSpacing(); + break; + case 'font-size': + this.fontSize = await initFontSize(this.selectedElement?.element); + break; + case 'text-align': + this.align = await AlignUtils.getAlignment(this.selectedElement?.element); + } + }, + }); } private async updateAlign($event: CustomEvent): Promise { - if (!this.selectedElement || !$event || !$event.detail) { + if (!this.selectedElement || !this.selectedElement.element || !$event || !$event.detail) { return; } - this.selectedElement.style.textAlign = $event.detail.value; this.align = $event.detail.value; - this.textDidChange.emit(); + this.updateAlignCSS($event.detail.value); } private handleAlignInput($event: CustomEvent) { this.alignCSS = ($event.target as InputTargetEvent).value as TextAlign; } - private updateAlignCSS() { - if (!this.selectedElement) { + private updateAlignCSS(alignCSS: string) { + if (!this.selectedElement || !this.selectedElement.element) { return; } - this.selectedElement.style.textAlign = this.alignCSS; + this.updateStyle({property: 'text-align', value: alignCSS}); this.textDidChange.emit(); } @@ -191,33 +233,29 @@ export class AppText { this.fontSize = $event.detail.value; - if (!this.selectedElement) { + if (!this.selectedElement || !this.selectedElement.element) { return; } - this.selectedElement.style.removeProperty('font-size'); - - const size: string | undefined = toggleFontSize(this.selectedElement, this.fontSize); + const size: string | undefined = toggleFontSize(this.selectedElement.element, this.fontSize); if (!size) { return; } - this.selectedElement.style.setProperty('font-size', size); - - this.textDidChange.emit(); + this.updateFontSizeCSS(size); } private handleInput($event: CustomEvent) { this.fontSizeCSS = ($event.target as InputTargetEvent).value; } - private async updateFontSizeCSS() { - this.selectedElement.style.setProperty('font-size', this.fontSizeCSS); + private updateFontSizeCSS(size: string) { + this.updateStyle({property: 'font-size', value: size}); this.textDidChange.emit(); } - + render() { return ( - - {i18n.state.editor.scale} - - - - {i18n.state.editor.size} - - this.toggleFontSize($event)} - interface="popover" - mode="md" - class="ion-padding-start ion-padding-end"> - {i18n.state.editor.very_small} - {i18n.state.editor.small} - {i18n.state.editor.normal} - {i18n.state.editor.big} - {i18n.state.editor.very_big} - { - this.fontSize === FontSize.CUSTOM ? {i18n.state.editor.custom} : undefined - } - - - - - ) => this.handleInput(e)} - onIonChange={async () => await this.updateFontSizeCSS()}> - - + return ( + + + {i18n.state.editor.scale} + + + + {i18n.state.editor.size} + + this.toggleFontSize($event)} + interface="popover" + mode="md" + class="ion-padding-start ion-padding-end"> + {i18n.state.editor.very_small} + {i18n.state.editor.small} + {i18n.state.editor.normal} + {i18n.state.editor.big} + {i18n.state.editor.very_big} + {this.fontSize === FontSize.CUSTOM ? {i18n.state.editor.custom} : undefined} + + + + + ) => this.handleInput(e)} + onIonChange={() => this.updateFontSizeCSS(this.fontSizeCSS)}> + + + ); } private renderLetterSpacing() { @@ -303,7 +341,7 @@ export class AppText { placeholder={i18n.state.editor.letter_spacing} debounce={500} onIonInput={(e: CustomEvent) => this.handleLetterSpacingInput(e)} - onIonChange={async () => await this.updateLetterSpacingCSS()}> + onIonChange={() => this.updateLetterSpacingCSS(this.letterSpacingCSS)}> ); @@ -342,7 +380,7 @@ export class AppText { placeholder={i18n.state.editor.text_align} debounce={500} onIonInput={(e: CustomEvent) => this.handleAlignInput(e)} - onIonChange={() => this.updateAlignCSS()}> + onIonChange={() => this.updateAlignCSS(this.alignCSS)}> ); diff --git a/studio/src/app/handlers/editor/events/deck/deck-events.handler.ts b/studio/src/app/handlers/editor/events/deck/deck-events.handler.ts index 133a58843..f914595a5 100644 --- a/studio/src/app/handlers/editor/events/deck/deck-events.handler.ts +++ b/studio/src/app/handlers/editor/events/deck/deck-events.handler.ts @@ -167,7 +167,7 @@ export class DeckEventsHandler { this.debounceUpdateSlide(parent); }; - private onInputChange = async ($event: Event) => { + private onInputChange = async ($event: InputEvent) => { if (!$event || !$event.target || !($event.target instanceof HTMLElement)) { return; } diff --git a/studio/src/app/handlers/editor/events/editor/editor-events.handler.ts b/studio/src/app/handlers/editor/events/editor/editor-events.handler.ts index 5506d2cc1..b28b11f3a 100644 --- a/studio/src/app/handlers/editor/events/editor/editor-events.handler.ts +++ b/studio/src/app/handlers/editor/events/editor/editor-events.handler.ts @@ -1,3 +1,5 @@ +import { redo, undo } from "../../../../utils/editor/undo-redo.utils"; + export class EditorEventsHandler { private mainRef: HTMLElement; private actionsEditorRef: HTMLAppActionsEditorElement | undefined; @@ -44,6 +46,17 @@ export class EditorEventsHandler { private onKeyDown = async ($event: KeyboardEvent) => { if ($event && $event.key === 'Escape') { await this.selectDeck(); + return; + } + + if ($event?.metaKey && $event.key === 'z' && !$event?.shiftKey) { + await undo($event); + return; + } + + if ($event?.metaKey && $event.key === 'z' && $event?.shiftKey) { + await redo($event); + return; } }; diff --git a/studio/src/app/pages/editor/app-editor/app-editor.tsx b/studio/src/app/pages/editor/app-editor/app-editor.tsx index 2206e4ef2..358642808 100644 --- a/studio/src/app/pages/editor/app-editor/app-editor.tsx +++ b/studio/src/app/pages/editor/app-editor/app-editor.tsx @@ -8,6 +8,7 @@ import deckStore from '../../../stores/deck.store'; import busyStore from '../../../stores/busy.store'; import authStore from '../../../stores/auth.store'; import colorStore from '../../../stores/color.store'; +import undoRedoStore from '../../../stores/undo-redo.store'; import {debounce, isAndroidTablet, isFullscreen, isIOS, isIPad, isMobile} from '@deckdeckgo/utils'; @@ -207,6 +208,7 @@ export class AppEditor { await this.remoteEventsHandler.destroy(); deckStore.reset(); + undoRedoStore.reset(); } async componentDidLoad() { diff --git a/studio/src/app/popovers/editor/style/app-element-style/app-element-style.tsx b/studio/src/app/popovers/editor/style/app-element-style/app-element-style.tsx index 1be497b4d..029e8f823 100644 --- a/studio/src/app/popovers/editor/style/app-element-style/app-element-style.tsx +++ b/studio/src/app/popovers/editor/style/app-element-style/app-element-style.tsx @@ -197,11 +197,11 @@ export class AppElementStyle { return undefined; } - return this.emitStyleChange()}>; + return this.emitStyleChange()}>; } private renderText() { - return this.emitStyleChange()}>; + return this.emitStyleChange()}>; } private renderBackground() { @@ -217,9 +217,9 @@ export class AppElementStyle { if (this.selectedElement.type === 'element') { background.push( - this.emitStyleChange()}> + this.emitStyleChange()}> ); - background.push( this.emitStyleChange()}>); + background.push( this.emitStyleChange()}>); } return background; diff --git a/studio/src/app/stores/editor.store.ts b/studio/src/app/stores/editor.store.ts index 098c26747..56a45b30b 100644 --- a/studio/src/app/stores/editor.store.ts +++ b/studio/src/app/stores/editor.store.ts @@ -9,7 +9,7 @@ interface EditorStore { const {state} = createStore({ step: BreadcrumbsStep.DECK, - style: null, + style: null }); export default {state}; diff --git a/studio/src/app/stores/undo-redo.store.ts b/studio/src/app/stores/undo-redo.store.ts new file mode 100644 index 000000000..53e16360c --- /dev/null +++ b/studio/src/app/stores/undo-redo.store.ts @@ -0,0 +1,20 @@ +import {createStore} from '@stencil/store'; + +import {UndoRedoChange} from '../types/editor/undo-redo'; + +interface UndoRedoStore { + undo: UndoRedoChange[] | undefined; + redo: UndoRedoChange[] | undefined; + + // The innerHTML (clone would work but, we cannot maintain a reference) of the selected element which is about to be edited (text input). + // On changes, we push the value in the undo stack. + elementInnerHTML: string | undefined; +} + +const {state, onChange, reset} = createStore({ + undo: undefined, + redo: undefined, + elementInnerHTML: undefined, +}); + +export default {state, onChange, reset}; diff --git a/studio/src/app/types/editor/undo-redo.ts b/studio/src/app/types/editor/undo-redo.ts new file mode 100644 index 000000000..bba2de36a --- /dev/null +++ b/studio/src/app/types/editor/undo-redo.ts @@ -0,0 +1,21 @@ +export interface UndoRedoChangeAttribute { + attribute: string; + value: string; + updateUI: (value: string) => void; +} + +export interface UndoRedoChangeStyle { + value: string | null; + type: 'deck' | 'slide' | 'element'; + updateUI: (value: string) => Promise; +} + +export interface UndoRedoChangeElement { + innerHTML: string; +} + +export interface UndoRedoChange { + type: 'input' | 'attribute' | 'style'; + target: HTMLElement; + data: UndoRedoChangeAttribute | UndoRedoChangeElement | UndoRedoChangeStyle; +} diff --git a/studio/src/app/utils/editor/undo-redo.utils.ts b/studio/src/app/utils/editor/undo-redo.utils.ts new file mode 100644 index 000000000..6cc6bfd7a --- /dev/null +++ b/studio/src/app/utils/editor/undo-redo.utils.ts @@ -0,0 +1,198 @@ +import undoRedoStore from '../../stores/undo-redo.store'; + +import {UndoRedoChange, UndoRedoChangeAttribute, UndoRedoChangeElement, UndoRedoChangeStyle} from '../../types/editor/undo-redo'; + +export const setAttribute = (target: HTMLElement, {attribute, value, updateUI}: UndoRedoChangeAttribute) => { + if (!undoRedoStore.state.undo) { + undoRedoStore.state.undo = []; + } + + undoRedoStore.state.undo.push({ + type: 'attribute', + target, + data: { + attribute, + value: target.getAttribute(attribute), + updateUI, + }, + }); + + undoRedoStore.state.redo = []; + + target.setAttribute(attribute, value); + + updateUI(value); +}; + +export const setStyle = ( + target: HTMLElement, + { + properties, + type, + updateUI, + }: { + properties: {property: string; value: string | null}[]; + type: 'deck' | 'slide' | 'element'; + updateUI: (value: string) => Promise; + } +) => { + if (!undoRedoStore.state.undo) { + undoRedoStore.state.undo = []; + } + + undoRedoStore.state.undo.push({ + type: 'style', + target, + data: { + value: target.getAttribute('style'), + type, + updateUI, + }, + }); + + undoRedoStore.state.redo = []; + + properties.forEach(({property, value}) => { + if (value === null) { + target.style.removeProperty(property); + return; + } + + target.style.setProperty(property, value); + }); +}; + +export const undo = async ($event: KeyboardEvent) => { + const result: {from: UndoRedoChange[]} | undefined = await undoRedo({ + from: undoRedoStore.state.undo, + to: undoRedoStore.state.redo, + $event, + }); + + if (!result) { + return; + } + + const {from}: {from: UndoRedoChange[]} = result; + + undoRedoStore.state.undo = from; +}; + +export const redo = async ($event: KeyboardEvent) => { + const result: {from: UndoRedoChange[]} | undefined = await undoRedo({ + from: undoRedoStore.state.redo, + to: undoRedoStore.state.undo, + $event, + }); + + if (!result) { + return; + } + + const {from}: {from: UndoRedoChange[]} = result; + + undoRedoStore.state.redo = from; +}; + +export const undoRedo = async ({ + $event, + from, + to, +}: { + from: UndoRedoChange[] | undefined; + to: UndoRedoChange[] | undefined; + $event: KeyboardEvent; +}): Promise<{from: UndoRedoChange[]} | undefined> => { + if (undoRedoStore.state.elementInnerHTML !== undefined) { + return undefined; + } + + if (from === undefined || to === undefined) { + return undefined; + } + + $event.preventDefault(); + + const undoChange: UndoRedoChange | undefined = from[from.length - 1]; + + if (!undoChange) { + return undefined; + } + + const {type, data, target} = undoChange; + + if (type === 'input') { + to.push({type, target, data: {innerHTML: target.innerHTML}}); + + undoRedoElement(target, data as UndoRedoChangeElement); + } + + if (type === 'style') { + to.push({ + type, + target, + data: { + ...data, + value: target.getAttribute('style'), + }, + }); + + await undoRedoSetStyle(target, data as UndoRedoChangeStyle); + } + + if (type === 'attribute') { + const {attribute} = data as UndoRedoChangeAttribute; + + to.push({ + type, + target, + data: { + ...data, + value: target.getAttribute(attribute), + }, + }); + + undoRedoSetAttribute(target, data as UndoRedoChangeAttribute); + } + + return { + from: [...from.slice(0, from.length - 1)], + }; +}; + +const undoRedoElement = (target: HTMLElement, {innerHTML}: UndoRedoChangeElement) => { + target.innerHTML = innerHTML; + + emitDidUpdate({target: target.parentElement, eventName: 'slideDidChange'}); +}; + +const undoRedoSetStyle = async (target: HTMLElement, {value, type, updateUI}: UndoRedoChangeStyle) => { + target.setAttribute('style', value); + + if (type === 'deck') { + emitDidUpdate({target, eventName: 'deckDidChange'}); + } else if (type === 'slide') { + emitDidUpdate({target, eventName: 'slideDidChange'}); + } else { + emitDidUpdate({target: target.parentElement, eventName: 'slideDidChange'}); + } + + await updateUI(value); +}; + +const undoRedoSetAttribute = (target: HTMLElement, {attribute, value, updateUI}: UndoRedoChangeAttribute) => { + target.setAttribute(attribute, value); + + emitDidUpdate({target, eventName: 'deckDidChange'}); + + updateUI(value); +}; + +const emitDidUpdate = ({eventName, target}: {target: HTMLElement; eventName: 'deckDidChange' | 'slideDidChange'}) => { + const didUpdate: CustomEvent = new CustomEvent(eventName, { + bubbles: true, + detail: target, + }); + + target.dispatchEvent(didUpdate); +}; diff --git a/studio/src/components.d.ts b/studio/src/components.d.ts index 64cf264b8..36977e4e9 100644 --- a/studio/src/components.d.ts +++ b/studio/src/components.d.ts @@ -6,12 +6,12 @@ */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; import { EventEmitter, JSX } from "@stencil/core"; +import { SelectedElement } from "./app/types/editor/selected-element"; import { PrismLanguage } from "./app/types/editor/prism-language"; import { InitStyleColor } from "./app/utils/editor/color.utils"; import { Deck } from "./app/models/data/deck"; import { DeckDashboardCloneResult } from "./app/services/deck/deck-dashboard.service"; import { DeckAction } from "./app/types/editor/deck-action"; -import { SelectedElement } from "./app/types/editor/selected-element"; import { EditAction } from "./app/types/editor/edit-action"; import { ImageHelper } from "./app/helpers/editor/image.helper"; import { Expanded } from "./app/types/core/settings"; @@ -66,7 +66,7 @@ export namespace Components { "reset": () => Promise; "slideCopy": EventEmitter; "slideTransform": EventEmitter; - "touch": (element: HTMLElement, autoOpen?: boolean) => Promise; + "touch": (element: HTMLElement | undefined, autoOpen?: boolean) => Promise; "unSelect": () => Promise; } interface AppAvatar { @@ -76,15 +76,15 @@ export namespace Components { interface AppBackgroundFolders { } interface AppBlock { - "selectedElement": HTMLElement; + "selectedElement": SelectedElement; } interface AppBorderRadius { - "selectedElement": HTMLElement; + "selectedElement": SelectedElement; } interface AppBottomSheet { } interface AppBoxShadow { - "selectedElement": HTMLElement; + "selectedElement": SelectedElement; } interface AppBreadcrumbs { "slideNumber": number; @@ -404,7 +404,7 @@ export namespace Components { interface AppTemplatesUser { } interface AppText { - "selectedElement": HTMLElement; + "selectedElement": SelectedElement; } interface AppTransformElement { "selectedElement": HTMLElement; @@ -1345,18 +1345,18 @@ declare namespace LocalJSX { } interface AppBlock { "onBlockChange"?: (event: CustomEvent) => void; - "selectedElement"?: HTMLElement; + "selectedElement"?: SelectedElement; } interface AppBorderRadius { "onBorderRadiusDidChange"?: (event: CustomEvent) => void; - "selectedElement"?: HTMLElement; + "selectedElement"?: SelectedElement; } interface AppBottomSheet { "onSheetChanged"?: (event: CustomEvent<'open' | 'close'>) => void; } interface AppBoxShadow { "onBoxShadowDidChange"?: (event: CustomEvent) => void; - "selectedElement"?: HTMLElement; + "selectedElement"?: SelectedElement; } interface AppBreadcrumbs { "onStepTo"?: (event: CustomEvent) => void; @@ -1726,7 +1726,7 @@ declare namespace LocalJSX { } interface AppText { "onTextDidChange"?: (event: CustomEvent) => void; - "selectedElement"?: HTMLElement; + "selectedElement"?: SelectedElement; } interface AppTransformElement { "selectedElement"?: HTMLElement;