import { safeDestr as parse } from 'destr'; import { Canvas, CanvasEditor } from "#shared/canvas.util"; import render from "#shared/markdown.util"; import { confirm, contextmenu, tooltip } from "#shared/floating.util"; import { cancelPropagation, dom, icon, text, type Node } from "#shared/dom.util"; import { async, loading } from "#shared/components.util"; import prose, { h1, h2 } from "#shared/proses"; import { getID, parsePath } from '#shared/general.util'; import { 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 { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; 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 { id: string; path: string; owner: number; title: string; timestamp: Date; navigable: boolean; private: boolean; order: number; type: T; } export type ProjectContent = 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 = () => {}; private rej: (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.rej = rej; }); } 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().catch(e => this.rej(e)).then(() => { 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 = ProjectContent & { localEdit?: boolean; error?: boolean; }; export class Content { private static _ready = false; private static initPromise?: Promise; private static root: FileSystemDirectoryHandle; private static _overview: Record>; private static _reverseMapping: Record; private static queue = new AsyncQueue(); static init(): Promise { if(Content._ready) return Promise.resolve(true); if(Content.initPromise) return Content.initPromise; Content.initPromise = new Promise(async (res) => { 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); await Content.pull(); } catch(e) { Content._overview = {}; await Content.pull(true); } Content._reverseMapping = Object.values(Content._overview).reduce((p, v) => { p[v.path] = v.id; return p; }, {} as Record); Content._ready = true; } catch(e) { console.error(e); } res(Content._ready); }); return Content.initPromise; } static getFromPath(path: string) { const id = Content.idFromPath(path); return id ? Content._overview[id] : undefined; } static idFromPath(path: string) { return Content._reverseMapping[path]; } static get(id: string) { return Content._overview[id]; } static async getContent(id: string): Promise { const overview = Content._overview[id]; if(!overview) return; return { ...overview, content: Content.fromString(overview, (await Content.read(id, { create: true }))!) }; } static set(id: string, overview?: Omit | Recursive>) { if(overview === undefined) { delete Content._overview[id]; } else { const { id: _id, path: _path, owner: _owner, title: _title, timestamp: _timestamp, navigable: _navigable, private: _private, order: _order, type: _type, ...rest } = overview as Recursive; Content._overview[id] = { id: _id, path: _path, owner: _owner, title: _title, timestamp: _timestamp, navigable: _navigable, private: _private, order: _order, type: _type, }; } } static async save(content?: ProjectContent) { const overviewAsString = JSON.stringify(Content._overview) Content.queue.queue(() => Content.write("overview", overviewAsString)); if(content && content.content) { const contentAsString = Content.toString(content); Content.queue.queue(() => Content.write(content.id, contentAsString)); } return Content.queue.promise; } static async pull(force: boolean = false) { const overview = (await useRequestFetch()('/api/file/overview', { cache: 'no-cache' })) as ProjectContent[] | undefined; if(!overview) { //TODO: Cannot get data :'( //Add a warning ? return; } const deletable = Object.keys(Content._overview); for(const file of overview) { const _overview = Content._overview[file.id]; if(force || !_overview || new Date(_overview.timestamp) < new Date(file.timestamp)) { Content._overview[file.id] = file; Content.queue.queue(() => { return useRequestFetch()(`/api/file/content/${file.id}`, { cache: 'no-cache' }).then(async (content: string | undefined | null) => { if(content) { if(file.type !== 'folder') { Content.queue.queue(() => Content.write(file.id, content, { create: true })); //Content.queue.queue(() => Content.write('storage/' + file.path + (file.type === 'canvas' ? '.canvas' : '.md'), Content.toString({ ...file, content: Content.fromString(file, content) }), { create: true })); } } else Content._overview[file.id]!.error = true; }).catch(e => { Content._overview[file.id]!.error = true; }); }); } deletable.splice(deletable.findIndex(e => e === file.id), 1); } for(const id of deletable) Content.queue.queue(() => Content.remove(id).then(e => delete Content._overview[id])); return Content.queue.queue(() => { return Content.write('overview', JSON.stringify(Content._overview), { create: true }); }); } static async push() { const blocked = (await useRequestFetch()('/api/file/overview', { method: 'POST', body: Object.values(Content._overview), cache: 'no-cache' })); for(const [id, value] of Object.entries(Content._overview).filter(e => !blocked.includes(e[0]))) { if(value.type === 'folder') continue; Content.queue.queue(() => Content.read(id).then(e => { if(e) Content.queue.queue(() => useRequestFetch()(`/api/file/content/${id}`, { method: 'POST', body: e, cache: 'no-cache' })); })); } return Content.queue.promise; } //Maybe store the file handles ? Is it safe to keep them ? private static async read(path: string, options?: FileSystemGetFileOptions): Promise { try { const handle = await Content.root.getFileHandle(path, options); const file = await handle.getFile(); //@ts-ignore const response = await new Response(file.stream().pipeThrough(new DecompressionStream('gzip'))); return await response.text(); } catch(e) { console.error(path, e); } } private static async goto(path: string, options?: FileSystemGetDirectoryOptions): Promise { const splitPath = path.split("/"); let handle = Content.root; try { for(const p of splitPath) { handle = await handle.getDirectoryHandle(p, options); } return handle; } catch(e) { return undefined; } } //Easy to use, but not very performant. private static async write(path: string, content: string, options?: FileSystemGetFileOptions): Promise { try { const parent = path.split('/').slice(0, -1).join('/'), basename = path.split('/').slice(-1).join('/'); const handle = await (await Content.goto(parent, { create: true }) ?? Content.root).getFileHandle(basename, options); const file = await handle.createWritable({ keepExistingData: false }); await new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(content)); controller.close(); } }).pipeThrough(new CompressionStream("gzip")).pipeTo(file); } catch(e) { console.error(path, e); } } private static async remove(path: string): Promise { try { const parent = path.split('/').slice(0, -1).join('/'), basename = path.split('/').slice(-1).join('/'); return (await Content.goto(parent, { create: true }) ?? Content.root).removeEntry(basename); } catch(e) { console.error(path, e); } } static get estimate(): Promise { return Content._ready ? navigator.storage.estimate() : Promise.reject(); } static toString(content: ProjectContent): string { return handlers[content.type].toString(content.content); } static fromString(overview: Omit, 'content'>, content: string): ContentMap[T] { return handlers[overview.type].fromString(content); } static async render(parent: HTMLElement, path: string): Promise | undefined> { parent.appendChild(dom('div', { class: 'flex, flex-1 justify-center items-center' }, [loading('normal')])) await Content.ready; const overview = Content.getFromPath(path); if(!!overview) { function _render(content: LocalContent): void { const el = handlers[content.type].render(content); el && parent.replaceChildren(el); } Content.getContent(overview.id).then(content => _render(content!)); } else { parent.replaceChildren(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; }; function reshapeLinks(content: string | null, all: ProjectContent[]) { return content?.replace(/\[\[(.*?)?(#.*?)?(\|.*?)?\]\]/g, (str, link, header, title) => { return `[[${link ? parsePath(all.find(e => e.path.endsWith(parsePath(link)))?.path ?? parsePath(link)) : ''}${header ?? ''}${title ?? ''}]]`; }); } const handlers: { [K in FileType]: ContentTypeHandler } = { canvas: { toString: (content) => { const mapping: Record = { 'red': '1', 'orange': '2', 'yellow': '3', 'green': '4', 'cyan': '5', 'purple': '6', }; //@ts-ignore content.edges?.forEach(e => e.color = e.color ? e.color.hex ?? (e.color.class ? mapping[e.color.class]! : undefined) : undefined); //@ts-ignore content.nodes?.forEach(e => e.color = e.color ? e.color.hex ?? (e.color.class ? mapping[e.color.class]! : undefined) : undefined); return JSON.stringify(content); }, fromString: (str) => JSON.parse(str), render: (content) => { const c = new Canvas(content.content); queueMicrotask(() => c.mount()); return c.container; }, renderEditor: (content) => { let element: HTMLElement; if(content.hasOwnProperty('content')) { const c = new CanvasEditor(content.content); queueMicrotask(() => c.mount()); element = c.container; } else { element = loading("large"); Content.getContent(content.id).then(e => { if(!e) return element.parentElement?.replaceChild(dom('div', { class: '', text: 'Une erreur est survenue.' }), element); content.content = e.content as CanvasContent; const c = new CanvasEditor(content.content); queueMicrotask(() => c.mount()); element.parentElement?.replaceChild(c.container, element); }); } return element; }, }, 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, undefined, { class: 'pb-64' }), ]) }, renderEditor: (content) => { let element: HTMLElement; if(content.hasOwnProperty('content')) { MarkdownEditor.singleton.content = content.content; element = MarkdownEditor.singleton.dom; } else { element = loading("large"); Content.getContent(content.id).then(e => { if(!e) return element.parentElement?.replaceChild(dom('div', { class: '', text: 'Une erreur est survenue.' }), 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(action.element.id); 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(); action.element.path = path; }, redo: (action) => { this.tree.tree.remove(action.element.id); 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(); action.element.path = path; }, }, add: { undo: (action) => { this.tree.tree.remove(action.element.id); if(this.selected === action.element) this.select(); action.element.cleanup(); action.element.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(action.element.id); if(this.selected === action.element) this.select(); action.element.cleanup(); action.element.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); 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); 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 }] })); }, }, }, () => { this.tree.tree.each(e => Content.set(e.id, e)); Content.save(); }); Content.ready.then(() => { 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 } }), tooltip(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 }] })]), 'Navigable', 'left'), tooltip(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 }] })]), 'Privé', 'right'), ])]); }, (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 } }), tooltip(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 }] })]), 'Navigable', 'left'), tooltip(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 }] })]), 'Privé', 'right'), ])]); }); this.select(this.tree.tree.find(useRouter().currentRoute.value.hash.substring(1)) as Recursive | undefined); this.cleanup = this.setupDnD(); }); this.instruction = dom('div', { class: 'absolute h-full w-full top-0 right-0 border-light-50 dark:border-dark-50' }); 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: Recursive) { e.preventDefault(); const { close } = contextmenu(e.clientX, e.clientY, [ 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) => { close(); confirm(`Confirmer la suppression de ${item.title}${item.children ? ' et de ses enfants' : ''} ?`).then(e => { if(e) this.remove(item)}) }} }, [icon('radix-icons:trash'), text('Supprimer')]), ], { placement: 'right-start', offset: 8 }); } 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 }> = { id: getID(), navigable: true, private: false, owner: 0, order: nextTo.order + 1, timestamp: new Date(), title: count === 0 ? 'Nouveau' : `Nouveau (${count})`, type: type, 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); }, }), autoScrollForElements({ element: this.tree.container, })); } 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: item.id }; 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; } useRouter().push({ hash: this.selected ? '#' + this.selected.id : '' }) 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; }