diff --git a/src/component/app.tsx b/src/component/app.tsx index 4597f5777..def0cde86 100644 --- a/src/component/app.tsx +++ b/src/component/app.tsx @@ -44,19 +44,13 @@ interface AppProps { @observer class App extends React.Component { - private static PatternListID = 'patternlist'; - private static PropertiesListID = 'propertieslist'; + private static PATTERN_LIST_ID = 'patternlist'; + private static PROPERTIES_LIST_ID = 'propertieslist'; - @MobX.observable protected activeTab: string = App.PatternListID; + @MobX.observable protected activeTab: string = App.PATTERN_LIST_ID; + private ctrlDown: boolean = false; @MobX.observable protected projectListVisible: boolean = false; - @MobX.computed - protected get isPatternListVisible(): boolean { - return Boolean(this.activeTab === App.PatternListID); - } - @MobX.computed - protected get isPropertiesListVisible(): boolean { - return Boolean(this.activeTab === App.PropertiesListID); - } + private shiftDown: boolean = false; public constructor(props: AppProps) { super(props); @@ -69,6 +63,7 @@ class App extends React.Component { public componentDidMount(): void { createMenu(this.props.store); + this.redirectUndoRedo(); } private getDevTools(): React.StatelessComponent | null { @@ -92,7 +87,6 @@ class App extends React.Component { } const designkitPath = PathUtils.join(appPath, 'build', 'designkit'); - console.log(`Design kit path is: ${designkitPath}`); dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] }, filePaths => { if (filePaths.length <= 0) { return; @@ -121,6 +115,45 @@ class App extends React.Component { this.activeTab = id; } + @MobX.computed + protected get isPatternListVisible(): boolean { + return Boolean(this.activeTab === App.PATTERN_LIST_ID); + } + + @MobX.computed + protected get isPropertiesListVisible(): boolean { + return Boolean(this.activeTab === App.PROPERTIES_LIST_ID); + } + + private redirectUndoRedo(): void { + document.body.onkeydown = event => { + if (event.keyCode === 16) { + this.shiftDown = true; + } else if (event.keyCode === 17 || event.keyCode === 91) { + this.ctrlDown = true; + } else if (this.ctrlDown && event.keyCode === 90) { + event.preventDefault(); + if (this.shiftDown) { + this.props.store.redo(); + } else { + this.props.store.undo(); + } + + return false; + } + + return true; + }; + + document.body.onkeyup = event => { + if (event.keyCode === 16) { + this.shiftDown = false; + } else if (event.keyCode === 17 || event.keyCode === 91) { + this.ctrlDown = false; + } + }; + } + public render(): JSX.Element { // Todo: project and page don't update on page change const project = this.props.store.getCurrentProject(); @@ -194,7 +227,7 @@ class App extends React.Component { } } -const store = new Store(); +const store: Store = Store.getInstance(); store.openFromPreferences(); ipcRenderer.on('preview-ready', (readyEvent: {}, readyMessage: JsonObject) => { diff --git a/src/component/container/element-list.tsx b/src/component/container/element-list.tsx index ed22b8a39..ce7118de7 100644 --- a/src/component/container/element-list.tsx +++ b/src/component/container/element-list.tsx @@ -1,4 +1,5 @@ import { elementMenu } from '../../electron/context-menus'; +import { ElementCommand } from '../../store/page/command/element-command'; import { ElementWrapper } from './element-wrapper'; import { ListItemProps } from '../../lsg/patterns/list'; import { createMenu } from '../../electron/menu'; @@ -68,43 +69,54 @@ export class ElementList extends React.Component { elementMenu(this.props.store, element); }, handleDragStart: (e: React.DragEvent) => { - this.props.store.setRearrangeElement(element); + this.props.store.setDraggedElement(element); }, handleDragDropForChild: (e: React.DragEvent) => { const patternId = e.dataTransfer.getData('patternId'); - const parentElement = element.getParent(); - let pageElement: PageElement | undefined; + const newParent = element.getParent(); + let draggedElement: PageElement | undefined; if (!patternId) { - pageElement = this.props.store.getRearrangeElement(); + draggedElement = this.props.store.getDraggedElement(); } else { const styleguide = this.props.store.getStyleguide(); if (!styleguide) { return; } - pageElement = new PageElement({ + draggedElement = new PageElement({ pattern: styleguide.getPattern(patternId), page: this.props.store.getCurrentPage() as Page, setDefaults: true }); } - if (!parentElement || !pageElement || pageElement.isAncestorOf(parentElement)) { + if (!newParent || !draggedElement || draggedElement.isAncestorOf(newParent)) { return; } - parentElement.addChild(pageElement, element.getIndex()); - this.props.store.setSelectedElement(pageElement); + let newIndex = element.getIndex(); + if (draggedElement.getParent() === newParent) { + const currentIndex = draggedElement.getIndex(); + if (newIndex > currentIndex) { + newIndex--; + } + if (newIndex === currentIndex) { + return; + } + } + + this.props.store.execute(ElementCommand.addChild(newParent, draggedElement, newIndex)); + this.props.store.setSelectedElement(draggedElement); }, handleDragDrop: (e: React.DragEvent) => { const patternId = e.dataTransfer.getData('patternId'); - let pageElement: PageElement | undefined; + let draggedElement: PageElement | undefined; if (!patternId) { - pageElement = this.props.store.getRearrangeElement(); + draggedElement = this.props.store.getDraggedElement(); } else { const styleguide = this.props.store.getStyleguide(); @@ -112,19 +124,19 @@ export class ElementList extends React.Component { return; } - pageElement = new PageElement({ + draggedElement = new PageElement({ pattern: styleguide.getPattern(patternId), page: this.props.store.getCurrentPage() as Page, setDefaults: true }); } - if (!pageElement || pageElement.isAncestorOf(element)) { + if (!draggedElement || draggedElement.isAncestorOf(element)) { return; } - element.addChild(pageElement); - this.props.store.setSelectedElement(pageElement); + this.props.store.execute(ElementCommand.addChild(element, draggedElement)); + this.props.store.setSelectedElement(draggedElement); }, children: items, active: element === selectedElement diff --git a/src/component/container/page-list.tsx b/src/component/container/page-list.tsx index d09d327b3..1a609202d 100644 --- a/src/component/container/page-list.tsx +++ b/src/component/container/page-list.tsx @@ -2,8 +2,8 @@ import Dropdown from '../../lsg/patterns/dropdown'; import { DropdownItemEditableLink } from '../../lsg/patterns/dropdown-item'; import * as MobX from 'mobx'; import { observer } from 'mobx-react'; -import { PageRef } from '../../store/project/page-ref'; -import { Project } from '../../store/project/project'; +import { PageRef } from '../../store/page/page-ref'; +import { Project } from '../../store/project'; import * as React from 'react'; import { Store } from '../../store/store'; diff --git a/src/component/container/pattern-list.tsx b/src/component/container/pattern-list.tsx index b441e2984..cdbb398e3 100644 --- a/src/component/container/pattern-list.tsx +++ b/src/component/container/pattern-list.tsx @@ -1,4 +1,5 @@ import Input from '../../lsg/patterns/input/'; +import { ElementCommand } from '../../store/page/command/element-command'; import { PatternFolder } from '../../store/styleguide/folder'; import { action } from 'mobx'; import { observer } from 'mobx-react'; @@ -118,7 +119,7 @@ export class PatternListContainer extends React.Component { } protected getValue(id: string, path?: string): PropertyValue { - if (path) { - const parts = `${path}.${id}`.split('.'); - const [rootId, ...propertyPath] = parts; - - return this.props.element.getPropertyValue(rootId, propertyPath.join('.')); - } - - return this.props.element.getPropertyValue(id); + const fullPath = path ? `${path}.${id}` : id; + const [rootId, ...propertyPath] = fullPath.split('.'); + return this.props.element.getPropertyValue(rootId, propertyPath.join('.')); } // tslint:disable-next-line:no-any protected handleChange(id: string, value: any, context?: ObjectContext): void { - if (context) { - const parts = `${context.path}.${id}`.split('.'); - const [rootId, ...path] = parts; - - this.props.element.setPropertyValue(rootId, value, path.join('.')); - return; - } - - this.props.element.setPropertyValue(id, value); + const fullPath: string = context ? `${context.path}.${id}` : id; + const [rootId, ...propertyPath] = fullPath.split('.'); + this.props.store.execute( + new PropertyValueCommand(this.props.element, rootId, value, propertyPath.join('.')) + ); } @action @@ -154,7 +147,14 @@ class PropertyTree extends React.Component { property: objectProperty }; - return ; + return ( + + ); default: return
Unknown type: {type}
; @@ -182,6 +182,6 @@ export class PropertyList extends React.Component { return
No Element selected
; } - return ; + return ; } } diff --git a/src/electron/context-menus.ts b/src/electron/context-menus.ts index 80b157e31..30a7c1976 100644 --- a/src/electron/context-menus.ts +++ b/src/electron/context-menus.ts @@ -1,7 +1,7 @@ import { MenuItemConstructorOptions, remote } from 'electron'; +import { ElementCommand } from '../store/page/command/element-command'; import { PageElement } from '../store/page/page-element'; import { Store } from '../store/store'; -const { Menu } = remote; export function elementMenu(store: Store, element: PageElement): void { const clipboardElement: PageElement | undefined = store.getClipboardElement(); @@ -11,7 +11,7 @@ export function elementMenu(store: Store, element: PageElement): void { label: 'Cut Element', click: () => { store.setClipboardElement(element); - element.remove(); + store.execute(ElementCommand.remove(element)); } }, { @@ -23,7 +23,7 @@ export function elementMenu(store: Store, element: PageElement): void { { label: 'Delete element', click: () => { - element.remove(); + store.execute(ElementCommand.remove(element)); } }, { @@ -36,7 +36,7 @@ export function elementMenu(store: Store, element: PageElement): void { const newPageElement = clipboardElement && clipboardElement.clone(); if (newPageElement) { - element.addSibling(newPageElement); + store.execute(ElementCommand.addSibling(element, newPageElement)); } } }, @@ -47,12 +47,12 @@ export function elementMenu(store: Store, element: PageElement): void { const newPageElement = clipboardElement && clipboardElement.clone(); if (newPageElement) { - element.addChild(newPageElement); + store.execute(ElementCommand.addChild(element, newPageElement)); } } } ]; - const menu = Menu.buildFromTemplate(template); + const menu = remote.Menu.buildFromTemplate(template); menu.popup(); } diff --git a/src/electron/menu.ts b/src/electron/menu.ts index 83c6644dd..b0fee75ab 100644 --- a/src/electron/menu.ts +++ b/src/electron/menu.ts @@ -6,6 +6,7 @@ import { remote, WebviewTag } from 'electron'; +import { ElementCommand } from '../store/page/command/element-command'; import * as FileExtraUtils from 'fs-extra'; import { PageElement } from '../store/page/page-element'; import * as PathUtils from 'path'; @@ -28,7 +29,6 @@ export function createMenu(store: Store): void { } const designkitPath = PathUtils.join(appPath, 'build', 'designkit'); - console.log(`Design kit path is: ${designkitPath}`); dialog.showOpenDialog( { properties: ['openDirectory', 'createDirectory'] }, filePaths => { @@ -174,12 +174,16 @@ export function createMenu(store: Store): void { { label: '&Undo', accelerator: 'CmdOrCtrl+Z', - role: 'undo' + click: () => { + store.undo(); + } }, { label: '&Redo', accelerator: 'Shift+CmdOrCtrl+Z', - role: 'redo' + click: () => { + store.redo(); + } }, { type: 'separator' @@ -192,7 +196,7 @@ export function createMenu(store: Store): void { const selectedElement: PageElement | undefined = store.getSelectedElement(); if (selectedElement && store.isElementFocussed()) { store.setClipboardElement(selectedElement); - selectedElement.remove(); + store.execute(ElementCommand.remove(selectedElement)); } Menu.sendActionToFirstResponder('cut:'); } @@ -218,7 +222,7 @@ export function createMenu(store: Store): void { const clipboardElement: PageElement | undefined = store.getClipboardElement(); if (selectedElement && clipboardElement && store.isElementFocussed()) { const newPageElement = clipboardElement.clone(); - selectedElement.addSibling(newPageElement); + store.execute(ElementCommand.addSibling(selectedElement, newPageElement)); store.setSelectedElement(newPageElement); } Menu.sendActionToFirstResponder('paste:'); @@ -235,7 +239,7 @@ export function createMenu(store: Store): void { const selectedElement: PageElement | undefined = store.getSelectedElement(); if (selectedElement && store.isElementFocussed()) { const newPageElement = selectedElement.clone(); - selectedElement.addSibling(newPageElement); + store.execute(ElementCommand.addSibling(selectedElement, newPageElement)); store.setSelectedElement(newPageElement); } } @@ -264,7 +268,7 @@ export function createMenu(store: Store): void { click: () => { const selectedElement: PageElement | undefined = store.getSelectedElement(); if (selectedElement) { - selectedElement.remove(); + store.execute(ElementCommand.remove(selectedElement)); store.setSelectedElement(undefined); } else { if (process.platform === 'darwin') { diff --git a/src/store/command/command.ts b/src/store/command/command.ts new file mode 100644 index 000000000..44389237b --- /dev/null +++ b/src/store/command/command.ts @@ -0,0 +1,53 @@ +import { PageElement } from '../page/page-element'; + +/** + * A user operation on a page or project, with the ability to undo and redo. + * @see Store.execute() + * @see Store.undo() + * @see Store.redo() + */ +export abstract class Command { + /** + * Performs this user operation (forward execute or redo). + * @return Whether the execution was successful. + * Returning false will drop the undo and redo buffers, as the state is unknown then. + */ + public abstract execute(): boolean; + + /** + * Returns the ID of a given element, if it is already part of a page. + * Can be used to memorize a reference instead of the actual element, + * so that the command does not break when pages are closed and opened. + * @param element The element to analyze. May be undefined. + * @return The ID or undefined, if the element is not set or not part of a page. + */ + protected getElementIdIfPartOfPage(element?: PageElement): string | undefined { + return element && element.getPage() ? element.getId() : undefined; + } + + /** + * Returns the type of this user command (a string derived from the class name). + * This can be used to determine whether a user command can combine with a previous one, + * to reduce similar undo steps. + */ + public abstract getType(): string; + + /** + * Looks at a given previous command, checking whether the types are compatible + * and the changes are too similar to keep both. If so, the method modifies the previous + * command and returns true, indicating not to put this newer command into the undo buffer. + * @param previousCommand The previous command. + * @return Whether the method has merged itself into the previous command. + * false keeps both methods separate. + */ + public maybeMergeWith(previousCommand: Command): boolean { + return false; + } + + /** + * Reverts this user operation (undo). + * @return Whether the revert was successful. + * Returning false will drop the undo and redo buffers, as the state is unknown then. + */ + public abstract undo(): boolean; +} diff --git a/src/store/command/element-command.ts b/src/store/command/element-command.ts new file mode 100644 index 000000000..b7eafa0e2 --- /dev/null +++ b/src/store/command/element-command.ts @@ -0,0 +1,248 @@ +import { Command } from './command'; +import { Page } from '../page/page'; +import { PageElement } from '../page/page-element'; +import { Store } from '../store'; + +/** + * A user operation to add or remove a child to/from a parent, or to relocate it. + */ +export class ElementCommand extends Command { + /** + * The element to change the parent of, or to remove. + */ + private child: PageElement; + + /** + * The ID of the element the user operation is performed on, + * if the element is already part of a page. + */ + private childId: string | undefined; + + /** + * The new position within the parent's children, if a parent is given. + * Leaving out this value puts the child to the end of the parent's children. + */ + private index?: number; + + /** + * The ID of the page the operation is performed on. + */ + private pageId: string; + + /** + * The new parent for the child. undefined removes the child. + */ + private parent?: PageElement; + + /** + * The ID ofg the target parent of the child element. + */ + private parentId?: string; + + /** + * The previous position, for undo. + */ + private previousIndex?: number; + + /** + * The previous parent, for undo. + */ + private previousParent?: PageElement; + + /** + * The ID of the previous parent, for undo. + */ + private previousParentId?: string | undefined; + + /** + * Creates a new user operation to add or remove a child to/from a parent, or to relocate it. + * @param child The element to change the parent of, or to remove. + * @param parent The new parent for the child. undefined removes the child. + * @param index The new position within the parent's children, if a parent is given. + * Leaving out this value puts the child to the end of the parent's children. + */ + public constructor(child: PageElement, parent?: PageElement, index?: number) { + super(); + + this.child = child; + this.parent = parent; + this.index = index; + + this.previousParent = child.getParent(); + this.previousIndex = this.previousParent ? child.getIndex() : undefined; + + // Memorize the page IDs. + // This way, closing and opening a page does not break the command. + + const page = child.getPage(); + if (page) { + this.pageId = page.getId(); + } else if (parent) { + const parentPage = parent.getPage(); + // If the element is not known to the page, memorize the page ID from the target parent. + if (parentPage) { + this.pageId = parentPage.getId(); + } + } + + if (!this.pageId) { + throw new Error( + 'Element commands require either a child already added to a page, or a target parent for a new child' + ); + } + } + + /** + * Creates a command to add a child element to a given parent element + * (and remove it from any other parent). + * @param parent The parent to add the child to. + * @param child The child element to add. + * @param index The 0-based new position within the parent's children. + * Leaving out the position adds it at the end of the list. + * @return The new element command. To register and run the command it, call Store.execute(). + * @see Store.execute() + */ + public static addChild(parent: PageElement, child: PageElement, index?: number): ElementCommand { + return new ElementCommand(child, parent, index); + } + + /** + * Creates a command to add a page element as another child of an element's parent, + * directly after that element. On execution, also removes the element from any previous parent. + * @param newSibling The element to add at a given location. + * @param location The element to add the new sibling after. + * @return The new element command. To register and run the command it, call Store.execute(). + * @see Store.execute() + */ + public static addSibling(newSibling: PageElement, location: PageElement): ElementCommand { + const parent: PageElement | undefined = location.getParent(); + return new ElementCommand(newSibling, parent, parent ? location.getIndex() + 1 : undefined); + } + + /** + * Creates a command to remove a page element from its parent. + * You may later re-add it using a command created with addChild() or setParent(). + * @param element The element to remove from its parent. + * @return The new element command. To register and run the command it, call Store.execute(). + * @see addChild() + * @see setParent() + * @see Store.execute() + */ + public static remove(element: PageElement): ElementCommand { + return new ElementCommand(element); + } + + /** + * Creates a command to set a new parent for this element (and remove it + * from its previous parent). If no parent is provided, only removes it from its parent. + * @param child The element to set the new parent of. + * @param parent The (optional) new parent for the element. + * @param index The 0-based new position within the children of the new parent. + * Leaving out the position adds it at the end of the list. + * @return The new element command. To register and run the command it, call Store.execute(). + * @see Store.execute() + */ + public static setParent( + child: PageElement, + parent: PageElement, + index?: number + ): ElementCommand { + return new ElementCommand(child, parent, index); + } + + /** + * Ensures that the page of this command is currently open in the store, and opens it if not. + * Then ensures that the child element is used from that open page. + * @return Whether the operation was successful. On failure, the execute/undo should abort. + */ + protected ensurePageAndChild(): boolean { + let currentPage: Page | undefined = Store.getInstance().getCurrentPage(); + if (!currentPage || currentPage.getId() !== this.pageId) { + if (!Store.getInstance().openPage(this.pageId)) { + return false; + } + currentPage = Store.getInstance().getCurrentPage() as Page; + } + + if (this.childId) { + const child: PageElement | undefined = currentPage.getElementById(this.childId); + if (!child) { + return false; + } + this.child = child; + } + + if (this.parentId) { + const parent: PageElement | undefined = currentPage.getElementById(this.parentId); + if (!parent) { + return false; + } + this.parent = parent; + } + + if (this.previousParentId) { + const previousParent: PageElement | undefined = currentPage.getElementById( + this.previousParentId + ); + if (!previousParent) { + return false; + } + this.previousParent = previousParent; + } + + return true; + } + + /** + * @inheritDoc + */ + public execute(): boolean { + if (!this.ensurePageAndChild()) { + return false; + } + + this.child.setParent(this.parent, this.index); + this.memorizeElementIds(); + + if (this.child.getPage()) { + Store.getInstance().setSelectedElement(this.child); + } + + return true; + } + + /** + * @inheritDoc + */ + public getType(): string { + return 'element-location'; + } + + /** + * Stores the ID of the page elements if they are currently added to the page, + * to prevent issues with undo/redo when pages are closed and reopened. + */ + private memorizeElementIds(): void { + this.childId = this.getElementIdIfPartOfPage(this.child); + this.parentId = this.getElementIdIfPartOfPage(this.parent); + this.previousParentId = this.getElementIdIfPartOfPage(this.previousParent); + } + + /** + * @inheritDoc + */ + public undo(): boolean { + if (!this.ensurePageAndChild()) { + return false; + } + + this.child.setParent(this.previousParent, this.previousIndex); + this.memorizeElementIds(); + + if (this.child.getPage()) { + Store.getInstance().setSelectedElement(this.child); + } + + return true; + } +} diff --git a/src/store/command/property-value-command.ts b/src/store/command/property-value-command.ts new file mode 100644 index 000000000..2bcdee4ee --- /dev/null +++ b/src/store/command/property-value-command.ts @@ -0,0 +1,166 @@ +import { Command } from './command'; +import { Page } from '../page/page'; +import { PageElement } from '../page/page-element'; +import { Store } from '../store'; + +/** + * A user operation to set the value of a page element property. + */ +export class PropertyValueCommand extends Command { + /** + * The element the user operation is performed on. + */ + private element: PageElement; + + /** + * The ID of the element the user operation is performed on, + * if the element is already part of a page. + */ + private elementId: string; + + /** + * The ID of the page the operation is performed on. + */ + private pageId: string; + + /** + * A dot ('.') separated optional path within an object property to point to a deep + * property. E.g., setting propertyId to 'image' and path to 'src.srcSet.xs', + * the operation edits 'image.src.srcSet.xs' on the element. + */ + private path?: string; + + /** + * The previous value, for undo. + */ + // tslint:disable-next-line:no-any + private previousValue: any; + + /** + * The ID of the property to modify. + */ + private propertyId: string; + + // tslint:disable-next-line:no-any + private value: any; + + /** + * Creates a new user operation to set the value of a page element property. + * @param element The element to change a property value of. + * @param propertyId The ID of the property to change. + * @param value The new value for the property. + * @param path A dot ('.') separated optional path within an object property to point to a deep + * property. E.g., setting propertyId to 'image' and path to 'src.srcSet.xs', + * the operation edits 'image.src.srcSet.xs' on the element. + */ + // tslint:disable-next-line:no-any + public constructor(element: PageElement, propertyId: string, value: any, path?: string) { + super(); + + this.element = element; + this.propertyId = propertyId; + this.value = value; + this.path = path; + this.previousValue = element.getPropertyValue(propertyId, path); + + // Memorize the element and page IDs. + // This way, closing and opening a page does not break the command. + + this.elementId = element.getId(); + const page = element.getPage(); + if (page) { + this.pageId = page.getId(); + } else { + throw new Error( + 'Property value commands require that the element is already added to a page' + ); + } + } + + /** + * Ensures that the page of this command is currently open in the store, and opens it if not. + * Then ensures that the element is used from that open page. + * @return Whether the operation was successful. On failure, the execute/undo should abort. + */ + protected ensurePageAndElement(): boolean { + let currentPage: Page | undefined = Store.getInstance().getCurrentPage(); + if (!currentPage || currentPage.getId() !== this.pageId) { + if (!Store.getInstance().openPage(this.pageId)) { + return false; + } + currentPage = Store.getInstance().getCurrentPage() as Page; + } + + if (this.elementId) { + const element: PageElement | undefined = currentPage.getElementById(this.elementId); + if (!element) { + return false; + } + this.element = element; + } + + return true; + } + + /** + * @inheritDoc + */ + public execute(): boolean { + if (!this.ensurePageAndElement()) { + return false; + } + + this.element.setPropertyValue(this.propertyId, this.value, this.path); + + if (this.element.getPage()) { + Store.getInstance().setSelectedElement(this.element); + } + + return true; + } + + /** + * @inheritDoc + */ + public getType(): string { + return 'set-property-value'; + } + + /** + * @inheritDoc + */ + public maybeMergeWith(previousCommand: Command): boolean { + if (previousCommand.getType() !== this.getType()) { + return false; + } + + const previousPropertyCommand: PropertyValueCommand = previousCommand as PropertyValueCommand; + if ( + previousPropertyCommand.element.getId() !== this.element.getId() || + previousPropertyCommand.propertyId !== this.propertyId || + previousPropertyCommand.path !== this.path + ) { + return false; + } + + previousPropertyCommand.value = this.value; + return true; + } + + /** + * @inheritDoc + */ + public undo(): boolean { + if (!this.ensurePageAndElement()) { + return false; + } + + this.element.setPropertyValue(this.propertyId, this.previousValue, this.path); + + if (this.element.getPage()) { + Store.getInstance().setSelectedElement(this.element); + } + + return true; + } +} diff --git a/src/store/page/page-element.ts b/src/store/page/page-element.ts index 6bda568a8..caaa24ab9 100644 --- a/src/store/page/page-element.ts +++ b/src/store/page/page-element.ts @@ -74,11 +74,6 @@ export class PageElement { if (properties.setDefaults && this.pattern) { this.pattern.getProperties().forEach(property => { this.setPropertyValue(property.getId(), property.getDefaultValue()); - console.log( - `Property ${property.getId()}: Set default ${JSON.stringify( - this.getPropertyValue(property.getId()) - )}` - ); }); } @@ -149,18 +144,6 @@ export class PageElement { child.setParent(this, index); } - /** - * Adds a page element as another child of this element's parent, directly after this element. - * Also removes the element from any previous parent. - * @param child The child element to add. - */ - public addSibling(child: PageElement): void { - const parentElement: PageElement | undefined = this.getParent(); - if (parentElement) { - child.setParent(parentElement, this.getIndex() + 1); - } - } - /** * Returns a deep clone of this page element (i.e. cloning all values and children as well). * The new clone does not have any parent. @@ -270,8 +253,9 @@ export class PageElement { /** * The content of a property of this page element. * @param id The ID of the property to return the value of. - * @param path If the property value you are trying to access is buried inside an object property use the path paremeter to access it. - * eg: `getPropertyValue('image', 'src.srcSet')`. + * @param path A dot ('.') separated optional path within an object property to point to a deep + * property. E.g., setting propertyId to 'image' and path to 'src.srcSet.xs', + * the operation edits 'image.src.srcSet.xs' on the element. * @return The content value (as provided by the designer). */ public getPropertyValue(id: string, path?: string): PropertyValue { @@ -393,8 +377,9 @@ export class PageElement { * Any given value is automatically converted to be compatible to the property type. * For instance, the string "true" is converted to true if the property is boolean. * @param id The ID of the property to set the value for. - * @param path If want to set a property inside an object property use the path paremeter to access it. - * eg: `setPropertyValue('image', 'http://someimageurl.jpeg', src.srcSet.XS')`. + * @param path A dot ('.') separated optional path within an object property to point to a deep + * property. E.g., setting propertyId to 'image' and path to 'src.srcSet.xs', + * the operation edits 'image.src.srcSet.xs' on the element. * @param value The value to set (which is automatically converted, see above). */ // tslint:disable-next-line:no-any diff --git a/src/store/project/page-ref.ts b/src/store/page/page-ref.ts similarity index 99% rename from src/store/project/page-ref.ts rename to src/store/page/page-ref.ts index ea2cb7a1d..7db90470b 100644 --- a/src/store/project/page-ref.ts +++ b/src/store/page/page-ref.ts @@ -1,6 +1,6 @@ import { JsonObject } from '../json'; import * as MobX from 'mobx'; -import { Project } from './project'; +import { Project } from '../project'; import { Store } from '../store'; import * as Uuid from 'uuid'; diff --git a/src/store/page/page.ts b/src/store/page/page.ts index 742ebad12..1bafa9ebd 100644 --- a/src/store/page/page.ts +++ b/src/store/page/page.ts @@ -1,8 +1,8 @@ import { JsonObject } from '../json'; import * as MobX from 'mobx'; import { PageElement } from './page-element'; -import { PageRef } from '../project/page-ref'; -import { Project } from '../project/project'; +import { PageRef } from '../page/page-ref'; +import { Project } from '../project'; import { Store } from '../store'; /** @@ -111,15 +111,6 @@ export class Page { element.getChildren().forEach(child => this.registerElementAndChildren(child)); } - /** - * Sets the human-friendly name of the page. - * In the frontend, to be displayed instead of the ID. - * @param name The human-friendly name of the page. - */ - public setName(name: string): void { - this.pageRef.setName(name); - } - /** * Serializes the page into a JSON object for persistence. * @return The JSON object to be persisted. diff --git a/src/store/project/project.ts b/src/store/project.ts similarity index 97% rename from src/store/project/project.ts rename to src/store/project.ts index 517c09262..914d29dae 100644 --- a/src/store/project/project.ts +++ b/src/store/project.ts @@ -1,7 +1,7 @@ -import { JsonArray, JsonObject } from '../json'; +import { JsonArray, JsonObject } from './json'; import * as MobX from 'mobx'; -import { PageRef } from './page-ref'; -import { Store } from '../store'; +import { PageRef } from './page/page-ref'; +import { Store } from './store'; import * as Uuid from 'uuid'; export interface ProjectProperties { diff --git a/src/store/store.ts b/src/store/store.ts index c85813ced..b5cccee56 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,3 +1,4 @@ +import { Command } from './command/command'; import * as FileUtils from 'fs'; import * as FileExtraUtils from 'fs-extra'; import { JsonArray, JsonObject, Persister } from './json'; @@ -7,10 +8,10 @@ import { IObservableArray } from 'mobx/lib/types/observablearray'; import * as OsUtils from 'os'; import { Page } from './page/page'; import { PageElement } from './page/page-element'; -import { PageRef } from './project/page-ref'; +import { PageRef } from './page/page-ref'; import * as PathUtils from 'path'; import { Preferences } from './preferences'; -import { Project } from './project//project'; +import { Project } from './project'; import { Styleguide } from './styleguide/styleguide'; /** @@ -19,6 +20,11 @@ import { Styleguide } from './styleguide/styleguide'; * and call the respective business methods to perform operations. */ export class Store { + /** + * The store singleton instance. + */ + private static INSTANCE: Store; + /** * The name of the analyzer that should be used for the open styleguide. */ @@ -44,6 +50,11 @@ export class Store { */ @MobX.observable private currentProject?: Project; + /** + * The element that is currently being dragged, or undefined if there is none. + */ + @MobX.observable private draggedElement?: PageElement; + /** * Whether the currently selected element also has focus. * In this case, keyboard operations such as copy, cut, or delete @@ -52,6 +63,15 @@ export class Store { */ @MobX.observable private elementFocussed?: boolean = false; + /** + * Whether the last executed user operation is now complete, so that similar operations do + * not merge with the last one. For instance, if you edit the text of a page element property, + * all subsequent edits on this property automatically merge. After leaving the input, setting + * this property to true will cause the next text editing on this property to stay a separate + * undo command. + */ + private lastCommandComplete: boolean; + /** * The current search term in the patterns list, or an empty string if there is none. */ @@ -70,9 +90,11 @@ export class Store { @MobX.observable private projects: Project[] = []; /** - * The element that is currently being dragged, or undefined if there is none. + * The most recent undone user commands (user operations) to provide a redo feature. + * Note that operations that close or open a page clear this buffer. + * The last command in the list is the most recent undone. */ - @MobX.observable private rearrangeElement?: PageElement; + @MobX.observable private redoBuffer: Command[] = []; /** * The currently selected element in the element list. @@ -88,10 +110,17 @@ export class Store { */ @MobX.observable private styleguide?: Styleguide; + /** + * The most recent user commands (user operations) to provide an undo feature. + * Note that operations that close or open a page clear this buffer. + * The last command in the list is the most recent executed one. + */ + @MobX.observable private undoBuffer: Command[] = []; + /** * Creates a new store. */ - public constructor() { + private constructor() { try { this.preferences = Preferences.fromJsonObject( Persister.loadYamlOrJson(this.getPreferencesPath()) @@ -101,6 +130,18 @@ export class Store { } } + /** + * Returns (or creates) the one global store instance. + * @return The one global store instance. + */ + public static getInstance(): Store { + if (!Store.INSTANCE) { + Store.INSTANCE = new Store(); + } + + return Store.INSTANCE; + } + /** * Tries to guess a human-friendly name from an ID by splitting words at camel-case positions, * and capitalizing the first letter. If an actual name is provided, this comes first. @@ -130,6 +171,15 @@ export class Store { this.projects.push(project); } + /** + * Clears the undo and redo buffers (e.g. if a page is loaded or the page state get + * incompatible with the buffers). + */ + public clearUndoRedoBuffers(): void { + this.undoBuffer = []; + this.redoBuffer = []; + } + /** * Closes the current page in edit and preview, setting it to undefined. * @see getCurrentPage() @@ -149,6 +199,29 @@ export class Store { }); } + /** + * Executes a user command (user operation) and registers it as undoable command. + * @param command The command to execute and register. + */ + public execute(command: Command): void { + if (command.execute()) { + const previousCommand = this.undoBuffer[this.undoBuffer.length - 1]; + if ( + !previousCommand || + this.lastCommandComplete || + !command.maybeMergeWith(previousCommand) + ) { + this.undoBuffer.push(command); + } + + this.redoBuffer = []; + } else { + this.clearUndoRedoBuffers(); + } + + this.lastCommandComplete = false; + } + /** * Tries to find an available path for the page file, starting with a normalized version of * the project and page names. @@ -235,6 +308,14 @@ export class Store { return this.currentProject; } + /** + * Returns the element that is currently being dragged, or undefined if there is none. + * @return The dragged element or undefined. + */ + public getDraggedElement(): PageElement | undefined { + return this.draggedElement; + } + /** * Returns a page reference (containing ID and name) object by its ID. * @param id The page ID. @@ -309,14 +390,6 @@ export class Store { return this.projects; } - /** - * Returns the element that is currently being dragged, or undefined if there is none. - * @return The rearrange element or undefined. - */ - public getRearrangeElement(): PageElement | undefined { - return this.rearrangeElement; - } - /** * Returns the currently selected element in the element list. * The properties pane shows the properties of this element, @@ -336,6 +409,24 @@ export class Store { return this.styleguide; } + /** + * Returns whether there is a user comment (user operation) to redo. + * @return Whether there is a user comment (user operation) to redo. + * @see redo + */ + public hasRedoCommand(): boolean { + return this.redoBuffer.length > 0; + } + + /** + * Returns whether there is a user comment (user operation) to undo. + * @return Whether there is a user comment (user operation) to undo. + * @see undo + */ + public hasUndoCommand(): boolean { + return this.undoBuffer.length > 0; + } + /** * Returns whether the currently selected element also has focus. * In this case, keyboard operations such as copy, cut, or delete @@ -411,7 +502,6 @@ export class Store { throw new Error('Cannot open page: No styleguide open'); } - // TODO: Replace workaround by proper dirty-check handling this.save(); MobX.transaction(() => { @@ -459,7 +549,6 @@ export class Store { * @param styleguidePath The absolute and OS-dependent file-system path to the styleguide. */ public openStyleguide(styleguidePath: string): void { - // TODO: Replace workaround by proper dirty-check handling if (this.currentPage) { this.save(); } @@ -474,7 +563,7 @@ export class Store { (this.projects as IObservableArray).clear(); const alvaYamlPath = PathUtils.join(styleguidePath, 'alva/alva.yaml'); - // Todo: Converts old alva.yaml structure to new one. + // TODO: Converts old alva.yaml structure to new one. // This should be removed after the next version. const projectsPath = PathUtils.join(styleguidePath, 'alva/projects.yaml'); try { @@ -495,7 +584,7 @@ export class Store { let json: JsonObject = Persister.loadYamlOrJson(alvaYamlPath); - // Todo: Converts old alva.yaml structure to new one. + // TODO: Converts old alva.yaml structure to new one. // This should be removed after the next version. if (json.config) { json = json.config as JsonObject; @@ -510,10 +599,30 @@ export class Store { }); }); + this.clearUndoRedoBuffers(); + this.preferences.setLastStyleguidePath(styleguidePath); this.savePreferences(); } + /** + * Redoes the last undone user operation, if available. + * @return Whether the redo was successful. + * @see hasRedoCommand + */ + public redo(): boolean { + const command: Command | undefined = this.redoBuffer.pop(); + if (!command) { + return false; + } else if (command.execute()) { + this.undoBuffer.push(command); + return true; + } else { + this.clearUndoRedoBuffers(); + return false; + } + } + /** * Removes a given page from the styleguide designs. * If the page is currently open, it is closed first. @@ -641,6 +750,14 @@ export class Store { this.clipboardElement = clipboardElement; } + /** + * Sets the element that is currently being dragged, or undefined if there is none. + * @param draggedElement The dragged element or undefined. + */ + public setDraggedElement(draggedElement: PageElement): void { + this.draggedElement = draggedElement; + } + /** * Sets whether the currently selected element also has focus. * In this case, keyboard operations such as copy, cut, or delete @@ -652,6 +769,16 @@ export class Store { this.elementFocussed = elementFocussed; } + /** + * Marks that the last executed user operation is now complete, so that similar operations do + * not merge with the last one. For instance, if you edit the text of a page element property, + * all subsequent edits on this property automatically merge. After leaving the input, call this + * method, and the next text editing on this property stays a separate undo command. + */ + public setLastCommandComplete(): void { + this.lastCommandComplete = true; + } + /** * Loads a given JSON object into the store as current page. * Used internally within the store, do not use from UI components. @@ -675,14 +802,6 @@ export class Store { this.patternSearchTerm = patternSearchTerm; } - /** - * Sets the element that is currently being dragged, or undefined if there is none. - * @param rearrangeElement The rearrange element or undefined. - */ - public setRearrangeElement(rearrangeElement: PageElement): void { - this.rearrangeElement = rearrangeElement; - } - /** * Sets the currently selected element in the element list. * The properties pane shows the properties of this element, @@ -715,6 +834,26 @@ export class Store { } else { this.styleguide = undefined; } + + this.clearUndoRedoBuffers(); }); } + + /** + * Undoes the last user operation, if available. + * @return Whether the undo was successful. + * @see hasUndoCommand + */ + public undo(): boolean { + const command: Command | undefined = this.undoBuffer.pop(); + if (!command) { + return false; + } else if (command.undo()) { + this.redoBuffer.push(command); + return true; + } else { + this.clearUndoRedoBuffers(); + return false; + } + } } diff --git a/src/store/styleguide/folder.ts b/src/store/styleguide/folder.ts index c5d36db85..8ac2fbc95 100644 --- a/src/store/styleguide/folder.ts +++ b/src/store/styleguide/folder.ts @@ -49,7 +49,7 @@ export class PatternFolder { * @param indentation The current indentation level, if invoked from a parent pattern folder. */ public dump(indentation: number = 0): void { - console.log(`${' '.repeat(indentation)}Folder '${this.name}'`); + console.info(`${' '.repeat(indentation)}Folder '${this.name}'`); for (const child of this.children.values()) { child.dump(indentation + 1); } diff --git a/src/store/styleguide/pattern.ts b/src/store/styleguide/pattern.ts index 67c23fe92..3d9177ed8 100644 --- a/src/store/styleguide/pattern.ts +++ b/src/store/styleguide/pattern.ts @@ -87,7 +87,7 @@ export class Pattern { * @param indentation The current indentation level, if invoked from a pattern folder. */ public dump(indentation: number = 0): void { - console.log( + console.info( `${' '.repeat(indentation)}Pattern '${this.id}', path '${this.implementationPath}'` ); }