import { safeDestr as parse } from 'destr'; import { Canvas } from "#shared/canvas.util"; import render from "#shared/markdown.util"; import { contextmenu, popper } from "#shared/floating.util"; import { cancelPropagation, dom, icon, text, type Node } from "#shared/dom.util"; import prose, { h1, h2, loading } from "#shared/proses"; import { parsePath } from '#shared/general.util'; import { Tree, TreeDOM, type Recursive } from '#shared/tree'; import { History } from '#shared/history.util'; import { MarkdownEditor } from '#shared/editor.util'; import type { CanvasContent } from '~/types/canvas'; import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { type Instruction, attachInstruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; import type { CleanupFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types'; export type FileType = keyof ContentMap; export interface ContentMap { markdown: string; file: string; canvas: CanvasContent; map: string; folder: null; } export interface Overview { path: string; owner: number; title: string; timestamp: Date; navigable: boolean; private: boolean; order: number; visit: number; type: T; } export type ExploreContent = Overview & { content: ContentMap[T] }; class AsyncQueue { private size: number; private count: number = 0; private _queue: Array<() => Promise>; promise: Promise = Promise.resolve(); finished: boolean = true; private res: (value: void | PromiseLike) => void = () => {}; constructor(size: number = 8) { this.size = size; this._queue = []; } queue(fn: () => Promise): Promise { if(this.finished) { this.finished = false; this.promise = new Promise((res, rej) => { this.res = res; }); } this._queue.push(fn); this.refresh(); return this.promise; } private refresh() { for(let i = this.count; i < this.size && this._queue.length > 0; i++) { this.count++; const fn = this._queue.shift()!; fn().finally(() => { this.count--; this.refresh(); }); } if(this.count === 0 && this._queue.length === 0 && !this.finished) { this.finished = true; this.res(); } } } export const DEFAULT_CONTENT: Record = { map: {}, canvas: { nodes: [], edges: []}, markdown: '', file: '', folder: null, }; export type LocalContent = ExploreContent & { localEdit?: boolean; error?: boolean; }; export class Content { private static _ready = false; private static initPromise?: Promise; private static root: FileSystemDirectoryHandle; private static _overview: Omit[]; private static dlQueue = new AsyncQueue(); private static writeQueue = new AsyncQueue(1); static init(): Promise { if(Content._ready) return Promise.resolve(true); Content.initPromise = new Promise(async (res, rej) => { try { if(!('storage' in navigator)) return false; Content.root = await navigator.storage.getDirectory(); const overview = await Content.read('overview', { create: true }); try { Content._overview = parse[]>(overview); } catch(e) { Content._overview = []; await Content.pull(); } Content._ready = true; } catch(e) { console.error(e); } res(Content._ready); }); return Content.initPromise; } static overview(path: string): Omit | undefined { return Content._overview.find(e => getPath(e) === path); } static async content(path: string): Promise { const overview = Content._overview.find(e => getPath(e) === path); return overview ? { ...overview, content: Content.fromString(overview, await Content.read(encodeURIComponent(path)) ?? '') } as LocalContent : undefined; } static update(item: Recursive) { const index = Content._overview.findIndex(e => e.path === getPath(item)); if(index !== -1) Content._overview[index] = item; const overview = JSON.stringify(Content._overview, (k, v) => ['parent', 'children', 'content'].includes(k) ? undefined : v); return Content.writeQueue.queue(() => Content.write('overview', overview)); } static rename(from: string, to: string) { const index = Content._overview.findIndex(e => getPath(e) === from); if(index !== -1) Content._overview[index].path = to; return Content.writeQueue.queue(async () => { const content = await Content.read(encodeURIComponent(from)); if(content !== undefined) { await Content.write(encodeURIComponent(to), content, { create: true }); await Content.remove(encodeURIComponent(from)); } }); } static save(content?: Recursive) { if(!content) return; const string = Content.toString(content), path = getPath(content); return Content.writeQueue.queue(() => Content.write(encodeURIComponent(path), string, { create: true })); } private static async pull() { const overview = (await useRequestFetch()('/api/file/overview', { cache: 'no-cache' })) as ExploreContent[] | undefined; if(!overview) { //TODO: Cannot get data :'( //Add a warning ? return; } for(const file of overview) { let index = Content._overview.findIndex(e => getPath(e) === file.path), _overview = (index === -1 ? undefined : Content._overview[index]); if(!_overview || _overview.localEdit) { const encoded = encodeURIComponent(file.path); if(!_overview) { index = Content._overview.push(file) - 1; } else Content._overview[index] = file; _overview = file; if(file.type === 'folder') continue; Content.dlQueue.queue(() => { return useRequestFetch()(`/api/file/content/${encoded}`, { cache: 'no-cache' }).then(async (content: ContentMap[FileType] | undefined) => { if(content) await Content.write(encoded, Content.toString({ ...file, content }), { create: true }); else Content._overview[index].error = true; }).catch(e => { Content._overview[index].error = true; }) }); } } Content.dlQueue.queue(() => { return Content.write('overview', JSON.stringify(Content._overview), { create: true }); }); await Content.dlQueue.promise; } private static async push() { } //Maybe store the file handles ? Is it safe to keep them ? private static async read(path: string, options?: FileSystemGetFileOptions): Promise { try { console.time(`Reading '${path}'`); const handle = await Content.root.getFileHandle(path, options); const file = await handle.getFile(); const text = await file.text(); console.timeEnd(`Reading '${path}'`); return text; } catch(e) { console.error(path, e); console.timeEnd(`Reading '${path}'`); } } //Easy to use, but not very performant. private static async write(path: string, content: string, options?: FileSystemGetFileOptions): Promise { const size = new TextEncoder().encode(content).byteLength; console.time(`Writing ${size} bytes to '${path}'`); try { const handle = await Content.root.getFileHandle(path, options); const file = await handle.createWritable({ keepExistingData: false }); await file.write(content); await file.close(); } catch(e) { console.error(path, e); } console.timeEnd(`Writing ${size} bytes to '${path}'`); } private static async remove(path: string): Promise { console.time(`Removing '${path}'`); await Content.root.removeEntry(path) console.timeEnd(`Removing '${path}'`); } static get estimate(): Promise { return Content._ready ? navigator.storage.estimate() : Promise.reject(); } static toString(content: ExploreContent): string { return handlers[content.type].toString(content.content); } static fromString(overview: Omit, 'content'>, content: string): ContentMap[T] { return handlers[overview.type].fromString(content); } static render(parent: HTMLElement, path: string): Omit | undefined { const overview = Content.overview(path); if(!!overview) { const load = dom('div', { class: 'flex, flex-1 justify-center items-center' }, [loading('normal')]); parent.appendChild(load); function _render(content: LocalContent): void { const el = handlers[content.type].render(content); el && parent.replaceChild(el, load); } Content.content(path).then(content => _render(content!)); } else { parent.appendChild(dom('h2', { class: 'flex-1 text-center', text: "Impossible d'afficher le contenu demandé" })); } return overview; } static get files() { return Object.freeze(Content._overview); } static get tree() { const arr: Recursive>[] = []; function addChild(arr: Recursive>[], overview: Omit): void { const parent = arr.find(f => overview.path.startsWith(f.path)); if(parent) { if(!parent.children) parent.children = []; (overview as Recursive).parent = parent; addChild(parent.children, overview); } else { arr.push({ ...overview }); arr.sort((a, b) => { if(a.order !== b.order) return a.order - b.order; return a.title.localeCompare(b.title); }); } } for(const element of Object.values(Content._overview)) { addChild(arr, element); } return arr; } static get ready(): Promise { return Content._ready ? Promise.resolve(true) : Content.initPromise ?? Promise.resolve(false); } } type ContentTypeHandler = { toString: (content: ContentMap[T]) => string; fromString: (str: string) => ContentMap[T]; render: (content: LocalContent) => Node; renderEditor: (content: LocalContent) => Node; }; const handlers: { [K in FileType]: ContentTypeHandler } = { canvas: { toString: (content) => JSON.stringify(content), fromString: (str) => JSON.parse(str), render: (content) => new Canvas(content.content).container, renderEditor: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }), }, markdown: { toString: (content) => content, fromString: (str) => str, render: (content) => { return dom('div', { class: 'flex flex-1 justify-start items-start flex-col lg:px-16 xl:px-32 2xl:px-64 py-6' }, [ dom('div', { class: 'flex flex-1 flex-row justify-between items-center' }, [ prose('h1', h1, [text(content.title)]), dom('div', { class: 'flex gap-4' }, [ //TODO: Edition link ]), ]), render(content.content), ]) }, renderEditor: (content) => { let element: HTMLElement; if(content.hasOwnProperty('content')) { MarkdownEditor.singleton.content = content.content; element = MarkdownEditor.singleton.dom; } else { element = loading("large"); Content.content(content.path).then(e => { if(!e) return element.parentElement?.replaceChild(dom('div', { class: '', text: '' }), element); MarkdownEditor.singleton.content = content.content = (e as typeof content).content; element.parentElement?.replaceChild(MarkdownEditor.singleton.dom, element); }); } MarkdownEditor.singleton.onChange = (value) => { content.content = value; }; return dom('div', { class: 'flex flex-1 justify-start items-start flex-col lg:px-16 xl:px-32 2xl:px-64 py-6' }, [ dom('div', { class: 'flex flex-row justify-between items-center' }, [ prose('h1', h1, [text(content.title)]) ]), dom('div', { class: 'flex flex-1 w-full justify-stretch items-stretch py-2 px-1.5 font-sans text-base' }, [element]), ]) }, }, file: { toString: (content) => content, fromString: (str) => str, render: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }), renderEditor: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }), }, map: { toString: (content) => content, fromString: (str) => str, render: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }), renderEditor: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }), }, folder: { toString: (_) => '', fromString: (_) => null, render: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }), renderEditor: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }), } }; export const iconByType: Record = { 'folder': 'lucide:folder', 'canvas': 'ph:graph-light', 'file': 'radix-icons:file', 'markdown': 'radix-icons:file-text', 'map': 'lucide:map', } export class Editor { tree: TreeDOM; container: HTMLDivElement; selected?: Recursive; private instruction: HTMLDivElement; private cleanup: CleanupFn; private history: History; constructor() { this.history = new History(); this.history.register('overview', { move: { undo: (action) => { this.tree.tree.remove(getPath(action.element)); action.element.parent = action.from.parent; action.element.order = action.from.order; const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length; this.tree.tree.insertAt(action.element, action.to.order as number); action.element.element = this.tree.render(action.element, depth); action.element?.cleanup(); this.dragndrop(action.element, depth, action.element.parent); this.tree.update(); Content.rename(action.element.path, path); action.element.path = path; }, redo: (action) => { this.tree.tree.remove(getPath(action.element)); action.element.parent = action.to.parent; action.element.order = action.to.order; const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length; this.tree.tree.insertAt(action.element, action.to.order as number); action.element.element = this.tree.render(action.element, depth); action.element?.cleanup(); this.dragndrop(action.element, depth, action.element.parent); this.tree.update(); Content.rename(action.element.path, path); action.element.path = path; }, }, add: { undo: (action) => { this.tree.tree.remove(getPath(action.element)); if(this.selected === action.element) this.select(); action.element.cleanup(); action.element.remove(); }, redo: (action) => { if(!action.element) { const depth = getPath(action.element as LocalContent).split('/').length; action.element.element = this.tree.render(action.element as LocalContent, depth) as HTMLElement; this.dragndrop(action.element as LocalContent, depth, (action.element as Recursive).parent); } this.tree.tree.insertAt(action.element as Recursive, action.to as number); this.tree.update(); }, }, remove: { undo: (action) => { this.tree.tree.insertAt(action.element, action.from as number); this.tree.update(); }, redo: (action) => { this.tree.tree.remove(getPath(action.element)); if(this.selected === action.element) this.select(); action.element.cleanup(); action.element.remove(); }, }, rename: { undo: (action) => { action.element.title = action.from; action.element.element!.children[0].children[1].textContent = action.from; action.element.element!.children[0].children[1].setAttribute('title', action.from); const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length; action.element?.cleanup(); this.dragndrop(action.element, depth, action.element.parent); Content.rename(action.element.path, path); action.element.path = path; }, redo: (action) => { action.element.title = action.to; action.element.element!.children[0].children[1].textContent = action.to; action.element.element!.children[0].children[1].setAttribute('title', action.to); const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length; action.element?.cleanup(); this.dragndrop(action.element, depth, action.element.parent); Content.rename(action.element.path, path); action.element.path = path; }, }, navigable: { undo: (action) => { action.element.navigable = action.from; action.element.element!.children[0].children[2].children[0].replaceWith(icon(action.element.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !action.element.navigable }] })); }, redo: (action) => { action.element.navigable = action.to; action.element.element!.children[0].children[2].children[0].replaceWith(icon(action.element.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !action.element.navigable }] })); }, }, private: { undo: (action) => { action.element.private = action.from; action.element.element!.children[0].children[3].children[0].replaceWith(icon(action.element.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !action.element.private }] })); }, redo: (action) => { action.element.private = action.to; action.element.element!.children[0].children[3].children[0].replaceWith(icon(action.element.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !action.element.private }] })); }, }, }, action => Content.update(action.element)); this.tree = new TreeDOM((item, depth) => { return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-left': `${depth / 2 - 0.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full cursor-pointer font-medium'], attributes: { 'data-private': item.private }, listeners: { contextmenu: (e) => this.contextmenu(e, item as LocalContent)} }, [ icon('radix-icons:chevron-right', { class: 'h-4 w-4 transition-transform absolute group-data-[state=open]:rotate-90', style: { 'left': `${depth / 2 - 1.5}em` } }), dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }), popper(dom('span', { class: 'flex', listeners: { click: e => this.toggleNavigable(e, item as LocalContent) } }, [icon(item.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !item.navigable }] })]), { delay: 150, offset: 8, placement: 'left', arrow: true, content: [text('Navigable')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), popper(dom('span', { class: 'flex', listeners: { click: e => this.togglePrivate(e, item as LocalContent) } }, [icon(item.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !item.private }] })]), { delay: 150, offset: 8, placement: 'right', arrow: true, content: [text('Privé')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), ])]); }, (item, depth) => { return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-left': `${depth / 2 - 0.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full'], attributes: { 'data-private': item.private }, listeners: { contextmenu: (e) => this.contextmenu(e, item as LocalContent), click: () => this.select(item as LocalContent) } }, [ icon(iconByType[item.type], { class: 'w-5 h-5', width: 20, height: 20 }), dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }), popper(dom('span', { class: 'flex', listeners: { click: e => this.toggleNavigable(e, item as LocalContent) } }, [icon(item.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !item.navigable }] })]), { delay: 150, offset: 8, placement: 'left', arrow: true, content: [text('Navigable')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), popper(dom('span', { class: 'flex', listeners: { click: e => this.togglePrivate(e, item as LocalContent) } }, [icon(item.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !item.private }] })]), { delay: 150, offset: 8, placement: 'right', arrow: true, content: [text('Privé')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }), ])]); }); this.instruction = dom('div', { class: 'absolute h-full w-full top-0 right-0 border-light-50 dark:border-dark-50' }); this.cleanup = this.setupDnD(); this.container = dom('div', { class: 'flex flex-1 flex-col items-start justify-start max-h-full relative' }, [dom('div', { class: 'py-4 flex-1 w-full max-h-full flex overflow-auto xl:px-12 lg:px-8 px-6 relative' })]); } private contextmenu(e: MouseEvent, item: LocalContent) { e.preventDefault(); const close = contextmenu(e.clientX, e.clientY, { placement: 'right-start', offset: 8, content: [ dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-100 dark:text-dark-100', listeners: { click: (e) => { this.add("markdown", item); close() }} }, [icon('radix-icons:plus'), text('Ajouter')]), dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-100 dark:text-dark-100', listeners: { click: (e) => { this.rename(item); close() }} }, [icon('radix-icons:input'), text('Renommer')]), dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-red dark:text-dark-red', listeners: { click: (e) => { this.remove(item); close() }} }, [icon('radix-icons:trash'), text('Supprimer')]), ] }); } private add(type: FileType, nextTo: Recursive) { const count = Object.values(Content.files).filter(e => e.title.match(/^Nouveau( \(\d+\))?$/)).length; const item: Recursive & { element?: HTMLElement }> = { navigable: true, private: false, owner: 0, order: nextTo.order + 1, timestamp: new Date(), title: count === 0 ? 'Nouveau' : `Nouveau (${count})`, type: type, visit: 0, parent: nextTo.parent }; this.history.add('overview', 'add', [{ element: item, from: undefined, to: nextTo.order + 1 }]); } private remove(item: LocalContent & { element?: HTMLElement }) { this.history.add('overview', 'remove', [{ element: item, from: item.order, to: undefined }], true); } private rename(item: LocalContent & { element?: HTMLElement }) { let exists = true; const change = () => { const value = input.value || item.title; if(exists) { exists = false; input.parentElement?.replaceChild(text, input); input.remove(); if(value !== item.title) this.history.add('overview', 'rename', [{ element: item, from: item.title, to: value }], true); } } const text = item.element!.children[0].children[1]; const input = dom('input', { attributes: { type: 'text', value: item.title }, class: 'bg-light-20 dark:bg-dark-20 outline outline-light-35 dark:outline-dark-35 outline-offset-0 pl-1.5 py-1.5 flex-1', listeners: { mousedown: cancelPropagation, click: cancelPropagation, blur: change, change: change } }); text.parentElement?.replaceChild(input, text); input.focus(); } private toggleNavigable(e: Event, item: LocalContent & { element?: HTMLElement }) { cancelPropagation(e); this.history.add('overview', 'navigable', [{ element: item, from: item.navigable, to: !item.navigable }], true); } private togglePrivate(e: Event, item: LocalContent & { element?: HTMLElement }) { cancelPropagation(e); this.history.add('overview', 'private', [{ element: item, from: item.private, to: !item.private }], true); } private setupDnD(): CleanupFn { return combine(...this.tree.tree.accumulate(this.dragndrop.bind(this)), monitorForElements({ onDrop: ({ location }) => { if (location.initial.dropTargets.length === 0) return; if (location.current.dropTargets.length === 0) return; const target = location.current.dropTargets[0]; const instruction = extractInstruction(target.data); if (instruction !== null) this.updateTree(instruction, location.initial.dropTargets[0].data.id as string, target.data.id as string); }, })); } private dragndrop(item: Omit void }, "content">, depth: number, parent?: Omit): CleanupFn { item.cleanup && item.cleanup(); let opened = false, draggedOpened = false; const element = item.element!; item.cleanup = combine(draggable({ element, onDragStart: () => { element.classList.toggle('opacity-50', true); opened = this.tree.opened(item)!; this.tree.toggle(item, false); }, onDrop: () => { element.classList.toggle('opacity-50', false); this.tree.toggle(item, opened); }, canDrag: ({ element }) => { return !element.querySelector('input[type="text"]'); } }), dropTargetForElements({ element, getData: ({ input }) => { const data = { id: getPath(item) }; return attachInstruction(data, { input, element, indentPerLevel: 16, currentLevel: depth, mode: !!(item as Recursive).children ? 'expanded' : parent ? ((parent as Recursive).children!.length === item.order + 1 ? 'last-in-group' : 'standard') : this.tree.tree.flatten.slice(-1)[0] === item ? 'last-in-group' : 'standard', block: [], }) }, canDrop: ({ source }) => { return source.data.id !== getPath(item); }, onDrag: ({ self }) => { const instruction = extractInstruction(self.data) as Instruction; if(instruction) { if('currentLevel' in instruction) this.instruction.style.width = `calc(100% - ${instruction.currentLevel / 2 - 1.5}em)`; this.instruction.classList.toggle('!border-b-4', instruction?.type === 'reorder-below'); this.instruction.classList.toggle('!border-t-4', instruction?.type === 'reorder-above'); this.instruction.classList.toggle('!border-4', instruction?.type === 'make-child' || instruction?.type === 'reparent'); if(this.instruction.parentElement === null) element.appendChild(this.instruction); } }, onDragEnter: () => { draggedOpened = this.tree.opened(item)!; this.tree.toggle(item, true); }, onDragLeave: () => { this.tree.toggle(item, draggedOpened); this.instruction.remove(); }, onDrop: () => { this.tree.toggle(item, true); this.instruction.remove(); }, getIsSticky: () => true, })); return item.cleanup; } private updateTree(instruction: Instruction, source: string, target: string) { const sourceItem = this.tree.tree.find(source); const targetItem = this.tree.tree.find(target); if(!sourceItem || !targetItem || instruction.type === 'instruction-blocked') return; const from = { parent: (sourceItem as Recursive).parent, order: sourceItem.order }; if (source === target) return; if (instruction.type === 'reorder-above') this.history.add('overview', 'move', [{ element: sourceItem, from: from, to: { parent: (targetItem as Recursive).parent, order: targetItem!.order }}], true); if (instruction.type === 'reorder-below') this.history.add('overview', 'move', [{ element: sourceItem, from: from, to: { parent: (targetItem as Recursive).parent, order: targetItem!.order + 1 }}], true); if (instruction.type === 'make-child' && targetItem.type === 'folder') this.history.add('overview', 'move', [{ element: sourceItem, from: from, to: { parent: targetItem, order: 0 }}], true); } private render(item: LocalContent): Node { return handlers[item.type].renderEditor(item); } private select(item?: LocalContent & { element?: HTMLElement }) { if(this.selected && item) { Content.save(this.selected); } if(this.selected === item) { item?.element!.classList.remove('text-accent-blue'); this.selected = undefined; } else { this.selected?.element!.classList.remove('text-accent-blue'); item?.element!.classList.add('text-accent-blue'); this.selected = item; } this.container.firstElementChild!.replaceChildren(); this.selected && this.container.firstElementChild!.appendChild(this.render(this.selected) as HTMLElement); } unmount() { this.cleanup(); } } export function getPath(item: Recursive>): string export function getPath(item: Omit): string export function getPath(item: any): string { if(item.hasOwnProperty('parent') && item.parent !== undefined) return [getPath(item.parent), parsePath(item.title)].filter(e => !!e).join('/'); else if(item.hasOwnProperty('parent')) return parsePath(item.title); else return parsePath(item.title) ?? item.path; }