From 60d8ba139a824504c1668139ebf48fec497fe784 Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 16:38:17 +0900 Subject: [PATCH 01/17] Add PathHierarchy --- .../editor/src/views/outline/PageTreeView.tsx | 10 +- packages/model/src/models/PageList.ts | 86 +---------------- packages/model/src/models/PathHierarchy.ts | 93 +++++++++++++++++++ packages/model/src/models/index.ts | 1 + 4 files changed, 104 insertions(+), 86 deletions(-) create mode 100644 packages/model/src/models/PathHierarchy.ts diff --git a/packages/editor/src/views/outline/PageTreeView.tsx b/packages/editor/src/views/outline/PageTreeView.tsx index 04f7b332a..c8bc58ea5 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, PathHierarchy } from "@uimix/model/src/models"; import { showContextMenu } from "../ContextMenu"; interface PageTreeViewItem extends TreeViewItem { - entry: PageHierarchyEntry; + entry: PathHierarchy; } function buildTreeViewItem( - entry: PageHierarchyEntry, + entry: PathHierarchy, parent?: PageTreeViewItem ): PageTreeViewItem { const treeViewItem: PageTreeViewItem = { @@ -57,12 +57,12 @@ const PageRow = observer( const { entry } = item; const selected = - entry.type === "file" && entry.page.id === projectState.page?.id; + entry.type === "file" && entry.target.id === projectState.page?.id; const collapsed = projectState.collapsedPaths.has(entry.path); const onClick = action(() => { if (entry.type === "file") { - projectState.openPage(entry.page); + projectState.openPage(entry.target); } }); const onCollapsedChange = action((value: boolean) => { diff --git a/packages/model/src/models/PageList.ts b/packages/model/src/models/PageList.ts index b1a0e5d75..17960c1e7 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"; @@ -6,26 +5,7 @@ import { compact } from "lodash-es"; 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; +import { PathHierarchyFolder, PathHierarchy } from "./PathHierarchy"; export class PageList { constructor(project: Project) { @@ -54,68 +34,12 @@ export class PageList { 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 + "/") - ); + toHierarchy(): PathHierarchyFolder { + return PathHierarchy.build(this.all); } delete(path: string): Page[] { - const deletedPages = this.pagesForPath(path); + const deletedPages = PathHierarchy.targetsForPath(this.all, path); for (const page of deletedPages) { page.node.remove(); @@ -140,7 +64,7 @@ export class PageList { }; } - const originalPages = this.pagesForPath(path); + const originalPages = PathHierarchy.targetsForPath(this.all, path); const newPages: Page[] = []; for (const page of originalPages) { diff --git a/packages/model/src/models/PathHierarchy.ts b/packages/model/src/models/PathHierarchy.ts new file mode 100644 index 000000000..61236dedc --- /dev/null +++ b/packages/model/src/models/PathHierarchy.ts @@ -0,0 +1,93 @@ +export interface PathHierarchyTarget { + id: string; + filePath: string; +} + +export interface PathHierarchyFolder { + type: "directory"; + id: string; + path: string; + name: string; + children: PathHierarchy[]; +} + +export interface PathHierarchyFile { + type: "file"; + id: string; + path: string; + name: string; + target: T; +} + +export type PathHierarchy = + | PathHierarchyFolder + | PathHierarchyFile; + +function build( + targets: T[] +): PathHierarchyFolder { + const root: PathHierarchyFolder = { + type: "directory", + id: "", + name: "", + path: "", + children: [], + }; + const parents = new Map>(); + parents.set("", root); + + const mkdirp = (segments: string[]): PathHierarchyFolder => { + 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: PathHierarchyFolder = { + 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: PathHierarchyFile = { + 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 + "/") + ); +} + +export const PathHierarchy = { + build, + targetsForPath, +}; diff --git a/packages/model/src/models/index.ts b/packages/model/src/models/index.ts index 3533901b0..8301c297e 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 "./PathHierarchy"; export * from "./Project"; export * from "../collaborative/ProjectData"; export * from "./Selectable"; From 0e2f27818b664a48af247028ac7cd39587d5d2e4 Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 16:44:17 +0900 Subject: [PATCH 02/17] Add PathTreeModel --- packages/model/src/models/PathTreeModel.ts | 128 +++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 packages/model/src/models/PathTreeModel.ts diff --git a/packages/model/src/models/PathTreeModel.ts b/packages/model/src/models/PathTreeModel.ts new file mode 100644 index 000000000..e17430726 --- /dev/null +++ b/packages/model/src/models/PathTreeModel.ts @@ -0,0 +1,128 @@ +export interface PathTreeModelTarget { + id: string; + filePath: string; +} + +export interface FolderItem { + type: "directory"; + id: string; + path: string; + name: string; + children: Item[]; +} + +export interface FileItem { + type: "file"; + id: string; + path: string; + name: string; + target: T; +} + +export type Item = FolderItem | FileItem; + +function build(targets: T[]): FolderItem { + const root: FolderItem = { + type: "directory", + id: "", + name: "", + path: "", + children: [], + }; + const parents = new Map>(); + parents.set("", root); + + const mkdirp = (segments: string[]): FolderItem => { + 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: FolderItem = { + 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: FileItem = { + 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 + "/") + ); +} + +export const PathHierarchy = { + build, + targetsForPath, +}; + +interface Delegate { + getTargets: () => T[]; + delete(target: T): void; + rename(target: T, newName: string): void; +} + +export class PathTreeModel { + constructor(delegate: Delegate) { + this.delegate = delegate; + } + + private delegate: Delegate; + + get targets(): T[] { + return this.delegate.getTargets(); + } + + get root(): FolderItem { + return build(this.targets); + } + + delete(path: string) { + for (const target of PathHierarchy.targetsForPath(this.targets, path)) { + this.delegate.delete(target); + } + } + + rename(path: string, newPath: string) { + if (path === newPath) { + return; + } + + for (const target of PathHierarchy.targetsForPath(this.targets, path)) { + const newName = newPath + target.filePath.slice(path.length); + this.delegate.rename(target, newName); + } + } +} From ce3cc53f017c3868179c3e65d8769174e1b4cf9f Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 16:45:16 +0900 Subject: [PATCH 03/17] Refactor --- packages/model/src/models/PathTreeModel.ts | 43 +++++++++++----------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/model/src/models/PathTreeModel.ts b/packages/model/src/models/PathTreeModel.ts index e17430726..3e8c31ffc 100644 --- a/packages/model/src/models/PathTreeModel.ts +++ b/packages/model/src/models/PathTreeModel.ts @@ -3,15 +3,15 @@ export interface PathTreeModelTarget { filePath: string; } -export interface FolderItem { +export interface PathTreeModelFolderItem { type: "directory"; id: string; path: string; name: string; - children: Item[]; + children: PathTreeModelItem[]; } -export interface FileItem { +export interface PathTreeModelFileItem { type: "file"; id: string; path: string; @@ -19,20 +19,24 @@ export interface FileItem { target: T; } -export type Item = FolderItem | FileItem; +export type PathTreeModelItem = + | PathTreeModelFolderItem + | PathTreeModelFileItem; -function build(targets: T[]): FolderItem { - const root: FolderItem = { +function buildTree( + targets: T[] +): PathTreeModelFolderItem { + const root: PathTreeModelFolderItem = { type: "directory", id: "", name: "", path: "", children: [], }; - const parents = new Map>(); + const parents = new Map>(); parents.set("", root); - const mkdirp = (segments: string[]): FolderItem => { + const mkdirp = (segments: string[]): PathTreeModelFolderItem => { if (segments.length === 0) { return root; } @@ -43,7 +47,7 @@ function build(targets: T[]): FolderItem { } const parent = mkdirp(segments.slice(0, -1)); - const dir: FolderItem = { + const dir: PathTreeModelFolderItem = { type: "directory", id: segments.join("/"), name: segments[segments.length - 1], @@ -61,7 +65,7 @@ function build(targets: T[]): FolderItem { const segments = target.filePath.split("/"); const parent = mkdirp(segments.slice(0, -1)); - const item: FileItem = { + const item: PathTreeModelFileItem = { type: "file", id: target.id, name: segments[segments.length - 1], @@ -83,34 +87,29 @@ function targetsForPath( ); } -export const PathHierarchy = { - build, - targetsForPath, -}; - -interface Delegate { +interface PathTreeModelDelegate { getTargets: () => T[]; delete(target: T): void; rename(target: T, newName: string): void; } export class PathTreeModel { - constructor(delegate: Delegate) { + constructor(delegate: PathTreeModelDelegate) { this.delegate = delegate; } - private delegate: Delegate; + private delegate: PathTreeModelDelegate; get targets(): T[] { return this.delegate.getTargets(); } - get root(): FolderItem { - return build(this.targets); + get root(): PathTreeModelFolderItem { + return buildTree(this.targets); } delete(path: string) { - for (const target of PathHierarchy.targetsForPath(this.targets, path)) { + for (const target of targetsForPath(this.targets, path)) { this.delegate.delete(target); } } @@ -120,7 +119,7 @@ export class PathTreeModel { return; } - for (const target of PathHierarchy.targetsForPath(this.targets, path)) { + for (const target of targetsForPath(this.targets, path)) { const newName = newPath + target.filePath.slice(path.length); this.delegate.rename(target, newName); } From 0c5af07f533d864210bfb058f210303452861ef2 Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 17:02:41 +0900 Subject: [PATCH 04/17] Use PathTreeModel --- packages/editor/src/state/Commands.tsx | 8 +-- packages/editor/src/state/ProjectState.ts | 57 +++++++++++-------- .../editor/src/views/outline/PageTreeView.tsx | 8 +-- packages/model/src/models/PathTreeModel.ts | 14 +++-- packages/model/src/models/index.ts | 2 +- 5 files changed, 51 insertions(+), 38 deletions(-) diff --git a/packages/editor/src/state/Commands.tsx b/packages/editor/src/state/Commands.tsx index 33bf02bd8..d4c56f09e 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,7 +660,7 @@ class Commands { ]; } - contextMenuForFile(file: PageHierarchyEntry): MenuItemDef[] { + contextMenuForFile(file: PathTreeModelItem): MenuItemDef[] { if (file.type === "directory") { return [ { @@ -676,7 +676,7 @@ class Commands { text: "Delete", // disabled: projectState.project.pages.count === 1, onClick: action(() => { - projectState.deletePageOrPageFolder(file.path); + projectState.pageTreeModel.delete(file.path); }), }, ]; @@ -687,7 +687,7 @@ class Commands { text: "Delete", // disabled: projectState.project.pages.count === 1, onClick: action(() => { - projectState.deletePageOrPageFolder(file.path); + projectState.pageTreeModel.delete(file.path); }), }, ]; diff --git a/packages/editor/src/state/ProjectState.ts b/packages/editor/src/state/ProjectState.ts index a25df57b6..ef8f592f7 100644 --- a/packages/editor/src/state/ProjectState.ts +++ b/packages/editor/src/state/ProjectState.ts @@ -1,8 +1,13 @@ 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 { getPageID, usedImageHashesInStyle } from "@uimix/model/src/data/util"; +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() { @@ -260,6 +261,21 @@ export class ProjectState { // 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; + }, + }); + + readonly collapsedPaths = observable.set(); + openPage(page: Page) { this.pageID = page.id; } @@ -278,29 +294,20 @@ export class ProjectState { } 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; - } - + this.pageTreeModel.delete(path); this.undoManager.stopCapturing(); } renamePageOrPageFolder(path: string, newPath: string) { - const { originalPages, newPages } = this.project.pages.rename( - path, - newPath - ); - - const selectedOriginalPageIndex = originalPages.findIndex( - (page) => page.id === this.pageID - ); - if (selectedOriginalPageIndex !== -1) { - this.pageID = newPages[selectedOriginalPageIndex].id; + const oldCurrent = this.page; + const oldToNew = this.pageTreeModel.rename(path, newPath); + console.log(path, newPath); + + 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/PageTreeView.tsx b/packages/editor/src/views/outline/PageTreeView.tsx index c8bc58ea5..8036fdad5 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 { Page, PathHierarchy } from "@uimix/model/src/models"; +import { Page, PathTreeModelItem } from "@uimix/model/src/models"; import { showContextMenu } from "../ContextMenu"; interface PageTreeViewItem extends TreeViewItem { - entry: PathHierarchy; + entry: PathTreeModelItem; } function buildTreeViewItem( - entry: PathHierarchy, + entry: PathTreeModelItem, parent?: PageTreeViewItem ): PageTreeViewItem { const treeViewItem: PageTreeViewItem = { @@ -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 ( ( interface PathTreeModelDelegate { getTargets: () => T[]; delete(target: T): void; - rename(target: T, newName: string): void; + rename(target: T, newName: string): T; } export class PathTreeModel { @@ -114,14 +114,20 @@ export class PathTreeModel { } } - rename(path: string, newPath: string) { + rename(path: string, newPath: string): Map { if (path === newPath) { - return; + return new Map(); } + const oldToNew = new Map(); + for (const target of targetsForPath(this.targets, path)) { const newName = newPath + target.filePath.slice(path.length); - this.delegate.rename(target, newName); + const newTarget = this.delegate.rename(target, newName); + + oldToNew.set(target, newTarget); } + + return oldToNew; } } diff --git a/packages/model/src/models/index.ts b/packages/model/src/models/index.ts index 8301c297e..dc77c4f50 100644 --- a/packages/model/src/models/index.ts +++ b/packages/model/src/models/index.ts @@ -7,7 +7,7 @@ export * from "./Node"; export * from "./ObjectData"; export * from "./Page"; export * from "./PageList"; -export * from "./PathHierarchy"; +export * from "./PathTreeModel"; export * from "./Project"; export * from "../collaborative/ProjectData"; export * from "./Selectable"; From cb8b83d34541a451ee49ff84ccfcfb76646a4f7e Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 17:04:28 +0900 Subject: [PATCH 05/17] Rename --- packages/editor/src/state/Commands.tsx | 10 +++++----- packages/editor/src/views/outline/PageTreeView.tsx | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/state/Commands.tsx b/packages/editor/src/state/Commands.tsx index d4c56f09e..f5030b989 100644 --- a/packages/editor/src/state/Commands.tsx +++ b/packages/editor/src/state/Commands.tsx @@ -660,14 +660,14 @@ class Commands { ]; } - contextMenuForFile(file: PathTreeModelItem): 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.pageTreeModel.delete(file.path); + projectState.deletePageOrPageFolder(page.path); }), }, ]; @@ -687,7 +687,7 @@ class Commands { text: "Delete", // disabled: projectState.project.pages.count === 1, onClick: action(() => { - projectState.pageTreeModel.delete(file.path); + projectState.deletePageOrPageFolder(page.path); }), }, ]; diff --git a/packages/editor/src/views/outline/PageTreeView.tsx b/packages/editor/src/views/outline/PageTreeView.tsx index 8036fdad5..ad868f363 100644 --- a/packages/editor/src/views/outline/PageTreeView.tsx +++ b/packages/editor/src/views/outline/PageTreeView.tsx @@ -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" > @@ -143,7 +143,7 @@ export const PageTreeView = observer(() => { onContextMenu={action((e) => { e.preventDefault(); - showContextMenu(e, commands.contextMenuForFile(rootItem.entry)); + showContextMenu(e, commands.contextMenuForPage(rootItem.entry)); })} /> } From 3ea707dba435a07f29583b4cc9b30e391ac8d9a2 Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 17:05:08 +0900 Subject: [PATCH 06/17] Rename --- packages/editor/src/state/Commands.tsx | 4 ++-- packages/editor/src/state/ProjectState.ts | 4 ++-- packages/editor/src/views/outline/PageTreeView.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/state/Commands.tsx b/packages/editor/src/state/Commands.tsx index f5030b989..657eeb866 100644 --- a/packages/editor/src/state/Commands.tsx +++ b/packages/editor/src/state/Commands.tsx @@ -676,7 +676,7 @@ class Commands { text: "Delete", // disabled: projectState.project.pages.count === 1, onClick: action(() => { - projectState.deletePageOrPageFolder(page.path); + projectState.deletePagePath(page.path); }), }, ]; @@ -687,7 +687,7 @@ class Commands { text: "Delete", // disabled: projectState.project.pages.count === 1, onClick: action(() => { - projectState.deletePageOrPageFolder(page.path); + projectState.deletePagePath(page.path); }), }, ]; diff --git a/packages/editor/src/state/ProjectState.ts b/packages/editor/src/state/ProjectState.ts index ef8f592f7..2859ba75f 100644 --- a/packages/editor/src/state/ProjectState.ts +++ b/packages/editor/src/state/ProjectState.ts @@ -293,12 +293,12 @@ export class ProjectState { this.undoManager.stopCapturing(); } - deletePageOrPageFolder(path: string) { + deletePagePath(path: string) { this.pageTreeModel.delete(path); this.undoManager.stopCapturing(); } - renamePageOrPageFolder(path: string, newPath: string) { + renamePagePath(path: string, newPath: string) { const oldCurrent = this.page; const oldToNew = this.pageTreeModel.rename(path, newPath); console.log(path, newPath); diff --git a/packages/editor/src/views/outline/PageTreeView.tsx b/packages/editor/src/views/outline/PageTreeView.tsx index ad868f363..ad5d33ed0 100644 --- a/packages/editor/src/views/outline/PageTreeView.tsx +++ b/packages/editor/src/views/outline/PageTreeView.tsx @@ -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); })} /> @@ -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); } }); }} From 6b311b94f3eddb2d3248634efe9275abefbf0b7f Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 17:05:49 +0900 Subject: [PATCH 07/17] Cleanup --- packages/editor/src/state/ProjectState.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/editor/src/state/ProjectState.ts b/packages/editor/src/state/ProjectState.ts index 2859ba75f..9f3a2d122 100644 --- a/packages/editor/src/state/ProjectState.ts +++ b/packages/editor/src/state/ProjectState.ts @@ -1,7 +1,7 @@ import { computed, makeObservable, observable } from "mobx"; import * as Y from "yjs"; import * as Data from "@uimix/model/src/data/v1"; -import { getPageID, usedImageHashesInStyle } from "@uimix/model/src/data/util"; +import { usedImageHashesInStyle } from "@uimix/model/src/data/util"; import { Project, Page, @@ -301,7 +301,6 @@ export class ProjectState { renamePagePath(path: string, newPath: string) { const oldCurrent = this.page; const oldToNew = this.pageTreeModel.rename(path, newPath); - console.log(path, newPath); if (oldCurrent) { const newCurrent = oldToNew.get(oldCurrent); From c3479d0da72126ddf7c0d3ae74b240a197896d13 Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 17:07:32 +0900 Subject: [PATCH 08/17] Cleanup --- packages/model/src/models/PageList.ts | 51 ------------ packages/model/src/models/PathHierarchy.ts | 93 ---------------------- 2 files changed, 144 deletions(-) delete mode 100644 packages/model/src/models/PathHierarchy.ts diff --git a/packages/model/src/models/PageList.ts b/packages/model/src/models/PageList.ts index 17960c1e7..a82b08f6d 100644 --- a/packages/model/src/models/PageList.ts +++ b/packages/model/src/models/PageList.ts @@ -5,7 +5,6 @@ import { compact } from "lodash-es"; import { assertNonNull } from "@uimix/foundation/src/utils/Assert"; import { Project } from "./Project"; import { getPageID } from "../data/util"; -import { PathHierarchyFolder, PathHierarchy } from "./PathHierarchy"; export class PageList { constructor(project: Project) { @@ -33,54 +32,4 @@ export class PageList { const page = assertNonNull(Page.from(node)); return page; } - - toHierarchy(): PathHierarchyFolder { - return PathHierarchy.build(this.all); - } - - delete(path: string): Page[] { - const deletedPages = PathHierarchy.targetsForPath(this.all, 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 = PathHierarchy.targetsForPath(this.all, 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/PathHierarchy.ts b/packages/model/src/models/PathHierarchy.ts deleted file mode 100644 index 61236dedc..000000000 --- a/packages/model/src/models/PathHierarchy.ts +++ /dev/null @@ -1,93 +0,0 @@ -export interface PathHierarchyTarget { - id: string; - filePath: string; -} - -export interface PathHierarchyFolder { - type: "directory"; - id: string; - path: string; - name: string; - children: PathHierarchy[]; -} - -export interface PathHierarchyFile { - type: "file"; - id: string; - path: string; - name: string; - target: T; -} - -export type PathHierarchy = - | PathHierarchyFolder - | PathHierarchyFile; - -function build( - targets: T[] -): PathHierarchyFolder { - const root: PathHierarchyFolder = { - type: "directory", - id: "", - name: "", - path: "", - children: [], - }; - const parents = new Map>(); - parents.set("", root); - - const mkdirp = (segments: string[]): PathHierarchyFolder => { - 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: PathHierarchyFolder = { - 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: PathHierarchyFile = { - 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 + "/") - ); -} - -export const PathHierarchy = { - build, - targetsForPath, -}; From 8160429038f37f698fd5a9c0344ec7d0fb252f9d Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 17:26:44 +0900 Subject: [PATCH 09/17] Move collapsedPaths --- packages/editor/src/state/ProjectState.ts | 2 -- packages/editor/src/views/outline/PageTreeView.tsx | 8 ++++---- packages/model/src/models/PathTreeModel.ts | 4 ++++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/state/ProjectState.ts b/packages/editor/src/state/ProjectState.ts index 9f3a2d122..15b02baa4 100644 --- a/packages/editor/src/state/ProjectState.ts +++ b/packages/editor/src/state/ProjectState.ts @@ -274,8 +274,6 @@ export class ProjectState { }, }); - readonly collapsedPaths = observable.set(); - openPage(page: Page) { this.pageID = page.id; } diff --git a/packages/editor/src/views/outline/PageTreeView.tsx b/packages/editor/src/views/outline/PageTreeView.tsx index ad5d33ed0..d722ffa6a 100644 --- a/packages/editor/src/views/outline/PageTreeView.tsx +++ b/packages/editor/src/views/outline/PageTreeView.tsx @@ -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) @@ -58,7 +58,7 @@ const PageRow = observer( const selected = entry.type === "file" && entry.target.id === projectState.page?.id; - const collapsed = projectState.collapsedPaths.has(entry.path); + const collapsed = projectState.pageTreeModel.collapsedPaths.has(entry.path); const onClick = action(() => { if (entry.type === "file") { @@ -67,9 +67,9 @@ const PageRow = observer( }); 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); } }); diff --git a/packages/model/src/models/PathTreeModel.ts b/packages/model/src/models/PathTreeModel.ts index 48949e5fc..0cbfde762 100644 --- a/packages/model/src/models/PathTreeModel.ts +++ b/packages/model/src/models/PathTreeModel.ts @@ -1,3 +1,5 @@ +import { observable } from "mobx"; + export interface PathTreeModelTarget { id: string; filePath: string; @@ -100,6 +102,8 @@ export class PathTreeModel { private delegate: PathTreeModelDelegate; + readonly collapsedPaths = observable.set(); + get targets(): T[] { return this.delegate.getTargets(); } From 32247ab1b977d0ce425bddeb2ca3039f3b7c49e1 Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 17:29:41 +0900 Subject: [PATCH 10/17] Simplify id --- packages/editor/src/views/outline/PageTreeView.tsx | 2 +- packages/model/src/models/PathTreeModel.ts | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/editor/src/views/outline/PageTreeView.tsx b/packages/editor/src/views/outline/PageTreeView.tsx index d722ffa6a..b7f63bb17 100644 --- a/packages/editor/src/views/outline/PageTreeView.tsx +++ b/packages/editor/src/views/outline/PageTreeView.tsx @@ -26,7 +26,7 @@ function buildTreeViewItem( parent?: PageTreeViewItem ): PageTreeViewItem { const treeViewItem: PageTreeViewItem = { - key: entry.id, + key: entry.path, parent, entry, children: [], diff --git a/packages/model/src/models/PathTreeModel.ts b/packages/model/src/models/PathTreeModel.ts index 0cbfde762..a2eca65c4 100644 --- a/packages/model/src/models/PathTreeModel.ts +++ b/packages/model/src/models/PathTreeModel.ts @@ -1,13 +1,11 @@ import { observable } from "mobx"; export interface PathTreeModelTarget { - id: string; filePath: string; } export interface PathTreeModelFolderItem { type: "directory"; - id: string; path: string; name: string; children: PathTreeModelItem[]; @@ -15,7 +13,6 @@ export interface PathTreeModelFolderItem { export interface PathTreeModelFileItem { type: "file"; - id: string; path: string; name: string; target: T; @@ -30,7 +27,6 @@ function buildTree( ): PathTreeModelFolderItem { const root: PathTreeModelFolderItem = { type: "directory", - id: "", name: "", path: "", children: [], @@ -51,7 +47,6 @@ function buildTree( const parent = mkdirp(segments.slice(0, -1)); const dir: PathTreeModelFolderItem = { type: "directory", - id: segments.join("/"), name: segments[segments.length - 1], path: segments.join("/"), children: [], @@ -69,7 +64,6 @@ function buildTree( const item: PathTreeModelFileItem = { type: "file", - id: target.id, name: segments[segments.length - 1], path: target.filePath, target, From d47bb6ae2a3c09a27e74aabee438fc53ea4c7e5b Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 17:34:32 +0900 Subject: [PATCH 11/17] Add Image class --- packages/cli/src/project/WorkspaceLoader.ts | 4 +-- packages/model/src/models/ImageManager.ts | 31 +++++++++++++++++---- packages/model/src/models/Project.test.ts | 2 +- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/project/WorkspaceLoader.ts b/packages/cli/src/project/WorkspaceLoader.ts index 142f81573..978788c51 100644 --- a/packages/cli/src/project/WorkspaceLoader.ts +++ b/packages/cli/src/project/WorkspaceLoader.ts @@ -223,7 +223,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,7 +288,7 @@ 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; } diff --git a/packages/model/src/models/ImageManager.ts b/packages/model/src/models/ImageManager.ts index 3ff3fb188..9dcd94509 100644 --- a/packages/model/src/models/ImageManager.ts +++ b/packages/model/src/models/ImageManager.ts @@ -5,13 +5,34 @@ 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, filePath: string) { + this.manager = manager; + this.filePath = filePath; + } + + get data(): Data.Image { + return ( + this.manager.project.data.images.get(this.filePath) ?? { + width: 0, + height: 0, + url: "", + type: "image/png", + } + ); + } + + readonly manager: ImageManager; + readonly filePath: 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); } @@ -31,7 +52,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]; } @@ -51,12 +72,12 @@ export class ImageManager { 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 +95,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/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); }); From 6474edb68eda942c26ea86fe194213a61ff588fa Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 17:35:19 +0900 Subject: [PATCH 12/17] Add images getter --- packages/model/src/models/ImageManager.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/model/src/models/ImageManager.ts b/packages/model/src/models/ImageManager.ts index 9dcd94509..f0e3c2828 100644 --- a/packages/model/src/models/ImageManager.ts +++ b/packages/model/src/models/ImageManager.ts @@ -36,6 +36,11 @@ export class ImageManager { 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, From a4a8530b1b9c0b69ef3352bafc43a07ecaa33bca Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 17:40:46 +0900 Subject: [PATCH 13/17] Add ImageTreeView --- packages/editor/src/state/ProjectState.ts | 13 ++ .../src/views/outline/ImageTreeView.tsx | 175 ++++++++++++++++++ .../src/views/outline/OutlineSideBar.tsx | 18 ++ 3 files changed, 206 insertions(+) create mode 100644 packages/editor/src/views/outline/ImageTreeView.tsx diff --git a/packages/editor/src/state/ProjectState.ts b/packages/editor/src/state/ProjectState.ts index 15b02baa4..17226f232 100644 --- a/packages/editor/src/state/ProjectState.ts +++ b/packages/editor/src/state/ProjectState.ts @@ -259,6 +259,19 @@ 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({ diff --git a/packages/editor/src/views/outline/ImageTreeView.tsx b/packages/editor/src/views/outline/ImageTreeView.tsx new file mode 100644 index 000000000..f72c77c97 --- /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.path, + 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 + + { + + + + + ); }); From d256aa31ed72ac8c468029ad770b68cdccdebf34 Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 17:46:35 +0900 Subject: [PATCH 14/17] Add hash to image data --- packages/model/src/data/v1/image.ts | 14 ++++++++++++++ packages/model/src/data/v1/index.ts | 5 +++-- packages/model/src/data/v1/project.ts | 13 +------------ packages/model/src/models/ImageManager.ts | 4 +++- 4 files changed, 21 insertions(+), 15 deletions(-) create mode 100644 packages/model/src/data/v1/image.ts diff --git a/packages/model/src/data/v1/image.ts b/packages/model/src/data/v1/image.ts new file mode 100644 index 000000000..cf51082cf --- /dev/null +++ b/packages/model/src/data/v1/image.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const ImageType = z.enum(["image/png", "image/jpeg"]); +export type ImageType = z.infer; + +export const Image = z.object({ + hash: z.string(), // URL-safe base64 of the sha256 hash of the image data + width: z.number(), + height: z.number(), + type: ImageType, + url: z.string(), +}); + +export type Image = z.infer; diff --git a/packages/model/src/data/v1/index.ts b/packages/model/src/data/v1/index.ts index 77b297986..603d9eab4 100644 --- a/packages/model/src/data/v1/index.ts +++ b/packages/model/src/data/v1/index.ts @@ -1,4 +1,5 @@ -export * from "./style.js"; +export * from "./clipboard.js"; +export * from "./image.js"; export * from "./node.js"; export * from "./project.js"; -export * from "./clipboard.js"; +export * from "./style.js"; diff --git a/packages/model/src/data/v1/project.ts b/packages/model/src/data/v1/project.ts index 6cab1c7bb..c487ae71a 100644 --- a/packages/model/src/data/v1/project.ts +++ b/packages/model/src/data/v1/project.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { Node } from "./node.js"; import { Style } from "./style.js"; +import { Image } from "./image.js"; export const ColorToken = z.object({ name: z.string(), @@ -10,18 +11,6 @@ export const ColorToken = z.object({ }); export type ColorToken = z.infer; -export const ImageType = z.enum(["image/png", "image/jpeg"]); -export type ImageType = z.infer; - -export const Image = z.object({ - width: z.number(), - height: z.number(), - type: ImageType, - url: z.string(), -}); - -export type Image = z.infer; - export const Project = z.object({ // TODO: version nodes: z.record(Node), diff --git a/packages/model/src/models/ImageManager.ts b/packages/model/src/models/ImageManager.ts index f0e3c2828..8c72324f9 100644 --- a/packages/model/src/models/ImageManager.ts +++ b/packages/model/src/models/ImageManager.ts @@ -14,6 +14,7 @@ export class Image { get data(): Data.Image { return ( this.manager.project.data.images.get(this.filePath) ?? { + hash: "", width: 0, height: 0, url: "", @@ -71,13 +72,14 @@ export class ImageManager { const imgElem = await imageFromURL(url); const image: Data.Image = { + hash: hash, width: imgElem.width, height: imgElem.height, url, type, }; - this.data.set(hash, image); + this.data.set("images/" + hash, image); return [hash, image]; } From 8396ef5d4dffe7cea08bd2953cb1668f55dca5ba Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 17:47:09 +0900 Subject: [PATCH 15/17] Fix --- packages/model/src/data/v1/clipboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/model/src/data/v1/clipboard.ts b/packages/model/src/data/v1/clipboard.ts index 89043c89c..733fb80bf 100644 --- a/packages/model/src/data/v1/clipboard.ts +++ b/packages/model/src/data/v1/clipboard.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { NodeType, VariantCondition } from "./node"; -import { Image } from "./project"; +import { Image } from "./image"; import { Style } from "./style"; const SelectableBase = z.object({ From 675531f39cf9af51dd42081af4aebfb397954a21 Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 18:04:28 +0900 Subject: [PATCH 16/17] WIP use file path as key --- packages/editor/src/state/Clipboard.ts | 4 +- .../src/views/inspector/style/ImagePane.tsx | 26 ++++--- .../dragHandler/NodeInsertDragHandler.ts | 6 +- packages/model/src/models/ImageManager.ts | 67 ++++++++++++------- 4 files changed, 61 insertions(+), 42 deletions(-) diff --git a/packages/editor/src/state/Clipboard.ts b/packages/editor/src/state/Clipboard.ts index 8575fe0da..06dde77b1 100644 --- a/packages/editor/src/state/Clipboard.ts +++ b/packages/editor/src/state/Clipboard.ts @@ -17,7 +17,7 @@ export class Clipboard { static async readNodes(): Promise { const imageDataURL = await this.handler.get("image"); if (imageDataURL) { - const [hash] = await projectState.project.imageManager.insertDataURL( + const image = await projectState.project.imageManager.insertDataURL( imageDataURL ); @@ -32,7 +32,7 @@ export class Clipboard { style: { width: { type: "hug" }, height: { type: "hug" }, - imageHash: hash, + imageHash: image.filePath, }, children: [], }, diff --git a/packages/editor/src/views/inspector/style/ImagePane.tsx b/packages/editor/src/views/inspector/style/ImagePane.tsx index 538088941..dc01ad813 100644 --- a/packages/editor/src/views/inspector/style/ImagePane.tsx +++ b/packages/editor/src/views/inspector/style/ImagePane.tsx @@ -23,15 +23,14 @@ export const ImagePane: React.FC = observer(function ImagePane() { const imageManager = projectState.project.imageManager; - const src = imageHash && imageManager.get(imageHash)?.url; + const image = imageHash != null ? imageManager.get(imageHash) : undefined; const copyImage = async () => { - const imageWithDataURL = - imageHash && (await imageManager.getWithDataURL(imageHash)); - if (!imageWithDataURL) { + const dataURL = await image?.getDataURL(); + if (!dataURL) { return; } - await Clipboard.handler.set("image", imageWithDataURL.url); + await Clipboard.handler.set("image", dataURL); }; const pasteImage = async () => { @@ -39,10 +38,10 @@ export const ImagePane: React.FC = observer(function ImagePane() { if (!dataURL) { return; } - const [hash] = await imageManager.insertDataURL(dataURL); + const image = await imageManager.insertDataURL(dataURL); runInAction(() => { for (const selectable of selectables) { - selectable.style.imageHash = hash; + selectable.style.imageHash = image.filePath; } projectState.undoManager.stopCapturing(); }); @@ -53,24 +52,23 @@ export const ImagePane: React.FC = observer(function ImagePane() { if (!file) { return; } - const [hash] = await imageManager.insert(file); + const image = await imageManager.insert(file); runInAction(() => { for (const selectable of selectables) { - selectable.style.imageHash = hash; + selectable.style.imageHash = image.filePath; } projectState.undoManager.stopCapturing(); }); }; const downloadImage = async () => { - const imageWithDataURL = - imageHash && (await imageManager.getWithDataURL(imageHash)); - if (!imageWithDataURL) { + const image = imageHash && imageManager.get(imageHash); + if (!image) { return; } const a = document.createElement("a"); - a.href = imageWithDataURL.url; + a.href = await image.getDataURL(); a.download = "image.png"; a.click(); }; @@ -80,7 +78,7 @@ export const ImagePane: React.FC = observer(function ImagePane() { diff --git a/packages/editor/src/views/viewport/dragHandler/NodeInsertDragHandler.ts b/packages/editor/src/views/viewport/dragHandler/NodeInsertDragHandler.ts index 5b96173fa..01250aeac 100644 --- a/packages/editor/src/views/viewport/dragHandler/NodeInsertDragHandler.ts +++ b/packages/editor/src/views/viewport/dragHandler/NodeInsertDragHandler.ts @@ -50,9 +50,9 @@ export class NodeInsertDragHandler implements DragHandler { this.selectable.style.width = { type: "fixed", value: 100 }; this.selectable.style.height = { type: "fixed", value: 100 }; void projectState.project.imageManager.insert(mode.blob).then( - action(([hash]) => { - console.log(hash); - this.selectable.style.imageHash = hash; + action((image) => { + console.log(image.filePath); + this.selectable.style.imageHash = image.filePath; }) ); } else { diff --git a/packages/model/src/models/ImageManager.ts b/packages/model/src/models/ImageManager.ts index 8c72324f9..ee30ccbf4 100644 --- a/packages/model/src/models/ImageManager.ts +++ b/packages/model/src/models/ImageManager.ts @@ -23,6 +23,36 @@ export class Image { ); } + get hash(): string { + return this.data.hash; + } + + get width(): number { + return this.data.width; + } + + get height(): number { + return this.data.height; + } + + get url(): string { + return this.data.url; + } + + get type(): Data.ImageType { + return this.data.type; + } + + async getDataURL(): Promise { + if (this.url.startsWith("data:")) { + return this.url; + } + + const response = await fetch(this.url); + const blob = await response.blob(); + return await blobToDataURL(blob); + } + readonly manager: ImageManager; readonly filePath: string; } @@ -48,19 +78,20 @@ export class ImageManager { data: Uint8Array ) => Promise; - async insertDataURL(dataURL: string): Promise<[string, Data.Image]> { + async insertDataURL(dataURL: string): Promise { return this.insert(await (await fetch(dataURL)).blob()); } - async insert(blob: Blob): Promise<[string, Data.Image]> { + async insert(blob: Blob): Promise { const type = Data.ImageType.parse(blob.type); const buffer = await blob.arrayBuffer(); const hash = await getURLSafeBase64Hash(buffer); - const existing = this.data.get(hash); + // TODO: index hash + const existing = this.images.find((image) => image.data.hash === hash); if (existing) { - return [hash, existing]; + return existing; } const uploadImage = this.uploadImage; @@ -72,33 +103,23 @@ export class ImageManager { const imgElem = await imageFromURL(url); const image: Data.Image = { - hash: hash, + hash, width: imgElem.width, height: imgElem.height, url, type, }; - this.data.set("images/" + hash, image); - return [hash, image]; + const filePath = `images/${hash}.png`; + this.data.set(filePath, image); + return new Image(this, filePath); } - get(hashBase64: string): Data.Image | undefined { - return this.data.get(hashBase64); - } - - async getWithDataURL(hashBase64: string): Promise { - const image = this.get(hashBase64); - if (!image) { - return; + get(filePath: string): Image | undefined { + const data = this.data.get(filePath); + if (data) { + return new Image(this, filePath); } - const response = await fetch(image.url); - const blob = await response.blob(); - const dataURL = await blobToDataURL(blob); - return { - ...image, - url: dataURL, - }; } has(hashBase64: string): boolean { @@ -120,7 +141,7 @@ export class ImageManager { return; } const blob = await fetch(image.url).then((res) => res.blob()); - return await this.insert(blob); + return [hash, (await this.insert(blob)).data] as [string, Data.Image]; }) ) ); From 7b6c79b3e59fdbd4cd255756c99f219429511652 Mon Sep 17 00:00:00 2001 From: Ryohei Ikegami Date: Sat, 22 Apr 2023 18:13:59 +0900 Subject: [PATCH 17/17] Add dataURLFromURL util --- packages/foundation/src/utils/Blob.ts | 9 +++++++++ packages/model/src/models/ImageManager.ts | 10 ++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/foundation/src/utils/Blob.ts b/packages/foundation/src/utils/Blob.ts index 34e9ce0ad..3d3044abf 100644 --- a/packages/foundation/src/utils/Blob.ts +++ b/packages/foundation/src/utils/Blob.ts @@ -37,3 +37,12 @@ export async function imageToBlob(imageURL: string): Promise { canvas.toBlob((blob) => resolve(assertNonNull(blob)), "image/png"); }); } + +export async function dataURLFromURL(url: string) { + if (url.startsWith("data:")) { + return url; + } + const response = await fetch(url); + const blob = await response.blob(); + return await blobToDataURL(blob); +} diff --git a/packages/model/src/models/ImageManager.ts b/packages/model/src/models/ImageManager.ts index ee30ccbf4..996b2978d 100644 --- a/packages/model/src/models/ImageManager.ts +++ b/packages/model/src/models/ImageManager.ts @@ -1,4 +1,4 @@ -import { blobToDataURL, imageFromURL } from "@uimix/foundation/src/utils/Blob"; +import { dataURLFromURL, imageFromURL } from "@uimix/foundation/src/utils/Blob"; import { Project } from "./Project"; import { ObservableYMap } from "@uimix/foundation/src/utils/ObservableYMap"; import * as Data from "../data/v1"; @@ -44,13 +44,7 @@ export class Image { } async getDataURL(): Promise { - if (this.url.startsWith("data:")) { - return this.url; - } - - const response = await fetch(this.url); - const blob = await response.blob(); - return await blobToDataURL(blob); + return await dataURLFromURL(this.url); } readonly manager: ImageManager;