UNPKG

tandem-front-end

Version:

Visual editor for web components

1,741 lines (1,603 loc) 47 kB
import { Action } from "redux"; import * as path from "path"; import { CanvasToolArtboardTitleClicked, NEW_FILE_ADDED, PC_LAYER_EDIT_LABEL_BLUR, CANVAS_TOOL_ARTBOARD_TITLE_CLICKED, PROJECT_LOADED, PC_LAYER_DOUBLE_CLICK, ProjectLoaded, SYNTHETIC_WINDOW_OPENED, CanvasToolOverlayMouseMoved, PROJECT_DIRECTORY_LOADED, ProjectDirectoryLoaded, FILE_NAVIGATOR_ITEM_CLICKED, FileNavigatorItemClicked, DOCUMENT_RENDERED, DocumentRendered, CANVAS_WHEEL, CANVAS_MOUSE_MOVED, CANVAS_MOUNTED, CANVAS_MOUSE_CLICKED, WrappedEvent, CanvasToolOverlayClicked, RESIZER_MOUSE_DOWN, ResizerMouseDown, ResizerMoved, RESIZER_MOVED, RESIZER_PATH_MOUSE_STOPPED_MOVING, RESIZER_STOPPED_MOVING, ResizerPathStoppedMoving, RESIZER_PATH_MOUSE_MOVED, ResizerPathMoved, SHORTCUT_ESCAPE_KEY_DOWN, INSERT_TOOL_FINISHED, InsertToolFinished, SHORTCUT_DELETE_KEY_DOWN, CANVAS_TOOL_WINDOW_BACKGROUND_CLICKED, SYNTHETIC_NODES_PASTED, SyntheticVisibleNodesPasted, FILE_NAVIGATOR_ITEM_DOUBLE_CLICKED, OPEN_FILE_ITEM_CLICKED, OPEN_FILE_ITEM_CLOSE_CLICKED, OpenFilesItemClick, SAVED_FILE, SavedFile, SAVED_ALL_FILES, RAW_CSS_TEXT_CHANGED, RawCSSTextChanged, PC_LAYER_MOUSE_OVER, PC_LAYER_MOUSE_OUT, PC_LAYER_CLICK, PC_LAYER_EXPAND_TOGGLE_CLICK, TreeLayerLabelChanged, TreeLayerClick, TreeLayerDroppedNode, TreeLayerExpandToggleClick, TreeLayerMouseOut, FILE_NAVIGATOR_TOGGLE_DIRECTORY_CLICKED, TreeLayerMouseOver, PC_LAYER_DROPPED_NODE, FILE_NAVIGATOR_NEW_FILE_CLICKED, FILE_NAVIGATOR_NEW_DIRECTORY_CLICKED, NewFileAdded, FILE_NAVIGATOR_DROPPED_ITEM, FileNavigatorDroppedItem, SHORTCUT_UNDO_KEY_DOWN, SHORTCUT_REDO_KEY_DOWN, SLOT_TOGGLE_CLICK, PC_LAYER_LABEL_CHANGED, NATIVE_NODE_TYPE_CHANGED, TEXT_VALUE_CHANGED, TextValueChanged, ElementTypeChanged, NativeNodeTypeChanged, SHORTCUT_QUICK_SEARCH_KEY_DOWN, QUICK_SEARCH_ITEM_CLICKED, QuickSearchItemClicked, QUICK_SEARCH_BACKGROUND_CLICK, NEW_VARIANT_NAME_ENTERED, NewVariantNameEntered, COMPONENT_VARIANT_NAME_DEFAULT_TOGGLE_CLICK, ComponentVariantNameDefaultToggleClick, COMPONENT_VARIANT_REMOVED, COMPONENT_VARIANT_NAME_CHANGED, ComponentVariantNameChanged, COMPONENT_VARIANT_NAME_CLICKED, ComponentVariantNameClicked, ELEMENT_VARIANT_TOGGLED, ElementVariantToggled, EDITOR_TAB_CLICKED, EditorTabClicked, CanvasWheel, SHORTCUT_ZOOM_IN_KEY_DOWN, SHORTCUT_ZOOM_OUT_KEY_DOWN, CanvasMounted, CANVAS_DROPPED_ITEM, CanvasDroppedItem, CANVAS_DRAGGED_OVER, SHORTCUT_CONVERT_TO_COMPONENT_KEY_DOWN, SHORTCUT_T_KEY_DOWN, SHORTCUT_R_KEY_DOWN, CanvasDraggingOver, ELEMENT_TYPE_CHANGED, CSS_PROPERTY_CHANGED, CSS_PROPERTY_CHANGE_COMPLETED, ATTRIBUTE_CHANGED, CSSPropertyChanged, FRAME_MODE_CHANGE_COMPLETE, FrameModeChangeComplete, TOOLBAR_TOOL_CLICKED, ToolbarToolClicked, EDITOR_TAB_CLOSE_BUTTON_CLICKED, SHORTCUT_SELECT_NEXT_TAB, SHORTCUT_SELECT_PREVIOUS_TAB, SHORTCUT_CLOSE_CURRENT_TAB, COMPONENT_PICKER_BACKGROUND_CLICK, ComponentPickerItemClick, COMPONENT_PICKER_ITEM_CLICK, SHORTCUT_C_KEY_DOWN } from "../actions"; import { queueOpenFile, fsSandboxReducer, isImageUri, hasFileCacheItem, FS_SANDBOX_ITEM_LOADED, FSSandboxItemLoaded, isSvgUri } from "fsbox"; import { RootState, setActiveFilePath, updateRootState, updateOpenFileCanvas, getCanvasMouseTargetNodeId, setSelectedSyntheticVisibleNodeIds, getSelectionBounds, getBoundedSelection, ToolType, setTool, persistRootState, getOpenFile, addOpenFile, upsertOpenFile, removeTemporaryOpenFiles, setNextOpenFile, updateOpenFile, deselectRootProjectFiles, setHoveringSyntheticVisibleNodeIds, setRootStateSyntheticVisibleNodeExpanded, setSelectedFileNodeIds, InsertFileType, setInsertFile, undo, redo, openSyntheticVisibleNodeOriginFile, setRootStateSyntheticVisibleNodeLabelEditing, getEditorWithActiveFileUri, openEditorFileUri, openSecondEditor, getActiveEditorWindow, getEditorWindowWithFileUri, updateEditorWindow, getSyntheticWindowBounds, centerEditorCanvas, getCanvasMouseTargetNodeIdFromPoint, isSelectionMovable, SyntheticVisibleNodeMetadataKeys, selectInsertedSyntheticVisibleNodes, RegisteredComponent, closeFile, shiftActiveEditorTab } from "../state"; import { PCSourceTagNames, PCVisibleNode, PCTextNode, PCElement, paperclipReducer, PC_SYNTHETIC_FRAME_RENDERED, SyntheticElement, createPCElement, createPCTextNode, getSyntheticSourceFrame, getSyntheticVisibleNodeRelativeBounds, getSyntheticVisibleNodeDocument, getSyntheticSourceNode, getSyntheticNodeById, SyntheticVisibleNode, getPCNodeDependency, updateSyntheticVisibleNodePosition, updateFrameBounds, updateSyntheticVisibleNodeBounds, persistInsertNode, persistChangeLabel, removeSyntheticVisibleNode, persistSyntheticVisibleNodeBounds, persistRemoveSyntheticVisibleNode, getSyntheticNodeSourceDependency, persistConvertNodeToComponent, PCModule, persistMoveSyntheticVisibleNode, persistAppendPCClips, getPCNodeModule, persistChangeSyntheticTextNodeValue, persistRawCSSText, SyntheticTextNode, updatePCNodeMetadata, PCVisibleNodeMetadataKey, getSyntheticDocumentByDependencyUri, SyntheticBaseNode, getFrameSyntheticNode, SyntheticDocument, getFrameByContentNodeId, PC_DEPENDENCY_GRAPH_LOADED, PCDependencyGraphLoaded, SYNTHETIC_DOCUMENT_NODE_NAME, DEFAULT_FRAME_BOUNDS, isPaperclipUri, evaluateDependency, isSyntheticDocumentRoot, isSyntheticVisibleNode, persistChangeElementType, getSyntheticDocumentById, persistAddComponentController, persistCSSProperty, persistAttribute, getPCNode, updateSyntheticVisibleNode, persistSyntheticNodeMetadata, createPCComponentInstance } from "paperclip"; import { getTreeNodePath, getTreeNodeFromPath, File, EMPTY_OBJECT, TreeNode, StructReference, roundBounds, scaleInnerBounds, moveBounds, keepBoundsAspectRatio, keepBoundsCenter, Bounded, Struct, Bounds, getBoundsSize, shiftBounds, flipPoint, diffArray, isDirectory, updateNestedNode, FileAttributeNames, Directory, getNestedTreeNodeById, isFile, arraySplice, getParentTreeNode, appendChildNode, removeNestedTreeNode, resizeBounds, updateNestedNodeTrail, boundsFromRect, centerTransformZoom, Translate, zoomBounds, getBoundsPoint, TreeMoveOffset, shiftPoint, Point, zoomPoint, cloneTreeNode, createTreeNode, FSItemNamespaces, FSItemTagNames, FSItem, getFileFromUri, createFile, stripProtocol, createDirectory, sortFSItems } from "tandem-common"; import { difference, pull, clamp, merge } from "lodash"; import { select } from "redux-saga/effects"; const DEFAULT_RECT_COLOR = "#CCC"; const INSERT_TEXT_OFFSET = { left: -5, top: -10 }; const PANE_SENSITIVITY = process.platform === "win32" ? 0.1 : 1; const ZOOM_SENSITIVITY = process.platform === "win32" ? 2500 : 250; const MIN_ZOOM = 0.02; const MAX_ZOOM = 6400 / 100; const INITIAL_ZOOM_PADDING = 50; export const rootReducer = (state: RootState, action: Action): RootState => { state = fsSandboxReducer(state, action); state = paperclipReducer(state, action); state = canvasReducer(state, action); state = shortcutReducer(state, action); state = clipboardReducer(state, action); switch (action.type) { case PROJECT_DIRECTORY_LOADED: { const { directory } = action as ProjectDirectoryLoaded; return updateRootState({ projectDirectory: directory }, state); } case FILE_NAVIGATOR_ITEM_CLICKED: { const { node } = action as FileNavigatorItemClicked; const uri = node.uri; state = setSelectedFileNodeIds(state, node.id); state = setFileExpanded(node, true, state); if (!isDirectory(node)) { state = maybeEvaluateFile(uri, state); state = setActiveFilePath(uri, state); return state; } return state; } case QUICK_SEARCH_ITEM_CLICKED: { const { file } = action as QuickSearchItemClicked; const uri = file.uri; state = maybeEvaluateFile(uri, state); state = setSelectedFileNodeIds(state, file.id); state = setActiveFilePath(uri, state); state = upsertOpenFile(uri, false, state); state = updateRootState({ showQuickSearch: false }, state); return state; } case QUICK_SEARCH_BACKGROUND_CLICK: { return (state = updateRootState({ showQuickSearch: false }, state)); } case FILE_NAVIGATOR_TOGGLE_DIRECTORY_CLICKED: { const { node } = action as FileNavigatorItemClicked; state = setFileExpanded(node, !node.expanded, state); return state; } case FILE_NAVIGATOR_ITEM_DOUBLE_CLICKED: { const { node } = action as FileNavigatorItemClicked; const uri = node.uri; const file = getFileFromUri(uri, state.projectDirectory); if (isFile(file)) { state = upsertOpenFile(uri, false, state); state = openEditorFileUri(uri, state); } return state; } case FILE_NAVIGATOR_NEW_FILE_CLICKED: { return setInsertFile(InsertFileType.FILE, state); } case FILE_NAVIGATOR_NEW_DIRECTORY_CLICKED: { return setInsertFile(InsertFileType.DIRECTORY, state); } case CANVAS_MOUNTED: { const { fileUri, element } = action as CanvasMounted; if (!element) { return state; } const { width = 400, height = 300 } = element.getBoundingClientRect() || {}; state = updateEditorWindow( { container: element }, fileUri, state ); return centerEditorCanvas(state, fileUri); } case FILE_NAVIGATOR_DROPPED_ITEM: { const { node, targetNode } = action as FileNavigatorDroppedItem; const parent: Directory = getParentTreeNode( node.id, state.projectDirectory ); const parentUri = parent.uri; const nodeUri = node.uri; state = updateRootState( { projectDirectory: updateNestedNode( parent, state.projectDirectory, parent => removeNestedTreeNode(node, parent) ) }, state ); const targetDir: Directory = targetNode.name !== FSItemTagNames.FILE ? targetNode : getParentTreeNode(targetNode.id, state.projectDirectory); const targetUri = targetDir.uri; state = updateRootState( { projectDirectory: updateNestedNode( targetDir, state.projectDirectory, targetNode => { return appendChildNode( { ...node, uri: nodeUri.replace(parentUri, targetUri) } as FSItem, targetNode ); } ) }, state ); return state; } case NEW_FILE_ADDED: { const { uri, fileType } = action as NewFileAdded; const directory = getFileFromUri( path.dirname(uri), state.projectDirectory ); state = updateRootState( { insertFileInfo: null, projectDirectory: updateNestedNode( directory, state.projectDirectory, dir => { return { ...dir, children: sortFSItems([ ...dir.children, fileType === FSItemTagNames.FILE ? createFile(uri) : createDirectory(uri) ]) }; } ) }, state ); if (fileType === FSItemTagNames.FILE) { state = setActiveFilePath(uri, state); state = maybeEvaluateFile(uri, state); } return state; } case FS_SANDBOX_ITEM_LOADED: { const { uri, mimeType } = action as FSSandboxItemLoaded; // const pcState = paperclipReducer(state, action); const editor = getEditorWindowWithFileUri(uri, state); // TODO - move this to paperclip-tandem package if (editor && editor.activeFilePath === uri) { state = maybeEvaluateFile(uri, state); } return state; } case OPEN_FILE_ITEM_CLICKED: { const { uri, sourceEvent } = action as OpenFilesItemClick; if (getEditorWithActiveFileUri(uri, state)) { return state; } state = setNextOpenFile( removeTemporaryOpenFiles( sourceEvent.metaKey ? openSecondEditor(uri, state) : openEditorFileUri(uri, state) ) ); return state; } case SAVED_FILE: { const { uri } = action as SavedFile; return updateOpenFile({ newContent: null }, uri, state); } case SAVED_ALL_FILES: { return updateRootState( { openFiles: state.openFiles.map(openFile => ({ ...openFile, newContent: null })) }, state ); } case ELEMENT_VARIANT_TOGGLED: { // const { newVariants } = action as ElementVariantToggled; // const sourceNode = getSyntheticSourceNode( // state.selectedNodeIds[0], // state.paperclip // ); // state = persistRootState( // browser => // persistSetElementVariants( // newVariants, // sourceNode.id, // state.selectedComponentVariantName, // browser // ), // state // ); return state; } case NEW_VARIANT_NAME_ENTERED: { // const { value } = action as NewVariantNameEntered; // const sourceNode = getSyntheticSourceNode( // state.selectedNodeIds[0], // state.paperclip // ) as PCComponentNode; // state = persistRootState( // browser => persistInsertNewComponentVariant(value, sourceNode, browser), // state // ); return state; } case COMPONENT_VARIANT_NAME_DEFAULT_TOGGLE_CLICK: { const { name, value } = action as ComponentVariantNameDefaultToggleClick; // const sourceComponent = getSyntheticVisibleNodeSourceComponent( // state.selectedNodeIds[0], // state.paperclip // ); // state = persistRootState( // browser => // persistComponentVariantChanged( // { [PCSourceNamespaces.CORE]: { isDefault: value } }, // name, // sourceComponent.id, // browser // ), // state // ); return state; } case COMPONENT_VARIANT_REMOVED: { const { name, value } = action as ComponentVariantNameDefaultToggleClick; // const sourceComponent = getSyntheticVisibleNodeSourceComponent( // state.selectedNodeIds[0], // state.paperclip // ); // state = persistRootState( // browser => // persistRemoveComponentVariant(name, sourceComponent.id, browser), // state // ); return state; } case COMPONENT_VARIANT_NAME_CLICKED: { const { name } = action as ComponentVariantNameClicked; state = updateRootState({ selectedComponentVariantName: name }, state); return state; } case COMPONENT_VARIANT_NAME_CHANGED: { const { oldName, newName } = action as ComponentVariantNameChanged; // const sourceComponentNode = getSyntheticVisibleNodeSourceComponent( // state.selectedNodeIds[0], // state.paperclip // ); // state = persistRootState( // browser => // persistComponentVariantChanged( // { // [PCSourceNamespaces.CORE]: { name: newName } // }, // oldName, // sourceComponentNode.id, // browser // ), // state // ); return state; } case PC_LAYER_MOUSE_OVER: { const { node } = action as TreeLayerMouseOver; state = setHoveringSyntheticVisibleNodeIds(state, node.id); return state; } case PC_LAYER_DOUBLE_CLICK: { const { node } = action as TreeLayerClick; state = setRootStateSyntheticVisibleNodeLabelEditing( node.id, true, state ); return state; } case PC_LAYER_EDIT_LABEL_BLUR: { const { node } = action as TreeLayerClick; state = setRootStateSyntheticVisibleNodeLabelEditing( node.id, false, state ); return state; } case PC_LAYER_LABEL_CHANGED: { const { label, node } = action as TreeLayerLabelChanged; state = setRootStateSyntheticVisibleNodeLabelEditing( node.id, false, state ); state = persistRootState( browser => persistChangeLabel(label, node as SyntheticVisibleNode, browser), state ); return state; } case PC_LAYER_DROPPED_NODE: { const { node, targetNode, offset } = action as TreeLayerDroppedNode; const oldState = state; state = persistRootState( state => persistMoveSyntheticVisibleNode( node as SyntheticVisibleNode, targetNode as SyntheticVisibleNode, offset, state ), state ); const document = getSyntheticVisibleNodeDocument( targetNode.id, state.documents ); const mutatedTarget = offset === TreeMoveOffset.APPEND || offset === TreeMoveOffset.PREPEND ? targetNode : getParentTreeNode(targetNode.id, document); state = selectInsertedSyntheticVisibleNodes( oldState, state, mutatedTarget ); return state; } case PC_LAYER_MOUSE_OUT: { const { node } = action as TreeLayerMouseOut; state = setHoveringSyntheticVisibleNodeIds(state); return state; } case PC_LAYER_CLICK: { const { node, sourceEvent } = action as TreeLayerClick; if (sourceEvent.altKey) { // state = openSyntheticVisibleNodeOriginFile(node.id, state); } else { const doc = getSyntheticVisibleNodeDocument(node.id, state.documents); const dep = getSyntheticNodeSourceDependency(doc, state.graph); state = setActiveFilePath(dep.uri, state); state = setSelectedSyntheticVisibleNodeIds( state, ...(sourceEvent.shiftKey ? [...state.selectedNodeIds, node.id] : [node.id]) ); } return state; } case PC_LAYER_EXPAND_TOGGLE_CLICK: { const { node } = action as TreeLayerExpandToggleClick; state = setRootStateSyntheticVisibleNodeExpanded( node.id, !(node as SyntheticVisibleNode).metadata[ SyntheticVisibleNodeMetadataKeys.EXPANDED ], state ); return state; } case OPEN_FILE_ITEM_CLOSE_CLICKED: { // TODO - flag confirm remove state const { uri } = action as OpenFilesItemClick; return closeFile(uri, state); } case EDITOR_TAB_CLICKED: { const { uri } = action as EditorTabClicked; return openEditorFileUri(uri, state); } case EDITOR_TAB_CLOSE_BUTTON_CLICKED: { const { uri } = action as EditorTabClicked; return closeFile(uri, state); } case PC_DEPENDENCY_GRAPH_LOADED: { const { graph } = action as PCDependencyGraphLoaded; state = centerEditorCanvas(state, state.activeEditorFilePath); return state; } } return state; }; export const canvasReducer = (state: RootState, action: Action) => { switch (action.type) { case RESIZER_MOVED: { const { point: newPoint } = action as ResizerMoved; state = updateEditorWindow( { movingOrResizing: true }, state.activeEditorFilePath, state ); if (isSelectionMovable(state)) { const selectionBounds = getSelectionBounds(state); const nodeId = state.selectedNodeIds[0]; let movedBounds = moveBounds(selectionBounds, newPoint); for (const nodeId of state.selectedNodeIds) { const itemBounds = getSyntheticVisibleNodeRelativeBounds( getSyntheticNodeById(nodeId, state.documents), state.frames ); const newBounds = roundBounds( scaleInnerBounds(itemBounds, selectionBounds, movedBounds) ); state = updateSyntheticVisibleNodePosition( newBounds, getSyntheticNodeById(nodeId, state.documents), state ); } } return state; } case RESIZER_MOUSE_DOWN: { const { sourceEvent } = action as ResizerMouseDown; if (sourceEvent.metaKey) { // state = openSyntheticVisibleNodeOriginFile(state.selectedNodeIds[0], state); } return state; } case COMPONENT_PICKER_BACKGROUND_CLICK: { return setTool(null, state); } case COMPONENT_PICKER_ITEM_CLICK: { const { component } = action as ComponentPickerItemClick; return { ...state, selectedComponentId: component.id }; } case TOOLBAR_TOOL_CLICKED: { const { toolType } = action as ToolbarToolClicked; if (toolType === ToolType.POINTER) { state = setTool(null, state); } else { state = setTool(toolType, state); } return state; } case RESIZER_STOPPED_MOVING: { const { point } = action as ResizerMoved; const oldGraph = state.graph; if (isSelectionMovable(state)) { const selectionBounds = getSelectionBounds(state); state = persistRootState(state => { return state.selectedNodeIds.reduce((state, nodeId) => { return persistSyntheticVisibleNodeBounds( getSyntheticNodeById(nodeId, state.documents), state ); }, state); }, state); } state = updateEditorWindow( { movingOrResizing: false }, state.activeEditorFilePath, state ); return state; } case CANVAS_WHEEL: { const { metaKey, ctrlKey, deltaX, deltaY, canvasHeight, canvasWidth } = action as CanvasWheel; const editorWindow = getActiveEditorWindow(state); const openFile = getOpenFile(editorWindow.activeFilePath, state); let translate = openFile.canvas.translate; if (metaKey || ctrlKey) { translate = centerTransformZoom( translate, boundsFromRect({ width: canvasWidth, height: canvasHeight }), clamp( translate.zoom + translate.zoom * deltaY / ZOOM_SENSITIVITY, MIN_ZOOM, MAX_ZOOM ), editorWindow.mousePosition ); } else { translate = { ...translate, left: translate.left - deltaX, top: translate.top - deltaY }; } state = updateEditorWindow( { smooth: false }, editorWindow.activeFilePath, state ); state = updateOpenFileCanvas( { translate }, editorWindow.activeFilePath, state ); return state; } case CANVAS_DROPPED_ITEM: { let { item, point, editorUri } = action as CanvasDroppedItem; const targetNodeId = getCanvasMouseTargetNodeIdFromPoint( state, point, getDragFilter(item) ); let sourceNode: PCVisibleNode; if (isFile(item)) { let src = path.relative(path.dirname(editorUri), item.uri); if (src.charAt(0) !== ".") { src = "./" + src; } if (isImageUri(item.uri)) { sourceNode = createPCElement( "img", {}, { src } ); if (isSvgUri(item.uri)) { sourceNode = createPCElement( "object", {}, { data: src, type: "image/svg+xml" }, [sourceNode] ); } } else if (isJavaScriptFile(item.uri)) { return persistRootState(state => { return persistAddComponentController( (item as FSItem).uri, getSyntheticNodeById(targetNodeId, state.documents), state ); }, state); } } else if (isSyntheticVisibleNode(item)) { sourceNode = getSyntheticSourceNode(item, state.graph) as PCVisibleNode; } else { sourceNode = cloneTreeNode((item as RegisteredComponent).template); } if (!sourceNode) { console.error(`Unrecognized dropped item.`); return state; } const targetId = getCanvasMouseTargetNodeIdFromPoint( state, point, node => node.name !== PCSourceTagNames.TEXT ); let target: SyntheticVisibleNode | SyntheticDocument = targetId ? getSyntheticNodeById(targetId, state.documents) : getSyntheticDocumentByDependencyUri( editorUri, state.documents, state.graph ); if (target.name === SYNTHETIC_DOCUMENT_NODE_NAME) { sourceNode = updatePCNodeMetadata( { [PCVisibleNodeMetadataKey.BOUNDS]: moveBounds( sourceNode.metadata[PCVisibleNodeMetadataKey.BOUNDS] || DEFAULT_FRAME_BOUNDS, point ) }, sourceNode ); } return persistRootState( browser => persistInsertNode(sourceNode, target, TreeMoveOffset.APPEND, browser), state ); } case SHORTCUT_ZOOM_IN_KEY_DOWN: { const editor = getActiveEditorWindow(state); const openFile = getOpenFile(editor.activeFilePath, state); state = setCanvasZoom( normalizeZoom(openFile.canvas.translate.zoom) * 2, false, editor.activeFilePath, state ); return state; } case SHORTCUT_ZOOM_OUT_KEY_DOWN: { const editor = getActiveEditorWindow(state); const openFile = getOpenFile(editor.activeFilePath, state); state = setCanvasZoom( normalizeZoom(openFile.canvas.translate.zoom) / 2, false, editor.activeFilePath, state ); return state; } case SHORTCUT_SELECT_NEXT_TAB: { return shiftActiveEditorTab(1, state); } case SHORTCUT_SELECT_PREVIOUS_TAB: { return shiftActiveEditorTab(-1, state); } case SHORTCUT_CLOSE_CURRENT_TAB: { return closeFile(state.activeEditorFilePath, state); } case CANVAS_MOUSE_MOVED: { const { sourceEvent: { pageX, pageY } } = action as WrappedEvent<React.MouseEvent<any>>; state = updateEditorWindow( { mousePosition: { left: pageX, top: pageY } }, state.activeEditorFilePath, state ); let targetNodeId: string; const editorWindow = getActiveEditorWindow(state); const openFile = getOpenFile(editorWindow.activeFilePath, state); if (!editorWindow.movingOrResizing) { targetNodeId = getCanvasMouseTargetNodeId( state, action as CanvasToolOverlayMouseMoved ); } state = updateRootState( { hoveringNodeIds: targetNodeId ? [targetNodeId] : [] }, state ); return state; } case CANVAS_DRAGGED_OVER: { const { item, offset } = action as CanvasDraggingOver; state = updateEditorWindow( { mousePosition: offset }, state.activeEditorFilePath, state ); // remove selection so that hovering state is visible state = setSelectedSyntheticVisibleNodeIds(state); // TODO - in the future, we'll probably want to be able to highlight hovered nodes as the user is moving an element around to indicate where // they can drop the element. let targetNodeId: string; const editor = getActiveEditorWindow(state); targetNodeId = getCanvasMouseTargetNodeIdFromPoint( state, offset, getDragFilter(item) ); state = updateRootState( { hoveringNodeIds: targetNodeId ? [targetNodeId] : [] }, state ); return state; } // TODO case CANVAS_MOUSE_CLICKED: { if (state.toolType != null) { return state; } state = deselectRootProjectFiles(state); const { sourceEvent } = action as CanvasToolOverlayClicked; if (/textarea|input/i.test((sourceEvent.target as Element).nodeName)) { return state; } // alt key opens up a new link const altKey = sourceEvent.altKey; const editorWindow = getActiveEditorWindow(state); const openFile = getOpenFile(editorWindow.activeFilePath, state); // do not allow selection while window is panning (scrolling) if (openFile.canvas.panning || editorWindow.movingOrResizing) return state; const targetNodeId = getCanvasMouseTargetNodeId( state, action as CanvasToolOverlayMouseMoved ); if (!targetNodeId) { return setSelectedSyntheticVisibleNodeIds(state); } // if (altKey) { // state = openSyntheticVisibleNodeOriginFile(targetNodeId, state); // return state; // } if (!altKey) { state = handleArtboardSelectionFromAction( state, targetNodeId, action as CanvasToolOverlayMouseMoved ); state = updateEditorWindow( { secondarySelection: false }, editorWindow.activeFilePath, state ); return state; } return state; } case RESIZER_PATH_MOUSE_MOVED: { state = updateEditorWindow( { movingOrResizing: true }, state.activeEditorFilePath, state ); // TODO - possibly use BoundsStruct instead of Bounds since there are cases where bounds prop doesn't exist const newBounds = getResizeActionBounds(action as ResizerPathMoved); for (const nodeId of getBoundedSelection(state)) { state = updateSyntheticVisibleNodeBounds( getNewSyntheticVisibleNodeBounds( newBounds, getSyntheticNodeById(nodeId, state.documents), state ), getSyntheticNodeById(nodeId, state.documents), state ); } return state; } case RESIZER_PATH_MOUSE_STOPPED_MOVING: { state = updateEditorWindow( { movingOrResizing: false }, state.activeEditorFilePath, state ); // TODO - possibly use BoundsStruct instead of Bounds since there are cases where bounds prop doesn't exist const newBounds = getResizeActionBounds( action as ResizerPathStoppedMoving ); state = persistRootState(state => { return state.selectedNodeIds.reduce( (state, nodeId) => persistSyntheticVisibleNodeBounds( getSyntheticNodeById(nodeId, state.documents), state ), state ); }, state); return state; } case RAW_CSS_TEXT_CHANGED: { const { value: cssText } = action as RawCSSTextChanged; state = persistRootState(browser => { return state.selectedNodeIds.reduce( (state, nodeId) => persistRawCSSText( cssText, getSyntheticNodeById(nodeId, state.documents), state ), state ); }, state); return state; } case CSS_PROPERTY_CHANGED: { const { name, value } = action as CSSPropertyChanged; state = state.selectedNodeIds.reduce((state, nodeId) => { return updateSyntheticVisibleNode( getSyntheticNodeById(nodeId, state.documents), state, node => { return { ...node, style: { ...node.style, [name]: value } }; } ); }, state); return state; } case FRAME_MODE_CHANGE_COMPLETE: { const { frame, mode } = action as FrameModeChangeComplete; state = persistRootState(state => { return persistSyntheticNodeMetadata( { mode }, getSyntheticNodeById(frame.contentNodeId, state.documents), state ); }, state); return state; } case CSS_PROPERTY_CHANGE_COMPLETED: { const { name, value } = action as CSSPropertyChanged; state = persistRootState(browser => { return state.selectedNodeIds.reduce( (state, nodeId) => persistCSSProperty( name, value, getSyntheticNodeById(nodeId, state.documents), state ), state ); }, state); return state; } case ATTRIBUTE_CHANGED: { const { name, value } = action as CSSPropertyChanged; state = persistRootState(browser => { return state.selectedNodeIds.reduce( (state, nodeId) => persistAttribute( name, value, getSyntheticNodeById(nodeId, state.documents) as SyntheticElement, state ), state ); }, state); return state; } case SLOT_TOGGLE_CLICK: { // state = persistRootState(browser => { // return persistToggleSlotContainer( // getSyntheticSourceNode(state.selectedNodeIds[0], state.paperclip).id, // browser // ); // }, state); return state; } case NATIVE_NODE_TYPE_CHANGED: { const { nativeType } = action as NativeNodeTypeChanged; // state = persistRootState(browser => { // return persistChangeNodeType( // nativeType, // getSyntheticSourceNode( // state.selectedNodeIds[0], // state.paperclip // ) as PCElement, // browser // ); // }, state); return state; } case TEXT_VALUE_CHANGED: { const { value } = action as TextValueChanged; state = persistRootState(state => { return persistChangeSyntheticTextNodeValue( value, getSyntheticNodeById( state.selectedNodeIds[0], state.documents ) as SyntheticTextNode, state ); }, state); return state; } case ELEMENT_TYPE_CHANGED: { const { value } = action as ElementTypeChanged; state = persistRootState(state => { return persistChangeElementType( value, getSyntheticNodeById( state.selectedNodeIds[0], state.documents ) as SyntheticElement, state ); }, state); return state; } case CANVAS_TOOL_ARTBOARD_TITLE_CLICKED: { const { frame, sourceEvent } = action as CanvasToolArtboardTitleClicked; sourceEvent.stopPropagation(); const contentNode = getFrameSyntheticNode(frame, state.documents); state = updateEditorWindow( { smooth: false }, getPCNodeDependency( getSyntheticSourceNode(contentNode, state.graph).id, state.graph ).uri, state ); return handleArtboardSelectionFromAction( state, frame.contentNodeId, action as CanvasToolArtboardTitleClicked ); } case CANVAS_TOOL_WINDOW_BACKGROUND_CLICKED: { return setSelectedSyntheticVisibleNodeIds(state); } case INSERT_TOOL_FINISHED: { let { point, fileUri } = action as InsertToolFinished; const editor = getEditorWithActiveFileUri(fileUri, state); const toolType = state.toolType; switch (toolType) { case ToolType.COMPONENT: { const componentId = state.selectedComponentId; state = { ...state, selectedComponentId: null }; const component = getPCNode(componentId, state.graph); return persistInsertNodeFromPoint( createPCComponentInstance( componentId, [], null, null, null, component.metadata ), fileUri, point, state ); } case ToolType.ELEMENT: { return persistInsertNodeFromPoint( createPCElement( "div", { "box-sizing": "border-box" }, null, null, "Element" ), fileUri, point, state ); } case ToolType.TEXT: { return persistInsertNodeFromPoint( createPCTextNode("Click to edit", "Text"), fileUri, point, state ); } } } } return state; }; const isJavaScriptFile = (file: string) => /(ts|js)x?$/.test(file); const INSERT_ARTBOARD_WIDTH = 100; const INSERT_ARTBOARD_HEIGHT = 100; const persistInsertNodeFromPoint = ( node: PCVisibleNode, fileUri: string, point: Point, state: RootState ) => { const oldState = state; const targetNodeId = getCanvasMouseTargetNodeIdFromPoint(state, point); let targetNode: SyntheticVisibleNode | SyntheticDocument = targetNodeId && getSyntheticNodeById(targetNodeId, state.documents); if (!targetNode) { const newPoint = shiftPoint( normalizePoint(getOpenFile(fileUri, state).canvas.translate, point), { left: -(INSERT_ARTBOARD_WIDTH / 2), top: -(INSERT_ARTBOARD_HEIGHT / 2) } ); let bounds = { left: 0, top: 0, right: INSERT_ARTBOARD_WIDTH, bottom: INSERT_ARTBOARD_HEIGHT, ...(node.metadata[PCVisibleNodeMetadataKey.BOUNDS] || {}) }; bounds = moveBounds(bounds, newPoint); node = updatePCNodeMetadata( { [PCVisibleNodeMetadataKey.BOUNDS]: bounds }, node ); targetNode = getSyntheticDocumentByDependencyUri( fileUri, state.documents, state.graph ); } state = persistRootState( browser => { return persistInsertNode(node, targetNode, TreeMoveOffset.APPEND, state); }, state, targetNode ); state = setTool(null, state); state = selectInsertedSyntheticVisibleNodes(oldState, state, targetNode); return state; }; const getDragFilter = (item: any) => { let filter = (node: SyntheticVisibleNode) => node.name !== PCSourceTagNames.TEXT; if (isFile(item) && isJavaScriptFile(item.uri)) { filter = (node: SyntheticVisibleNode) => { return ( node.isContentNode && node.isCreatedFromComponent && !node.isComponentInstance ); }; } return filter; }; const setFileExpanded = (node: FSItem, value: boolean, state: RootState) => { state = updateRootState( { projectDirectory: updateNestedNode( node, state.projectDirectory, (node: FSItem) => ({ ...node, expanded: value }) ) }, state ); return state; }; const getNewSyntheticVisibleNodeBounds = ( newBounds: Bounds, node: SyntheticVisibleNode, state: RootState ) => { const currentBounds = getSelectionBounds(state); const innerBounds = getSyntheticVisibleNodeRelativeBounds(node, state.frames); return scaleInnerBounds(innerBounds, currentBounds, newBounds); }; const getResizeActionBounds = (action: ResizerPathMoved | ResizerMoved) => { let { anchor, originalBounds, newBounds, sourceEvent } = action as ResizerPathMoved; const keepAspectRatio = sourceEvent.shiftKey; const keepCenter = sourceEvent.altKey; if (keepCenter) { // TODO - need to test. this might not work newBounds = keepBoundsCenter(newBounds, originalBounds, anchor); } if (keepAspectRatio) { newBounds = keepBoundsAspectRatio( newBounds, originalBounds, anchor, keepCenter ? { left: 0.5, top: 0.5 } : anchor ); } return newBounds; }; const isInputSelected = (state: RootState) => { // ick -- this needs to be moved into a saga return ( document.activeElement && /textarea|input|button/i.test(document.activeElement.tagName) ); }; const shortcutReducer = (state: RootState, action: Action): RootState => { switch (action.type) { case SHORTCUT_QUICK_SEARCH_KEY_DOWN: { return isInputSelected(state) ? state : updateRootState( { showQuickSearch: !state.showQuickSearch }, state ); } case SHORTCUT_UNDO_KEY_DOWN: { return undo(state); } case SHORTCUT_REDO_KEY_DOWN: { return redo(state); } case SHORTCUT_T_KEY_DOWN: { return isInputSelected(state) ? state : setTool(ToolType.TEXT, state); } case SHORTCUT_R_KEY_DOWN: { return isInputSelected(state) ? state : setTool(ToolType.ELEMENT, state); } case SHORTCUT_C_KEY_DOWN: { return isInputSelected(state) ? state : setTool(ToolType.COMPONENT, state); } case SHORTCUT_CONVERT_TO_COMPONENT_KEY_DOWN: { // TODO - should be able to conver all selected nodes to components if (state.selectedNodeIds.length > 1) { return state; } const oldState = state; state = persistRootState( state => persistConvertNodeToComponent( getSyntheticNodeById(state.selectedNodeIds[0], state.documents), state ), state ); state = selectInsertedSyntheticVisibleNodes( oldState, state, getSyntheticDocumentByDependencyUri( state.activeEditorFilePath, state.documents, state.graph ) ); return state; } case SHORTCUT_ESCAPE_KEY_DOWN: { if (isInputSelected(state)) { return state; } if (state.toolType != null) { return setTool(null, state); } else { state = setSelectedSyntheticVisibleNodeIds(state); state = setSelectedFileNodeIds(state); state = updateRootState({ insertFileInfo: null }, state); return state; } } case SHORTCUT_DELETE_KEY_DOWN: { if (isInputSelected(state)) { return state; } return persistRootState(state => { const firstNode = getSyntheticNodeById( state.selectedNodeIds[0], state.documents ); const document = getSyntheticVisibleNodeDocument( firstNode.id, state.documents ); let parent = getParentTreeNode(firstNode.id, document); const index = parent.children.indexOf(firstNode); state = state.selectedNodeIds.reduce((state, nodeId) => { return persistRemoveSyntheticVisibleNode( getSyntheticNodeById(nodeId, state.documents), state ); }, state); parent = getSyntheticNodeById(parent.id, state.documents); state = setSelectedSyntheticVisibleNodeIds( state, ...(parent.children.length ? [parent.children[Math.min(index, parent.children.length - 1)].id] : parent.name !== SYNTHETIC_DOCUMENT_NODE_NAME ? [parent.id] : []) ); return state; }, state); } } return state; }; const clipboardReducer = (state: RootState, action: Action) => { switch (action.type) { case SYNTHETIC_NODES_PASTED: { const { clips } = action as SyntheticVisibleNodesPasted; const oldState = state; let offset: TreeMoveOffset = TreeMoveOffset.AFTER; let targetNode: SyntheticVisibleNode | SyntheticDocument; let scopeNode: SyntheticVisibleNode | SyntheticDocument; if (state.selectedNodeIds.length) { const nodeId = state.selectedNodeIds[0]; scopeNode = targetNode = getSyntheticNodeById(nodeId, state.documents); const clipsContainTarget = clips.some( clip => clip.node.id === targetNode.source.nodeId ); // if selected node is the pasted element, then paste if (!clipsContainTarget) { offset = TreeMoveOffset.PREPEND; } else { scopeNode = getParentTreeNode( scopeNode.id, getSyntheticVisibleNodeDocument(scopeNode.id, state.documents) ); } } else { offset = TreeMoveOffset.PREPEND; scopeNode = targetNode = getSyntheticDocumentByDependencyUri( state.activeEditorFilePath, state.documents, state.graph ); } state = persistRootState( state => persistAppendPCClips(clips, targetNode, offset, state), state ); if (scopeNode === targetNode) { state = selectInsertedSyntheticVisibleNodes(oldState, state, scopeNode); } return state; } } return state; }; const isDroppableNode = (node: SyntheticVisibleNode) => { return ( node.name !== "text" && !/input/.test(String((node as SyntheticElement).name)) ); }; const maybeEvaluateFile = (uri: string, state: RootState) => { if (isPaperclipUri(uri) && hasFileCacheItem(uri, state)) { return evaluateDependency(uri, state); } return queueOpenFile(uri, state); }; const handleArtboardSelectionFromAction = < T extends { sourceEvent: React.MouseEvent<any> } >( state: RootState, nodeId: string, event: T ) => { const { sourceEvent } = event; state = setRootStateSyntheticVisibleNodeExpanded(nodeId, true, state); return setSelectedSyntheticVisibleNodeIds(state, nodeId); }; const setCanvasZoom = ( zoom: number, smooth: boolean = true, uri: string, state: RootState ) => { const editorWindow = getEditorWindowWithFileUri(uri, state); const openFile = getOpenFile(uri, state); return updateOpenFileCanvas( { translate: centerTransformZoom( openFile.canvas.translate, editorWindow.container.getBoundingClientRect(), clamp(zoom, MIN_ZOOM, MAX_ZOOM), editorWindow.mousePosition ) }, uri, state ); }; const normalizeBounds = (translate: Translate, bounds: Bounds) => { return zoomBounds( shiftBounds(bounds, { left: -translate.left, top: -translate.top }), 1 / translate.zoom ); }; const normalizePoint = (translate: Translate, point: Point) => { return zoomPoint( shiftPoint(point, { left: -translate.left, top: -translate.top }), 1 / translate.zoom ); }; const normalizeZoom = zoom => { return zoom < 1 ? 1 / Math.round(1 / zoom) : Math.round(zoom); };