tandem-front-end
Version:
Visual editor for web components
1,443 lines (1,348 loc) • 39.7 kB
JavaScript
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,
SyntheticWindowOpened,
PROJECT_DIRECTORY_LOADED,
ProjectDirectoryLoaded,
FILE_NAVIGATOR_ITEM_CLICKED,
FileNavigatorItemClicked,
DEPENDENCY_ENTRY_LOADED,
DependencyEntryLoaded,
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,
SyntheticNodesPasted,
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,
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_REGISTERED_COMPONENT,
CanvasDroppedRegisteredComponent,
CANVAS_DRAGGED_OVER,
SHORTCUT_CONVERT_TO_COMPONENT_KEY_DOWN,
SHORTCUT_T_KEY_DOWN,
SHORTCUT_R_KEY_DOWN
} from "../actions";
import {
RootState,
setActiveFilePath,
updateRootState,
updateRootStateSyntheticBrowser,
updateRootStateSyntheticWindow,
updateRootStateSyntheticWindowDocument,
updateEditorCanvas,
getCanvasMouseTargetNodeId,
setSelectedSyntheticNodeIds,
getSelectionBounds,
updateRootSyntheticPosition,
getBoundedSelection,
updateRootSyntheticBounds,
ToolType,
getActiveWindows,
setTool,
getCanvasMouseDocumentRootId,
getDocumentRootIdFromPoint,
persistRootStateBrowser,
getInsertedWindowElementIds,
getInsertedDocumentElementIds,
getOpenFile,
addOpenFile,
upsertOpenFile,
removeTemporaryOpenFiles,
setNextOpenFile,
updateOpenFile,
deselectRootProjectFiles,
setHoveringSyntheticNodeIds,
setRootStateSyntheticNodeExpanded,
setSelectedFileNodeIds,
InsertFileType,
setInsertFile,
undo,
redo,
openSyntheticWindow,
openSyntheticNodeOriginWindow,
setRootStateSyntheticNodeLabelEditing,
getEditorWithActiveFileUri,
openEditorFileUri,
openSecondEditor,
getActiveEditor,
getEditorWithFileUri,
updateEditor,
getSyntheticWindowBounds,
centerEditorCanvas,
snapBounds,
getCanvasMouseTargetNodeIdFromPoint,
isSelectionMovable
} from "../state";
import {
updateSyntheticBrowser,
addSyntheticWindow,
createSyntheticWindow,
SyntheticNode,
evaluateDependencyEntry,
createSyntheticDocument,
getSyntheticWindow,
getSyntheticNodeBounds,
getSyntheticDocumentWindow,
persistSyntheticItemPosition,
persistSyntheticItemBounds,
SyntheticObjectType,
getSyntheticDocumentById,
persistNewComponent,
persistDeleteSyntheticItems,
SyntheticDocument,
SyntheticBrowser,
persistPasteSyntheticNodes,
getSyntheticSourceNode,
getSyntheticNodeById,
SyntheticWindow,
getModifiedDependencies,
persistRawCSSText,
getSyntheticNodeDocument,
getSyntheticNodeWindow,
expandSyntheticNode,
persistMoveSyntheticNode,
getSyntheticOriginSourceNodeUri,
getSyntheticOriginSourceNode,
findSourceSyntheticNode,
persistToggleSlotContainer,
updateSyntheticNodeAttributes,
persistChangeNodeLabel,
persistChangeNodeType,
persistTextValue,
persistInsertNewComponentVariant,
persistComponentVariantChanged,
persistRemoveComponentVariant,
getSyntheticNodeSourceComponent,
persistSetElementVariants,
PCSourceTagNames,
persistInsertNode,
PCVisibleNode,
PCComponentNode,
persistConvertNodeToComponent,
isSyntheticDocumentRoot,
PCTextNode,
PCElement,
SyntheticElement,
createPCElement,
createPCTextNode
} from "paperclip";
import {
getTreeNodePath,
getTreeNodeFromPath,
getFilePath,
File,
getFilePathFromNodePath,
EMPTY_OBJECT,
TreeNode,
StructReference,
roundBounds,
scaleInnerBounds,
moveBounds,
keepBoundsAspectRatio,
keepBoundsCenter,
Bounded,
Struct,
Bounds,
getBoundsSize,
shiftBounds,
flipPoint,
diffArray,
getFileFromUri,
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,
mergeNodeAttributes,
FSItem
} from "tandem-common";
import { difference, pull, clamp, merge } from "lodash";
import { select } from "redux-saga/effects";
import { PCSourceNamespaces } from "paperclip";
import { isNullOrUndefined } from "util";
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 = 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.attributes.core.uri;
state = setSelectedFileNodeIds(state, node.id);
state = setFileExpanded(node, true, state);
if (!isDirectory(node)) {
state = setActiveFilePath(uri, state);
return state;
}
return state;
}
case QUICK_SEARCH_ITEM_CLICKED: {
const { file } = action as QuickSearchItemClicked;
const uri = file.attributes.core.uri;
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.attributes.core.expanded, state);
return state;
}
case FILE_NAVIGATOR_ITEM_DOUBLE_CLICKED: {
const { node } = action as FileNavigatorItemClicked;
const uri = node.attributes.core.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;
const { width = 400, height = 300 } =
element.getBoundingClientRect() || {};
state = updateEditorCanvas(
{
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.attributes.core.uri;
const nodeUri = node.attributes.core.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.attributes.core.uri;
state = updateRootState(
{
projectDirectory: updateNestedNode(
targetDir,
state.projectDirectory,
targetNode => {
return appendChildNode(
mergeNodeAttributes(node, {
[FSItemNamespaces.CORE]: {
uri: nodeUri.replace(parentUri, targetUri)
}
}),
targetNode
);
}
)
},
state
);
return state;
}
case NEW_FILE_ADDED: {
const { directoryId, basename, fileType } = action as NewFileAdded;
const directory: Directory = getNestedTreeNodeById(
directoryId,
state.projectDirectory
);
let uri = directory.attributes.core.uri + basename;
if (fileType === "directory") {
uri += "/";
}
state = updateRootState(
{
insertFileInfo: null,
projectDirectory: updateNestedNode(
directory,
state.projectDirectory,
dir => {
return {
...dir,
children: [
...dir.children,
createTreeNode(fileType, {
[FSItemNamespaces.CORE]: {
[FileAttributeNames.URI]: uri,
[FileAttributeNames.BASENAME]: basename
}
})
]
};
}
)
},
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.browser
);
state = persistRootStateBrowser(
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.browser
) as PCComponentNode;
state = persistRootStateBrowser(
browser => persistInsertNewComponentVariant(value, sourceNode, browser),
state
);
return state;
}
case COMPONENT_VARIANT_NAME_DEFAULT_TOGGLE_CLICK: {
const { name, value } = action as ComponentVariantNameDefaultToggleClick;
const sourceComponent = getSyntheticNodeSourceComponent(
state.selectedNodeIds[0],
state.browser
);
state = persistRootStateBrowser(
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 = getSyntheticNodeSourceComponent(
state.selectedNodeIds[0],
state.browser
);
state = persistRootStateBrowser(
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 = getSyntheticNodeSourceComponent(
state.selectedNodeIds[0],
state.browser
);
state = persistRootStateBrowser(
browser =>
persistComponentVariantChanged(
{
[PCSourceNamespaces.CORE]: { name: newName }
},
oldName,
sourceComponentNode.id,
browser
),
state
);
return state;
}
case PC_LAYER_MOUSE_OVER: {
const { node } = action as TreeLayerMouseOver;
state = setHoveringSyntheticNodeIds(state, node.id);
return state;
}
case PC_LAYER_DOUBLE_CLICK: {
const { node } = action as TreeLayerClick;
state = setRootStateSyntheticNodeLabelEditing(node.id, true, state);
return state;
}
case PC_LAYER_EDIT_LABEL_BLUR: {
const { node } = action as TreeLayerClick;
state = setRootStateSyntheticNodeLabelEditing(node.id, false, state);
return state;
}
case PC_LAYER_LABEL_CHANGED: {
const { label, node } = action as TreeLayerLabelChanged;
state = setRootStateSyntheticNodeLabelEditing(node.id, false, state);
state = persistRootStateBrowser(
browser =>
persistChangeNodeLabel(
label,
getSyntheticSourceNode(node.id, browser) as PCVisibleNode,
browser
),
state
);
return state;
}
case PC_LAYER_DROPPED_NODE: {
const { node, targetNode, offset } = action as TreeLayerDroppedNode;
const oldDocument = getSyntheticNodeDocument(
targetNode.id,
state.browser
);
const targetNodeWindow = getSyntheticNodeWindow(
targetNode.id,
state.browser
);
state = persistRootStateBrowser(
browser =>
persistMoveSyntheticNode(
node as SyntheticNode,
targetNode.id,
offset,
browser
),
state
);
// if (!getSyntheticNodeById(node.id, state.browser)) {
// state = setSelectedSyntheticNodeIds(state, ...getInsertedDocumentElementIds(oldDocument, state.browser));
// }
state = setActiveFilePath(targetNodeWindow.location, state);
// deselect until fixed -- exception thrown in various conditions
// where synthetic node no longer exists.
state = setSelectedSyntheticNodeIds(state);
return state;
}
case PC_LAYER_MOUSE_OUT: {
const { node } = action as TreeLayerMouseOut;
state = setHoveringSyntheticNodeIds(state);
return state;
}
case PC_LAYER_CLICK: {
const { node, sourceEvent } = action as TreeLayerClick;
if (sourceEvent.altKey) {
state = openSyntheticNodeOriginWindow(node.id, state);
} else {
const window = getSyntheticNodeWindow(node.id, state.browser);
state = setActiveFilePath(window.location, state);
state = setSelectedSyntheticNodeIds(
state,
...(sourceEvent.shiftKey
? [...state.selectedNodeIds, node.id]
: [node.id])
);
}
return state;
}
case PC_LAYER_EXPAND_TOGGLE_CLICK: {
const { node } = action as TreeLayerExpandToggleClick;
state = setRootStateSyntheticNodeExpanded(
node.id,
!(node as FSItem).attributes.core.expanded,
state
);
return state;
}
case OPEN_FILE_ITEM_CLOSE_CLICKED: {
// TODO - flag confirm remove state
const { uri } = action as OpenFilesItemClick;
return setNextOpenFile(
updateRootState(
{
openFiles: state.openFiles.filter(openFile => openFile.uri !== uri)
},
state
)
);
}
case EDITOR_TAB_CLICKED: {
const { uri } = action as EditorTabClicked;
return openEditorFileUri(uri, state);
}
case DEPENDENCY_ENTRY_LOADED: {
const { entry, graph } = action as DependencyEntryLoaded;
state = updateRootStateSyntheticBrowser(
{
graph: {
...(state.browser.graph || EMPTY_OBJECT),
...graph
}
},
state
);
state = openSyntheticWindow(entry.uri, state);
state = centerEditorCanvas(state, entry.uri);
return state;
}
case DOCUMENT_RENDERED: {
const { info, documentId, nativeMap } = action as DocumentRendered;
return updateRootStateSyntheticWindowDocument(
documentId,
{
nativeNodeMap: nativeMap,
computed: info
},
state
);
}
}
return state;
};
export const canvasReducer = (state: RootState, action: Action) => {
switch (action.type) {
case RESIZER_MOVED: {
const { point: newPoint } = action as ResizerMoved;
state = updateEditorCanvas(
{
movingOrResizing: true
},
state.activeEditorFilePath,
state
);
if (isSelectionMovable(state)) {
const selectionBounds = getSelectionBounds(state);
const nodeId = state.selectedNodeIds[0];
const document = getSyntheticNodeDocument(nodeId, state.browser);
let movedBounds = moveBounds(selectionBounds, newPoint);
for (const nodeId of state.selectedNodeIds) {
const itemBounds = getSyntheticNodeBounds(nodeId, state.browser);
const newBounds = roundBounds(
scaleInnerBounds(itemBounds, selectionBounds, movedBounds)
);
state = updateRootSyntheticPosition(newBounds, nodeId, state);
}
}
return state;
}
case RESIZER_MOUSE_DOWN: {
const { sourceEvent } = action as ResizerMouseDown;
if (sourceEvent.metaKey) {
state = openSyntheticNodeOriginWindow(state.selectedNodeIds[0], state);
}
return state;
}
case RESIZER_STOPPED_MOVING: {
const { point } = action as ResizerMoved;
const oldGraph = state.browser.graph;
if (isSelectionMovable(state)) {
const selectionBounds = getSelectionBounds(state);
state = persistRootStateBrowser(browser => {
return state.selectedNodeIds.reduce((state, nodeId) => {
const itemBounds = getSyntheticNodeBounds(nodeId, browser);
const newBounds = roundBounds(
scaleInnerBounds(
itemBounds,
selectionBounds,
moveBounds(selectionBounds, point)
)
);
return persistSyntheticItemPosition(newBounds, nodeId, state);
}, browser);
}, state);
}
state = updateEditorCanvas(
{
movingOrResizing: false
},
state.activeEditorFilePath,
state
);
return state;
}
case CANVAS_WHEEL: {
const {
metaKey,
ctrlKey,
deltaX,
deltaY,
canvasHeight,
canvasWidth
} = action as CanvasWheel;
const editor = getActiveEditor(state);
let translate = editor.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
),
editor.canvas.mousePosition
);
} else {
translate = {
...translate,
left: translate.left - deltaX,
top: translate.top - deltaY
};
}
return updateEditorCanvas(
{ translate, smooth: false },
editor.activeFilePath,
state
);
}
case CANVAS_DROPPED_REGISTERED_COMPONENT: {
let {
item,
point,
editorUri
} = action as CanvasDroppedRegisteredComponent;
const targetNodeId = getCanvasMouseTargetNodeIdFromPoint(state, point);
let sourceNode: TreeNode<any> = cloneTreeNode(item.template);
let targetSourceId: string;
if (targetNodeId) {
targetSourceId = getSyntheticSourceNode(targetNodeId, state.browser).id;
} else {
targetSourceId = state.browser.graph[editorUri].content.id;
point = normalizePoint(
getEditorWithFileUri(editorUri, state).canvas.translate,
point
);
sourceNode = mergeNodeAttributes(sourceNode, {
[PCSourceNamespaces.CORE]: {
style: {
left: point.left,
top: point.top,
width: 200,
height: 200
}
}
});
}
return persistRootStateBrowser(
browser =>
persistInsertNode(
sourceNode,
targetSourceId,
TreeMoveOffset.APPEND,
browser
),
state
);
}
case SHORTCUT_ZOOM_IN_KEY_DOWN: {
const editor = getActiveEditor(state);
state = setCanvasZoom(
normalizeZoom(editor.canvas.translate.zoom) * 2,
false,
editor.activeFilePath,
state
);
return state;
}
case SHORTCUT_ZOOM_OUT_KEY_DOWN: {
const editor = getActiveEditor(state);
state = setCanvasZoom(
normalizeZoom(editor.canvas.translate.zoom) / 2,
false,
editor.activeFilePath,
state
);
return state;
}
case CANVAS_MOUSE_MOVED: {
const {
sourceEvent: { pageX, pageY }
} = action as WrappedEvent<React.MouseEvent<any>>;
state = updateEditorCanvas(
{ mousePosition: { left: pageX, top: pageY } },
state.activeEditorFilePath,
state
);
let targetNodeId: string;
const editor = getActiveEditor(state);
if (!editor.canvas.movingOrResizing) {
targetNodeId = getCanvasMouseTargetNodeId(
state,
action as CanvasToolOverlayMouseMoved
);
}
state = updateRootState(
{
hoveringNodeIds: targetNodeId ? [targetNodeId] : []
},
state
);
return state;
}
case CANVAS_DRAGGED_OVER: {
const {
sourceEvent: { pageX, pageY }
} = action as WrappedEvent<React.MouseEvent<any>>;
state = updateEditorCanvas(
{ mousePosition: { left: pageX, top: pageY } },
state.activeEditorFilePath,
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 = getActiveEditor(state);
targetNodeId = getCanvasMouseTargetNodeId(
state,
action as CanvasToolOverlayMouseMoved,
node => node.name !== PCSourceTagNames.TEXT
);
const node = getSyntheticNodeById(targetNodeId, state.browser);
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 metaKey = sourceEvent.metaKey;
const editor = getActiveEditor(state);
// do not allow selection while window is panning (scrolling)
if (editor.canvas.panning || editor.canvas.movingOrResizing) return state;
const targetNodeId = getCanvasMouseTargetNodeId(
state,
action as CanvasToolOverlayMouseMoved
);
if (!targetNodeId) {
return state;
}
if (altKey) {
state = openSyntheticNodeOriginWindow(targetNodeId, state);
return state;
}
if (!altKey) {
state = handleArtboardSelectionFromAction(
state,
targetNodeId,
action as CanvasToolOverlayMouseMoved
);
state = updateEditorCanvas(
{
secondarySelection: false
},
editor.activeFilePath,
state
);
return state;
}
return state;
}
case RESIZER_PATH_MOUSE_MOVED: {
state = updateEditorCanvas(
{
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 = updateRootSyntheticBounds(
getNewSyntheticNodeBounds(newBounds, nodeId, state),
nodeId,
state
);
}
return state;
}
case RESIZER_PATH_MOUSE_STOPPED_MOVING: {
state = updateEditorCanvas(
{
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 = persistRootStateBrowser(browser => {
return state.selectedNodeIds.reduce(
(browserState, nodeId) =>
persistSyntheticItemBounds(
getNewSyntheticNodeBounds(newBounds, nodeId, state),
nodeId,
browserState
),
state.browser
);
}, state);
return state;
}
case RAW_CSS_TEXT_CHANGED: {
const { value: cssText } = action as RawCSSTextChanged;
return persistRootStateBrowser(browser => {
return state.selectedNodeIds.reduce(
(browserState, nodeId) =>
persistRawCSSText(
cssText,
nodeId,
state.selectedComponentVariantName,
browserState
),
state.browser
);
}, state);
}
case SLOT_TOGGLE_CLICK: {
state = persistRootStateBrowser(browser => {
return persistToggleSlotContainer(
getSyntheticSourceNode(state.selectedNodeIds[0], state.browser).id,
browser
);
}, state);
return state;
}
case NATIVE_NODE_TYPE_CHANGED: {
const { nativeType } = action as NativeNodeTypeChanged;
state = persistRootStateBrowser(browser => {
return persistChangeNodeType(
nativeType,
getSyntheticSourceNode(
state.selectedNodeIds[0],
state.browser
) as PCElement,
browser
);
}, state);
return state;
}
case TEXT_VALUE_CHANGED: {
const { value } = action as TextValueChanged;
state = persistRootStateBrowser(browser => {
return persistTextValue(
value,
getSyntheticSourceNode(
state.selectedNodeIds[0],
state.browser
) as PCTextNode,
browser
);
}, state);
return state;
}
case CANVAS_TOOL_ARTBOARD_TITLE_CLICKED: {
const {
documentId,
sourceEvent
} = action as CanvasToolArtboardTitleClicked;
const window = getSyntheticDocumentWindow(documentId, state.browser);
state = updateEditorCanvas({ smooth: false }, window.location, state);
return handleArtboardSelectionFromAction(
state,
getSyntheticDocumentById(documentId, state.browser).root.id,
action as CanvasToolArtboardTitleClicked
);
}
case CANVAS_TOOL_WINDOW_BACKGROUND_CLICKED: {
return setSelectedSyntheticNodeIds(state);
}
case INSERT_TOOL_FINISHED: {
let { point, fileUri } = action as InsertToolFinished;
const editor = getEditorWithActiveFileUri(fileUri, state);
const toolType = state.toolType;
state = setTool(null, state);
switch (toolType) {
// case ToolType.ARTBOARD: {
// state = persistRootStateBrowser(browser => persistNewComponent(point, fileUri, browser), state);
// const newActiveWindow = getSyntheticWindow(fileUri, state.browser);
// const newDocument = newActiveWindow.documents[newActiveWindow.documents.length - 1];
// state = setSelectedSyntheticNodeIds(state, newDocument.root.id);
// return state;
// }
case ToolType.ELEMENT: {
return persistInsertNodeFromPoint(
createPCElement({
[PCSourceNamespaces.CORE]: {}
}),
fileUri,
point,
state
);
}
case ToolType.TEXT: {
return persistInsertNodeFromPoint(
createPCTextNode({
[PCSourceNamespaces.CORE]: {
value: "edit me"
}
}),
fileUri,
point,
state
);
}
}
}
}
return state;
};
const INSERT_ARTBOARD_WIDTH = 100;
const INSERT_ARTBOARD_HEIGHT = 100;
const persistInsertNodeFromPoint = (
node: PCVisibleNode,
fileUri: string,
point: Point,
state: RootState
) => {
const targetNodeId = getCanvasMouseTargetNodeIdFromPoint(state, point);
const oldWindow = getSyntheticWindow(fileUri, state.browser);
const dep = state.browser.graph[oldWindow.location].content.id;
const document =
targetNodeId && getSyntheticNodeDocument(targetNodeId, state.browser);
state = persistRootStateBrowser(browser => {
return persistInsertNode(
targetNodeId
? node
: mergeNodeAttributes(node, {
[PCSourceNamespaces.CORE]: {
style: {
...shiftPoint(
normalizePoint(
getEditorWithFileUri(fileUri, state).canvas.translate,
point
),
{
left: -(INSERT_ARTBOARD_WIDTH / 2),
top: -(INSERT_ARTBOARD_HEIGHT / 2)
}
),
width: INSERT_ARTBOARD_WIDTH,
height: INSERT_ARTBOARD_HEIGHT
}
}
}),
targetNodeId
? getSyntheticSourceNode(targetNodeId, browser).id
: browser.graph[oldWindow.location].content.id,
TreeMoveOffset.APPEND,
browser
);
}, state);
state = setSelectedSyntheticNodeIds(
state,
...getInsertedWindowElementIds(
oldWindow,
document && document.id,
state.browser
)
);
return state;
};
const setFileExpanded = (node: FSItem, value: boolean, state: RootState) => {
state = updateRootState(
{
projectDirectory: updateNestedNode(node, state.projectDirectory, node =>
mergeNodeAttributes(node, {
[FSItemNamespaces.CORE]: {
expanded: value
}
})
)
},
state
);
return state;
};
const getNewSyntheticNodeBounds = (
newBounds: Bounds,
nodeId: string,
state: RootState
) => {
const currentBounds = getSelectionBounds(state);
const innerBounds = getSyntheticNodeBounds(nodeId, state.browser);
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_CONVERT_TO_COMPONENT_KEY_DOWN: {
if (state.selectedNodeIds.length > 1) {
return state;
}
state = persistRootStateBrowser(
browser =>
persistConvertNodeToComponent(state.selectedNodeIds[0], browser),
state
);
state = setSelectedSyntheticNodeIds(state);
return state;
}
case SHORTCUT_ESCAPE_KEY_DOWN: {
if (isInputSelected(state)) {
return state;
}
if (state.toolType) {
return setTool(null, state);
} else {
state = setSelectedSyntheticNodeIds(state);
state = setSelectedFileNodeIds(state);
state = updateRootState({ insertFileInfo: null }, state);
return state;
}
}
case SHORTCUT_DELETE_KEY_DOWN: {
if (isInputSelected(state)) {
return state;
}
const selection = state.selectedNodeIds;
return setSelectedSyntheticNodeIds(
persistRootStateBrowser(
browser => persistDeleteSyntheticItems(selection, state.browser),
state
)
);
}
}
return state;
};
const clipboardReducer = (state: RootState, action: Action) => {
switch (action.type) {
case SYNTHETIC_NODES_PASTED: {
const { clips } = action as SyntheticNodesPasted;
let targetSourceNode: TreeNode<any>;
if (state.selectedNodeIds.length) {
const nodeId = state.selectedNodeIds[0];
targetSourceNode = getSyntheticSourceNode(nodeId, state.browser);
} else {
targetSourceNode =
state.browser.graph[state.activeEditorFilePath].content;
}
const oldWindow = getSyntheticWindow(
state.activeEditorFilePath,
state.browser
);
state = persistRootStateBrowser(
browser =>
persistPasteSyntheticNodes(
state.activeEditorFilePath,
targetSourceNode.id,
clips,
browser
),
state
);
// TODO - selected new element IDS that are within the target synthetic node
// const elementIds = getInsertedWindowElementIds(oldWindow, state.browser);
// state = setSelectedSyntheticNodeIds(state, elementIds[elementIds.length - 1]);
return state;
}
}
return state;
};
const isDroppableNode = (node: SyntheticNode) => {
return (
node.name !== "text" &&
!/input/.test(String((node as SyntheticElement).attributes.core.nativeType))
);
};
const handleArtboardSelectionFromAction = <
T extends { sourceEvent: React.MouseEvent<any> }
>(
state: RootState,
nodeId: string,
event: T
) => {
const { sourceEvent } = event;
state = setRootStateSyntheticNodeExpanded(nodeId, true, state);
return setSelectedSyntheticNodeIds(
state,
...(event.sourceEvent.shiftKey
? [...state.selectedNodeIds, nodeId]
: [nodeId])
);
};
// const resizeFullScreenArtboard = (state: RootState, width: number, height: number) => {
// const workspace = getSelectedWorkspace(state);
// if (workspace.stage.fullScreen && workspace.stage.container) {
// // TODO - do not all getBoundingClientRect here. Dimensions need to be
// return updateArtboardSize(state, workspace.stage.fullScreen.documentId, width, height);
// }
// return state;
// }
const setCanvasZoom = (
zoom: number,
smooth: boolean = true,
uri: string,
state: RootState
) => {
const editor = getEditorWithFileUri(uri, state);
return updateEditorCanvas(
{
translate: centerTransformZoom(
editor.canvas.translate,
editor.canvas.container.getBoundingClientRect(),
clamp(zoom, MIN_ZOOM, MAX_ZOOM),
editor.canvas.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);
};