diff --git a/biome.jsonc b/biome.jsonc index 0020c28..b2a3cd8 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -8,7 +8,6 @@ "noStaticElementInteractions": "off", "useKeyWithClickEvents": "off" }, - "style": { "noNonNullAssertion": "error" }, "complexity": { "noVoid": "error" } } }, 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" diff --git a/src/components/App.tsx b/src/components/App.tsx index 9d52a1c..c6f1ff8 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -13,12 +13,15 @@ import Toolbar from "./Toolbar"; function App() { const { pages, - currentPageIndex, - setCurrentPageIndex, + setPages, + currentPageId, + setCurrentPageId, addPage, + 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,10 @@ function App() { setCurrentMatchIndex(0); }; + const selectPageByTreeItem = (itemId: string) => { + setCurrentPageId(itemId); + }; + onMount(() => { const handleKeyDown = (e: KeyboardEvent) => { if ( @@ -123,13 +130,14 @@ function App() { <> setToolbarOpacity(1)} onPagesClick={() => setIsPagesMenuOpen(!isPagesMenuOpen())} onSettingsClick={() => setIsSettingsOpen(true)} onSearchClick={handleOpenSearch} - renamePage={renamePage} + renameItem={renameItem} + pages={pages()} /> void; - onContextMenu: (e: MouseEvent) => void; -}): JSX.Element => { - return ( - + +
{ + isDragging = true; + props.onDragStart(new DragEvent("dragstart")); + }} + onDragEnd={() => { + isDragging = false; + props.onDragEnd(); + }} + > +
+ + + +
+ {props.children} +
+
+ + ); +}; + +export default TreeItem; diff --git a/src/components/TreeList.tsx b/src/components/TreeList.tsx new file mode 100644 index 0000000..a87496e --- /dev/null +++ b/src/components/TreeList.tsx @@ -0,0 +1,98 @@ +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; + isRoot?: boolean; +}; + +const TreeList = (props: TreeListProps): JSX.Element => { + 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}> + + + + )} + , + ); +}; + +export default TreeList; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 1a0c1fc..2aba107 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -3,15 +3,11 @@ import { splitProps } from "solid-js"; type ButtonAttributes = JSX.ButtonHTMLAttributes; -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 = { default: "", - "context-menu": `rounded bg-transparent`, + "context-menu": "rounded bg-transparent", page: "rounded border-2 border-solid", toolbar: "size-10", }; @@ -22,9 +18,11 @@ type ButtonVariants = { variant?: TVariant; }; -type ButtonProps = ButtonAttributes & - LabeledOrNot & - 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/components/ui/ContextMenu.tsx b/src/components/ui/ContextMenu.tsx index c84ad86..7ff85aa 100644 --- a/src/components/ui/ContextMenu.tsx +++ b/src/components/ui/ContextMenu.tsx @@ -5,30 +5,34 @@ import Button from "./Button"; type ContextMenuItem = { label: string; - onClick: () => void; + onClick(): void; show?: boolean; }; -const ContextMenu = (props: { +function ContextMenu(props: { x: number; y: number; items: ContextMenuItem[]; -}): JSX.Element => { +}): JSX.Element { return (
{(item) => ( -
); -}; +} export default ContextMenu; diff --git a/src/hooks/usePages.ts b/src/hooks/usePages.ts index de2f43d..6cb228f 100644 --- a/src/hooks/usePages.ts +++ b/src/hooks/usePages.ts @@ -1,38 +1,170 @@ import { createEffect, createSignal, onMount } from "solid-js"; - +import { moveItemOut } from "#lib/page-tree"; import type { Page } 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: "" }; export function usePages() { const [pages, setPages] = createSignal([DEFAULT_PAGE]); - const [currentPageIndex, setCurrentPageIndex] = createSignal(0); + const [currentPageId, setCurrentPageId] = createSignal( + DEFAULT_PAGE.id, + ); + + const findPageInTree = (items: Page[], targetId: string): Page | null => { + for (const item of items) { + if (item.id === targetId) return item; + if (item.children) { + const found = findPageInTree(item.children, targetId); + if (found) return found; + } + } + return null; + }; + + const findPageParent = ( + items: Page[], + targetId: string, + parent: Page[] | null = null, + ): Page[] | null => { + for (const item of items) { + if (item.id === targetId) return parent; + if (item.children) { + const found = findPageParent(item.children, targetId, item.children); + if (found !== null) return found; + } + } + return null; + }; + + 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: Page) => Page, + ): void => { + const newPages = structuredClone(pages()); + const update = (items: Page[]): 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(newPages); + setPages(newPages); + }; + + const removeFromTree = (targetId: string): Page | null => { + const newPages = structuredClone(pages()); + 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) { + removed = item; + items.splice(i, 1); + return true; + } + if (item?.children) { + if (remove(item.children)) return true; + } + } + return false; + }; + remove(newPages); + setPages(newPages); + return removed; + }; + + const addToTree = (parentId: string | null, item: Page): void => { + const newPages = structuredClone(pages()); + if (parentId === null) { + newPages.push(item); + setPages(newPages); + return; + } + const add = (items: Page[]): 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(newPages); + setPages(newPages); + }; onMount(() => { const storedPages = localStorage.getItem("pages"); - const storedCurrentPage = localStorage.getItem("currentPage"); + 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 (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) => { 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 === "currentPageId") { + 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); } } }); window.addEventListener("unload", () => { - localStorage.setItem("currentPage", currentPageIndex().toString()); + localStorage.setItem("currentPageId", currentPageId()); }); }); @@ -40,62 +172,190 @@ export function usePages() { localStorage.setItem("pages", JSON.stringify(pages())); }); - const addPage = () => { + createEffect(() => { + localStorage.setItem("currentPageId", currentPageId()); + }); + + const addPage = (parentFolderId?: string) => { + const countPages = (items: Page[]): number => { + let count = 0; + for (const item of items) { + count++; + if (item.children) { + count += countPages(item.children); + } + } + return count; + }; + const newPage: Page = { - name: `Page ${pages().length + 1}`, + id: generateId(), + name: `Page ${countPages(pages()) + 1}`, content: "", }; - setPages([...pages(), newPage]); - setCurrentPageIndex(pages().length - 1); + + addToTree(parentFolderId ?? null, newPage); + 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 renameItem = (itemId: string, newName: string) => { + updateTreeAt(itemId, (item) => ({ ...item, name: newName.trim() })); + }; + + const deleteItem = (itemId: string) => { + const currentId = currentPageId(); + const findInSubtree = (targetId: string, items: Page[]): boolean => { + for (const item of items) { + if (item.id === targetId) return true; + if (item.children && findInSubtree(targetId, item.children)) + return true; + } + return false; + }; + + 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); + } + } + } + }; + collectSurviving(pages()); + if (survivingItems.length > 0 && survivingItems[0]) { + setCurrentPageId(survivingItems[0].id); } } + + removeFromTree(itemId); }; - 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 moveItem = ( + itemId: string, + direction: "up" | "down" | "in" | "out", + ) => { + let parentItems = findPageParent(pages(), itemId); + + if (direction === "out") { + if (!parentItems) return; + } else { + if (!parentItems) { + parentItems = pages(); } } - }; - const deletePage = (index: number) => { - if (pages().length <= 1) return; // Don't delete the last page + const currentIndex = findPageIndex(parentItems, itemId); + if (currentIndex < 0) return; - if (index >= 0 && index < pages().length) { - const newPages = pages().filter((_, i) => i !== index); - setPages(newPages.length > 0 ? newPages : [DEFAULT_PAGE]); + const item = findItemInTree(itemId); + if (!item) return; - let newIndex = currentPageIndex(); - if (newIndex >= newPages.length) { - newIndex = newPages.length - 1; - } else if (index < newIndex) { - newIndex = newIndex - 1; + 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); } - setCurrentPageIndex(newIndex); + } 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) { + const removed = removeFromTree(itemId); + if (removed) { + const newPages = structuredClone(pages()); + const add = (items: Page[]): 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(newPages); + setPages(newPages); + } + } + } else if (direction === "out") { + const newPages = moveItemOut(pages(), itemId); + setPages(newPages); } }; + const updateParentSiblings = (newSiblings: Page[], oldSiblings: Page[]) => { + const newPages = structuredClone(pages()); + const oldFirst = oldSiblings[0]; + const update = (items: Page[]): boolean => { + for (let i = 0; i < items.length; i++) { + const it = items[i]; + if (it && oldFirst && it.id === oldFirst.id) { + 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) { + if (update(it.children)) return true; + } + } + return false; + }; + update(newPages); + setPages(newPages); + }; + + const getCurrentPage = (): Page | null => { + const id = currentPageId(); + return findPageInTree(pages(), id); + }; + + const updatePageContent = (content: string) => { + updateTreeAt(currentPageId(), (item) => ({ ...item, content })); + }; + return { pages, - currentPageIndex, - setCurrentPageIndex, + setPages, + currentPageId, + setCurrentPageId, addPage, + renameItem, + deleteItem, + moveItem, updatePageContent, - renamePage, - deletePage, + getCurrentPage, + findItemInTree, }; } 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..9603236 --- /dev/null +++ b/src/lib/page-tree.ts @@ -0,0 +1,251 @@ +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[] { + 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 targetParent = findParentOf(newItems, beforeItemId); + if (!targetParent) return newItems; + + const insertIdx = findIndexInParent(targetParent, beforeItemId); + if (insertIdx < 0) return newItems; + + const item = removeItemFromTree(newItems, itemId); + if (!item) return newItems; + + 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; +} + +function moveItemAfter( + items: Page[], + 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); + if (!targetParent) return newItems; + + const afterIdx = findIndexInParent(targetParent, afterItemId); + if (afterIdx < 0) return newItems; + + const item = removeItemFromTree(newItems, itemId); + if (!item) return newItems; + + 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; +} + +function moveItemInto( + items: Page[], + itemId: string, + intoItemId: string, +): Page[] { + 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 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; +} + +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, + findParentItemAndArray, + findParentOf, + moveItemAfter, + moveItemBefore, + moveItemInto, + moveItemOut, +}; diff --git a/src/types/index.ts b/src/types/index.ts index ee8a778..ad8c0ae 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,8 @@ export interface Page { + id: string; name: string; content: string; + children?: Page[]; } export namespace EditorSettings { diff --git a/tests/page-tree.lib.test.ts b/tests/page-tree.lib.test.ts new file mode 100644 index 0000000..a91aaa7 --- /dev/null +++ b/tests/page-tree.lib.test.ts @@ -0,0 +1,250 @@ +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 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: "" }, + { 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"]); + }); + + 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", () => { + 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"]); + }); +});