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";