diff --git a/packages/cli/src/project/WorkspaceLoader.ts b/packages/cli/src/project/WorkspaceLoader.ts index 142f81573..5aacbc2e1 100644 --- a/packages/cli/src/project/WorkspaceLoader.ts +++ b/packages/cli/src/project/WorkspaceLoader.ts @@ -202,6 +202,7 @@ export class WorkspaceLoader { const size = sizeOf(imageData); const image: Data.Image = { + filePath: path.relative(projectPath, filePath), width: size.width ?? 0, height: size.height ?? 0, type: mimeType as Data.ImageType, @@ -223,7 +224,7 @@ export class WorkspaceLoader { const loader = new ProjectLoader(existingProject?.project); loader.load(pages); for (const [key, image] of images) { - loader.project.imageManager.images.set(key, image); + loader.project.imageManager.data.set(key, image); } if (isEqual(pages, existingProject?.pages ?? new Map())) { @@ -288,15 +289,24 @@ export class WorkspaceLoader { project.project.toJSON() ); - for (const [hash, image] of project.project.imageManager.images) { + for (const [hash, image] of project.project.imageManager.data) { if (!usedImageHashes.has(hash)) { continue; } const decoded = dataUriToBuffer(image.url); - const suffix = mime.extension(decoded.type) || "bin"; + // const suffix = mime.extension(decoded.type) || "bin"; + + // TODO: + // - update file if hash is different + // - auto-fix name collisions + + const filePath = path.join(projectPath, image.filePath); + if (await this.fileAccess.stat(filePath)) { + continue; + } await this.fileAccess.writeFile( - path.join(projectPath, "src/images", `${hash}.${suffix}`), + path.join(projectPath, image.filePath), decoded ); } diff --git a/packages/dashboard/src/components/editor/VSCodeEditor.tsx b/packages/dashboard/src/components/editor/VSCodeEditor.tsx index 58894d79c..203b513dd 100644 --- a/packages/dashboard/src/components/editor/VSCodeEditor.tsx +++ b/packages/dashboard/src/components/editor/VSCodeEditor.tsx @@ -131,7 +131,7 @@ const VSCodeEditor: React.FC = () => { "://", // TODO: use unique ID for subdomain? `://local.` - ) + "?embed=true&uiScaling=0.75&fontSize=11&narrowMode=true"; + ) + "?embed=true&uiScaling=0.75&fontSize=11&narrowMode=false"; return (
diff --git a/packages/editor/src/state/Commands.tsx b/packages/editor/src/state/Commands.tsx index 33bf02bd8..657eeb866 100644 --- a/packages/editor/src/state/Commands.tsx +++ b/packages/editor/src/state/Commands.tsx @@ -3,9 +3,9 @@ import { isTextInput } from "@uimix/foundation/src/utils/Focus"; import { Shortcut } from "@uimix/foundation/src/utils/Shortcut"; import { Selectable, - PageHierarchyEntry, Component, Page, + PathTreeModelItem, } from "@uimix/model/src/models"; import { exportToJSON as exportJSON, importJSON } from "./JSONExport"; import { viewportState } from "./ViewportState"; @@ -660,14 +660,14 @@ class Commands { ]; } - contextMenuForFile(file: PageHierarchyEntry): MenuItemDef[] { - if (file.type === "directory") { + contextMenuForPage(page: PathTreeModelItem): MenuItemDef[] { + if (page.type === "directory") { return [ { type: "command", text: "New File", onClick: action(() => { - const newPath = path.join(file.path, "Page 1"); + const newPath = path.join(page.path, "Page 1"); projectState.createPage(newPath); }), }, @@ -676,7 +676,7 @@ class Commands { text: "Delete", // disabled: projectState.project.pages.count === 1, onClick: action(() => { - projectState.deletePageOrPageFolder(file.path); + projectState.deletePagePath(page.path); }), }, ]; @@ -687,7 +687,7 @@ class Commands { text: "Delete", // disabled: projectState.project.pages.count === 1, onClick: action(() => { - projectState.deletePageOrPageFolder(file.path); + projectState.deletePagePath(page.path); }), }, ]; diff --git a/packages/editor/src/state/ProjectState.ts b/packages/editor/src/state/ProjectState.ts index a25df57b6..17226f232 100644 --- a/packages/editor/src/state/ProjectState.ts +++ b/packages/editor/src/state/ProjectState.ts @@ -2,7 +2,12 @@ import { computed, makeObservable, observable } from "mobx"; import * as Y from "yjs"; import * as Data from "@uimix/model/src/data/v1"; import { usedImageHashesInStyle } from "@uimix/model/src/data/util"; -import { Project, Page, Selectable } from "@uimix/model/src/models"; +import { + Project, + Page, + Selectable, + PathTreeModel, +} from "@uimix/model/src/models"; import { getIncrementalUniqueName } from "@uimix/foundation/src/utils/Name"; import { PageState } from "./PageState"; import { ScrollState } from "./ScrollState"; @@ -54,10 +59,6 @@ export class ProjectState { return this.pageState?.selectedSelectables ?? []; } - // MARK: Collapsing - - readonly collapsedPaths = observable.set(); - // MARK: Nodes loadDemoFile() { @@ -258,8 +259,34 @@ export class ProjectState { ); } + // MARK: Images + + readonly imageTreeModel = new PathTreeModel({ + getTargets: () => this.project.imageManager.images, + delete: (image) => { + // TODO + }, + rename: (image, newName) => { + // TODO + return image; + }, + }); + // MARK: Pages + readonly pageTreeModel = new PathTreeModel({ + getTargets: () => this.project.pages.all, + delete: (page) => { + page.node.remove(); + }, + rename: (page, newName) => { + const newPage = this.project.pages.create(newName); + newPage.node.append(page.node.children); + page.node.remove(); + return newPage; + }, + }); + openPage(page: Page) { this.pageID = page.id; } @@ -277,30 +304,20 @@ export class ProjectState { this.undoManager.stopCapturing(); } - deletePageOrPageFolder(path: string) { - const deletedPages = this.project.pages.delete(path); - - const deletingCurrent = this.page - ? deletedPages.includes(this.page) - : false; - if (deletingCurrent) { - this.pageID = this.project.pages.all[0]?.id; - } - + deletePagePath(path: string) { + this.pageTreeModel.delete(path); this.undoManager.stopCapturing(); } - renamePageOrPageFolder(path: string, newPath: string) { - const { originalPages, newPages } = this.project.pages.rename( - path, - newPath - ); + renamePagePath(path: string, newPath: string) { + const oldCurrent = this.page; + const oldToNew = this.pageTreeModel.rename(path, newPath); - const selectedOriginalPageIndex = originalPages.findIndex( - (page) => page.id === this.pageID - ); - if (selectedOriginalPageIndex !== -1) { - this.pageID = newPages[selectedOriginalPageIndex].id; + if (oldCurrent) { + const newCurrent = oldToNew.get(oldCurrent); + if (newCurrent && newCurrent !== oldCurrent) { + this.pageID = newCurrent.id; + } } this.undoManager.stopCapturing(); diff --git a/packages/editor/src/views/outline/ImageTreeView.tsx b/packages/editor/src/views/outline/ImageTreeView.tsx new file mode 100644 index 000000000..913a5a05a --- /dev/null +++ b/packages/editor/src/views/outline/ImageTreeView.tsx @@ -0,0 +1,175 @@ +import { posix as path } from "path-browserify"; +import { action, runInAction } from "mobx"; +import { observer } from "mobx-react-lite"; +import { twMerge } from "tailwind-merge"; +import { TreeView, TreeViewItem } from "react-draggable-tree"; +import { Icon } from "@iconify/react"; +import fileIcon from "@iconify-icons/ic/outline-insert-drive-file"; +import folderIcon from "@iconify-icons/ic/folder"; +import { + DropBetweenIndicator, + DropOverIndicator, + ToggleCollapsedButton, + DoubleClickToEdit, +} from "@uimix/foundation/src/components"; +import { projectState } from "../../state/ProjectState"; +import { commands } from "../../state/Commands"; +import { Image, PathTreeModelItem } from "@uimix/model/src/models"; +import { showContextMenu } from "../ContextMenu"; + +interface ImageTreeViewItem extends TreeViewItem { + entry: PathTreeModelItem; +} + +function buildTreeViewItem( + entry: PathTreeModelItem, + parent?: ImageTreeViewItem +): ImageTreeViewItem { + const treeViewItem: ImageTreeViewItem = { + key: entry.id, + parent, + entry, + children: [], + }; + + if ( + entry.type === "directory" && + !projectState.imageTreeModel.collapsedPaths.has(entry.path) + ) { + treeViewItem.children = entry.children.map((child) => + buildTreeViewItem(child, treeViewItem) + ); + } + + return treeViewItem; +} + +const ImageRow = observer( + ({ + depth, + indentation, + item, + }: { + depth: number; + indentation: number; + item: ImageTreeViewItem; + }) => { + const { entry } = item; + + const selected = false; + const collapsed = projectState.imageTreeModel.collapsedPaths.has( + entry.path + ); + + const onClick = action(() => { + // TODO + }); + const onCollapsedChange = action((value: boolean) => { + if (value) { + projectState.imageTreeModel.collapsedPaths.add(entry.path); + } else { + projectState.imageTreeModel.collapsedPaths.delete(entry.path); + } + }); + + return ( +
{ + e.preventDefault(); + //showContextMenu(e, commands.contextMenuForPage(entry)); + })} + className="w-full h-7 px-1" + > +
+ + {entry.type === "file" ? ( + + ) : ( + + )} + { + const newPath = path.join(path.dirname(entry.path), name); + projectState.imageTreeModel.rename(entry.path, newPath); + })} + /> +
+
+ ); + } +); + +export const ImageTreeView = observer(() => { + const rootItem = buildTreeViewItem(projectState.imageTreeModel.root); + + return ( + } + footer={
} + rootItem={rootItem} + background={ +
{ + //state.selectedPath = undefined; + })} + onContextMenu={action((e) => { + e.preventDefault(); + + //showContextMenu(e, commands.contextMenuForPage(rootItem.entry)); + })} + /> + } + dropBetweenIndicator={DropBetweenIndicator} + dropOverIndicator={DropOverIndicator} + renderRow={(props) => } + handleDragStart={() => { + return true; + }} + canDrop={({ item, draggedItem }) => { + return !!draggedItem && item.entry.type === "directory"; + }} + handleDrop={({ item, draggedItem }) => { + runInAction(() => { + if (draggedItem) { + const entry = draggedItem.entry; + const newDir = item.entry.path; + const oldName = draggedItem.entry.name; + const newPath = path.join(newDir, oldName); + + projectState.imageTreeModel.rename(entry.path, newPath); + } + }); + }} + nonReorderable + /> + ); +}); + +ImageTreeView.displayName = "DocumentTreeView"; diff --git a/packages/editor/src/views/outline/OutlineSideBar.tsx b/packages/editor/src/views/outline/OutlineSideBar.tsx index ac964d4ea..bbb6a4e1f 100644 --- a/packages/editor/src/views/outline/OutlineSideBar.tsx +++ b/packages/editor/src/views/outline/OutlineSideBar.tsx @@ -9,6 +9,7 @@ import { import { NodeTreeView } from "./NodeTreeView"; import { Icon } from "@iconify/react"; import { PageTreeView } from "./PageTreeView"; +import { ImageTreeView } from "./ImageTreeView"; export const OutlineSideBar: React.FC = observer(() => { return ( @@ -35,6 +36,15 @@ export const OutlineSideBar: React.FC = observer(() => { Layers + + + + Images + + { + + + + + ); }); diff --git a/packages/editor/src/views/outline/PageTreeView.tsx b/packages/editor/src/views/outline/PageTreeView.tsx index 04f7b332a..d722ffa6a 100644 --- a/packages/editor/src/views/outline/PageTreeView.tsx +++ b/packages/editor/src/views/outline/PageTreeView.tsx @@ -14,15 +14,15 @@ import { } from "@uimix/foundation/src/components"; import { projectState } from "../../state/ProjectState"; import { commands } from "../../state/Commands"; -import { PageHierarchyEntry } from "@uimix/model/src/models"; +import { Page, PathTreeModelItem } from "@uimix/model/src/models"; import { showContextMenu } from "../ContextMenu"; interface PageTreeViewItem extends TreeViewItem { - entry: PageHierarchyEntry; + entry: PathTreeModelItem; } function buildTreeViewItem( - entry: PageHierarchyEntry, + entry: PathTreeModelItem, parent?: PageTreeViewItem ): PageTreeViewItem { const treeViewItem: PageTreeViewItem = { @@ -34,7 +34,7 @@ function buildTreeViewItem( if ( entry.type === "directory" && - !projectState.collapsedPaths.has(entry.path) + !projectState.pageTreeModel.collapsedPaths.has(entry.path) ) { treeViewItem.children = entry.children.map((child) => buildTreeViewItem(child, treeViewItem) @@ -57,19 +57,19 @@ const PageRow = observer( const { entry } = item; const selected = - entry.type === "file" && entry.page.id === projectState.page?.id; - const collapsed = projectState.collapsedPaths.has(entry.path); + entry.type === "file" && entry.target.id === projectState.page?.id; + const collapsed = projectState.pageTreeModel.collapsedPaths.has(entry.path); const onClick = action(() => { if (entry.type === "file") { - projectState.openPage(entry.page); + projectState.openPage(entry.target); } }); const onCollapsedChange = action((value: boolean) => { if (value) { - projectState.collapsedPaths.add(entry.path); + projectState.pageTreeModel.collapsedPaths.add(entry.path); } else { - projectState.collapsedPaths.delete(entry.path); + projectState.pageTreeModel.collapsedPaths.delete(entry.path); } }); @@ -78,7 +78,7 @@ const PageRow = observer( onClick={onClick} onContextMenu={action((e) => { e.preventDefault(); - showContextMenu(e, commands.contextMenuForFile(entry)); + showContextMenu(e, commands.contextMenuForPage(entry)); })} className="w-full h-7 px-1" > @@ -116,7 +116,7 @@ const PageRow = observer( value={entry.name} onChange={action((name: string) => { const newPath = path.join(path.dirname(entry.path), name); - projectState.renamePageOrPageFolder(entry.path, newPath); + projectState.renamePagePath(entry.path, newPath); })} />
@@ -126,7 +126,7 @@ const PageRow = observer( ); export const PageTreeView = observer(() => { - const rootItem = buildTreeViewItem(projectState.project.pages.toHierarchy()); + const rootItem = buildTreeViewItem(projectState.pageTreeModel.root); return ( { onContextMenu={action((e) => { e.preventDefault(); - showContextMenu(e, commands.contextMenuForFile(rootItem.entry)); + showContextMenu(e, commands.contextMenuForPage(rootItem.entry)); })} /> } @@ -164,7 +164,7 @@ export const PageTreeView = observer(() => { const oldName = draggedItem.entry.name; const newPath = path.join(newDir, oldName); - projectState.renamePageOrPageFolder(entry.path, newPath); + projectState.renamePagePath(entry.path, newPath); } }); }} diff --git a/packages/model/src/data/v1/project.ts b/packages/model/src/data/v1/project.ts index 6cab1c7bb..49a0f15f4 100644 --- a/packages/model/src/data/v1/project.ts +++ b/packages/model/src/data/v1/project.ts @@ -14,6 +14,7 @@ export const ImageType = z.enum(["image/png", "image/jpeg"]); export type ImageType = z.infer; export const Image = z.object({ + filePath: z.string(), width: z.number(), height: z.number(), type: ImageType, diff --git a/packages/model/src/models/ImageManager.ts b/packages/model/src/models/ImageManager.ts index 3ff3fb188..4d97901b2 100644 --- a/packages/model/src/models/ImageManager.ts +++ b/packages/model/src/models/ImageManager.ts @@ -5,16 +5,51 @@ import * as Data from "../data/v1"; import { getURLSafeBase64Hash } from "@uimix/foundation/src/utils/Hash"; import { compact } from "lodash-es"; +export class Image { + constructor(manager: ImageManager, hash: string) { + this.manager = manager; + this.hash = hash; + } + + get data(): Data.Image { + return ( + this.manager.project.data.images.get(this.hash) ?? { + filePath: "__invalid.png", + width: 0, + height: 0, + url: "", + type: "image/png", + } + ); + } + + get id(): string { + return this.hash; + } + + get filePath(): string { + return this.data.filePath; + } + + readonly manager: ImageManager; + readonly hash: string; +} + export class ImageManager { constructor(project: Project) { this.project = project; } readonly project: Project; - get images(): ObservableYMap { + get data(): ObservableYMap { return ObservableYMap.get(this.project.data.images); } + get images(): Image[] { + const paths = [...this.data.keys()]; + return paths.map((path) => new Image(this, path)); + } + uploadImage?: ( hash: string, contentType: string, @@ -31,7 +66,7 @@ export class ImageManager { const hash = await getURLSafeBase64Hash(buffer); - const existing = this.images.get(hash); + const existing = this.data.get(hash); if (existing) { return [hash, existing]; } @@ -45,18 +80,19 @@ export class ImageManager { const imgElem = await imageFromURL(url); const image: Data.Image = { + filePath: `images/${hash.slice(0, 8)}.png`, width: imgElem.width, height: imgElem.height, url, type, }; - this.images.set(hash, image); + this.data.set(hash, image); return [hash, image]; } get(hashBase64: string): Data.Image | undefined { - return this.images.get(hashBase64); + return this.data.get(hashBase64); } async getWithDataURL(hashBase64: string): Promise { @@ -74,7 +110,7 @@ export class ImageManager { } has(hashBase64: string): boolean { - return this.images.has(hashBase64); + return this.data.has(hashBase64); } async uploadImages( diff --git a/packages/model/src/models/PageList.ts b/packages/model/src/models/PageList.ts index b1a0e5d75..a82b08f6d 100644 --- a/packages/model/src/models/PageList.ts +++ b/packages/model/src/models/PageList.ts @@ -1,4 +1,3 @@ -import { posix as path } from "path-browserify"; import { Node } from "./Node"; import { computed, makeObservable } from "mobx"; import { Page } from "./Page"; @@ -7,26 +6,6 @@ import { assertNonNull } from "@uimix/foundation/src/utils/Assert"; import { Project } from "./Project"; import { getPageID } from "../data/util"; -export interface PageHierarchyFolderEntry { - type: "directory"; - id: string; - path: string; - name: string; - children: PageHierarchyEntry[]; -} - -export interface PageHierarchyPageEntry { - type: "file"; - id: string; - path: string; - name: string; - page: Page; -} - -export type PageHierarchyEntry = - | PageHierarchyFolderEntry - | PageHierarchyPageEntry; - export class PageList { constructor(project: Project) { this.project = project; @@ -53,110 +32,4 @@ export class PageList { const page = assertNonNull(Page.from(node)); return page; } - - toHierarchy(): PageHierarchyFolderEntry { - const root: PageHierarchyFolderEntry = { - type: "directory", - id: "", - name: "", - path: "", - children: [], - }; - const parents = new Map(); - parents.set("", root); - - const mkdirp = (segments: string[]): PageHierarchyFolderEntry => { - if (segments.length === 0) { - return root; - } - - const existing = parents.get(segments.join("/")); - if (existing) { - return existing; - } - - const parent = mkdirp(segments.slice(0, -1)); - const dir: PageHierarchyFolderEntry = { - type: "directory", - id: segments.join("/"), - name: segments[segments.length - 1], - path: segments.join("/"), - children: [], - }; - parent.children.push(dir); - parents.set(segments.join("/"), dir); - return dir; - }; - - const pages = Array.from(this.all); - pages.sort((a, b) => a.filePath.localeCompare(b.filePath)); - - for (const page of pages) { - const segments = page.filePath.split(path.sep); - const parent = mkdirp(segments.slice(0, -1)); - - const item: PageHierarchyPageEntry = { - type: "file", - id: page.id, - name: segments[segments.length - 1], - path: page.filePath, - page, - }; - parent.children.push(item); - } - - return root; - } - - pagesForPath(path: string): Page[] { - return this.all.filter( - (page) => page.filePath === path || page.filePath.startsWith(path + "/") - ); - } - - delete(path: string): Page[] { - const deletedPages = this.pagesForPath(path); - - for (const page of deletedPages) { - page.node.remove(); - // TODO: delete dangling nodes? - //this.project.nodes.remove(doc.root); - } - - return deletedPages; - } - - rename( - path: string, - newPath: string - ): { - originalPages: Page[]; - newPages: Page[]; - } { - if (path === newPath) { - return { - originalPages: [], - newPages: [], - }; - } - - const originalPages = this.pagesForPath(path); - const newPages: Page[] = []; - - for (const page of originalPages) { - const newName = newPath + page.filePath.slice(path.length); - const newPage = this.create(newName); - newPage.node.append(page.node.children); - newPages.push(newPage); - } - - for (const page of originalPages) { - page.node.remove(); - } - - return { - originalPages, - newPages, - }; - } } diff --git a/packages/model/src/models/PathTreeModel.ts b/packages/model/src/models/PathTreeModel.ts new file mode 100644 index 000000000..b50846b16 --- /dev/null +++ b/packages/model/src/models/PathTreeModel.ts @@ -0,0 +1,137 @@ +import { observable } from "mobx"; + +export interface PathTreeModelTarget { + filePath: string; + id: string; +} + +export interface PathTreeModelFolderItem { + type: "directory"; + id: string; + path: string; + name: string; + children: PathTreeModelItem[]; +} + +export interface PathTreeModelFileItem { + type: "file"; + id: string; + path: string; + name: string; + target: T; +} + +export type PathTreeModelItem = + | PathTreeModelFolderItem + | PathTreeModelFileItem; + +function buildTree( + targets: T[] +): PathTreeModelFolderItem { + const root: PathTreeModelFolderItem = { + type: "directory", + id: "", + name: "", + path: "", + children: [], + }; + const parents = new Map>(); + parents.set("", root); + + const mkdirp = (segments: string[]): PathTreeModelFolderItem => { + if (segments.length === 0) { + return root; + } + + const existing = parents.get(segments.join("/")); + if (existing) { + return existing; + } + + const parent = mkdirp(segments.slice(0, -1)); + const dir: PathTreeModelFolderItem = { + type: "directory", + id: segments.join("/"), + name: segments[segments.length - 1], + path: segments.join("/"), + children: [], + }; + parent.children.push(dir); + parents.set(segments.join("/"), dir); + return dir; + }; + + targets = [...targets].sort((a, b) => a.filePath.localeCompare(b.filePath)); + + for (const target of targets) { + const segments = target.filePath.split("/"); + const parent = mkdirp(segments.slice(0, -1)); + + const item: PathTreeModelFileItem = { + type: "file", + id: target.id, + name: segments[segments.length - 1], + path: target.filePath, + target, + }; + parent.children.push(item); + } + + return root; +} + +function targetsForPath( + targets: T[], + path: string +) { + return targets.filter( + (page) => page.filePath === path || page.filePath.startsWith(path + "/") + ); +} + +interface PathTreeModelDelegate { + getTargets: () => T[]; + delete(target: T): void; + rename(target: T, newName: string): T; +} + +export class PathTreeModel { + constructor(delegate: PathTreeModelDelegate) { + this.delegate = delegate; + } + + private delegate: PathTreeModelDelegate; + + readonly collapsedPaths = observable.set(); + + get targets(): T[] { + return this.delegate.getTargets(); + } + + get root(): PathTreeModelFolderItem { + return buildTree(this.targets); + } + + delete(path: string) { + for (const target of targetsForPath(this.targets, path)) { + this.delegate.delete(target); + } + } + + rename(path: string, newPath: string): Map { + if (path === newPath) { + return new Map(); + } + + const oldToNew = new Map(); + + for (const target of targetsForPath(this.targets, path)) { + const newName = newPath + target.filePath.slice(path.length); + const newTarget = this.delegate.rename(target, newName); + + oldToNew.set(target, newTarget); + } + + return oldToNew; + } +} diff --git a/packages/model/src/models/Project.test.ts b/packages/model/src/models/Project.test.ts index b315b30b4..91012b7f8 100644 --- a/packages/model/src/models/Project.test.ts +++ b/packages/model/src/models/Project.test.ts @@ -16,7 +16,7 @@ describe(Project.name, () => { expect([...project.nodes.data.keys()]).toMatchSnapshot(); expect([...project.selectables.stylesData.keys()]).toMatchSnapshot(); - expect(project.imageManager.images.toJSON()).toMatchSnapshot(); + expect(project.imageManager.data.toJSON()).toMatchSnapshot(); expect(project.componentURLs).toMatchSnapshot(); expect(project.toJSON()).toEqual(projectJSON); }); diff --git a/packages/model/src/models/index.ts b/packages/model/src/models/index.ts index 3533901b0..dc77c4f50 100644 --- a/packages/model/src/models/index.ts +++ b/packages/model/src/models/index.ts @@ -7,6 +7,7 @@ export * from "./Node"; export * from "./ObjectData"; export * from "./Page"; export * from "./PageList"; +export * from "./PathTreeModel"; export * from "./Project"; export * from "../collaborative/ProjectData"; export * from "./Selectable";