UNPKG

tandem-front-end

Version:

Visual editor for web components

1,808 lines (1,586 loc) 42.9 kB
import * as path from "path"; import { arraySplice, Directory, memoize, EMPTY_ARRAY, Point, Translate, Bounds, pointIntersectsBounds, getSmallestBounds, mergeBounds, getNestedTreeNodeById, stripProtocol, getParentTreeNode, TreeNode, getBoundsSize, centerTransformZoom, createZeroBounds, FSItem, getTreeNodePath, KeyValue, updateNestedNodeTrail, getTreeNodeFromPath, EMPTY_OBJECT, TreeNodeUpdater, findNestedNode, findTreeNodeParent, containsNestedTreeNodeById, updateProperties, addProtocol, FILE_PROTOCOL } from "tandem-common"; import { SyntheticVisibleNode, PCEditorState, getSyntheticSourceNode, getPCNodeDependency, getSyntheticNodeById, getSyntheticVisibleNodeDocument, Frame, getSyntheticDocumentDependencyUri, getSyntheticVisibleNodeRelativeBounds, updateDependencyGraph, updateSyntheticVisibleNodeMetadata, isSyntheticVisibleNodeMovable, isSyntheticVisibleNodeResizable, diffTreeNode, TreeNodeOperationalTransformType, PCSourceTagNames, patchTreeNode, SyntheticDocument, updateSyntheticDocument, getFramesByDependencyUri, PCVisibleNode, PCVariant, TreeNodeOperationalTransform, getPCNode, findInstanceOfPCNode, isPCComponentInstance, PCComponent, PCModule, SyntheticNode, isSyntheticContentNode, PCNode, getPCNodeModule, getSyntheticInstancePath, syntheticNodeIsInShadow, PCComponentInstanceElement, isSlot, Dependency, DependencyGraph, getModifiedDependencies, PCConfig, inspectorNodeInShadow, getInspectorContentNodeContainingChild, getInspectorNodeParentShadow, getInspectorSourceNode, InspectorTreeNodeName, expandInspectorNodeById, getInspectorContentNode, getInspectorSyntheticNode, getInspectorNodeBySourceNodeId, getSyntheticDocumentByDependencyUri } from "paperclip"; import { CanvasToolOverlayMouseMoved, CanvasToolOverlayClicked, CanvasDroppedItem } from "../actions"; import { uniq, values, clamp } from "lodash"; import { FSSandboxRootState, queueOpenFile, hasFileCacheItem } from "fsbox"; import { refreshInspectorTree, InspectorTreeBaseNode, expandSyntheticInspectorNode, getSyntheticInspectorNode, evaluateModuleInspector, InspectorNode, getInsertableInspectorNode } from "paperclip"; import { ContextMenuItem, ContextMenu } from "../components/context-menu/view.pc"; import { Action } from "redux"; export enum ToolType { TEXT, POINTER, COMPONENT, ELEMENT } export type ProjectOptions = { allowCascadeFonts?: boolean; }; export type ProjectScripts = { previewServer?: string; build?: string; openApp?: string; }; export type ProjectConfig = { scripts?: ProjectScripts; // relative path to main file mainFilePath?: string; // path to PC file where all global data is stored globalFilePath?: string; options?: ProjectOptions; } & PCConfig; export type ProjectInfo = { config: ProjectConfig; path: string; }; export enum FrameMode { PREVIEW = "preview", DESIGN = "design" } export const REGISTERED_COMPONENT = "REGISTERED_COMPONENT"; export const SNAPSHOT_GAP = 50; export enum SyntheticVisibleNodeMetadataKeys { EDITING_LABEL = "editingLabel" } export type ScriptProcessLog = { error: boolean; text: string; }; export type ScriptProcess = { id: string; script: string; logs: ScriptProcessLog[]; label: string; }; export type RegisteredComponent = { uri?: string; tagName: string; template: TreeNode<any>; }; export type Canvas = { backgroundColor: string; panning?: boolean; translate: Translate; }; export type GraphHistoryItem = { snapshot?: DependencyGraph; transforms?: KeyValue<TreeNodeOperationalTransform[]>; }; export type GraphHistory = { index: number; items: GraphHistoryItem[]; }; export type Editor = { canvas: Canvas; }; export type EditorWindow = { tabUris: string[]; activeFilePath?: string; mousePosition?: Point; movingOrResizing?: boolean; smooth?: boolean; secondarySelection?: boolean; fullScreen?: boolean; container?: HTMLElement; }; export enum ConfirmType { ERROR, WARNING, SUCCESS } export type Confirm = { type: ConfirmType; message: string; }; export type Prompt = { label: string; defaultValue?: string; okActionType: string; }; export type FontFamily = { name: string; }; export enum QuickSearchResultType { URI = "uri", COMPONENT = "component" } export type BaseQuickSearchResult<TType extends QuickSearchResultType> = { label: string; description: string; type: TType; }; export enum ContextMenuOptionType { GROUP = "group", ITEM = "item" } export type ContextMenuItem = { type: ContextMenuOptionType.ITEM; label: string; action: Action; keyCombo?: string; }; export type ContextMenuGroup = { type: ContextMenuOptionType.GROUP; options: ContextMenuItem[]; }; export type ContextMenuOption = ContextMenuGroup | ContextMenuItem; export type QuickSearchUriResult = { uri: string; } & BaseQuickSearchResult<QuickSearchResultType.URI>; export type QuickSearchComponentResult = { componentId: string; } & BaseQuickSearchResult<QuickSearchResultType.COMPONENT>; export type QuickSearchResult = | QuickSearchUriResult | QuickSearchComponentResult; export type QuickSearch = { filter: string; // TODO - will eventually need to interface things like components matches: QuickSearchResult[]; }; export enum EditMode { PRIMARY, SECONDARY } export type ProjectTemplate = { id: string; icon: string; label: string; description: string; }; export type ProjectFileCreator = (options: Object) => KeyValue<string>; export enum RootReadyType { LOADING, LOADED, UNLOADING } export type Unloader = { id: string; completed: boolean; }; export type RootState = { editorWindows: EditorWindow[]; mount: Element; openFiles: OpenFile[]; toolType?: ToolType; activeEditorFilePath?: string; quickSearch?: QuickSearch; editMode: EditMode; showConfigureBuildModal?: boolean; scriptProcesses: ScriptProcess[]; unloaders: Unloader[]; buildScriptProcessId?: string; // defined by context menu editingBasenameUri?: string; confirm?: Confirm; prompt?: Prompt; selectedDirectoryPath?: string; prevGraph?: DependencyGraph; showSidebar?: boolean; showBottomGutter?: boolean; customChrome: boolean; renameInspectorNodeId?: string; // TODO - may need to be moved to EditorWindow selectedVariant?: PCVariant; recenterUriAfterEvaluation?: string; openedMain?: boolean; // seaprate from synthetic & AST since it represents both. May also have separate // tooling selectedInspectorNodes: InspectorNode[]; hoveringInspectorNodes: InspectorNode[]; fontFamilies?: FontFamily[]; sourceNodeInspector: InspectorTreeBaseNode<any>; sourceNodeInspectorMap: KeyValue<string[]>; // used for syncing sourceNodeInspectorGraph?: DependencyGraph; // TODO - should be ref selectedFileNodeIds: string[]; selectedComponentVariantName?: string; readyType?: RootReadyType; projectDirectory?: Directory; projectInfo?: ProjectInfo; history: GraphHistory; showQuickSearch?: boolean; selectedComponentId?: string; queuedDndInfo?: CanvasDroppedItem; } & PCEditorState & FSSandboxRootState; // TODO - change this to Editor export type OpenFile = { temporary: boolean; newContent?: Buffer; uri: string; canvas: Canvas; }; export const updateRootState = <TState extends RootState>( properties: Partial<TState>, root: TState ): TState => updateProperties(properties, root); export const deselectRootProjectFiles = (state: RootState) => updateRootState( { selectedFileNodeIds: [] }, state ); export const persistRootState = ( persistPaperclipState: (state: RootState) => RootState, state: RootState ) => { const oldGraph = state.prevGraph || state.graph; state = updateRootState(persistPaperclipState(state), state); state = keepActiveFileOpen(state); const modifiedDeps = getModifiedDependencies(state.graph, oldGraph); state = addHistory(oldGraph, state.graph, state); state = modifiedDeps.reduce( (state, dep: Dependency<any>) => setOpenFileContent(dep, state), state ); state = refreshModuleInspectorNodes(state); return state; }; const getUpdatedInspectorNodes = ( newState: RootState, oldState: RootState, scope: InspectorNode ) => { const MAX_DEPTH = 1; const oldScope: InspectorNode = getNestedTreeNodeById( scope.id, oldState.sourceNodeInspector ); const newScope: InspectorNode = getNestedTreeNodeById( scope.id, newState.sourceNodeInspector ); let newInspectorNodes: InspectorNode[] = []; let model = oldScope; diffTreeNode(oldScope, newScope).forEach(ot => { const target = getTreeNodeFromPath(ot.nodePath, model); model = patchTreeNode([ot], model); if (ot.nodePath.length > MAX_DEPTH) { return; } // TODO - will need to check if new parent is not in an instance of a component. // Will also need to consider child overrides though. if (ot.type === TreeNodeOperationalTransformType.INSERT_CHILD) { newInspectorNodes.push(ot.child as InspectorNode); } else if ( ot.type === TreeNodeOperationalTransformType.SET_PROPERTY && ot.name === "source" ) { newInspectorNodes.push(target); } }); // ensure that content nodes are not selected. newInspectorNodes = newInspectorNodes.map(node => { return node.name === InspectorTreeNodeName.CONTENT ? node.children[0] : node; }); return uniq(newInspectorNodes); }; export const selectInsertedSyntheticVisibleNodes = ( oldState: RootState, newState: RootState, scope: InspectorNode ) => { return setSelectedInspectorNodes( newState, ...getUpdatedInspectorNodes(newState, oldState, scope) ); }; export const getInsertableSourceNodeFromSyntheticNode = memoize( ( node: SyntheticVisibleNode, document: SyntheticDocument, graph: DependencyGraph ) => { const sourceNode = getSyntheticSourceNode(node, graph); if (syntheticNodeIsInShadow(node, document, graph)) { const module = getPCNodeModule(sourceNode.id, graph); const instancePath = getSyntheticInstancePath(node, document, graph); const instancePCComponent = getPCNode( (getPCNode(instancePath[0], graph) as PCComponentInstanceElement).is, graph ); const slot = findTreeNodeParent(sourceNode.id, module, (parent: PCNode) => isSlot(parent) ); return slot && containsNestedTreeNodeById(slot.id, instancePCComponent) ? slot : null; } else if ( sourceNode.name !== PCSourceTagNames.COMPONENT_INSTANCE && sourceNode.name !== PCSourceTagNames.TEXT ) { return sourceNode; } return null; } ); export const getInsertableSourceNodeScope = memoize( ( insertableSourceNode: PCNode, relative: SyntheticVisibleNode, rootInspectorNode: InspectorNode, document: SyntheticDocument, graph: DependencyGraph ): InspectorNode => { const containsSource = (current: SyntheticVisibleNode) => { const sourceNode = getSyntheticSourceNode(current, graph); return containsNestedTreeNodeById(insertableSourceNode.id, sourceNode); }; if (containsSource(relative)) { return getSyntheticInspectorNode( relative, document, rootInspectorNode, graph ); } return getSyntheticInspectorNode( findTreeNodeParent(relative.id, document, containsSource), document, rootInspectorNode, graph ); } ); export const teeHistory = (state: RootState) => { if (state.prevGraph) { return state; } return { ...state, prevGraph: state.graph }; }; export const getBuildScriptProcess = (state: RootState) => state.scriptProcesses.find( process => process.id === state.buildScriptProcessId ); export const getSyntheticRelativesOfParentSource = memoize( ( node: SyntheticVisibleNode, parentSourceNode: PCNode, documents: SyntheticDocument[], graph: DependencyGraph ) => { const document = getSyntheticVisibleNodeDocument(node.id, documents); const module = getPCNodeModule(parentSourceNode.id, graph); const relatedParent = findTreeNodeParent( node.id, document, (parent: SyntheticNode) => { const sourceNode = getSyntheticSourceNode(parent, graph); return ( getParentTreeNode(sourceNode.id, module).id === parentSourceNode.id ); } ); const relatedParentParent = getParentTreeNode(relatedParent.id, document); return relatedParentParent.children.filter((child: SyntheticNode) => { const sourceNode = getSyntheticSourceNode(child, graph); return ( getParentTreeNode(sourceNode.id, module).id === parentSourceNode.id ); }) as SyntheticVisibleNode[]; } ); const setOpenFileContent = (dep: Dependency<any>, state: RootState) => updateOpenFile( { temporary: false, newContent: new Buffer(JSON.stringify(dep.content, null, 2), "utf8") }, dep.uri, state ); const addHistory = ( oldGraph: DependencyGraph, newGraph: DependencyGraph, state: RootState ) => { const items = state.history.items.slice(0, state.history.index); const prevSnapshotItem: GraphHistoryItem = getNextHistorySnapshot(items); if ( !items.length || (prevSnapshotItem && items.length - items.indexOf(prevSnapshotItem) > SNAPSHOT_GAP) ) { items.push({ snapshot: oldGraph }); } const currentGraph = getGraphAtHistoricPoint(items); const modifiedDeps = getModifiedDependencies(newGraph, currentGraph); const transforms = {}; for (const dep of modifiedDeps) { transforms[dep.uri] = diffTreeNode( currentGraph[dep.uri].content, dep.content, EMPTY_OBJECT ); } return updateRootState( { prevGraph: null, history: { index: items.length + 1, items: [ ...items, { transforms } ] } }, state ); }; export const getGlobalFileUri = (info: ProjectInfo) => { const globalRelativeFilePath = (info && info.config.globalFilePath) || info.config.mainFilePath; return ( globalRelativeFilePath && addProtocol( FILE_PROTOCOL, path.join(path.dirname(info.path), globalRelativeFilePath) ) ); }; const getNextHistorySnapshot = (items: GraphHistoryItem[]) => { for (let i = items.length; i--; ) { const prevHistoryItem = items[i]; if (prevHistoryItem.snapshot) { return items[i]; } } }; const getGraphAtHistoricPoint = ( allItems: GraphHistoryItem[], index: number = allItems.length ) => { const items = allItems.slice(0, index); const snapshotItem = getNextHistorySnapshot(items); const snapshotIndex = items.indexOf(snapshotItem); const transformItems = items.slice(snapshotIndex + 1); const graphSnapshot = transformItems.reduce((graph, { transforms }) => { const newGraph = { ...graph }; for (const uri in transforms) { newGraph[uri] = { ...newGraph[uri], content: patchTreeNode(transforms[uri], graph[uri].content) }; } return newGraph; }, snapshotItem.snapshot) as DependencyGraph; return graphSnapshot; }; const moveDependencyRecordHistory = ( pos: number, state: RootState ): RootState => { if (!state.history.items.length) { return state; } const newIndex = clamp( state.history.index + pos, 1, state.history.items.length ); const graphSnapshot = getGraphAtHistoricPoint(state.history.items, newIndex); state = updateDependencyGraph(graphSnapshot, state); state = refreshModuleInspectorNodes(state); state = updateRootState( { history: { ...state.history, index: newIndex } }, state ); return state; }; export const isUnsaved = (state: RootState) => state.openFiles.some(openFile => Boolean(openFile.newContent)); export const removeBuildScriptProcess = (state: RootState) => { state = { ...state, scriptProcesses: state.scriptProcesses.filter( process => process.id !== state.buildScriptProcessId ), buildScriptProcessId: null }; return state; }; const DEFAULT_CANVAS: Canvas = { backgroundColor: "#EEE", translate: { left: 0, top: 0, zoom: 1 } }; export const confirm = (message: string, type: ConfirmType, state: RootState) => updateRootState({ confirm: { message, type } }, state); export const undo = (root: RootState) => moveDependencyRecordHistory(-1, root); export const redo = (root: RootState) => moveDependencyRecordHistory(1, root); export const getOpenFile = (uri: string, openFiles: OpenFile[]) => openFiles.find(openFile => openFile.uri === uri); export const getOpenFilesWithContent = (state: RootState) => state.openFiles.filter(openFile => openFile.newContent); export const updateOpenFileContent = ( uri: string, newContent: Buffer, state: RootState ) => { return updateOpenFile( { temporary: false, newContent }, uri, state ); }; export const updateProjectScripts = ( scripts: Partial<ProjectScripts>, state: RootState ) => { // todo - queue file to save state = { ...state, projectInfo: { ...state.projectInfo, config: { ...state.projectInfo.config, scripts: { ...(state.projectInfo.config.scripts || EMPTY_OBJECT), ...scripts } } } }; state = queueSaveProjectFile(state); return state; }; const queueSaveProjectFile = (state: RootState) => { state = updateOpenFile( { temporary: false, newContent: new Buffer(JSON.stringify(state.projectInfo.config, null, 2)) }, state.projectInfo.path, state ); return state; }; export const getActiveEditorWindow = (state: RootState) => getEditorWithActiveFileUri(state.activeEditorFilePath, state); export const updateOpenFile = ( properties: Partial<OpenFile>, uri: string, state: RootState ) => { const file = getOpenFile(uri, state.openFiles); if (!file) { state = addOpenFile(uri, false, state); return updateOpenFile(properties, uri, state); } const index = state.openFiles.indexOf(file); return updateRootState( { openFiles: arraySplice(state.openFiles, index, 1, { ...file, ...properties }) }, state ); }; export const openFile = ( uri: string, temporary: boolean, secondaryTab: boolean, state: RootState ): RootState => { let file = getOpenFile(uri, state.openFiles); state = openEditorFileUri(uri, secondaryTab, state); if (!file) { state = addOpenFile(uri, temporary, state); file = getOpenFile(uri, state.openFiles); state = centerEditorCanvasOrLater(state, uri); } if (!hasFileCacheItem(uri, state)) { state = queueOpenFile(uri, state); } return state; }; export const refreshModuleInspectorNodes = (state: RootState) => { const [sourceNodeInspector, sourceNodeInspectorMap] = refreshInspectorTree( state.sourceNodeInspector, state.graph, state.openFiles.map(({ uri }) => uri).filter(Boolean), state.sourceNodeInspectorMap, state.sourceNodeInspectorGraph ); state = updateRootState( { sourceNodeInspector, sourceNodeInspectorMap, sourceNodeInspectorGraph: state.graph, selectedInspectorNodes: state.selectedInspectorNodes.filter(node => Boolean(getNestedTreeNodeById(node.id, sourceNodeInspector)) ), hoveringInspectorNodes: state.hoveringInspectorNodes.filter(node => Boolean(getNestedTreeNodeById(node.id, sourceNodeInspector)) ) }, state ); return state; }; export const updateSourceInspectorNode = ( state: RootState, updater: TreeNodeUpdater<any> ) => { return updateRootState( { sourceNodeInspector: updater(state.sourceNodeInspector) }, state ); }; export const getEditorWindowWithFileUri = ( uri: string, state: RootState ): EditorWindow => { return state.editorWindows.find(window => window.tabUris.indexOf(uri) !== -1); }; export const getEditorWithActiveFileUri = ( uri: string, state: RootState ): EditorWindow => { return state.editorWindows.find(editor => editor.activeFilePath === uri); }; const createEditorWindow = ( tabUris: string[], activeFilePath: string ): EditorWindow => ({ tabUris, activeFilePath }); let scriptProcessCount = 0; export const createScriptProcess = ( label: string, script: string ): ScriptProcess => ({ label, script, id: `script${scriptProcessCount++}`, logs: [] }); let unloaderCount = 0; export const createUnloader = (): Unloader => ({ id: `script${unloaderCount++}`, completed: false }); export const isUnloaded = (state: RootState) => state.readyType === RootReadyType.UNLOADING && !state.unloaders.some(({ completed }) => !completed); export const getProjectCWD = (state: RootState) => state.projectInfo && path.dirname(stripProtocol(state.projectInfo.path)); export const getSyntheticWindowBounds = memoize( (uri: string, state: RootState) => { const frames = getFramesByDependencyUri( uri, state.frames, state.documents, state.graph ); if (!window) return createZeroBounds(); return mergeBounds(...(frames || EMPTY_ARRAY).map(frame => frame.bounds)); } ); export const isImageMimetype = (mimeType: string) => /^image\//.test(mimeType); export const pruneOpenFiles = (state: RootState) => { const openFiles = state.openFiles.filter(openFile => { return !!state.fileCache[openFile.uri]; }); const editorWindows = state.editorWindows .map(window => { const tabUris = window.tabUris.filter(uri => { return !!state.fileCache[uri]; }); if (!tabUris.length) { return null; } return { ...window, tabUris }; }) .filter(Boolean); state = updateRootState( { openFiles, editorWindows, activeEditorFilePath: null }, state ); state = setNextOpenFile(state); return state; }; export const openEditorFileUri = ( uri: string, secondaryTab: boolean, state: RootState ): RootState => { const editor = getEditorWindowWithFileUri(uri, state) || (secondaryTab ? state.editorWindows.length > 1 ? state.editorWindows[1] : null : state.editorWindows[0]); if ( secondaryTab && editor === state.editorWindows[0] && (editor.tabUris.length > 1 || state.editorWindows.length > 1) ) { state = closeEditorWindowUri(uri, state); state = openEditorFileUri(uri, true, state); return state; } return { ...state, selectedFileNodeIds: state.selectedFileNodeIds.length === 1 && (getNestedTreeNodeById( state.selectedFileNodeIds[0], state.projectDirectory ) as FSItem).uri === uri ? state.selectedFileNodeIds : EMPTY_ARRAY, selectedInspectorNodes: EMPTY_ARRAY, hoveringInspectorNodes: EMPTY_ARRAY, activeEditorFilePath: uri, editorWindows: editor ? arraySplice( state.editorWindows, state.editorWindows.indexOf(editor), 1, { ...editor, tabUris: editor.tabUris.indexOf(uri) === -1 ? [...editor.tabUris, uri] : editor.tabUris, activeFilePath: uri } ) : [...state.editorWindows, createEditorWindow([uri], uri)] }; }; export const shiftActiveEditorTab = ( delta: number, state: RootState ): RootState => { const editor = getActiveEditorWindow(state); // nothing open if (!editor) { return state; } const index = editor.tabUris.indexOf(editor.activeFilePath); let newIndex = index + delta; if (newIndex < 0) { newIndex = editor.tabUris.length + delta; } else if (newIndex >= editor.tabUris.length) { newIndex = -1 + delta; } newIndex = clamp(newIndex, 0, editor.tabUris.length - 1); return openEditorFileUri(editor.tabUris[newIndex], false, state); }; const removeEditorWindow = ( { activeFilePath }: EditorWindow, state: RootState ): RootState => { const editor = getEditorWithActiveFileUri(activeFilePath, state); return { ...state, editorWindows: arraySplice( state.editorWindows, state.editorWindows.indexOf(editor), 1 ) }; }; const closeEditorWindowUri = (uri: string, state: RootState): RootState => { const editorWindow = getEditorWindowWithFileUri(uri, state); if (editorWindow.tabUris.length === 1) { state = removeEditorWindow(editorWindow, state); } else { const index = editorWindow.tabUris.indexOf(uri); const tabUris = arraySplice(editorWindow.tabUris, index, 1); const nextActiveUri = tabUris[Math.max(0, index - 1)]; state = updateEditorWindow( { tabUris, activeFilePath: nextActiveUri }, uri, state ); state = updateRootState({ activeEditorFilePath: nextActiveUri }, state); } return state; }; export const closeFile = (uri: string, state: RootState): RootState => { state = closeEditorWindowUri(uri, state); state = updateRootState( { openFiles: state.openFiles.filter(openFile => openFile.uri !== uri) }, state ); state = setNextOpenFile(state); state = refreshModuleInspectorNodes(state); return state; }; export const setNextOpenFile = (state: RootState): RootState => { const hasOpenFile = state.openFiles.find(openFile => Boolean(getEditorWithActiveFileUri(openFile.uri, state)) ); if (hasOpenFile) { return state; } state = { ...state, hoveringInspectorNodes: EMPTY_ARRAY, selectedInspectorNodes: EMPTY_ARRAY }; if (state.openFiles.length) { state = openEditorFileUri(state.openFiles[0].uri, false, state); } return state; }; export const removeTemporaryOpenFiles = (state: RootState) => { return { ...state, openFiles: state.openFiles.filter(openFile => !openFile.temporary) }; }; export const openSyntheticVisibleNodeOriginFile = ( node: SyntheticVisibleNode, state: RootState ) => { let sourceNode = getSyntheticSourceNode( node as SyntheticVisibleNode, state.graph ) as PCVisibleNode | PCComponent; if (isPCComponentInstance(sourceNode)) { sourceNode = getPCNode(sourceNode.is, state.graph) as PCComponent; } const uri = getPCNodeDependency(sourceNode.id, state.graph).uri; const editors = state.editorWindows; const activeEditor = getActiveEditorWindow(state); const existingEditor = getEditorWindowWithFileUri(uri, state); // if existing editor, then don't open in second tab state = openFile( uri, false, activeEditor === editors[1] && !existingEditor, state ); const instance = findNestedNode(state.sourceNodeInspector, child => { return !child.instancePath && child.sourceNodeId === sourceNode.id; }); state = setSelectedInspectorNodes(state, instance); // state = centerCanvasToSelectedNodes(state); return state; }; export const centerCanvasToSelectedNodes = (state: RootState) => { state = centerEditorCanvasOrLater(state, state.activeEditorFilePath); return state; }; export const addOpenFile = ( uri: string, temporary: boolean, state: RootState ): RootState => { const file = getOpenFile(uri, state.openFiles); if (file) { return state; } state = removeTemporaryOpenFiles(state); state = { ...state, openFiles: [ ...state.openFiles, { uri, temporary, canvas: DEFAULT_CANVAS } ] }; // need to sync inspector nodes so that they show up in the inspector pane state = refreshModuleInspectorNodes(state); return state; }; export const upsertPCModuleInspectorNode = ( module: PCModule, state: RootState ) => {}; export const keepActiveFileOpen = (state: RootState): RootState => { return { ...state, openFiles: state.openFiles.map(openFile => ({ ...openFile, temporary: false })) }; }; export const setRootStateSyntheticVisibleNodeLabelEditing = ( nodeId: string, value: boolean, state: RootState ) => { const node = getSyntheticNodeById(nodeId, state.documents); const document = getSyntheticVisibleNodeDocument(node.id, state.documents); state = updateSyntheticDocument( updateSyntheticVisibleNodeMetadata( { [SyntheticVisibleNodeMetadataKeys.EDITING_LABEL]: value }, node, document ), document, state ); return state; }; export const setRootStateFileNodeExpanded = ( nodeId: string, value: boolean, state: RootState ) => { return updateRootState( { projectDirectory: updateNestedNodeTrail( getTreeNodePath(nodeId, state.projectDirectory), state.projectDirectory, (child: FSItem) => ({ ...child, expanded: value }) ) }, state ); }; export const updateEditorWindow = ( properties: Partial<EditorWindow>, uri: string, root: RootState ) => { const window = getEditorWindowWithFileUri(uri, root); const i = root.editorWindows.indexOf(window); if (i === -1) { return root; } return updateRootState( { editorWindows: arraySplice(root.editorWindows, i, 1, { ...window, ...properties }) }, root ); }; const INITIAL_ZOOM_PADDING = 50; export const centerEditorCanvasOrLater = ( state: RootState, editorFileUri: string ): RootState => { const document = getSyntheticDocumentByDependencyUri( editorFileUri, state.documents, state.graph ); return document ? centerEditorCanvas(state, editorFileUri) : { ...state, recenterUriAfterEvaluation: editorFileUri }; }; export const centerEditorCanvas = ( state: RootState, editorFileUri: string, innerBounds?: Bounds, smooth: boolean = false, zoomOrZoomToFit: boolean | number = true ) => { if (!innerBounds) { const frames = getFramesByDependencyUri( editorFileUri, state.frames, state.documents, state.graph ); if (!frames.length) { return state; } innerBounds = state.selectedInspectorNodes.length ? getSelectionBounds( state.selectedInspectorNodes, state.documents, state.frames, state.graph ) : getSyntheticWindowBounds(editorFileUri, state); } // no windows loaded if ( innerBounds.left + innerBounds.right + innerBounds.top + innerBounds.bottom === 0 ) { console.warn(` Cannot center when bounds has no size`); return updateOpenFileCanvas( { translate: { left: 0, top: 0, zoom: 1 } }, editorFileUri, state ); } const editorWindow = getEditorWindowWithFileUri(editorFileUri, state); const openFile = getOpenFile(editorFileUri, state.openFiles); const { container } = editorWindow; if (!container) { console.warn("cannot center canvas without a container"); return state; } const { canvas: { translate } } = openFile; const { width, height } = container.getBoundingClientRect(); const innerSize = getBoundsSize(innerBounds); const centered = { left: -innerBounds.left + width / 2 - innerSize.width / 2, top: -innerBounds.top + height / 2 - innerSize.height / 2 }; const scale = typeof zoomOrZoomToFit === "boolean" ? Math.min( (width - INITIAL_ZOOM_PADDING) / innerSize.width, (height - INITIAL_ZOOM_PADDING) / innerSize.height ) : typeof zoomOrZoomToFit === "number" ? zoomOrZoomToFit : translate.zoom; state = updateEditorWindow( { smooth }, editorFileUri, state ); state = updateOpenFileCanvas( { translate: centerTransformZoom( { ...centered, zoom: 1 }, { left: 0, top: 0, right: width, bottom: height }, Math.min(scale, 1) ) }, editorFileUri, state ); return state; }; export const updateOpenFileCanvas = ( properties: Partial<Canvas>, uri: string, root: RootState ) => { const openFile = getOpenFile(uri, root.openFiles); return updateOpenFile( { canvas: { ...openFile.canvas, ...properties } }, uri, root ); }; // export const setInsertFile = (type: InsertFileType, state: RootState) => { // const file = getNestedTreeNodeById( // state.selectedFileNodeIds[0] || state.projectDirectory.id, // state.projectDirectory // ); // return updateRootState( // { // insertFileInfo: { // type, // directoryId: isDirectory(file) // ? file.id // : getParentTreeNode(file.id, state.projectDirectory).id // } // }, // state // ); // }; export const setTool = (toolType: ToolType, root: RootState) => { if (!root.editorWindows.length) { return root; } root = { ...root, selectedComponentId: null }; root = updateRootState({ toolType }, root); return root; }; export const getActiveFrames = (root: RootState): Frame[] => values(root.frames).filter( frame => getActiveEditorWindow(root).activeFilePath === getSyntheticDocumentDependencyUri( getSyntheticVisibleNodeDocument( frame.syntheticContentNodeId, root.documents ), root.graph ) ); export const getCanvasTranslate = (canvas: Canvas) => canvas.translate; export const getScaledMouseCanvasPosition = ( state: RootState, point: Point ) => { const canvas = getOpenFile(state.activeEditorFilePath, state.openFiles) .canvas; const translate = getCanvasTranslate(canvas); const scaledPageX = (point.left - translate.left) / translate.zoom; const scaledPageY = (point.top - translate.top) / translate.zoom; return { left: scaledPageX, top: scaledPageY }; }; export const getCanvasMouseTargetNodeId = ( state: RootState, event: React.MouseEvent<any>, filter?: (node: TreeNode<any>) => boolean ): string => { return getCanvasMouseTargetNodeIdFromPoint( state, { left: event.pageX, top: event.pageY }, filter ); }; export const getCanvasMouseTargetInspectorNode = ( state: RootState, event: CanvasToolOverlayMouseMoved | CanvasToolOverlayClicked, filter?: (node: TreeNode<any>) => boolean ): InspectorNode => { const syntheticNodeId = getCanvasMouseTargetNodeId( state, event.sourceEvent, filter ); if (!syntheticNodeId) { return null; } const syntheticNode = getSyntheticNodeById( syntheticNodeId, state.documents ) as SyntheticVisibleNode; const assocInspectorNode = getSyntheticInspectorNode( syntheticNode, getSyntheticVisibleNodeDocument(syntheticNode.id, state.documents), state.sourceNodeInspector, state.graph ); const insertableSourceNode = getInsertableInspectorNode( assocInspectorNode, state.sourceNodeInspector, state.graph ); return insertableSourceNode; }; const getSelectedInspectorNodeParentShadowId = (state: RootState) => { const node = state.selectedInspectorNodes[0]; if (!node) { return null; } const inspectorNode = getNestedTreeNodeById( node.id, state.sourceNodeInspector ); const shadow = inspectorNode.name === InspectorTreeNodeName.SHADOW ? inspectorNode : getInspectorNodeParentShadow(inspectorNode, state.sourceNodeInspector); return shadow && shadow.id; }; const defaultCanvasNodeFilter = ({ id }: SyntheticNode, state: RootState) => { const syntheticNode = getSyntheticNodeById(id, state.documents); const document = getSyntheticVisibleNodeDocument(id, state.documents); const inspectorNode = getSyntheticInspectorNode( syntheticNode, document, state.sourceNodeInspector, state.graph ); if (!inspectorNode) { return false; } const contentNode = getInspectorContentNodeContainingChild( inspectorNode, state.sourceNodeInspector ) || inspectorNode; if (inspectorNodeInShadow(inspectorNode, contentNode)) { const selectedParentShadowId = getSelectedInspectorNodeParentShadowId( state ); if (selectedParentShadowId) { const selectedShadowInspectorNode = getNestedTreeNodeById( selectedParentShadowId, state.sourceNodeInspector ); const inspectorParentShadow = getInspectorNodeParentShadow( inspectorNode, state.sourceNodeInspector ); const inspectorNodeWithinSelectedShadow = containsNestedTreeNodeById( inspectorNode.id, selectedShadowInspectorNode ) && selectedShadowInspectorNode.id === inspectorParentShadow.id; const selectedShadowWithinInspectorParentShadow = containsNestedTreeNodeById( selectedShadowInspectorNode.id, inspectorParentShadow ); if ( !inspectorNodeWithinSelectedShadow && !selectedShadowWithinInspectorParentShadow ) { return false; } } else { return false; } } return true; }; export const getCanvasMouseTargetNodeIdFromPoint = ( state: RootState, point: Point, filter: ( node: TreeNode<any>, state: RootState ) => boolean = defaultCanvasNodeFilter ): string => { const scaledMousePos = getScaledMouseCanvasPosition(state, point); const frame = getFrameFromPoint(scaledMousePos, state); if (!frame) return null; const contentNode = getSyntheticNodeById( frame.syntheticContentNodeId, state.documents ); const { left: scaledPageX, top: scaledPageY } = scaledMousePos; const mouseX = scaledPageX - frame.bounds.left; const mouseY = scaledPageY - frame.bounds.top; const computedInfo = frame.computed || {}; const intersectingBounds: Bounds[] = []; const intersectingBoundsMap = new Map<Bounds, string>(); const mouseFramePoint = { left: mouseX, top: mouseY }; for (const id in computedInfo) { const { bounds } = computedInfo[id]; const node = getNestedTreeNodeById(id, contentNode); // synth nodes may be lagging behind graph if (!node) { continue; } if (pointIntersectsBounds(mouseFramePoint, bounds) && filter(node, state)) { intersectingBounds.unshift(bounds); intersectingBoundsMap.set(bounds, id); } } if (!intersectingBounds.length) return null; const smallestBounds = getSmallestBounds(...intersectingBounds); return intersectingBoundsMap.get(smallestBounds); }; export const getCanvasMouseFrame = ( state: RootState, event: CanvasToolOverlayMouseMoved | CanvasToolOverlayClicked ) => { return getFrameFromPoint( getScaledMouseCanvasPosition(state, { left: event.sourceEvent.pageX, top: event.sourceEvent.pageY }), state ); }; export const getFrameFromPoint = (point: Point, state: RootState) => { const activeFrames = getActiveFrames(state); if (!activeFrames.length) return null; for (let j = activeFrames.length; j--; ) { const frame = activeFrames[j]; if (pointIntersectsBounds(point, frame.bounds)) { return frame; } } }; export const setSelectedInspectorNodes = ( root: RootState, ...selection: InspectorNode[] ) => { root = updateRootState( { selectedInspectorNodes: selection }, root ); root = expandedSelectedInspectorNode(root); return root; }; const expandedSelectedInspectorNode = (state: RootState) => { return state.selectedInspectorNodes.reduce((state, node) => { state = updateSourceInspectorNode(state, sourceNodeInspector => expandInspectorNodeById(node.id, sourceNodeInspector) ); return state; }, state); }; export const setSelectedFileNodeIds = ( root: RootState, ...selectionIds: string[] ) => { const nodeIds = uniq([...selectionIds]); root = nodeIds.reduce( (state, nodeId) => setRootStateFileNodeExpanded(nodeId, true, root), root ); root = updateRootState( { selectedFileNodeIds: nodeIds }, root ); return root; }; export const setHoveringSyntheticVisibleNodeIds = ( root: RootState, selectionIds: string[] ) => { const hoveringSyntheticNodeIds = uniq( [...selectionIds].filter(nodeId => { const node = getSyntheticNodeById(nodeId, root.documents); if (!node) { console.warn(`node ${nodeId} does not exist`); } return Boolean(node); }) ); return updateRootState( { hoveringInspectorNodes: hoveringSyntheticNodeIds .map(nodeId => { const inspectorNode = getSyntheticInspectorNode( getSyntheticNodeById(nodeId, root.documents), getSyntheticVisibleNodeDocument(nodeId, root.documents), root.sourceNodeInspector, root.graph ); return inspectorNode; }) .filter(Boolean) }, root ); }; export const setHoveringInspectorNodes = ( root: RootState, hoveringInspectorNodes: InspectorNode[] ) => { return updateRootState( { hoveringInspectorNodes }, root ); }; export const getBoundedSelection = memoize( ( selectedInspectorNodes: InspectorNode[], documents: SyntheticDocument[], frames: Frame[], graph: DependencyGraph ): InspectorNode[] => { return selectedInspectorNodes.filter(node => { const syntheticNode = getInspectorSyntheticNode(node, documents); return ( syntheticNode && getSyntheticVisibleNodeRelativeBounds(syntheticNode, frames, graph) ); }); } ); export const getSelectionBounds = memoize( ( selectedInspectorNodes: InspectorNode[], documents: SyntheticDocument[], frames: Frame[], graph: DependencyGraph ) => mergeBounds( ...getBoundedSelection( selectedInspectorNodes, documents, frames, graph ).map(node => getSyntheticVisibleNodeRelativeBounds( getInspectorSyntheticNode(node, documents), frames, graph ) ) ) ); export const isSelectionMovable = memoize( ( selectedInspectorNodes: InspectorNode[], rootInspectorNode: InspectorNode, graph: DependencyGraph ) => { return selectedInspectorNodes.every(node => { return getInspectorContentNode(node, rootInspectorNode).id === node.id; }); } ); export const isSelectionResizable = memoize( ( selectedSyntheticNodes: InspectorNode[], rootInspectorNode: InspectorNode, graph: DependencyGraph ) => { return selectedSyntheticNodes.every(node => { return getInspectorContentNode(node, rootInspectorNode).id === node.id; }); } );