From c0ad5eefd7e5e502c7f9507c742b11188b16c61e Mon Sep 17 00:00:00 2001 From: Roman Date: Sun, 22 Mar 2026 22:46:57 +0000 Subject: [PATCH 01/18] feat: implement folders --- src/components/App.tsx | 45 +- src/components/PagesMenu.tsx | 843 +++++++++++++++++++++++++++++++---- src/components/Toolbar.tsx | 46 +- src/components/ui/Button.tsx | 11 +- src/hooks/usePages.ts | 368 +++++++++++++-- src/types/index.ts | 9 + 6 files changed, 1155 insertions(+), 167 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 9d52a1c..29c4c9c 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -12,13 +12,16 @@ import Toolbar from "./Toolbar"; function App() { const { - pages, - currentPageIndex, - setCurrentPageIndex, + tree, + currentPageId, + setCurrentPageId, addPage, + addFolder, + renameItem, + deleteItem, + moveItem, updatePageContent, - renamePage, - deletePage, + getCurrentPage, } = usePages(); const { settings, updateSettings } = useEditorSettings(); @@ -33,7 +36,7 @@ function App() { const [toolbarOpacity, setToolbarOpacity] = createSignal(1); - const currentPage = () => pages()[currentPageIndex()]; + const currentPage = () => getCurrentPage(); const PAGES_BROKEN = ""; @@ -104,6 +107,17 @@ function App() { setCurrentMatchIndex(0); }; + const selectPageByTreeItem = (itemId: string) => { + const item = + tree().find((t) => t.id === itemId) || + tree() + .flatMap((t) => t.children || []) + .find((c) => c.id === itemId); + if (item?.type === "page" && item.pageId) { + setCurrentPageId(item.pageId); + } + }; + onMount(() => { const handleKeyDown = (e: KeyboardEvent) => { if ( @@ -123,13 +137,14 @@ function App() { <> setToolbarOpacity(1)} onPagesClick={() => setIsPagesMenuOpen(!isPagesMenuOpen())} onSettingsClick={() => setIsSettingsOpen(true)} onSearchClick={handleOpenSearch} - renamePage={renamePage} + renameItem={renameItem} + tree={tree()} /> addPage()} + addFolder={() => addFolder()} + renameItem={renameItem} + deleteItem={deleteItem} + moveItem={moveItem} /> ( + + + + + + + + +); + +const FolderSvgIcon = (props: { isOpen: boolean }): JSX.Element => ( + + Folder + + +); + +const ChevronSvgIcon = (props: { isOpen: boolean }): JSX.Element => ( + + Expand + + +); + +const PageItem = (props: { + item: TreeItem; + isSelected: boolean; + isDragOver: boolean; + dragOverPosition: "before" | "after" | null; onClick: () => void; onContextMenu: (e: MouseEvent) => void; + onDragStart: (e: DragEvent) => void; + onDragOver: (e: DragEvent, position: "before" | "after") => void; + onDragLeave: (e: DragEvent) => void; + onDrop: (e: DragEvent) => void; + onDragEnd: () => void; +}): JSX.Element => ( +
{ + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + props.onDragOver(e, e.clientY < midY ? "before" : "after"); + }} + onDragLeave={props.onDragLeave} + onDrop={props.onDrop} + > + +
+ + +
+ +
+ +
+
+); + +const FolderItem = (props: { + item: TreeItem; + isSelected: boolean; + isOpen: boolean; + isDragOver: boolean; + isDragOverFolder: boolean; + dragOverPosition: "before" | "after" | null; + onToggle: () => void; + onContextMenu: (e: MouseEvent) => void; + onDragStart: (e: DragEvent) => void; + onDragOver: (e: DragEvent, position: "before" | "after") => void; + onDragOverFolder: (e: DragEvent) => void; + onDragLeaveFolder: () => void; + onDragLeave: (e: DragEvent) => void; + onDrop: (e: DragEvent) => void; + onDropFolder: (e: DragEvent) => void; + onDragEnd: () => void; + children?: JSX.Element; +}): JSX.Element => ( +
+
{ + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + props.onDragOver(e, e.clientY < midY ? "before" : "after"); + }} + onDragLeave={props.onDragLeave} + onDrop={props.onDrop} + > + +
+ + +
+ +
+ + +
+
+
+ {props.children} +
+
+); + +const TreeItemView = (props: { + item: TreeItem; + tree: TreeItem[]; + currentPageId: string; + openFolders: Set; + dragItemId: string | null; + dragOverItemId: string | null; + dragOverPosition: "before" | "after" | null; + onSelectPage: (pageId: string) => void; + onToggleFolder: (folderId: string) => void; + onContextMenu: (e: MouseEvent, item: TreeItem) => void; + onDragStart: (e: DragEvent, item: TreeItem) => void; + onDragOver: ( + e: DragEvent, + itemId: string, + position: "before" | "after", + ) => void; + onDragOverFolder: (e: DragEvent, folderId: string) => void; + onDragLeave: (e: DragEvent) => void; + onDragLeaveFolder: () => void; + onDrop: (e: DragEvent, item: TreeItem, position: "before" | "after") => void; + onDropFolder: (e: DragEvent, folderId: string) => void; + onDragEnd: () => void; }): JSX.Element => { + const isFolder = () => props.item.type === "folder"; + const isOpen = () => props.openFolders.has(props.item.id); + const isSelected = () => { + if (props.item.type === "page" && props.item.pageId) { + return props.currentPageId === props.item.pageId; + } + return false; + }; + return ( -
); const PagesContextMenu = (props: { - contextMenu: () => ContexMenuState; - onRenamePage: () => void; - onDeletePage: () => void; - showDeleteButton: boolean; + contextMenu: () => ContextMenuState; + onRename: () => void; + onDelete: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + onMoveIn: () => void; + onMoveOut: () => void; + canMoveUp: boolean; + canMoveDown: boolean; + canMoveIn: boolean; + canMoveOut: boolean; }) => ( {(contextMenu) => ( @@ -100,14 +465,23 @@ const PagesContextMenu = (props: { x={contextMenu().x} y={contextMenu().y} items={[ + { label: "Rename", onClick: props.onRename, show: true }, + { label: "Delete", onClick: props.onDelete, show: true }, + { label: "Move Up", onClick: props.onMoveUp, show: props.canMoveUp }, { - label: "Rename", - onClick: props.onRenamePage, + label: "Move Down", + onClick: props.onMoveDown, + show: props.canMoveDown, }, { - label: "Delete", - onClick: props.onDeletePage, - show: props.showDeleteButton, + label: "Move Into Folder", + onClick: props.onMoveIn, + show: props.canMoveIn, + }, + { + label: "Move Out of Folder", + onClick: props.onMoveOut, + show: props.canMoveOut, }, ]} /> @@ -115,58 +489,347 @@ const PagesContextMenu = (props: { ); +const findItemInTree = ( + items: TreeItem[], + targetId: string, +): TreeItem | null => { + for (const item of items) { + if (item.id === targetId) return item; + if (item.children) { + const found = findItemInTree(item.children, targetId); + if (found) return found; + } + } + return null; +}; + const PagesMenu = (props: { isOpen: boolean; - pages: Page[]; - currentPageIndex: number; - newPage: () => void; - selectPage: (index: number) => void; - renamePage: (index: number, newName: string) => void; - deletePage: (index: number) => void; + tree: TreeItem[]; + currentPageId: string; + selectPageByTreeItem: (itemId: string) => void; + addPage: () => void; + addFolder: () => void; + renameItem: (itemId: string, newName: string) => void; + deleteItem: (itemId: string) => void; + moveItem: (itemId: string, direction: "up" | "down" | "in" | "out") => void; }): JSX.Element => { - const [contextMenu, setContextMenu] = createSignal(null); + const [contextMenu, setContextMenu] = createSignal(null); + const [openFolders, setOpenFolders] = createSignal>(new Set()); + const [dragItemId, setDragItemId] = createSignal(null); + const [dragOverItemId, setDragOverItemId] = createSignal(null); + const [dragOverPosition, setDragOverPosition] = createSignal< + "before" | "after" | null + >(null); - const onRenamePage = () => { - const c = contextMenu(); - if (!c) { - throw new Error("BROKEN STATE"); + const toggleFolder = (folderId: string) => { + const newSet = new Set(openFolders()); + if (newSet.has(folderId)) { + newSet.delete(folderId); + } else { + newSet.add(folderId); } - const page = props.pages[c.pageIndex]; - if (!page) { - throw new Error("BROKEN STATE"); - } - const oldName = page.name; - const newName = prompt("Enter new name:", oldName); + setOpenFolders(newSet); + }; + + const onRename = () => { + const c = contextMenu(); + if (!c) return; + const item = findItemInTree(props.tree, c.itemId); + if (!item) return; + const newName = prompt("Enter new name:", item.name); if (newName) { - props.renamePage(c.pageIndex, newName); + props.renameItem(c.itemId, newName); } setContextMenu(null); }; - const onDeletePage = () => { + const onDelete = () => { const c = contextMenu(); - if (!c) { - throw new Error("BROKEN STATE"); - } - props.deletePage(c.pageIndex); + if (!c) return; + props.deleteItem(c.itemId); + setContextMenu(null); + }; + + const getParentItems = (itemId: string): TreeItem[] | null => { + const findParent = ( + items: TreeItem[], + targetId: string, + parent: TreeItem[] | null, + ): TreeItem[] | null => { + for (const item of items) { + if (item.id === targetId) return parent; + if (item.children) { + const found = findParent(item.children, targetId, item.children); + if (found !== null) return found; + } + } + return null; + }; + return findParent(props.tree, itemId, null); + }; + + const getItemIndex = (itemId: string): number => { + const parent = getParentItems(itemId); + if (!parent) return -1; + return parent.findIndex((item) => item.id === itemId); + }; + + const getPrevSibling = (itemId: string): TreeItem | null => { + const parent = getParentItems(itemId); + if (!parent) return null; + const idx = getItemIndex(itemId); + if (idx <= 0) return null; + return parent[idx - 1] ?? null; + }; + + const onMoveUp = () => { + const c = contextMenu(); + if (!c) return; + props.moveItem(c.itemId, "up"); setContextMenu(null); }; + const onMoveDown = () => { + const c = contextMenu(); + if (!c) return; + props.moveItem(c.itemId, "down"); + setContextMenu(null); + }; + + const onMoveIn = () => { + const c = contextMenu(); + if (!c) return; + props.moveItem(c.itemId, "in"); + setContextMenu(null); + }; + + const onMoveOut = () => { + const c = contextMenu(); + if (!c) return; + props.moveItem(c.itemId, "out"); + setContextMenu(null); + }; + + const canMoveUp = () => { + const c = contextMenu(); + if (!c) return false; + return getItemIndex(c.itemId) > 0; + }; + + const canMoveDown = () => { + const c = contextMenu(); + if (!c) return false; + const parent = getParentItems(c.itemId); + if (!parent) return false; + return getItemIndex(c.itemId) < parent.length - 1; + }; + + const canMoveIn = () => { + const c = contextMenu(); + if (!c) return false; + const prev = getPrevSibling(c.itemId); + return prev !== null && prev.type === "folder"; + }; + + const canMoveOut = () => { + const c = contextMenu(); + if (!c) return false; + return getParentItems(c.itemId) !== null; + }; + + const handleDragStart = (e: DragEvent, item: TreeItem) => { + setDragItemId(item.id); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = "move"; + } + }; + + const handleDragOver = ( + e: DragEvent, + itemId: string, + position: "before" | "after", + ) => { + e.preventDefault(); + setDragOverItemId(itemId); + setDragOverPosition(position); + }; + + const handleDragOverFolder = (e: DragEvent, folderId: string) => { + e.preventDefault(); + e.stopPropagation(); + setDragOverItemId(`folder-${folderId}`); + setDragOverPosition(null); + }; + + const handleDragLeave = (_e: DragEvent) => { + // Small delay to prevent flickering + setTimeout(() => { + if (dragOverItemId() === dragItemId()) { + setDragOverItemId(null); + setDragOverPosition(null); + } + }, 50); + }; + + const handleDragLeaveFolder = () => { + setTimeout(() => { + if (!dragOverItemId()?.startsWith("folder-")) { + setDragOverItemId(null); + } + }, 50); + }; + + const handleDrop = ( + e: DragEvent, + targetItem: TreeItem, + position: "before" | "after", + ) => { + e.preventDefault(); + e.stopPropagation(); + const draggedId = dragItemId(); + if (!draggedId || draggedId === targetItem.id) { + handleDragEnd(); + return; + } + + const draggedItem = findItemInTree(props.tree, draggedId); + if (!draggedItem) { + handleDragEnd(); + return; + } + + if ( + draggedItem.type === "page" && + targetItem.type === "page" && + draggedItem.pageId + ) { + props.selectPageByTreeItem(draggedItem.pageId); + } + + const targetParent = getParentItems(targetItem.id); + if (!targetParent) { + handleDragEnd(); + return; + } + + const draggedParent = getParentItems(draggedId); + const targetIdx = getItemIndex(targetItem.id); + + if ( + draggedParent && + draggedParent === targetParent && + draggedId === targetItem.id + ) { + handleDragEnd(); + return; + } + + if (position === "before") { + props.moveItem(draggedId, "out"); + for (let i = 0; i < targetIdx; i++) { + props.moveItem(draggedId, "up"); + } + } else { + props.moveItem(draggedId, "out"); + for (let i = 0; i <= targetIdx; i++) { + props.moveItem(draggedId, "down"); + } + } + + handleDragEnd(); + }; + + const handleDropFolder = (e: DragEvent, folderId: string) => { + e.preventDefault(); + e.stopPropagation(); + const draggedId = dragItemId(); + if (!draggedId || draggedId === folderId) { + handleDragEnd(); + return; + } + + const draggedItem = findItemInTree(props.tree, draggedId); + if (!draggedItem) { + handleDragEnd(); + return; + } + + if (draggedItem.type === "page" && draggedItem.pageId) { + props.selectPageByTreeItem(draggedItem.pageId); + } + + const folderItem = findItemInTree(props.tree, folderId); + if (!folderItem || folderItem.type !== "folder") { + handleDragEnd(); + return; + } + + const currentParent = getParentItems(draggedId); + if (currentParent && currentParent[0]?.id === folderId) { + handleDragEnd(); + return; + } + + props.moveItem(draggedId, "in"); + + const folderIndex = getItemIndex(folderId); + if (folderIndex >= 0) { + const targetItemBeforeFolder = currentParent + ? currentParent[folderIndex - 1] + : null; + if (targetItemBeforeFolder) { + for (let i = 0; i < folderIndex; i++) { + props.moveItem(draggedId, "up"); + } + } + } + + handleDragEnd(); + }; + + const handleDragEnd = () => { + setDragItemId(null); + setDragOverItemId(null); + setDragOverPosition(null); + }; + return ( 1} + onRename={onRename} + onDelete={onDelete} + onMoveUp={onMoveUp} + onMoveDown={onMoveDown} + onMoveIn={onMoveIn} + onMoveOut={onMoveOut} + canMoveUp={canMoveUp()} + canMoveDown={canMoveDown()} + canMoveIn={canMoveIn()} + canMoveOut={canMoveOut()} /> ); diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index c5b4db0..153691f 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -1,5 +1,6 @@ import type { JSX } from "solid-js"; +import type { TreeItem } from "#types"; import Button from "./ui/Button"; import SvgIcon from "./ui/SvgIcon"; @@ -25,10 +26,25 @@ const ToolbarLeft = (props: { onPagesClick: () => void }): JSX.Element => (
); +const findTreeItemByPageId = ( + items: TreeItem[], + pageId: string, +): TreeItem | null => { + for (const item of items) { + if (item.type === "page" && item.pageId === pageId) return item; + if (item.children) { + const found = findTreeItemByPageId(item.children, pageId); + if (found) return found; + } + } + return null; +}; + const PageTitle = (props: { currentPageTitle: string; - currentPageIndex: number; - renamePage: (index: number, newName: string) => void; + currentPageId: string; + tree: TreeItem[]; + renameItem: (itemId: string, newName: string) => void; }): JSX.Element => { const makeEditable = (span: HTMLSpanElement) => { span.contentEditable = "true"; @@ -37,9 +53,11 @@ const PageTitle = (props: { const handleRenameEnd = (span: HTMLSpanElement) => { const newName = span.textContent || props.currentPageTitle; span.textContent = newName; - props.renamePage(props.currentPageIndex, newName); + const treeItem = findTreeItemByPageId(props.tree, props.currentPageId); + if (treeItem) { + props.renameItem(treeItem.id, newName); + } span.contentEditable = "false"; - makeEditable(span); }; return ( @@ -62,16 +80,18 @@ const PageTitle = (props: { const ToolbarCenter = (props: { currentPageTitle: string; - currentPageIndex: number; - renamePage: (index: number, newName: string) => void; + currentPageId: string; + tree: TreeItem[]; + renameItem: (itemId: string, newName: string) => void; }): JSX.Element => (
); @@ -105,12 +125,13 @@ const ToolbarRight = (props: { const Toolbar = (props: { opacity: number; currentPageTitle: string; - currentPageIndex: number; + currentPageId: string; + tree: TreeItem[]; onMouseMove: () => void; onPagesClick: () => void; onSettingsClick: () => void; onSearchClick: () => void; - renamePage: (index: number, newName: string) => void; + renameItem: (itemId: string, newName: string) => void; }): JSX.Element => { return (
; -type LabeledOrNot = - | { label: string; children?: null } - | { label?: null; children?: JSX.Element }; - const base = `cursor-pointer w-full text-left p-2 rounded-md hover:bg-[#ddd] dark:hover:bg-[#333] text-black dark:text-white`; const variants = { @@ -22,9 +18,10 @@ type ButtonVariants = { variant?: TVariant; }; -type ButtonProps = ButtonAttributes & - LabeledOrNot & - ButtonVariants; +type ButtonProps = ButtonAttributes & { + label?: string; + children?: JSX.Element; +} & ButtonVariants; function Button(props: ButtonProps) { const [local, rest] = splitProps(props, [ diff --git a/src/hooks/usePages.ts b/src/hooks/usePages.ts index de2f43d..b8b4b63 100644 --- a/src/hooks/usePages.ts +++ b/src/hooks/usePages.ts @@ -1,38 +1,157 @@ import { createEffect, createSignal, onMount } from "solid-js"; -import type { Page } from "#types"; +import type { Page, TreeItem } from "#types"; -const DEFAULT_PAGE: Page = { name: "Page 1", content: "" }; +const generateId = () => Math.random().toString(36).substring(2, 9); + +const DEFAULT_PAGE: Page = { id: generateId(), name: "Page 1", content: "" }; +const DEFAULT_TREE: TreeItem[] = [ + { id: generateId(), type: "page", name: "Page 1", pageId: DEFAULT_PAGE.id }, +]; export function usePages() { const [pages, setPages] = createSignal([DEFAULT_PAGE]); - const [currentPageIndex, setCurrentPageIndex] = createSignal(0); + const [tree, setTree] = createSignal(DEFAULT_TREE); + const [currentPageId, setCurrentPageId] = createSignal( + DEFAULT_PAGE.id, + ); + + const findPageIndex = (pageId: string): number => + pages().findIndex((p) => p.id === pageId); + + const findItemInTree = ( + items: TreeItem[], + targetId: string, + ): TreeItem | null => { + for (const item of items) { + if (item.id === targetId) return item; + if (item.children) { + const found = findItemInTree(item.children, targetId); + if (found) return found; + } + } + return null; + }; + + const findItemParent = ( + items: TreeItem[], + targetId: string, + parent: TreeItem[] | null = null, + ): TreeItem[] | null => { + for (const item of items) { + if (item.id === targetId) return parent; + if (item.children) { + const found = findItemParent(item.children, targetId, item.children); + if (found !== null) return found; + } + } + return null; + }; + + const findItemIndex = (items: TreeItem[], targetId: string): number => { + return items.findIndex((item) => item.id === targetId); + }; + + const updateTreeAt = ( + targetId: string, + updater: (item: TreeItem) => TreeItem, + ): void => { + const newTree = JSON.parse(JSON.stringify(tree())) as TreeItem[]; + const update = (items: TreeItem[]): boolean => { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item && item.id === targetId) { + items[i] = updater(item); + return true; + } + if (item?.children) { + if (update(item.children)) return true; + } + } + return false; + }; + update(newTree); + setTree(newTree); + }; + + const removeFromTree = (targetId: string): TreeItem | null => { + const newTree = JSON.parse(JSON.stringify(tree())) as TreeItem[]; + let removed: TreeItem | null = null; + const remove = (items: TreeItem[]): boolean => { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item && item.id === targetId) { + removed = item; + items.splice(i, 1); + return true; + } + if (item?.children) { + if (remove(item.children)) return true; + } + } + return false; + }; + remove(newTree); + setTree(newTree); + return removed; + }; + + const addToTree = (parentId: string | null, item: TreeItem): void => { + const newTree = JSON.parse(JSON.stringify(tree())) as TreeItem[]; + if (parentId === null) { + newTree.push(item); + setTree(newTree); + return; + } + const add = (items: TreeItem[]): boolean => { + for (const it of items) { + if (it.id === parentId) { + if (!it.children) it.children = []; + it.children.push(item); + return true; + } + if (it.children) { + if (add(it.children)) return true; + } + } + return false; + }; + add(newTree); + setTree(newTree); + }; onMount(() => { const storedPages = localStorage.getItem("pages"); - const storedCurrentPage = localStorage.getItem("currentPage"); + const storedTree = localStorage.getItem("tree"); + const storedCurrentPage = localStorage.getItem("currentPageId"); if (storedPages) { const parsedPages = JSON.parse(storedPages) as Page[]; setPages(parsedPages.length > 0 ? parsedPages : [DEFAULT_PAGE]); } - const parsedIndex = parseInt(storedCurrentPage || "0", 10); - setCurrentPageIndex(parsedIndex); + if (storedTree) { + const parsedTree = JSON.parse(storedTree) as TreeItem[]; + setTree(parsedTree.length > 0 ? parsedTree : DEFAULT_TREE); + } + + if (storedCurrentPage) { + setCurrentPageId(storedCurrentPage); + } window.addEventListener("storage", (event) => { if (!event.newValue) return; if (event.key === "pages") { - const newPagesData = JSON.parse(event.newValue) as Page[]; - setPages(newPagesData); - if (currentPageIndex() >= pages().length) { - setCurrentPageIndex(pages().length - 1); - } + setPages(JSON.parse(event.newValue)); + } else if (event.key === "tree") { + setTree(JSON.parse(event.newValue)); + } else if (event.key === "currentPageId") { + setCurrentPageId(event.newValue); } }); window.addEventListener("unload", () => { - localStorage.setItem("currentPage", currentPageIndex().toString()); + localStorage.setItem("currentPageId", currentPageId()); }); }); @@ -40,62 +159,223 @@ export function usePages() { localStorage.setItem("pages", JSON.stringify(pages())); }); - const addPage = () => { + createEffect(() => { + localStorage.setItem("tree", JSON.stringify(tree())); + }); + + const addPage = (parentFolderId?: string) => { const newPage: Page = { + id: generateId(), name: `Page ${pages().length + 1}`, content: "", }; setPages([...pages(), newPage]); - setCurrentPageIndex(pages().length - 1); + + const newTreeItem: TreeItem = { + id: generateId(), + type: "page", + name: newPage.name, + pageId: newPage.id, + }; + + addToTree(parentFolderId ?? null, newTreeItem); + setCurrentPageId(newPage.id); + return newPage.id; }; - const updatePageContent = (content: string) => { - const idx = currentPageIndex(); - if (idx >= 0 && idx < pages().length) { - const currentPage = pages()[idx]; - if (currentPage) { - const newPages = [...pages()]; - newPages[idx] = { ...currentPage, content }; - setPages(newPages); + const addFolder = (parentFolderId?: string) => { + const newFolder: TreeItem = { + id: generateId(), + type: "folder", + name: "New Folder", + children: [], + }; + addToTree(parentFolderId ?? null, newFolder); + }; + + const renameItem = (itemId: string, newName: string) => { + const item = findItemInTree(tree(), itemId); + if (!item) return; + + if (item.type === "page" && item.pageId) { + const pageIndex = findPageIndex(item.pageId); + if (pageIndex >= 0) { + setPages( + pages().map((p, i) => + i === pageIndex ? { ...p, name: newName.trim() } : p, + ), + ); } } + + updateTreeAt(itemId, (i) => ({ ...i, name: newName.trim() })); }; - const renamePage = (index: number, newName: string) => { - if (index >= 0 && index < pages().length) { - const currentPage = pages()[index]; - if (currentPage) { - const newPages = [...pages()]; - newPages[index] = { ...currentPage, name: newName.trim() }; - setPages(newPages); + const deleteItem = (itemId: string) => { + const item = findItemInTree(tree(), itemId); + if (!item) return; + + if (item.type === "page" && item.pageId) { + const pageIndex = findPageIndex(item.pageId); + if (pageIndex >= 0) { + const newPages = pages().filter((_, i) => i !== pageIndex); + setPages(newPages.length > 0 ? newPages : [DEFAULT_PAGE]); + + if (currentPageId() === item.pageId) { + const firstPage = findItemInTree(tree(), itemId); + if (firstPage?.pageId) { + setCurrentPageId(firstPage.pageId); + } else { + setCurrentPageId(DEFAULT_PAGE.id); + } + } + } + } + + removeFromTree(itemId); + }; + + const moveItem = ( + itemId: string, + direction: "up" | "down" | "in" | "out", + ) => { + const parentItems = findItemParent(tree(), itemId); + if (!parentItems) return; + + const currentIndex = findItemIndex(parentItems, itemId); + if (currentIndex < 0) return; + + const item = findItemInTree(tree(), itemId); + if (!item) return; + + if (direction === "up" && currentIndex > 0) { + const siblings = [...parentItems]; + const prevItem = siblings[currentIndex - 1]; + const currItem = siblings[currentIndex]; + if (prevItem !== undefined && currItem !== undefined) { + siblings[currentIndex - 1] = currItem; + siblings[currentIndex] = prevItem; + updateParentSiblings(siblings, parentItems); } + } else if (direction === "down" && currentIndex < parentItems.length - 1) { + const siblings = [...parentItems]; + const currItem = siblings[currentIndex]; + const nextItem = siblings[currentIndex + 1]; + if (currItem !== undefined && nextItem !== undefined) { + siblings[currentIndex] = nextItem; + siblings[currentIndex + 1] = currItem; + updateParentSiblings(siblings, parentItems); + } + } else if (direction === "in" && currentIndex > 0) { + const targetFolder = parentItems[currentIndex - 1]; + if (targetFolder && targetFolder.type === "folder") { + const removed = removeFromTree(itemId); + if (removed) { + const newTree = JSON.parse(JSON.stringify(tree())) as TreeItem[]; + const add = (items: TreeItem[]): boolean => { + for (const it of items) { + if (it.id === targetFolder.id) { + if (!it.children) it.children = []; + it.children.push(removed); + return true; + } + if (it.children) { + if (add(it.children)) return true; + } + } + return false; + }; + add(newTree); + setTree(newTree); + } + } + } else if (direction === "out") { + const itemCopy = JSON.parse(JSON.stringify(item)) as TreeItem; + removeFromTree(itemId); + + const newTree = JSON.parse(JSON.stringify(tree())) as TreeItem[]; + const findAndInsert = (items: TreeItem[]): boolean => { + for (let i = 0; i < items.length; i++) { + const currentItem = items[i]; + const parentFirst = parentItems[0]; + if (currentItem && parentFirst && currentItem.id === parentFirst.id) { + items.splice(i + 1, 0, itemCopy); + return true; + } + if (currentItem?.children) { + if (findAndInsert(currentItem.children)) return true; + } + } + return false; + }; + findAndInsert(newTree); + setTree(newTree); } }; - const deletePage = (index: number) => { - if (pages().length <= 1) return; // Don't delete the last page + const updateParentSiblings = ( + newSiblings: TreeItem[], + oldSiblings: TreeItem[], + ) => { + const newTree = JSON.parse(JSON.stringify(tree())) as TreeItem[]; + const oldFirst = oldSiblings[0]; + const update = (items: TreeItem[]): boolean => { + for (let i = 0; i < items.length; i++) { + const it = items[i]; + if (it && oldFirst && it.id === oldFirst.id) { + if (it.children) { + it.children = newSiblings; + } else { + for (let j = 0; j < newSiblings.length; j++) { + const sibling = newSiblings[j]; + if (sibling !== undefined) { + items[j] = sibling; + } + } + } + return true; + } + if (it?.children) { + if (update(it.children)) return true; + } + } + return false; + }; + update(newTree); + setTree(newTree); + }; - if (index >= 0 && index < pages().length) { - const newPages = pages().filter((_, i) => i !== index); - setPages(newPages.length > 0 ? newPages : [DEFAULT_PAGE]); + const getCurrentPage = (): Page | null => { + const id = currentPageId(); + return pages().find((p) => p.id === id) || null; + }; - let newIndex = currentPageIndex(); - if (newIndex >= newPages.length) { - newIndex = newPages.length - 1; - } else if (index < newIndex) { - newIndex = newIndex - 1; + const updatePageContent = (content: string) => { + const id = currentPageId(); + const idx = findPageIndex(id); + if (idx >= 0) { + const newPages = [...pages()]; + const page = newPages[idx]; + if (page) { + newPages[idx] = { ...page, content }; + setPages(newPages); } - setCurrentPageIndex(newIndex); } }; return { pages, - currentPageIndex, - setCurrentPageIndex, + tree, + currentPageId, + setCurrentPageId, addPage, + addFolder, + renameItem, + deleteItem, + moveItem, updatePageContent, - renamePage, - deletePage, + getCurrentPage, + findItemInTree, + findPageIndex, }; } diff --git a/src/types/index.ts b/src/types/index.ts index ee8a778..3ce30fd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,8 +1,17 @@ export interface Page { + id: string; name: string; content: string; } +export interface TreeItem { + id: string; + type: "folder" | "page"; + name: string; + children?: TreeItem[]; + pageId?: string; +} + export namespace EditorSettings { export type FontFamily = | "Cousine" From 3138e1dc8a64eed2faf31ae8abf43e58ee6190cc Mon Sep 17 00:00:00 2001 From: Roman Date: Sun, 22 Mar 2026 22:57:27 +0000 Subject: [PATCH 02/18] wip --- src/components/App.tsx | 17 +-- src/components/PagesMenu.tsx | 112 ++++++++---------- src/components/Toolbar.tsx | 29 +---- src/hooks/usePages.ts | 212 +++++++++++++---------------------- src/types/index.ts | 9 +- 5 files changed, 131 insertions(+), 248 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 29c4c9c..724671a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -12,11 +12,10 @@ import Toolbar from "./Toolbar"; function App() { const { - tree, + pages, currentPageId, setCurrentPageId, addPage, - addFolder, renameItem, deleteItem, moveItem, @@ -108,14 +107,7 @@ function App() { }; const selectPageByTreeItem = (itemId: string) => { - const item = - tree().find((t) => t.id === itemId) || - tree() - .flatMap((t) => t.children || []) - .find((c) => c.id === itemId); - if (item?.type === "page" && item.pageId) { - setCurrentPageId(item.pageId); - } + setCurrentPageId(itemId); }; onMount(() => { @@ -144,7 +136,7 @@ function App() { onSettingsClick={() => setIsSettingsOpen(true)} onSearchClick={handleOpenSearch} renameItem={renameItem} - tree={tree()} + pages={pages()} /> addPage()} - addFolder={() => addFolder()} renameItem={renameItem} deleteItem={deleteItem} moveItem={moveItem} diff --git a/src/components/PagesMenu.tsx b/src/components/PagesMenu.tsx index 26d387c..825e867 100644 --- a/src/components/PagesMenu.tsx +++ b/src/components/PagesMenu.tsx @@ -1,7 +1,7 @@ import type { JSX, Setter } from "solid-js"; import { createSignal, For, Show } from "solid-js"; -import type { TreeItem } from "#types"; +import type { Page } from "#types"; import Button from "./ui/Button"; import ContextMenu from "./ui/ContextMenu"; import Divider from "./ui/Divider"; @@ -10,7 +10,6 @@ type ContextMenuState = { x: number; y: number; itemId: string; - itemType: "folder" | "page"; } | null; const DragHandleIcon = (): JSX.Element => ( @@ -63,7 +62,7 @@ const ChevronSvgIcon = (props: { isOpen: boolean }): JSX.Element => ( ); const PageItem = (props: { - item: TreeItem; + item: Page; isSelected: boolean; isDragOver: boolean; dragOverPosition: "before" | "after" | null; @@ -120,7 +119,7 @@ const PageItem = (props: { ); const FolderItem = (props: { - item: TreeItem; + item: Page; isSelected: boolean; isOpen: boolean; isDragOver: boolean; @@ -210,8 +209,8 @@ const FolderItem = (props: { ); const TreeItemView = (props: { - item: TreeItem; - tree: TreeItem[]; + item: Page; + pages: Page[]; currentPageId: string; openFolders: Set; dragItemId: string | null; @@ -219,8 +218,8 @@ const TreeItemView = (props: { dragOverPosition: "before" | "after" | null; onSelectPage: (pageId: string) => void; onToggleFolder: (folderId: string) => void; - onContextMenu: (e: MouseEvent, item: TreeItem) => void; - onDragStart: (e: DragEvent, item: TreeItem) => void; + onContextMenu: (e: MouseEvent, item: Page) => void; + onDragStart: (e: DragEvent, item: Page) => void; onDragOver: ( e: DragEvent, itemId: string, @@ -229,18 +228,14 @@ const TreeItemView = (props: { onDragOverFolder: (e: DragEvent, folderId: string) => void; onDragLeave: (e: DragEvent) => void; onDragLeaveFolder: () => void; - onDrop: (e: DragEvent, item: TreeItem, position: "before" | "after") => void; + onDrop: (e: DragEvent, item: Page, position: "before" | "after") => void; onDropFolder: (e: DragEvent, folderId: string) => void; onDragEnd: () => void; }): JSX.Element => { - const isFolder = () => props.item.type === "folder"; + const hasChildren = () => (props.item.children?.length ?? 0) > 0; + const isFolder = () => hasChildren(); const isOpen = () => props.openFolders.has(props.item.id); - const isSelected = () => { - if (props.item.type === "page" && props.item.pageId) { - return props.currentPageId === props.item.pageId; - } - return false; - }; + const isSelected = () => props.currentPageId === props.item.id; return ( { - if (props.item.pageId) { - props.onSelectPage(props.item.pageId); - } + props.onSelectPage(props.item.id); }} onContextMenu={(e) => props.onContextMenu(e, props.item)} onDragStart={(e) => props.onDragStart(e, props.item)} @@ -303,7 +296,7 @@ const TreeItemView = (props: { {(child) => ( ; dragItemId: string | null; @@ -337,8 +330,8 @@ const TreeList = (props: { dragOverPosition: "before" | "after" | null; onSelectPage: (pageId: string) => void; onToggleFolder: (folderId: string) => void; - onContextMenu: (e: MouseEvent, item: TreeItem) => void; - onDragStart: (e: DragEvent, item: TreeItem) => void; + onContextMenu: (e: MouseEvent, item: Page) => void; + onDragStart: (e: DragEvent, item: Page) => void; onDragOver: ( e: DragEvent, itemId: string, @@ -347,16 +340,16 @@ const TreeList = (props: { onDragOverFolder: (e: DragEvent, folderId: string) => void; onDragLeave: (e: DragEvent) => void; onDragLeaveFolder: () => void; - onDrop: (e: DragEvent, item: TreeItem, position: "before" | "after") => void; + onDrop: (e: DragEvent, item: Page, position: "before" | "after") => void; onDropFolder: (e: DragEvent, folderId: string) => void; onDragEnd: () => void; }): JSX.Element => (
- + {(item) => ( ; dragItemId: string | null; @@ -389,7 +382,7 @@ const Pages = (props: { onSelectPage: (pageId: string) => void; onToggleFolder: (folderId: string) => void; setContextMenu: Setter; - onDragStart: (e: DragEvent, item: TreeItem) => void; + onDragStart: (e: DragEvent, item: Page) => void; onDragOver: ( e: DragEvent, itemId: string, @@ -398,11 +391,10 @@ const Pages = (props: { onDragOverFolder: (e: DragEvent, folderId: string) => void; onDragLeave: (e: DragEvent) => void; onDragLeaveFolder: () => void; - onDrop: (e: DragEvent, item: TreeItem, position: "before" | "after") => void; + onDrop: (e: DragEvent, item: Page, position: "before" | "after") => void; onDropFolder: (e: DragEvent, folderId: string) => void; onDragEnd: () => void; newPage: () => void; - newFolder: () => void; }): JSX.Element => (
); @@ -474,12 +464,12 @@ const PagesContextMenu = (props: { show: props.canMoveDown, }, { - label: "Move Into Folder", + label: "Move Into Page", onClick: props.onMoveIn, show: props.canMoveIn, }, { - label: "Move Out of Folder", + label: "Move Out of Page", onClick: props.onMoveOut, show: props.canMoveOut, }, @@ -489,10 +479,7 @@ const PagesContextMenu = (props: {
); -const findItemInTree = ( - items: TreeItem[], - targetId: string, -): TreeItem | null => { +const findItemInTree = (items: Page[], targetId: string): Page | null => { for (const item of items) { if (item.id === targetId) return item; if (item.children) { @@ -505,11 +492,10 @@ const findItemInTree = ( const PagesMenu = (props: { isOpen: boolean; - tree: TreeItem[]; + pages: Page[]; currentPageId: string; selectPageByTreeItem: (itemId: string) => void; addPage: () => void; - addFolder: () => void; renameItem: (itemId: string, newName: string) => void; deleteItem: (itemId: string) => void; moveItem: (itemId: string, direction: "up" | "down" | "in" | "out") => void; @@ -535,7 +521,7 @@ const PagesMenu = (props: { const onRename = () => { const c = contextMenu(); if (!c) return; - const item = findItemInTree(props.tree, c.itemId); + const item = findItemInTree(props.pages, c.itemId); if (!item) return; const newName = prompt("Enter new name:", item.name); if (newName) { @@ -551,12 +537,12 @@ const PagesMenu = (props: { setContextMenu(null); }; - const getParentItems = (itemId: string): TreeItem[] | null => { + const getParentItems = (itemId: string): Page[] | null => { const findParent = ( - items: TreeItem[], + items: Page[], targetId: string, - parent: TreeItem[] | null, - ): TreeItem[] | null => { + parent: Page[] | null, + ): Page[] | null => { for (const item of items) { if (item.id === targetId) return parent; if (item.children) { @@ -566,7 +552,7 @@ const PagesMenu = (props: { } return null; }; - return findParent(props.tree, itemId, null); + return findParent(props.pages, itemId, null); }; const getItemIndex = (itemId: string): number => { @@ -575,7 +561,7 @@ const PagesMenu = (props: { return parent.findIndex((item) => item.id === itemId); }; - const getPrevSibling = (itemId: string): TreeItem | null => { + const getPrevSibling = (itemId: string): Page | null => { const parent = getParentItems(itemId); if (!parent) return null; const idx = getItemIndex(itemId); @@ -629,7 +615,7 @@ const PagesMenu = (props: { const c = contextMenu(); if (!c) return false; const prev = getPrevSibling(c.itemId); - return prev !== null && prev.type === "folder"; + return prev !== null && (prev.children?.length ?? 0) > 0; }; const canMoveOut = () => { @@ -638,7 +624,7 @@ const PagesMenu = (props: { return getParentItems(c.itemId) !== null; }; - const handleDragStart = (e: DragEvent, item: TreeItem) => { + const handleDragStart = (e: DragEvent, item: Page) => { setDragItemId(item.id); if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; @@ -663,7 +649,6 @@ const PagesMenu = (props: { }; const handleDragLeave = (_e: DragEvent) => { - // Small delay to prevent flickering setTimeout(() => { if (dragOverItemId() === dragItemId()) { setDragOverItemId(null); @@ -682,7 +667,7 @@ const PagesMenu = (props: { const handleDrop = ( e: DragEvent, - targetItem: TreeItem, + targetItem: Page, position: "before" | "after", ) => { e.preventDefault(); @@ -693,19 +678,13 @@ const PagesMenu = (props: { return; } - const draggedItem = findItemInTree(props.tree, draggedId); + const draggedItem = findItemInTree(props.pages, draggedId); if (!draggedItem) { handleDragEnd(); return; } - if ( - draggedItem.type === "page" && - targetItem.type === "page" && - draggedItem.pageId - ) { - props.selectPageByTreeItem(draggedItem.pageId); - } + props.selectPageByTreeItem(draggedId); const targetParent = getParentItems(targetItem.id); if (!targetParent) { @@ -749,18 +728,16 @@ const PagesMenu = (props: { return; } - const draggedItem = findItemInTree(props.tree, draggedId); + const draggedItem = findItemInTree(props.pages, draggedId); if (!draggedItem) { handleDragEnd(); return; } - if (draggedItem.type === "page" && draggedItem.pageId) { - props.selectPageByTreeItem(draggedItem.pageId); - } + props.selectPageByTreeItem(draggedId); - const folderItem = findItemInTree(props.tree, folderId); - if (!folderItem || folderItem.type !== "folder") { + const folderItem = findItemInTree(props.pages, folderId); + if (!folderItem || (folderItem.children?.length ?? 0) === 0) { handleDragEnd(); return; } @@ -797,7 +774,7 @@ const PagesMenu = (props: { return ( void }): JSX.Element => (
); -const findTreeItemByPageId = ( - items: TreeItem[], - pageId: string, -): TreeItem | null => { - for (const item of items) { - if (item.type === "page" && item.pageId === pageId) return item; - if (item.children) { - const found = findTreeItemByPageId(item.children, pageId); - if (found) return found; - } - } - return null; -}; - const PageTitle = (props: { currentPageTitle: string; - currentPageId: string; - tree: TreeItem[]; renameItem: (itemId: string, newName: string) => void; + currentPageId: string; }): JSX.Element => { const makeEditable = (span: HTMLSpanElement) => { span.contentEditable = "true"; @@ -53,10 +38,7 @@ const PageTitle = (props: { const handleRenameEnd = (span: HTMLSpanElement) => { const newName = span.textContent || props.currentPageTitle; span.textContent = newName; - const treeItem = findTreeItemByPageId(props.tree, props.currentPageId); - if (treeItem) { - props.renameItem(treeItem.id, newName); - } + props.renameItem(props.currentPageId, newName); span.contentEditable = "false"; }; @@ -81,7 +63,6 @@ const PageTitle = (props: { const ToolbarCenter = (props: { currentPageTitle: string; currentPageId: string; - tree: TreeItem[]; renameItem: (itemId: string, newName: string) => void; }): JSX.Element => (
@@ -126,7 +106,7 @@ const Toolbar = (props: { opacity: number; currentPageTitle: string; currentPageId: string; - tree: TreeItem[]; + pages: Page[]; onMouseMove: () => void; onPagesClick: () => void; onSettingsClick: () => void; @@ -144,7 +124,6 @@ const Toolbar = (props: { diff --git a/src/hooks/usePages.ts b/src/hooks/usePages.ts index b8b4b63..4314d98 100644 --- a/src/hooks/usePages.ts +++ b/src/hooks/usePages.ts @@ -1,63 +1,57 @@ import { createEffect, createSignal, onMount } from "solid-js"; -import type { Page, TreeItem } from "#types"; +import type { Page } from "#types"; const generateId = () => Math.random().toString(36).substring(2, 9); const DEFAULT_PAGE: Page = { id: generateId(), name: "Page 1", content: "" }; -const DEFAULT_TREE: TreeItem[] = [ - { id: generateId(), type: "page", name: "Page 1", pageId: DEFAULT_PAGE.id }, -]; export function usePages() { const [pages, setPages] = createSignal([DEFAULT_PAGE]); - const [tree, setTree] = createSignal(DEFAULT_TREE); const [currentPageId, setCurrentPageId] = createSignal( DEFAULT_PAGE.id, ); - const findPageIndex = (pageId: string): number => - pages().findIndex((p) => p.id === pageId); - - const findItemInTree = ( - items: TreeItem[], - targetId: string, - ): TreeItem | null => { + const findPageInTree = (items: Page[], targetId: string): Page | null => { for (const item of items) { if (item.id === targetId) return item; if (item.children) { - const found = findItemInTree(item.children, targetId); + const found = findPageInTree(item.children, targetId); if (found) return found; } } return null; }; - const findItemParent = ( - items: TreeItem[], + const findPageParent = ( + items: Page[], targetId: string, - parent: TreeItem[] | null = null, - ): TreeItem[] | null => { + parent: Page[] | null = null, + ): Page[] | null => { for (const item of items) { if (item.id === targetId) return parent; if (item.children) { - const found = findItemParent(item.children, targetId, item.children); + const found = findPageParent(item.children, targetId, item.children); if (found !== null) return found; } } return null; }; - const findItemIndex = (items: TreeItem[], targetId: string): number => { + const findPageIndex = (items: Page[], targetId: string): number => { return items.findIndex((item) => item.id === targetId); }; + const findItemInTree = (targetId: string): Page | null => { + return findPageInTree(pages(), targetId); + }; + const updateTreeAt = ( targetId: string, - updater: (item: TreeItem) => TreeItem, + updater: (item: Page) => Page, ): void => { - const newTree = JSON.parse(JSON.stringify(tree())) as TreeItem[]; - const update = (items: TreeItem[]): boolean => { + const newPages = JSON.parse(JSON.stringify(pages())) as Page[]; + const update = (items: Page[]): boolean => { for (let i = 0; i < items.length; i++) { const item = items[i]; if (item && item.id === targetId) { @@ -70,14 +64,14 @@ export function usePages() { } return false; }; - update(newTree); - setTree(newTree); + update(newPages); + setPages(newPages); }; - const removeFromTree = (targetId: string): TreeItem | null => { - const newTree = JSON.parse(JSON.stringify(tree())) as TreeItem[]; - let removed: TreeItem | null = null; - const remove = (items: TreeItem[]): boolean => { + const removeFromTree = (targetId: string): Page | null => { + const newPages = JSON.parse(JSON.stringify(pages())) as Page[]; + let removed: Page | null = null; + const remove = (items: Page[]): boolean => { for (let i = 0; i < items.length; i++) { const item = items[i]; if (item && item.id === targetId) { @@ -91,19 +85,19 @@ export function usePages() { } return false; }; - remove(newTree); - setTree(newTree); + remove(newPages); + setPages(newPages); return removed; }; - const addToTree = (parentId: string | null, item: TreeItem): void => { - const newTree = JSON.parse(JSON.stringify(tree())) as TreeItem[]; + const addToTree = (parentId: string | null, item: Page): void => { + const newPages = JSON.parse(JSON.stringify(pages())) as Page[]; if (parentId === null) { - newTree.push(item); - setTree(newTree); + newPages.push(item); + setPages(newPages); return; } - const add = (items: TreeItem[]): boolean => { + const add = (items: Page[]): boolean => { for (const it of items) { if (it.id === parentId) { if (!it.children) it.children = []; @@ -116,13 +110,12 @@ export function usePages() { } return false; }; - add(newTree); - setTree(newTree); + add(newPages); + setPages(newPages); }; onMount(() => { const storedPages = localStorage.getItem("pages"); - const storedTree = localStorage.getItem("tree"); const storedCurrentPage = localStorage.getItem("currentPageId"); if (storedPages) { @@ -130,11 +123,6 @@ export function usePages() { setPages(parsedPages.length > 0 ? parsedPages : [DEFAULT_PAGE]); } - if (storedTree) { - const parsedTree = JSON.parse(storedTree) as TreeItem[]; - setTree(parsedTree.length > 0 ? parsedTree : DEFAULT_TREE); - } - if (storedCurrentPage) { setCurrentPageId(storedCurrentPage); } @@ -143,8 +131,6 @@ export function usePages() { if (!event.newValue) return; if (event.key === "pages") { setPages(JSON.parse(event.newValue)); - } else if (event.key === "tree") { - setTree(JSON.parse(event.newValue)); } else if (event.key === "currentPageId") { setCurrentPageId(event.newValue); } @@ -159,76 +145,49 @@ export function usePages() { localStorage.setItem("pages", JSON.stringify(pages())); }); - createEffect(() => { - localStorage.setItem("tree", JSON.stringify(tree())); - }); - const addPage = (parentFolderId?: string) => { - const newPage: Page = { - id: generateId(), - name: `Page ${pages().length + 1}`, - content: "", + const countPages = (items: Page[]): number => { + let count = 0; + for (const item of items) { + count++; + if (item.children) { + count += countPages(item.children); + } + } + return count; }; - setPages([...pages(), newPage]); - const newTreeItem: TreeItem = { + const newPage: Page = { id: generateId(), - type: "page", - name: newPage.name, - pageId: newPage.id, + name: `Page ${countPages(pages()) + 1}`, + content: "", }; - addToTree(parentFolderId ?? null, newTreeItem); + addToTree(parentFolderId ?? null, newPage); setCurrentPageId(newPage.id); return newPage.id; }; - const addFolder = (parentFolderId?: string) => { - const newFolder: TreeItem = { - id: generateId(), - type: "folder", - name: "New Folder", - children: [], - }; - addToTree(parentFolderId ?? null, newFolder); - }; - const renameItem = (itemId: string, newName: string) => { - const item = findItemInTree(tree(), itemId); - if (!item) return; - - if (item.type === "page" && item.pageId) { - const pageIndex = findPageIndex(item.pageId); - if (pageIndex >= 0) { - setPages( - pages().map((p, i) => - i === pageIndex ? { ...p, name: newName.trim() } : p, - ), - ); - } - } - - updateTreeAt(itemId, (i) => ({ ...i, name: newName.trim() })); + updateTreeAt(itemId, (item) => ({ ...item, name: newName.trim() })); }; const deleteItem = (itemId: string) => { - const item = findItemInTree(tree(), itemId); - if (!item) return; - - if (item.type === "page" && item.pageId) { - const pageIndex = findPageIndex(item.pageId); - if (pageIndex >= 0) { - const newPages = pages().filter((_, i) => i !== pageIndex); - setPages(newPages.length > 0 ? newPages : [DEFAULT_PAGE]); - - if (currentPageId() === item.pageId) { - const firstPage = findItemInTree(tree(), itemId); - if (firstPage?.pageId) { - setCurrentPageId(firstPage.pageId); - } else { - setCurrentPageId(DEFAULT_PAGE.id); + if (currentPageId() === itemId) { + const allItems: Page[] = []; + const collectIds = (items: Page[]) => { + for (const item of items) { + if (item.id !== itemId) { + allItems.push(item); + } + if (item.children) { + collectIds(item.children); } } + }; + collectIds(pages()); + if (allItems.length > 0 && allItems[0]) { + setCurrentPageId(allItems[0].id); } } @@ -239,13 +198,13 @@ export function usePages() { itemId: string, direction: "up" | "down" | "in" | "out", ) => { - const parentItems = findItemParent(tree(), itemId); + const parentItems = findPageParent(pages(), itemId); if (!parentItems) return; - const currentIndex = findItemIndex(parentItems, itemId); + const currentIndex = findPageIndex(parentItems, itemId); if (currentIndex < 0) return; - const item = findItemInTree(tree(), itemId); + const item = findItemInTree(itemId); if (!item) return; if (direction === "up" && currentIndex > 0) { @@ -268,11 +227,11 @@ export function usePages() { } } else if (direction === "in" && currentIndex > 0) { const targetFolder = parentItems[currentIndex - 1]; - if (targetFolder && targetFolder.type === "folder") { + if (targetFolder) { const removed = removeFromTree(itemId); if (removed) { - const newTree = JSON.parse(JSON.stringify(tree())) as TreeItem[]; - const add = (items: TreeItem[]): boolean => { + const newPages = JSON.parse(JSON.stringify(pages())) as Page[]; + const add = (items: Page[]): boolean => { for (const it of items) { if (it.id === targetFolder.id) { if (!it.children) it.children = []; @@ -285,16 +244,16 @@ export function usePages() { } return false; }; - add(newTree); - setTree(newTree); + add(newPages); + setPages(newPages); } } } else if (direction === "out") { - const itemCopy = JSON.parse(JSON.stringify(item)) as TreeItem; + const itemCopy = JSON.parse(JSON.stringify(item)) as Page; removeFromTree(itemId); - const newTree = JSON.parse(JSON.stringify(tree())) as TreeItem[]; - const findAndInsert = (items: TreeItem[]): boolean => { + const newPages = JSON.parse(JSON.stringify(pages())) as Page[]; + const findAndInsert = (items: Page[]): boolean => { for (let i = 0; i < items.length; i++) { const currentItem = items[i]; const parentFirst = parentItems[0]; @@ -308,18 +267,15 @@ export function usePages() { } return false; }; - findAndInsert(newTree); - setTree(newTree); + findAndInsert(newPages); + setPages(newPages); } }; - const updateParentSiblings = ( - newSiblings: TreeItem[], - oldSiblings: TreeItem[], - ) => { - const newTree = JSON.parse(JSON.stringify(tree())) as TreeItem[]; + const updateParentSiblings = (newSiblings: Page[], oldSiblings: Page[]) => { + const newPages = JSON.parse(JSON.stringify(pages())) as Page[]; const oldFirst = oldSiblings[0]; - const update = (items: TreeItem[]): boolean => { + const update = (items: Page[]): boolean => { for (let i = 0; i < items.length; i++) { const it = items[i]; if (it && oldFirst && it.id === oldFirst.id) { @@ -341,41 +297,29 @@ export function usePages() { } return false; }; - update(newTree); - setTree(newTree); + update(newPages); + setPages(newPages); }; const getCurrentPage = (): Page | null => { const id = currentPageId(); - return pages().find((p) => p.id === id) || null; + return findPageInTree(pages(), id); }; const updatePageContent = (content: string) => { - const id = currentPageId(); - const idx = findPageIndex(id); - if (idx >= 0) { - const newPages = [...pages()]; - const page = newPages[idx]; - if (page) { - newPages[idx] = { ...page, content }; - setPages(newPages); - } - } + updateTreeAt(currentPageId(), (item) => ({ ...item, content })); }; return { pages, - tree, currentPageId, setCurrentPageId, addPage, - addFolder, renameItem, deleteItem, moveItem, updatePageContent, getCurrentPage, findItemInTree, - findPageIndex, }; } diff --git a/src/types/index.ts b/src/types/index.ts index 3ce30fd..ad8c0ae 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,14 +2,7 @@ export interface Page { id: string; name: string; content: string; -} - -export interface TreeItem { - id: string; - type: "folder" | "page"; - name: string; - children?: TreeItem[]; - pageId?: string; + children?: Page[]; } export namespace EditorSettings { From 60b86dd58f5655094187845ee89d1c4d2eaa7890 Mon Sep 17 00:00:00 2001 From: Roman Date: Sun, 22 Mar 2026 22:59:50 +0000 Subject: [PATCH 03/18] Update PagesMenu.tsx --- src/components/PagesMenu.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/PagesMenu.tsx b/src/components/PagesMenu.tsx index 825e867..c0b7503 100644 --- a/src/components/PagesMenu.tsx +++ b/src/components/PagesMenu.tsx @@ -413,13 +413,14 @@ const Pages = (props: { dragOverPosition={props.dragOverPosition} onSelectPage={props.onSelectPage} onToggleFolder={props.onToggleFolder} - onContextMenu={(e, item) => + onContextMenu={(e, item) => { + e.preventDefault(); props.setContextMenu({ x: e.pageX, y: e.pageY, itemId: item.id, - }) - } + }); + }} onDragStart={props.onDragStart} onDragOver={props.onDragOver} onDragOverFolder={props.onDragOverFolder} From d96d3bb7f812661315a9090f7aa6904ceed30bf1 Mon Sep 17 00:00:00 2001 From: Roman Date: Sun, 22 Mar 2026 23:09:05 +0000 Subject: [PATCH 04/18] Update PagesMenu.tsx --- src/components/PagesMenu.tsx | 39 ++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/components/PagesMenu.tsx b/src/components/PagesMenu.tsx index c0b7503..898a7db 100644 --- a/src/components/PagesMenu.tsx +++ b/src/components/PagesMenu.tsx @@ -398,8 +398,7 @@ const Pages = (props: { }): JSX.Element => (
props.setContextMenu(null)} @@ -687,33 +686,35 @@ const PagesMenu = (props: { props.selectPageByTreeItem(draggedId); + const draggedParent = getParentItems(draggedId); const targetParent = getParentItems(targetItem.id); if (!targetParent) { handleDragEnd(); return; } - const draggedParent = getParentItems(draggedId); - const targetIdx = getItemIndex(targetItem.id); - - if ( - draggedParent && - draggedParent === targetParent && - draggedId === targetItem.id - ) { + if (draggedParent === targetParent) { + props.moveItem(draggedId, position === "before" ? "up" : "down"); handleDragEnd(); return; } - if (position === "before") { - props.moveItem(draggedId, "out"); - for (let i = 0; i < targetIdx; i++) { - props.moveItem(draggedId, "up"); - } - } else { - props.moveItem(draggedId, "out"); - for (let i = 0; i <= targetIdx; i++) { - props.moveItem(draggedId, "down"); + const targetIdx = getItemIndex(targetItem.id); + props.moveItem(draggedId, "out"); + + const newParent = getParentItems(draggedId); + if (newParent) { + const newIdx = getItemIndex(draggedId); + const movesNeeded = + position === "before" ? newIdx - targetIdx : newIdx - targetIdx - 1; + if (movesNeeded > 0) { + for (let i = 0; i < movesNeeded; i++) { + props.moveItem(draggedId, "up"); + } + } else if (movesNeeded < 0) { + for (let i = 0; i < -movesNeeded; i++) { + props.moveItem(draggedId, "down"); + } } } From 5086a2e4cc55cf350b51d4826195275930edfed0 Mon Sep 17 00:00:00 2001 From: Roman Date: Sun, 22 Mar 2026 23:26:06 +0000 Subject: [PATCH 05/18] wip --- src/components/App.tsx | 2 + src/components/PagesMenu.tsx | 500 ++++++----------------------------- src/components/TreeItem.tsx | 141 ++++++++++ src/components/TreeList.tsx | 89 +++++++ src/hooks/usePages.ts | 15 +- 5 files changed, 321 insertions(+), 426 deletions(-) create mode 100644 src/components/TreeItem.tsx create mode 100644 src/components/TreeList.tsx diff --git a/src/components/App.tsx b/src/components/App.tsx index 724671a..532612d 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -13,6 +13,7 @@ import Toolbar from "./Toolbar"; function App() { const { pages, + setPages, currentPageId, setCurrentPageId, addPage, @@ -155,6 +156,7 @@ function App() { renameItem={renameItem} deleteItem={deleteItem} moveItem={moveItem} + setPages={setPages} /> ( - - - - - - - - -); - -const FolderSvgIcon = (props: { isOpen: boolean }): JSX.Element => ( - - Folder - - -); - -const ChevronSvgIcon = (props: { isOpen: boolean }): JSX.Element => ( - - Expand - - -); - -const PageItem = (props: { - item: Page; - isSelected: boolean; - isDragOver: boolean; - dragOverPosition: "before" | "after" | null; - onClick: () => void; - onContextMenu: (e: MouseEvent) => void; - onDragStart: (e: DragEvent) => void; - onDragOver: (e: DragEvent, position: "before" | "after") => void; - onDragLeave: (e: DragEvent) => void; - onDrop: (e: DragEvent) => void; - onDragEnd: () => void; -}): JSX.Element => ( -
{ - e.preventDefault(); - const rect = e.currentTarget.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - props.onDragOver(e, e.clientY < midY ? "before" : "after"); - }} - onDragLeave={props.onDragLeave} - onDrop={props.onDrop} - > - -
- - -
- -
- -
-
-); - -const FolderItem = (props: { - item: Page; - isSelected: boolean; - isOpen: boolean; - isDragOver: boolean; - isDragOverFolder: boolean; - dragOverPosition: "before" | "after" | null; - onToggle: () => void; - onContextMenu: (e: MouseEvent) => void; - onDragStart: (e: DragEvent) => void; - onDragOver: (e: DragEvent, position: "before" | "after") => void; - onDragOverFolder: (e: DragEvent) => void; - onDragLeaveFolder: () => void; - onDragLeave: (e: DragEvent) => void; - onDrop: (e: DragEvent) => void; - onDropFolder: (e: DragEvent) => void; - onDragEnd: () => void; - children?: JSX.Element; -}): JSX.Element => ( -
-
{ - e.preventDefault(); - const rect = e.currentTarget.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - props.onDragOver(e, e.clientY < midY ? "before" : "after"); - }} - onDragLeave={props.onDragLeave} - onDrop={props.onDrop} - > - -
- - -
- -
- - -
-
-
- {props.children} -
-
-); - -const TreeItemView = (props: { - item: Page; - pages: Page[]; - currentPageId: string; - openFolders: Set; - dragItemId: string | null; - dragOverItemId: string | null; - dragOverPosition: "before" | "after" | null; - onSelectPage: (pageId: string) => void; - onToggleFolder: (folderId: string) => void; - onContextMenu: (e: MouseEvent, item: Page) => void; - onDragStart: (e: DragEvent, item: Page) => void; - onDragOver: ( - e: DragEvent, - itemId: string, - position: "before" | "after", - ) => void; - onDragOverFolder: (e: DragEvent, folderId: string) => void; - onDragLeave: (e: DragEvent) => void; - onDragLeaveFolder: () => void; - onDrop: (e: DragEvent, item: Page, position: "before" | "after") => void; - onDropFolder: (e: DragEvent, folderId: string) => void; - onDragEnd: () => void; -}): JSX.Element => { - const hasChildren = () => (props.item.children?.length ?? 0) > 0; - const isFolder = () => hasChildren(); - const isOpen = () => props.openFolders.has(props.item.id); - const isSelected = () => props.currentPageId === props.item.id; - - return ( - { - props.onSelectPage(props.item.id); - }} - onContextMenu={(e) => props.onContextMenu(e, props.item)} - onDragStart={(e) => props.onDragStart(e, props.item)} - onDragOver={(e, pos) => props.onDragOver(e, props.item.id, pos)} - onDragLeave={props.onDragLeave} - onDrop={(e) => - props.onDrop(e, props.item, props.dragOverPosition || "after") - } - onDragEnd={props.onDragEnd} - /> - } - > - props.onToggleFolder(props.item.id)} - onContextMenu={(e) => props.onContextMenu(e, props.item)} - onDragStart={(e) => props.onDragStart(e, props.item)} - onDragOver={(e, pos) => props.onDragOver(e, props.item.id, pos)} - onDragOverFolder={(e) => props.onDragOverFolder(e, props.item.id)} - onDragLeaveFolder={props.onDragLeaveFolder} - onDragLeave={(e) => props.onDragLeave(e)} - onDrop={(e) => - props.onDrop(e, props.item, props.dragOverPosition || "after") - } - onDropFolder={(e) => props.onDropFolder(e, props.item.id)} - onDragEnd={props.onDragEnd} - > - - {(child) => ( - props.onDragLeave(e)} - onDragLeaveFolder={props.onDragLeaveFolder} - onDrop={props.onDrop} - onDropFolder={props.onDropFolder} - onDragEnd={props.onDragEnd} - /> - )} - - - - ); -}; - -const TreeList = (props: { - pages: Page[]; - currentPageId: string; - openFolders: Set; - dragItemId: string | null; - dragOverItemId: string | null; - dragOverPosition: "before" | "after" | null; - onSelectPage: (pageId: string) => void; - onToggleFolder: (folderId: string) => void; - onContextMenu: (e: MouseEvent, item: Page) => void; - onDragStart: (e: DragEvent, item: Page) => void; - onDragOver: ( - e: DragEvent, - itemId: string, - position: "before" | "after", - ) => void; - onDragOverFolder: (e: DragEvent, folderId: string) => void; - onDragLeave: (e: DragEvent) => void; - onDragLeaveFolder: () => void; - onDrop: (e: DragEvent, item: Page, position: "before" | "after") => void; - onDropFolder: (e: DragEvent, folderId: string) => void; - onDragEnd: () => void; -}): JSX.Element => ( -
- - {(item) => ( - - )} - -
-); - const Pages = (props: { pages: Page[]; currentPageId: string; @@ -388,19 +29,16 @@ const Pages = (props: { itemId: string, position: "before" | "after", ) => void; - onDragOverFolder: (e: DragEvent, folderId: string) => void; + onDragOverNestable: (e: DragEvent, folderId: string) => void; onDragLeave: (e: DragEvent) => void; - onDragLeaveFolder: () => void; + onDragLeaveNestable: () => void; onDrop: (e: DragEvent, item: Page, position: "before" | "after") => void; - onDropFolder: (e: DragEvent, folderId: string) => void; + onDropNestable: (e: DragEvent, folderId: string) => void; onDragEnd: () => void; newPage: () => void; }): JSX.Element => (
props.setContextMenu(null)} > @@ -499,6 +137,7 @@ const PagesMenu = (props: { renameItem: (itemId: string, newName: string) => void; deleteItem: (itemId: string) => void; moveItem: (itemId: string, direction: "up" | "down" | "in" | "out") => void; + setPages: (pages: Page[]) => void; }): JSX.Element => { const [contextMenu, setContextMenu] = createSignal(null); const [openFolders, setOpenFolders] = createSignal>(new Set()); @@ -641,10 +280,10 @@ const PagesMenu = (props: { setDragOverPosition(position); }; - const handleDragOverFolder = (e: DragEvent, folderId: string) => { + const handleDragOverNestable = (e: DragEvent, folderId: string) => { e.preventDefault(); e.stopPropagation(); - setDragOverItemId(`folder-${folderId}`); + setDragOverItemId(`nest-${folderId}`); setDragOverPosition(null); }; @@ -657,9 +296,9 @@ const PagesMenu = (props: { }, 50); }; - const handleDragLeaveFolder = () => { + const handleDragLeaveNestable = () => { setTimeout(() => { - if (!dragOverItemId()?.startsWith("folder-")) { + if (dragOverItemId()?.startsWith("nest-")) { setDragOverItemId(null); } }, 50); @@ -686,42 +325,55 @@ const PagesMenu = (props: { props.selectPageByTreeItem(draggedId); - const draggedParent = getParentItems(draggedId); const targetParent = getParentItems(targetItem.id); if (!targetParent) { handleDragEnd(); return; } - if (draggedParent === targetParent) { - props.moveItem(draggedId, position === "before" ? "up" : "down"); - handleDragEnd(); - return; - } + const newPages = structuredClone(props.pages); + const draggedItemCopy = structuredClone(draggedItem); + + const removeItem = (items: Page[]): Page | null => { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!item) continue; + if (item.id === draggedId) { + return items.splice(i, 1)[0] ?? null; + } + if (item.children) { + const found = removeItem(item.children); + if (found) return found; + } + } + return null; + }; + + removeItem(newPages); const targetIdx = getItemIndex(targetItem.id); - props.moveItem(draggedId, "out"); - - const newParent = getParentItems(draggedId); - if (newParent) { - const newIdx = getItemIndex(draggedId); - const movesNeeded = - position === "before" ? newIdx - targetIdx : newIdx - targetIdx - 1; - if (movesNeeded > 0) { - for (let i = 0; i < movesNeeded; i++) { - props.moveItem(draggedId, "up"); + const insertIdx = position === "before" ? targetIdx : targetIdx + 1; + + const insertInto = (items: Page[]): boolean => { + for (const item of items) { + if (item.id === targetItem.id) { + const parentArr = items; + parentArr.splice(insertIdx, 0, draggedItemCopy); + return true; } - } else if (movesNeeded < 0) { - for (let i = 0; i < -movesNeeded; i++) { - props.moveItem(draggedId, "down"); + if (item.children) { + if (insertInto(item.children)) return true; } } - } + return false; + }; + insertInto(newPages); + props.setPages(newPages); handleDragEnd(); }; - const handleDropFolder = (e: DragEvent, folderId: string) => { + const handleDropNestable = (e: DragEvent, folderId: string) => { e.preventDefault(); e.stopPropagation(); const draggedId = dragItemId(); @@ -738,32 +390,42 @@ const PagesMenu = (props: { props.selectPageByTreeItem(draggedId); - const folderItem = findItemInTree(props.pages, folderId); - if (!folderItem || (folderItem.children?.length ?? 0) === 0) { - handleDragEnd(); - return; - } + const draggedItemCopy = structuredClone(draggedItem); + const newPages = structuredClone(props.pages); - const currentParent = getParentItems(draggedId); - if (currentParent && currentParent[0]?.id === folderId) { - handleDragEnd(); - return; - } + const removeItem = (items: Page[]): Page | null => { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!item) continue; + if (item.id === draggedId) { + return items.splice(i, 1)[0] ?? null; + } + if (item.children) { + const found = removeItem(item.children); + if (found) return found; + } + } + return null; + }; - props.moveItem(draggedId, "in"); + removeItem(newPages); - const folderIndex = getItemIndex(folderId); - if (folderIndex >= 0) { - const targetItemBeforeFolder = currentParent - ? currentParent[folderIndex - 1] - : null; - if (targetItemBeforeFolder) { - for (let i = 0; i < folderIndex; i++) { - props.moveItem(draggedId, "up"); + const insertInto = (items: Page[]): boolean => { + for (const item of items) { + if (item.id === folderId) { + if (!item.children) item.children = []; + item.children.push(draggedItemCopy); + return true; + } + if (item.children) { + if (insertInto(item.children)) return true; } } - } + return false; + }; + insertInto(newPages); + props.setPages(newPages); handleDragEnd(); }; @@ -787,11 +449,11 @@ const PagesMenu = (props: { setContextMenu={setContextMenu} onDragStart={handleDragStart} onDragOver={handleDragOver} - onDragOverFolder={handleDragOverFolder} + onDragOverNestable={handleDragOverNestable} onDragLeave={handleDragLeave} - onDragLeaveFolder={handleDragLeaveFolder} + onDragLeaveNestable={handleDragLeaveNestable} onDrop={handleDrop} - onDropFolder={handleDropFolder} + onDropNestable={handleDropNestable} onDragEnd={handleDragEnd} newPage={props.addPage} /> diff --git a/src/components/TreeItem.tsx b/src/components/TreeItem.tsx new file mode 100644 index 0000000..2685533 --- /dev/null +++ b/src/components/TreeItem.tsx @@ -0,0 +1,141 @@ +import type { JSX } from "solid-js"; +import { Show } from "solid-js"; + +import type { Page } from "#types"; +import Button from "./ui/Button"; + +const DragHandleIcon = (): JSX.Element => ( + + + + + + + + +); + +const ChevronSvgIcon = (props: { isOpen: boolean }): JSX.Element => ( + + Expand + + +); + +type TreeItemProps = { + item: Page; + isSelected: boolean; + isOpen: boolean; + isDragOver: boolean; + isDragOverNestable: boolean; + dragOverPosition: "before" | "after" | null; + onSelect: () => void; + onToggle: () => void; + onContextMenu: (e: MouseEvent) => void; + onDragStart: (e: DragEvent) => void; + onDragOver: (e: DragEvent, position: "before" | "after") => void; + onDragOverNestable: (e: DragEvent) => void; + onDragLeaveNestable: () => void; + onDragLeave: (e: DragEvent) => void; + onDrop: (e: DragEvent) => void; + onDropNestable: (e: DragEvent) => void; + onDragEnd: () => void; + children?: JSX.Element; +}; + +const TreeItem = (props: TreeItemProps): JSX.Element => { + const hasChildren = () => (props.item.children?.length ?? 0) > 0; + + return ( +
+
{ + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + props.onDragOver(e, e.clientY < midY ? "before" : "after"); + }} + onDragLeave={props.onDragLeave} + onDrop={props.onDrop} + > + +
+ + +
+ +
+ + +
+
+ +
+ {props.children} +
+
+
+ ); +}; + +export default TreeItem; diff --git a/src/components/TreeList.tsx b/src/components/TreeList.tsx new file mode 100644 index 0000000..d2146c1 --- /dev/null +++ b/src/components/TreeList.tsx @@ -0,0 +1,89 @@ +import type { JSX } from "solid-js"; +import { For, Show } from "solid-js"; + +import type { Page } from "#types"; +import TreeItem from "./TreeItem"; + +type TreeListProps = { + pages: Page[]; + currentPageId: string; + openFolders: Set; + dragItemId: string | null; + dragOverItemId: string | null; + dragOverPosition: "before" | "after" | null; + onSelectPage: (pageId: string) => void; + onToggleFolder: (folderId: string) => void; + onContextMenu: (e: MouseEvent, item: Page) => void; + onDragStart: (e: DragEvent, item: Page) => void; + onDragOver: ( + e: DragEvent, + itemId: string, + position: "before" | "after", + ) => void; + onDragOverNestable: (e: DragEvent, folderId: string) => void; + onDragLeave: (e: DragEvent) => void; + onDragLeaveNestable: () => void; + onDrop: (e: DragEvent, item: Page, position: "before" | "after") => void; + onDropNestable: (e: DragEvent, folderId: string) => void; + onDragEnd: () => void; +}; + +const TreeList = (props: TreeListProps): JSX.Element => { + return ( +
+ + {(item) => ( + props.onSelectPage(item.id)} + onToggle={() => props.onToggleFolder(item.id)} + onContextMenu={(e) => props.onContextMenu(e, item)} + onDragStart={(e) => props.onDragStart(e, item)} + onDragOver={(e, pos) => props.onDragOver(e, item.id, pos)} + onDragOverNestable={(e) => props.onDragOverNestable(e, item.id)} + onDragLeaveNestable={props.onDragLeaveNestable} + onDragLeave={(e) => props.onDragLeave(e)} + onDrop={(e) => + props.onDrop(e, item, props.dragOverPosition || "after") + } + onDropNestable={(e) => props.onDropNestable(e, item.id)} + onDragEnd={props.onDragEnd} + > + 0}> + + + + )} + +
+ ); +}; + +export default TreeList; diff --git a/src/hooks/usePages.ts b/src/hooks/usePages.ts index 4314d98..0a38b86 100644 --- a/src/hooks/usePages.ts +++ b/src/hooks/usePages.ts @@ -50,7 +50,7 @@ export function usePages() { targetId: string, updater: (item: Page) => Page, ): void => { - const newPages = JSON.parse(JSON.stringify(pages())) as Page[]; + const newPages = structuredClone(pages()); const update = (items: Page[]): boolean => { for (let i = 0; i < items.length; i++) { const item = items[i]; @@ -69,7 +69,7 @@ export function usePages() { }; const removeFromTree = (targetId: string): Page | null => { - const newPages = JSON.parse(JSON.stringify(pages())) as Page[]; + const newPages = structuredClone(pages()); let removed: Page | null = null; const remove = (items: Page[]): boolean => { for (let i = 0; i < items.length; i++) { @@ -91,7 +91,7 @@ export function usePages() { }; const addToTree = (parentId: string | null, item: Page): void => { - const newPages = JSON.parse(JSON.stringify(pages())) as Page[]; + const newPages = structuredClone(pages()); if (parentId === null) { newPages.push(item); setPages(newPages); @@ -230,7 +230,7 @@ export function usePages() { if (targetFolder) { const removed = removeFromTree(itemId); if (removed) { - const newPages = JSON.parse(JSON.stringify(pages())) as Page[]; + const newPages = structuredClone(pages()); const add = (items: Page[]): boolean => { for (const it of items) { if (it.id === targetFolder.id) { @@ -249,10 +249,10 @@ export function usePages() { } } } else if (direction === "out") { - const itemCopy = JSON.parse(JSON.stringify(item)) as Page; + const itemCopy = structuredClone(item); removeFromTree(itemId); - const newPages = JSON.parse(JSON.stringify(pages())) as Page[]; + const newPages = structuredClone(pages()); const findAndInsert = (items: Page[]): boolean => { for (let i = 0; i < items.length; i++) { const currentItem = items[i]; @@ -273,7 +273,7 @@ export function usePages() { }; const updateParentSiblings = (newSiblings: Page[], oldSiblings: Page[]) => { - const newPages = JSON.parse(JSON.stringify(pages())) as Page[]; + const newPages = structuredClone(pages()); const oldFirst = oldSiblings[0]; const update = (items: Page[]): boolean => { for (let i = 0; i < items.length; i++) { @@ -312,6 +312,7 @@ export function usePages() { return { pages, + setPages, currentPageId, setCurrentPageId, addPage, From 502be83d734bdd5a1f94f8a106e6484bb6ba4c5a Mon Sep 17 00:00:00 2001 From: Roman Date: Sun, 22 Mar 2026 23:51:49 +0000 Subject: [PATCH 06/18] fix and add tests --- biome.jsonc | 3 +- src/components/PagesMenu.tsx | 143 +++++------------------- src/lib/get-matches.ts | 2 +- src/lib/page-tree.ts | 147 ++++++++++++++++++++++++ tests/usePageTree.lib.test.ts | 204 ++++++++++++++++++++++++++++++++++ 5 files changed, 383 insertions(+), 116 deletions(-) create mode 100644 src/lib/page-tree.ts create mode 100644 tests/usePageTree.lib.test.ts diff --git a/biome.jsonc b/biome.jsonc index 0020c28..350e2ba 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -9,7 +9,8 @@ "useKeyWithClickEvents": "off" }, "style": { "noNonNullAssertion": "error" }, - "complexity": { "noVoid": "error" } + "complexity": { "noVoid": "error" }, + "suspicious": { "noExplicitAny": "error" } } }, "css": { "parser": { "tailwindDirectives": true } } diff --git a/src/components/PagesMenu.tsx b/src/components/PagesMenu.tsx index ec97d1f..2fa4b8b 100644 --- a/src/components/PagesMenu.tsx +++ b/src/components/PagesMenu.tsx @@ -1,6 +1,13 @@ import type { JSX, Setter } from "solid-js"; import { createSignal, Show } from "solid-js"; - +import { + findIndexInParent, + findItemInTree, + findParentOf, + moveItemAfter, + moveItemBefore, + moveItemInto, +} from "#lib/page-tree"; import type { Page } from "#types"; import TreeList from "./TreeList"; import Button from "./ui/Button"; @@ -117,17 +124,6 @@ const PagesContextMenu = (props: {
); -const findItemInTree = (items: Page[], targetId: string): Page | null => { - for (const item of items) { - if (item.id === targetId) return item; - if (item.children) { - const found = findItemInTree(item.children, targetId); - if (found) return found; - } - } - return null; -}; - const PagesMenu = (props: { isOpen: boolean; pages: Page[]; @@ -176,32 +172,14 @@ const PagesMenu = (props: { setContextMenu(null); }; - const getParentItems = (itemId: string): Page[] | null => { - const findParent = ( - items: Page[], - targetId: string, - parent: Page[] | null, - ): Page[] | null => { - for (const item of items) { - if (item.id === targetId) return parent; - if (item.children) { - const found = findParent(item.children, targetId, item.children); - if (found !== null) return found; - } - } - return null; - }; - return findParent(props.pages, itemId, null); - }; - const getItemIndex = (itemId: string): number => { - const parent = getParentItems(itemId); + const parent = findParentOf(props.pages, itemId); if (!parent) return -1; - return parent.findIndex((item) => item.id === itemId); + return findIndexInParent(parent, itemId); }; const getPrevSibling = (itemId: string): Page | null => { - const parent = getParentItems(itemId); + const parent = findParentOf(props.pages, itemId); if (!parent) return null; const idx = getItemIndex(itemId); if (idx <= 0) return null; @@ -245,7 +223,7 @@ const PagesMenu = (props: { const canMoveDown = () => { const c = contextMenu(); if (!c) return false; - const parent = getParentItems(c.itemId); + const parent = findParentOf(props.pages, c.itemId); if (!parent) return false; return getItemIndex(c.itemId) < parent.length - 1; }; @@ -260,7 +238,7 @@ const PagesMenu = (props: { const canMoveOut = () => { const c = contextMenu(); if (!c) return false; - return getParentItems(c.itemId) !== null; + return findParentOf(props.pages, c.itemId) !== null; }; const handleDragStart = (e: DragEvent, item: Page) => { @@ -325,51 +303,23 @@ const PagesMenu = (props: { props.selectPageByTreeItem(draggedId); - const targetParent = getParentItems(targetItem.id); - if (!targetParent) { - handleDragEnd(); - return; - } + const draggedParent = findParentOf(props.pages, draggedId); + const targetParent = findParentOf(props.pages, targetItem.id); - const newPages = structuredClone(props.pages); - const draggedItemCopy = structuredClone(draggedItem); - - const removeItem = (items: Page[]): Page | null => { - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (!item) continue; - if (item.id === draggedId) { - return items.splice(i, 1)[0] ?? null; - } - if (item.children) { - const found = removeItem(item.children); - if (found) return found; - } - } - return null; - }; - - removeItem(newPages); - - const targetIdx = getItemIndex(targetItem.id); - const insertIdx = position === "before" ? targetIdx : targetIdx + 1; - - const insertInto = (items: Page[]): boolean => { - for (const item of items) { - if (item.id === targetItem.id) { - const parentArr = items; - parentArr.splice(insertIdx, 0, draggedItemCopy); - return true; - } - if (item.children) { - if (insertInto(item.children)) return true; - } - } - return false; - }; + if (draggedParent === targetParent) { + const newPages = + position === "before" + ? moveItemBefore(props.pages, draggedId, targetItem.id) + : moveItemAfter(props.pages, draggedId, targetItem.id); + props.setPages(newPages); + } else { + const newPages = + position === "before" + ? moveItemBefore(props.pages, draggedId, targetItem.id) + : moveItemAfter(props.pages, draggedId, targetItem.id); + props.setPages(newPages); + } - insertInto(newPages); - props.setPages(newPages); handleDragEnd(); }; @@ -389,42 +339,7 @@ const PagesMenu = (props: { } props.selectPageByTreeItem(draggedId); - - const draggedItemCopy = structuredClone(draggedItem); - const newPages = structuredClone(props.pages); - - const removeItem = (items: Page[]): Page | null => { - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (!item) continue; - if (item.id === draggedId) { - return items.splice(i, 1)[0] ?? null; - } - if (item.children) { - const found = removeItem(item.children); - if (found) return found; - } - } - return null; - }; - - removeItem(newPages); - - const insertInto = (items: Page[]): boolean => { - for (const item of items) { - if (item.id === folderId) { - if (!item.children) item.children = []; - item.children.push(draggedItemCopy); - return true; - } - if (item.children) { - if (insertInto(item.children)) return true; - } - } - return false; - }; - - insertInto(newPages); + const newPages = moveItemInto(props.pages, draggedId, folderId); props.setPages(newPages); handleDragEnd(); }; diff --git a/src/lib/get-matches.ts b/src/lib/get-matches.ts index c88992e..a570cb6 100644 --- a/src/lib/get-matches.ts +++ b/src/lib/get-matches.ts @@ -22,4 +22,4 @@ function getMatches( return matches; } -export { getMatches, type Match }; +export { getMatches }; diff --git a/src/lib/page-tree.ts b/src/lib/page-tree.ts new file mode 100644 index 0000000..026f2c9 --- /dev/null +++ b/src/lib/page-tree.ts @@ -0,0 +1,147 @@ +import type { Page } from "#types"; + +function findItemInTree(items: Page[], targetId: string): Page | null { + for (const item of items) { + if (item.id === targetId) return item; + if (item.children) { + const found = findItemInTree(item.children, targetId); + if (found) return found; + } + } + return null; +} + +function findParentOf(items: Page[], targetId: string): Page[] | null { + for (const item of items) { + if (item.id === targetId) return items; + if (item.children) { + const found = findParentOf(item.children, targetId); + if (found) return found; + } + } + return null; +} + +function findParentItemAndArray( + items: Page[], + targetId: string, +): { parentItem: Page; parentArray: Page[] } | null { + for (const item of items) { + if (item.id === targetId) { + return null; + } + if (item.children) { + if (item.children.some((child) => child.id === targetId)) { + return { parentItem: item, parentArray: item.children }; + } + const found = findParentItemAndArray(item.children, targetId); + if (found) return found; + } + } + return null; +} + +function findIndexInParent(items: readonly Page[], targetId: string): number { + return items.findIndex((item) => item.id === targetId); +} + +function removeItemFromTree(items: Page[], targetId: string): Page | null { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!item) continue; + if (item.id === targetId) { + return items.splice(i, 1)[0] ?? null; + } + if (item.children) { + const removed = removeItemFromTree(item.children, targetId); + if (removed) return removed; + } + } + return null; +} + +function moveItemBefore( + items: Page[], + itemId: string, + beforeItemId: string, +): Page[] { + const newItems = structuredClone(items); + const item = removeItemFromTree(newItems, itemId); + if (!item) return newItems; + + const targetParent = findParentOf(newItems, beforeItemId); + if (!targetParent) return newItems; + + const insertIdx = findIndexInParent(targetParent, beforeItemId); + if (insertIdx < 0) return newItems; + + targetParent.splice(insertIdx, 0, item); + return newItems; +} + +function moveItemAfter( + items: Page[], + itemId: string, + afterItemId: string, +): Page[] { + const newItems = structuredClone(items); + + const targetParent = findParentOf(newItems, afterItemId); + if (!targetParent) return newItems; + + const afterIdx = findIndexInParent(targetParent, afterItemId); + if (afterIdx < 0) return newItems; + + const item = removeItemFromTree(newItems, itemId); + if (!item) return newItems; + + targetParent.splice(afterIdx + 1, 0, item); + return newItems; +} + +function moveItemInto( + items: Page[], + itemId: string, + intoItemId: string, +): Page[] { + const newItems = structuredClone(items); + + const target = findItemInTree(newItems, intoItemId); + if (!target) return newItems; + + const item = removeItemFromTree(newItems, itemId); + if (!item) return newItems; + + if (!target.children) target.children = []; + target.children.push(item); + return newItems; +} + +function moveItemOut(items: Page[], itemId: string): Page[] { + const newItems = structuredClone(items); + const parentInfo = findParentItemAndArray(newItems, itemId); + if (!parentInfo) return newItems; + + const { parentItem } = parentInfo; + const grandParent = findParentOf(newItems, parentItem.id); + if (!grandParent) return newItems; + + const parentIdx = findIndexInParent(grandParent, parentItem.id); + if (parentIdx < 0) return newItems; + + const removedItem = removeItemFromTree(newItems, itemId); + if (!removedItem) return newItems; + + grandParent.splice(parentIdx + 1, 0, removedItem); + return newItems; +} + +export { + findIndexInParent, + findItemInTree, + findParentOf, + moveItemAfter, + moveItemBefore, + moveItemInto, + moveItemOut, +}; diff --git a/tests/usePageTree.lib.test.ts b/tests/usePageTree.lib.test.ts new file mode 100644 index 0000000..fe45ee2 --- /dev/null +++ b/tests/usePageTree.lib.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "bun:test"; +import { + findItemInTree, + findParentOf, + moveItemAfter, + moveItemBefore, + moveItemInto, + moveItemOut, +} from "#lib/page-tree"; +import type { Page } from "#types"; + +describe("findItemInTree", () => { + const tree = [ + { id: "a", name: "A", content: "" }, + { + id: "b", + name: "B", + content: "", + children: [ + { id: "c", name: "C", content: "" }, + { id: "d", name: "D", content: "" }, + ], + }, + { id: "e", name: "E", content: "" }, + ]; + + it("should find root item", () => { + const found = findItemInTree(tree, "a"); + expect(found?.id).toBe("a"); + expect(found?.name).toBe("A"); + }); + + it("should find nested item", () => { + const found = findItemInTree(tree, "c"); + expect(found?.id).toBe("c"); + expect(found?.name).toBe("C"); + }); + + it("should return null for non-existent item", () => { + const found = findItemInTree(tree, "xyz"); + expect(found).toBeNull(); + }); +}); + +describe("findParentOf", () => { + const tree = [ + { id: "a", name: "A", content: "" }, + { + id: "b", + name: "B", + content: "", + children: [ + { id: "c", name: "C", content: "" }, + { id: "d", name: "D", content: "" }, + ], + }, + { id: "e", name: "E", content: "" }, + ]; + + it("should return array for root item", () => { + const parent = findParentOf(tree, "a"); + expect(parent).toBe(tree); + }); + + it("should return children array for nested item", () => { + const parent = findParentOf(tree, "c"); + // @ts-expect-error + expect(parent).toEqual(tree[1].children); + }); + + it("should return null for non-existent item", () => { + const parent = findParentOf(tree, "xyz"); + expect(parent).toBeNull(); + }); +}); + +describe("moveItemBefore", () => { + it("should move item up in same level", () => { + const tree = [ + { id: "a", name: "A", content: "" }, + { id: "b", name: "B", content: "" }, + { id: "c", name: "C", content: "" }, + ]; + + const result = moveItemBefore(structuredClone(tree), "c", "a"); + expect(result.map((i) => i.id)).toEqual(["c", "a", "b"]); + }); + + it("should not move if item doesn't exist", () => { + const tree = [ + { id: "a", name: "A", content: "" }, + { id: "b", name: "B", content: "" }, + ]; + + const result = moveItemBefore(structuredClone(tree), "xyz", "a"); + expect(result.map((i) => i.id)).toEqual(["a", "b"]); + }); + + it("should move between different parents", () => { + const tree = [ + { id: "a", name: "A", content: "" }, + { + id: "b", + name: "B", + content: "", + children: [{ id: "c", name: "C", content: "" }], + }, + ]; + + const result = moveItemBefore(structuredClone(tree), "c", "a"); + expect(result[0]?.id).toBe("c"); + expect(result[1]?.id).toBe("a"); + const b = result.find((i) => i.id === "b") as Page; + expect(b.children?.length).toBe(0); + }); +}); + +describe("moveItemAfter", () => { + it("should move item to end (after target)", () => { + const tree = [ + { id: "a", name: "A", content: "" }, + { id: "b", name: "B", content: "" }, + { id: "c", name: "C", content: "" }, + ]; + + const result = moveItemAfter(structuredClone(tree), "a", "c"); + expect(result.map((i) => i.id)).toEqual(["b", "c", "a"]); + }); + + it("should not move if item doesn't exist", () => { + const tree = [ + { id: "a", name: "A", content: "" }, + { id: "b", name: "B", content: "" }, + ]; + + const result = moveItemAfter(structuredClone(tree), "xyz", "a"); + expect(result.map((i) => i.id)).toEqual(["a", "b"]); + }); +}); + +describe("moveItemInto", () => { + it("should move item into another item's children", () => { + const tree = [ + { id: "a", name: "A", content: "" }, + { + id: "b", + name: "B", + content: "", + children: [{ id: "c", name: "C", content: "" }], + }, + { id: "d", name: "D", content: "" }, + ]; + + const result = moveItemInto(structuredClone(tree), "d", "b"); + expect(result.map((i) => i.id)).toEqual(["a", "b"]); + const b = result.find((i) => i.id === "b") as Page; + expect(b.children?.map((i) => i.id)).toEqual(["c", "d"]); + }); + + it("should move into empty children", () => { + const tree: Page[] = [ + { id: "a", name: "A", content: "" }, + { id: "b", name: "B", content: "", children: [] }, + ]; + + const result = moveItemInto(structuredClone(tree), "a", "b"); + expect(result.map((i) => i.id)).toEqual(["b"]); + const b = result.find((i) => i.id === "b") as Page; + expect(b.children?.map((i) => i.id)).toEqual(["a"]); + }); +}); + +describe("moveItemOut", () => { + it("should move item out of parent after parent in grandparent", () => { + const tree = [ + { id: "a", name: "A", content: "" }, + { + id: "b", + name: "B", + content: "", + children: [ + { id: "c", name: "C", content: "" }, + { id: "d", name: "D", content: "" }, + ], + }, + { id: "e", name: "E", content: "" }, + ]; + + const result = moveItemOut(structuredClone(tree), "d"); + expect(result.map((i) => i.id)).toEqual(["a", "b", "d", "e"]); + const b = result.find((i) => i.id === "b") as Page; + expect(b.children?.map((i) => i.id)).toEqual(["c"]); + }); + + it("should not move root item out", () => { + const tree = [ + { id: "a", name: "A", content: "" }, + { id: "b", name: "B", content: "" }, + ]; + + const result = moveItemOut(structuredClone(tree), "a"); + expect(result.map((i) => i.id)).toEqual(["a", "b"]); + }); +}); From 36066b916654244f583e5bd4401302a6e6aeac37 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 23 Mar 2026 00:00:00 +0000 Subject: [PATCH 07/18] rename --- tests/{usePageTree.lib.test.ts => page-tree.lib.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{usePageTree.lib.test.ts => page-tree.lib.test.ts} (100%) diff --git a/tests/usePageTree.lib.test.ts b/tests/page-tree.lib.test.ts similarity index 100% rename from tests/usePageTree.lib.test.ts rename to tests/page-tree.lib.test.ts From 178b5fb3973d126693e86ae70be6004a9c45df4e Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 23 Mar 2026 00:23:48 +0000 Subject: [PATCH 08/18] update config --- biome.jsonc | 4 +--- bun.lock | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/biome.jsonc b/biome.jsonc index 350e2ba..b2a3cd8 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -8,9 +8,7 @@ "noStaticElementInteractions": "off", "useKeyWithClickEvents": "off" }, - "style": { "noNonNullAssertion": "error" }, - "complexity": { "noVoid": "error" }, - "suspicious": { "noExplicitAny": "error" } + "complexity": { "noVoid": "error" } } }, "css": { "parser": { "tailwindDirectives": true } } diff --git a/bun.lock b/bun.lock index 96af206..19d1848 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ "devDependencies": { "@astrojs/solid-js": "^6.0.1", "@biomejs/biome": "2.4.8", - "@gameroman/config": "^0.0.3", + "@gameroman/config": "^0.0.4", "@tailwindcss/vite": "^4.2.2", "typescript": "^5.9.3", "wrangler": "^4.76.0", @@ -165,7 +165,7 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], - "@gameroman/config": ["@gameroman/config@0.0.3", "", { "peerDependencies": { "@biomejs/biome": "^2.4.7", "oxfmt": ">=0.40.0", "oxlint": "^1.54.0", "oxlint-tsgolint": ">=0.17.0", "typescript": "^5.9.3" }, "optionalPeers": ["@biomejs/biome", "oxfmt", "oxlint", "oxlint-tsgolint", "typescript"] }, "sha512-uZ06DWb/ApyX/oATmz9/j5Ar0de593rNnfrgmP6X1CLP5v8SKrvHzSVCq/SC7FYFRSeD6IV/r7dOFu+Ks813HA=="], + "@gameroman/config": ["@gameroman/config@0.0.4", "", { "peerDependencies": { "@biomejs/biome": "^2.4.7", "oxfmt": ">=0.40.0", "oxlint": "^1.54.0", "oxlint-tsgolint": ">=0.17.0", "typescript": "^5.9.3" }, "optionalPeers": ["@biomejs/biome", "oxfmt", "oxlint", "oxlint-tsgolint", "typescript"] }, "sha512-YkEBtjP56aPi4iFGhQPnVH7suxAMeUw4U/EkU4GK6aD/n0avAFNRzTlZTJrXzykVtlWUSfwYuSyKiT0kM2XIsw=="], "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], diff --git a/package.json b/package.json index 2980e86..24d0348 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "devDependencies": { "@astrojs/solid-js": "^6.0.1", "@biomejs/biome": "2.4.8", - "@gameroman/config": "^0.0.3", + "@gameroman/config": "^0.0.4", "@tailwindcss/vite": "^4.2.2", "typescript": "^5.9.3", "wrangler": "^4.76.0" From 4f0b94724e96380462c915da8b387fced0e301bd Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 23 Mar 2026 00:38:40 +0000 Subject: [PATCH 09/18] fix --- src/components/PagesMenu.tsx | 2 +- src/components/TreeItem.tsx | 2 +- src/components/TreeList.tsx | 117 +++++++++++++++++++---------------- src/components/ui/Button.tsx | 9 +-- src/hooks/usePages.ts | 117 ++++++++++++++++++++++------------- src/lib/page-tree.ts | 89 ++++++++++++++++++++++++-- tests/page-tree.lib.test.ts | 35 +++++++++++ 7 files changed, 264 insertions(+), 107 deletions(-) diff --git a/src/components/PagesMenu.tsx b/src/components/PagesMenu.tsx index 2fa4b8b..0ae3270 100644 --- a/src/components/PagesMenu.tsx +++ b/src/components/PagesMenu.tsx @@ -232,7 +232,7 @@ const PagesMenu = (props: { const c = contextMenu(); if (!c) return false; const prev = getPrevSibling(c.itemId); - return prev !== null && (prev.children?.length ?? 0) > 0; + return prev !== null; }; const canMoveOut = () => { diff --git a/src/components/TreeItem.tsx b/src/components/TreeItem.tsx index 2685533..c9f8c30 100644 --- a/src/components/TreeItem.tsx +++ b/src/components/TreeItem.tsx @@ -104,8 +104,8 @@ const TreeItem = (props: TreeItemProps): JSX.Element => { ? "bg-[#ddd] dark:bg-[#333] border-blue-500 flex-1" : "bg-[#ededed] dark:bg-[#181818] border-[#ededed] dark:border-[#181818] flex-1" } - onClick={hasChildren() ? props.onToggle : props.onSelect} onMouseDown={(e) => { + e.preventDefault(); if (e.button === 0) { if (hasChildren()) { props.onToggle(); diff --git a/src/components/TreeList.tsx b/src/components/TreeList.tsx index d2146c1..a87496e 100644 --- a/src/components/TreeList.tsx +++ b/src/components/TreeList.tsx @@ -26,63 +26,72 @@ type TreeListProps = { onDrop: (e: DragEvent, item: Page, position: "before" | "after") => void; onDropNestable: (e: DragEvent, folderId: string) => void; onDragEnd: () => void; + isRoot?: boolean; }; const TreeList = (props: TreeListProps): JSX.Element => { - return ( -
- - {(item) => ( - props.onSelectPage(item.id)} - onToggle={() => props.onToggleFolder(item.id)} - onContextMenu={(e) => props.onContextMenu(e, item)} - onDragStart={(e) => props.onDragStart(e, item)} - onDragOver={(e, pos) => props.onDragOver(e, item.id, pos)} - onDragOverNestable={(e) => props.onDragOverNestable(e, item.id)} - onDragLeaveNestable={props.onDragLeaveNestable} - onDragLeave={(e) => props.onDragLeave(e)} - onDrop={(e) => - props.onDrop(e, item, props.dragOverPosition || "after") - } - onDropNestable={(e) => props.onDropNestable(e, item.id)} - onDragEnd={props.onDragEnd} - > - 0}> - - - - )} - -
+ const wrapper = (children: JSX.Element) => + props.isRoot !== false ? ( +
+ {children} +
+ ) : ( + children + ); + + return wrapper( + + {(item) => ( + props.onSelectPage(item.id)} + onToggle={() => props.onToggleFolder(item.id)} + onContextMenu={(e) => props.onContextMenu(e, item)} + onDragStart={(e) => props.onDragStart(e, item)} + onDragOver={(e, pos) => props.onDragOver(e, item.id, pos)} + onDragOverNestable={(e) => props.onDragOverNestable(e, item.id)} + onDragLeaveNestable={props.onDragLeaveNestable} + onDragLeave={(e) => props.onDragLeave(e)} + onDrop={(e) => + props.onDrop(e, item, props.dragOverPosition || "after") + } + onDropNestable={(e) => props.onDropNestable(e, item.id)} + onDragEnd={props.onDragEnd} + > + 0}> + + + + )} + , ); }; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index bbbf47e..95f9126 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -18,10 +18,11 @@ type ButtonVariants = { variant?: TVariant; }; -type ButtonProps = ButtonAttributes & { - label?: string; - children?: JSX.Element; -} & ButtonVariants; +type ButtonProps = + | ({ label: string; children?: never } & ButtonAttributes & + ButtonVariants) + | ({ children: JSX.Element; label?: never } & ButtonAttributes & + ButtonVariants); function Button(props: ButtonProps) { const [local, rest] = splitProps(props, [ diff --git a/src/hooks/usePages.ts b/src/hooks/usePages.ts index 0a38b86..020e978 100644 --- a/src/hooks/usePages.ts +++ b/src/hooks/usePages.ts @@ -1,5 +1,5 @@ import { createEffect, createSignal, onMount } from "solid-js"; - +import { moveItemOut } from "#lib/page-tree"; import type { Page } from "#types"; const generateId = () => Math.random().toString(36).substring(2, 9); @@ -121,10 +121,25 @@ export function usePages() { if (storedPages) { const parsedPages = JSON.parse(storedPages) as Page[]; setPages(parsedPages.length > 0 ? parsedPages : [DEFAULT_PAGE]); - } - if (storedCurrentPage) { - setCurrentPageId(storedCurrentPage); + if (storedCurrentPage) { + const findIdInPages = (items: Page[], targetId: string): boolean => { + for (const item of items) { + if (item.id === targetId) return true; + if (item.children && findIdInPages(item.children, targetId)) { + return true; + } + } + return false; + }; + if (findIdInPages(parsedPages, storedCurrentPage)) { + setCurrentPageId(storedCurrentPage); + } else if (parsedPages.length > 0 && parsedPages[0]) { + setCurrentPageId(parsedPages[0].id); + } + } else if (parsedPages.length > 0 && parsedPages[0]) { + setCurrentPageId(parsedPages[0].id); + } } window.addEventListener("storage", (event) => { @@ -132,7 +147,19 @@ export function usePages() { if (event.key === "pages") { setPages(JSON.parse(event.newValue)); } else if (event.key === "currentPageId") { - setCurrentPageId(event.newValue); + const newId = event.newValue; + const findIdInPages = (items: Page[], targetId: string): boolean => { + for (const item of items) { + if (item.id === targetId) return true; + if (item.children && findIdInPages(item.children, targetId)) { + return true; + } + } + return false; + }; + if (findIdInPages(pages(), newId)) { + setCurrentPageId(newId); + } } }); @@ -145,6 +172,10 @@ export function usePages() { localStorage.setItem("pages", JSON.stringify(pages())); }); + createEffect(() => { + localStorage.setItem("currentPageId", currentPageId()); + }); + const addPage = (parentFolderId?: string) => { const countPages = (items: Page[]): number => { let count = 0; @@ -173,21 +204,32 @@ export function usePages() { }; const deleteItem = (itemId: string) => { - if (currentPageId() === itemId) { - const allItems: Page[] = []; - const collectIds = (items: Page[]) => { + const currentId = currentPageId(); + const isDescendantOrSelf = (items: Page[]): boolean => { + for (const item of items) { + if (item.id === itemId) return true; + if (item.children && isDescendantOrSelf(item.children)) { + return true; + } + } + return false; + }; + + if (currentId === itemId || isDescendantOrSelf(pages())) { + const survivingItems: Page[] = []; + const collectSurviving = (items: Page[]) => { for (const item of items) { if (item.id !== itemId) { - allItems.push(item); + survivingItems.push(item); } if (item.children) { - collectIds(item.children); + collectSurviving(item.children); } } }; - collectIds(pages()); - if (allItems.length > 0 && allItems[0]) { - setCurrentPageId(allItems[0].id); + collectSurviving(pages()); + if (survivingItems.length > 0 && survivingItems[0]) { + setCurrentPageId(survivingItems[0].id); } } @@ -198,8 +240,15 @@ export function usePages() { itemId: string, direction: "up" | "down" | "in" | "out", ) => { - const parentItems = findPageParent(pages(), itemId); - if (!parentItems) return; + let parentItems = findPageParent(pages(), itemId); + + if (direction === "out") { + if (!parentItems) return; + } else { + if (!parentItems) { + parentItems = pages(); + } + } const currentIndex = findPageIndex(parentItems, itemId); if (currentIndex < 0) return; @@ -249,25 +298,7 @@ export function usePages() { } } } else if (direction === "out") { - const itemCopy = structuredClone(item); - removeFromTree(itemId); - - const newPages = structuredClone(pages()); - const findAndInsert = (items: Page[]): boolean => { - for (let i = 0; i < items.length; i++) { - const currentItem = items[i]; - const parentFirst = parentItems[0]; - if (currentItem && parentFirst && currentItem.id === parentFirst.id) { - items.splice(i + 1, 0, itemCopy); - return true; - } - if (currentItem?.children) { - if (findAndInsert(currentItem.children)) return true; - } - } - return false; - }; - findAndInsert(newPages); + const newPages = moveItemOut(pages(), itemId); setPages(newPages); } }; @@ -279,16 +310,18 @@ export function usePages() { for (let i = 0; i < items.length; i++) { const it = items[i]; if (it && oldFirst && it.id === oldFirst.id) { - if (it.children) { - it.children = newSiblings; - } else { - for (let j = 0; j < newSiblings.length; j++) { - const sibling = newSiblings[j]; - if (sibling !== undefined) { - items[j] = sibling; - } + for (let j = 0; j < newSiblings.length; j++) { + const sibling = newSiblings[j]; + if (sibling !== undefined) { + items[i + j] = sibling; } } + if (newSiblings.length < oldSiblings.length) { + items.splice( + i + newSiblings.length, + oldSiblings.length - newSiblings.length, + ); + } return true; } if (it?.children) { diff --git a/src/lib/page-tree.ts b/src/lib/page-tree.ts index 026f2c9..fd29c67 100644 --- a/src/lib/page-tree.ts +++ b/src/lib/page-tree.ts @@ -65,9 +65,33 @@ function moveItemBefore( itemId: string, beforeItemId: string, ): Page[] { + if (itemId === beforeItemId) return structuredClone(items); + + const findItem = (list: Page[], id: string): Page | null => { + for (const item of list) { + if (item.id === id) return item; + if (item.children) { + const found = findItem(item.children, id); + if (found) return found; + } + } + return null; + }; + + const movedItem = findItem(items, itemId); + if (movedItem?.children) { + const isInSubtree = (list: Page[], targetId: string): boolean => { + for (const item of list) { + if (item.id === targetId) return true; + if (item.children && isInSubtree(item.children, targetId)) return true; + } + return false; + }; + if (isInSubtree(movedItem.children, beforeItemId)) + return structuredClone(items); + } + const newItems = structuredClone(items); - const item = removeItemFromTree(newItems, itemId); - if (!item) return newItems; const targetParent = findParentOf(newItems, beforeItemId); if (!targetParent) return newItems; @@ -75,6 +99,9 @@ function moveItemBefore( const insertIdx = findIndexInParent(targetParent, beforeItemId); if (insertIdx < 0) return newItems; + const item = removeItemFromTree(newItems, itemId); + if (!item) return newItems; + targetParent.splice(insertIdx, 0, item); return newItems; } @@ -84,6 +111,32 @@ function moveItemAfter( itemId: string, afterItemId: string, ): Page[] { + if (itemId === afterItemId) return structuredClone(items); + + const findItem = (list: Page[], id: string): Page | null => { + for (const item of list) { + if (item.id === id) return item; + if (item.children) { + const found = findItem(item.children, id); + if (found) return found; + } + } + return null; + }; + + const movedItem = findItem(items, itemId); + if (movedItem?.children) { + const isInSubtree = (list: Page[], targetId: string): boolean => { + for (const item of list) { + if (item.id === targetId) return true; + if (item.children && isInSubtree(item.children, targetId)) return true; + } + return false; + }; + if (isInSubtree(movedItem.children, afterItemId)) + return structuredClone(items); + } + const newItems = structuredClone(items); const targetParent = findParentOf(newItems, afterItemId); @@ -104,14 +157,40 @@ function moveItemInto( itemId: string, intoItemId: string, ): Page[] { - const newItems = structuredClone(items); + if (itemId === intoItemId) return structuredClone(items); + + const findItem = (list: Page[], id: string): Page | null => { + for (const item of list) { + if (item.id === id) return item; + if (item.children) { + const found = findItem(item.children, id); + if (found) return found; + } + } + return null; + }; + + const movedItem = findItem(items, itemId); + if (movedItem?.children) { + const isInSubtree = (list: Page[], targetId: string): boolean => { + for (const item of list) { + if (item.id === targetId) return true; + if (item.children && isInSubtree(item.children, targetId)) return true; + } + return false; + }; + if (isInSubtree(movedItem.children, intoItemId)) + return structuredClone(items); + } - const target = findItemInTree(newItems, intoItemId); - if (!target) return newItems; + const newItems = structuredClone(items); const item = removeItemFromTree(newItems, itemId); if (!item) return newItems; + const target = findItemInTree(newItems, intoItemId); + if (!target) return newItems; + if (!target.children) target.children = []; target.children.push(item); return newItems; diff --git a/tests/page-tree.lib.test.ts b/tests/page-tree.lib.test.ts index fe45ee2..9dbec9d 100644 --- a/tests/page-tree.lib.test.ts +++ b/tests/page-tree.lib.test.ts @@ -168,6 +168,41 @@ describe("moveItemInto", () => { const b = result.find((i) => i.id === "b") as Page; expect(b.children?.map((i) => i.id)).toEqual(["a"]); }); + + it("should not move item into itself", () => { + const tree = [ + { + id: "a", + name: "A", + content: "", + children: [{ id: "b", name: "B", content: "" }], + }, + ]; + + const result = moveItemInto(structuredClone(tree), "a", "a"); + expect(result).toEqual(tree); + }); + + it("should not move item into its descendant", () => { + const tree = [ + { + id: "a", + name: "A", + content: "", + children: [ + { + id: "b", + name: "B", + content: "", + children: [{ id: "c", name: "C", content: "" }], + }, + ], + }, + ]; + + const result = moveItemInto(structuredClone(tree), "a", "c"); + expect(result).toEqual(tree); + }); }); describe("moveItemOut", () => { From 72af9459a98ee6766e72ba5859c12643fb3a8f90 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 23 Mar 2026 09:00:02 +0000 Subject: [PATCH 10/18] wip --- src/components/TreeItem.tsx | 3 ++- src/hooks/usePages.ts | 18 ++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/components/TreeItem.tsx b/src/components/TreeItem.tsx index c9f8c30..610fe30 100644 --- a/src/components/TreeItem.tsx +++ b/src/components/TreeItem.tsx @@ -107,7 +107,8 @@ const TreeItem = (props: TreeItemProps): JSX.Element => { onMouseDown={(e) => { e.preventDefault(); if (e.button === 0) { - if (hasChildren()) { + const hasModKey = e.ctrlKey || e.metaKey || e.shiftKey; + if (hasChildren() && !hasModKey) { props.onToggle(); } else { props.onSelect(); diff --git a/src/hooks/usePages.ts b/src/hooks/usePages.ts index 020e978..6cb228f 100644 --- a/src/hooks/usePages.ts +++ b/src/hooks/usePages.ts @@ -205,25 +205,27 @@ export function usePages() { const deleteItem = (itemId: string) => { const currentId = currentPageId(); - const isDescendantOrSelf = (items: Page[]): boolean => { + const findInSubtree = (targetId: string, items: Page[]): boolean => { for (const item of items) { - if (item.id === itemId) return true; - if (item.children && isDescendantOrSelf(item.children)) { + if (item.id === targetId) return true; + if (item.children && findInSubtree(targetId, item.children)) return true; - } } return false; }; - if (currentId === itemId || isDescendantOrSelf(pages())) { + if ( + currentId === itemId || + findInSubtree(currentId, findItemInTree(itemId)?.children ?? []) + ) { const survivingItems: Page[] = []; const collectSurviving = (items: Page[]) => { for (const item of items) { if (item.id !== itemId) { survivingItems.push(item); - } - if (item.children) { - collectSurviving(item.children); + if (item.children) { + collectSurviving(item.children); + } } } }; From 4ff9a09b12f97edca33f020c10efe01e7ab7f16b Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 23 Mar 2026 09:02:57 +0000 Subject: [PATCH 11/18] Update TreeItem.tsx --- src/components/TreeItem.tsx | 79 ++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 44 deletions(-) diff --git a/src/components/TreeItem.tsx b/src/components/TreeItem.tsx index 610fe30..feaa551 100644 --- a/src/components/TreeItem.tsx +++ b/src/components/TreeItem.tsx @@ -4,22 +4,6 @@ import { Show } from "solid-js"; import type { Page } from "#types"; import Button from "./ui/Button"; -const DragHandleIcon = (): JSX.Element => ( - - - - - - - - -); - const ChevronSvgIcon = (props: { isOpen: boolean }): JSX.Element => ( {
- - + +
- -
- -
-
- +
From e8beb7f5325005236b422c45d5ccb5d049de27b5 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 23 Mar 2026 09:04:46 +0000 Subject: [PATCH 12/18] Update TreeItem.tsx --- src/components/TreeItem.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/TreeItem.tsx b/src/components/TreeItem.tsx index feaa551..0634a97 100644 --- a/src/components/TreeItem.tsx +++ b/src/components/TreeItem.tsx @@ -41,6 +41,7 @@ type TreeItemProps = { const TreeItem = (props: TreeItemProps): JSX.Element => { const hasChildren = () => (props.item.children?.length ?? 0) > 0; + let isDragging = false; return (
@@ -94,8 +95,14 @@ const TreeItem = (props: TreeItemProps): JSX.Element => {
{ + isDragging = true; + props.onDragStart(new DragEvent("dragstart")); + }} + onDragEnd={() => { + isDragging = false; + props.onDragEnd(); + }} >
); -}; +} export default ContextMenu; From d312e9c56887d09db13eb8a1904f32b0dc438b8d Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 23 Mar 2026 09:27:05 +0000 Subject: [PATCH 14/18] wip --- src/components/PagesMenu.tsx | 3 ++- src/lib/page-tree.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/PagesMenu.tsx b/src/components/PagesMenu.tsx index 0ae3270..d421ae1 100644 --- a/src/components/PagesMenu.tsx +++ b/src/components/PagesMenu.tsx @@ -3,6 +3,7 @@ import { createSignal, Show } from "solid-js"; import { findIndexInParent, findItemInTree, + findParentItemAndArray, findParentOf, moveItemAfter, moveItemBefore, @@ -238,7 +239,7 @@ const PagesMenu = (props: { const canMoveOut = () => { const c = contextMenu(); if (!c) return false; - return findParentOf(props.pages, c.itemId) !== null; + return findParentItemAndArray(props.pages, c.itemId) !== null; }; const handleDragStart = (e: DragEvent, item: Page) => { diff --git a/src/lib/page-tree.ts b/src/lib/page-tree.ts index fd29c67..4470fb3 100644 --- a/src/lib/page-tree.ts +++ b/src/lib/page-tree.ts @@ -218,6 +218,7 @@ function moveItemOut(items: Page[], itemId: string): Page[] { export { findIndexInParent, findItemInTree, + findParentItemAndArray, findParentOf, moveItemAfter, moveItemBefore, From c202b67cab09453bfd01651c38e54e51ec2e5eb0 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 23 Mar 2026 09:34:41 +0000 Subject: [PATCH 15/18] fix --- src/lib/page-tree.ts | 28 ++++++++++++++++++++++++++-- tests/page-tree.lib.test.ts | 13 ++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/lib/page-tree.ts b/src/lib/page-tree.ts index 4470fb3..9603236 100644 --- a/src/lib/page-tree.ts +++ b/src/lib/page-tree.ts @@ -102,7 +102,19 @@ function moveItemBefore( const item = removeItemFromTree(newItems, itemId); if (!item) return newItems; - targetParent.splice(insertIdx, 0, item); + const itemParent = findParentOf(items, itemId); + const sameParent = + itemParent && findParentOf(items, beforeItemId) === itemParent; + + let finalIdx = insertIdx; + if (sameParent) { + const itemIdx = findIndexInParent(itemParent, itemId); + if (itemIdx >= 0 && itemIdx < insertIdx) { + finalIdx = insertIdx - 1; + } + } + + targetParent.splice(finalIdx, 0, item); return newItems; } @@ -148,7 +160,19 @@ function moveItemAfter( const item = removeItemFromTree(newItems, itemId); if (!item) return newItems; - targetParent.splice(afterIdx + 1, 0, item); + const itemParent = findParentOf(items, itemId); + const sameParent = + itemParent && findParentOf(items, afterItemId) === itemParent; + + let insertIdx = afterIdx + 1; + if (sameParent) { + const itemIdx = findIndexInParent(itemParent, itemId); + if (itemIdx >= 0 && itemIdx < afterIdx) { + insertIdx = afterIdx; + } + } + + targetParent.splice(insertIdx, 0, item); return newItems; } diff --git a/tests/page-tree.lib.test.ts b/tests/page-tree.lib.test.ts index 9dbec9d..a91aaa7 100644 --- a/tests/page-tree.lib.test.ts +++ b/tests/page-tree.lib.test.ts @@ -116,7 +116,18 @@ describe("moveItemBefore", () => { }); describe("moveItemAfter", () => { - it("should move item to end (after target)", () => { + it("should move item down one position", () => { + const tree = [ + { id: "a", name: "A", content: "" }, + { id: "b", name: "B", content: "" }, + { id: "c", name: "C", content: "" }, + ]; + + const result = moveItemAfter(structuredClone(tree), "a", "b"); + expect(result.map((i) => i.id)).toEqual(["b", "a", "c"]); + }); + + it("should move item down two positions", () => { const tree = [ { id: "a", name: "A", content: "" }, { id: "b", name: "B", content: "" }, From b481b3082e13ccf922d75163f3759d8ca98bd21f Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 23 Mar 2026 18:58:36 +0000 Subject: [PATCH 16/18] add child creation --- src/components/App.tsx | 2 +- src/components/PagesMenu.tsx | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 532612d..c6f1ff8 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -152,7 +152,7 @@ function App() { pages={pages()} currentPageId={currentPageId()} selectPageByTreeItem={selectPageByTreeItem} - addPage={() => addPage()} + addPage={addPage} renameItem={renameItem} deleteItem={deleteItem} moveItem={moveItem} diff --git a/src/components/PagesMenu.tsx b/src/components/PagesMenu.tsx index d421ae1..0d496e7 100644 --- a/src/components/PagesMenu.tsx +++ b/src/components/PagesMenu.tsx @@ -90,6 +90,7 @@ const PagesContextMenu = (props: { onMoveDown: () => void; onMoveIn: () => void; onMoveOut: () => void; + onCreateChild: () => void; canMoveUp: boolean; canMoveDown: boolean; canMoveIn: boolean; @@ -101,6 +102,11 @@ const PagesContextMenu = (props: { x={contextMenu().x} y={contextMenu().y} items={[ + { + label: "Create Child Page", + onClick: props.onCreateChild, + show: true, + }, { label: "Rename", onClick: props.onRename, show: true }, { label: "Delete", onClick: props.onDelete, show: true }, { label: "Move Up", onClick: props.onMoveUp, show: props.canMoveUp }, @@ -130,7 +136,7 @@ const PagesMenu = (props: { pages: Page[]; currentPageId: string; selectPageByTreeItem: (itemId: string) => void; - addPage: () => void; + addPage: (parentFolderId?: string) => void; renameItem: (itemId: string, newName: string) => void; deleteItem: (itemId: string) => void; moveItem: (itemId: string, direction: "up" | "down" | "in" | "out") => void; @@ -215,6 +221,16 @@ const PagesMenu = (props: { setContextMenu(null); }; + const onCreateChild = () => { + const c = contextMenu(); + if (!c) return; + props.addPage(c.itemId); + const newOpenFolders = new Set(openFolders()); + newOpenFolders.add(c.itemId); + setOpenFolders(newOpenFolders); + setContextMenu(null); + }; + const canMoveUp = () => { const c = contextMenu(); if (!c) return false; @@ -382,6 +398,7 @@ const PagesMenu = (props: { onMoveDown={onMoveDown} onMoveIn={onMoveIn} onMoveOut={onMoveOut} + onCreateChild={onCreateChild} canMoveUp={canMoveUp()} canMoveDown={canMoveDown()} canMoveIn={canMoveIn()} From b5900717e01408e22177965047e38524fd0f64a6 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 23 Mar 2026 19:23:47 +0000 Subject: [PATCH 17/18] Update PagesMenu.tsx --- src/components/PagesMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/PagesMenu.tsx b/src/components/PagesMenu.tsx index 0d496e7..0e62e1b 100644 --- a/src/components/PagesMenu.tsx +++ b/src/components/PagesMenu.tsx @@ -77,7 +77,7 @@ const Pages = (props: { />
-
); From 108e96ffa21f761960d52425eae31a8a11c36366 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 23 Mar 2026 19:32:57 +0000 Subject: [PATCH 18/18] Update PagesMenu.tsx --- src/components/PagesMenu.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/PagesMenu.tsx b/src/components/PagesMenu.tsx index 0e62e1b..9690b0b 100644 --- a/src/components/PagesMenu.tsx +++ b/src/components/PagesMenu.tsx @@ -95,6 +95,7 @@ const PagesContextMenu = (props: { canMoveDown: boolean; canMoveIn: boolean; canMoveOut: boolean; + canDelete: boolean; }) => ( {(contextMenu) => ( @@ -108,7 +109,7 @@ const PagesContextMenu = (props: { show: true, }, { label: "Rename", onClick: props.onRename, show: true }, - { label: "Delete", onClick: props.onDelete, show: true }, + { label: "Delete", onClick: props.onDelete, show: props.canDelete }, { label: "Move Up", onClick: props.onMoveUp, show: props.canMoveUp }, { label: "Move Down", @@ -258,6 +259,14 @@ const PagesMenu = (props: { return findParentItemAndArray(props.pages, c.itemId) !== null; }; + const canDelete = () => { + const c = contextMenu(); + if (!c) return false; + const parent = findParentOf(props.pages, c.itemId); + if (parent !== props.pages) return true; + return props.pages.length > 1; + }; + const handleDragStart = (e: DragEvent, item: Page) => { setDragItemId(item.id); if (e.dataTransfer) { @@ -403,6 +412,7 @@ const PagesMenu = (props: { canMoveDown={canMoveDown()} canMoveIn={canMoveIn()} canMoveOut={canMoveOut()} + canDelete={canDelete()} /> );