obsidian-visualiser/shared/content.util.ts

808 lines
35 KiB
TypeScript

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<T extends FileType>
{
path: string;
owner: number;
title: string;
timestamp: Date;
navigable: boolean;
private: boolean;
order: number;
visit: number;
type: T;
}
export type ExploreContent<T extends FileType = FileType> = Overview<T> & { content: ContentMap[T] };
class AsyncQueue
{
private size: number;
private count: number = 0;
private _queue: Array<() => Promise<any>>;
promise: Promise<void> = Promise.resolve();
finished: boolean = true;
private res: (value: void | PromiseLike<void>) => void = () => {};
constructor(size: number = 8)
{
this.size = size;
this._queue = [];
}
queue(fn: () => Promise<any>): Promise<void>
{
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<FileType, ContentMap[FileType]> = {
map: {},
canvas: { nodes: [], edges: []},
markdown: '',
file: '',
folder: null,
};
export type LocalContent<T extends FileType = FileType> = ExploreContent<T> & {
localEdit?: boolean;
error?: boolean;
};
export class Content
{
private static _ready = false;
private static initPromise?: Promise<boolean>;
private static root: FileSystemDirectoryHandle;
private static _overview: Omit<LocalContent, 'content'>[];
private static dlQueue = new AsyncQueue();
private static writeQueue = new AsyncQueue(1);
static init(): Promise<boolean>
{
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<Omit<LocalContent, 'content'>[]>(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<LocalContent, 'content'> | undefined
{
return Content._overview.find(e => getPath(e) === path);
}
static async content(path: string): Promise<LocalContent | undefined>
{
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<LocalContent>)
{
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<LocalContent>)
{
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<FileType>[] | 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<string | undefined>
{
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<void>
{
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<void>
{
console.time(`Removing '${path}'`);
await Content.root.removeEntry(path)
console.timeEnd(`Removing '${path}'`);
}
static get estimate(): Promise<StorageEstimate>
{
return Content._ready ? navigator.storage.estimate() : Promise.reject();
}
static toString<T extends FileType>(content: ExploreContent<T>): string
{
return handlers[content.type].toString(content.content);
}
static fromString<T extends FileType>(overview: Omit<ExploreContent<T>, 'content'>, content: string): ContentMap[T]
{
return handlers[overview.type].fromString(content);
}
static render(parent: HTMLElement, path: string): Omit<LocalContent, 'content'> | 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<T extends FileType>(content: LocalContent<T>): 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<Omit<LocalContent, 'content'>>[] = [];
function addChild(arr: Recursive<Omit<LocalContent, 'content'>>[], overview: Omit<LocalContent, 'content'>): void {
const parent = arr.find(f => overview.path.startsWith(f.path));
if(parent)
{
if(!parent.children)
parent.children = [];
(overview as Recursive<typeof overview>).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<boolean>
{
return Content._ready ? Promise.resolve(true) : Content.initPromise ?? Promise.resolve(false);
}
}
type ContentTypeHandler<T extends FileType> = {
toString: (content: ContentMap[T]) => string;
fromString: (str: string) => ContentMap[T];
render: (content: LocalContent<T>) => Node;
renderEditor: (content: LocalContent<T>) => Node;
};
const handlers: { [K in FileType]: ContentTypeHandler<K> } = {
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<FileType, string> = {
'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<LocalContent & { element?: HTMLElement }>;
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<LocalContent>).parent);
}
this.tree.tree.insertAt(action.element as Recursive<LocalContent>, 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<LocalContent>)
{
const count = Object.values(Content.files).filter(e => e.title.match(/^Nouveau( \(\d+\))?$/)).length;
const item: Recursive<Omit<LocalContent, 'path' | 'content'> & { 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<LocalContent & { element?: HTMLElement, cleanup?: () => void }, "content">, depth: number, parent?: Omit<LocalContent & { element?: HTMLElement }, "content">): 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<typeof item>).children ? 'expanded' : parent ? ((parent as Recursive<typeof item>).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<typeof targetItem>).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<typeof targetItem>).parent, order: targetItem!.order }}], true);
if (instruction.type === 'reorder-below')
this.history.add('overview', 'move', [{ element: sourceItem, from: from, to: { parent: (targetItem as Recursive<typeof targetItem>).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<T extends FileType>(item: LocalContent<T>): 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<Omit<LocalContent, 'content'>>): string
export function getPath(item: Omit<LocalContent, 'content'>): 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;
}