obsidian-visualiser/shared/content.util.ts

934 lines
39 KiB
TypeScript

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, type RedrawableHTML } 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<T extends FileType>
{
id: string;
path: string;
owner: number;
title: string;
timestamp: Date;
navigable: boolean;
private: boolean;
order: number;
type: T;
}
export type ProjectContent<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 = () => {};
private rej: (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.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<FileType, ContentMap[FileType]> = {
map: {},
canvas: { nodes: [], edges: []},
markdown: '',
file: '',
folder: null,
};
export type LocalContent<T extends FileType = FileType> = ProjectContent<T> & {
localEdit?: boolean;
error?: boolean;
};
export class Content
{
private static _ready = false;
private static initPromise?: Promise<boolean>;
private static root: FileSystemDirectoryHandle;
private static _overview: Record<string, Omit<LocalContent, 'content'>>;
private static _reverseMapping: Record<string, string> = {};
private static queue = new AsyncQueue();
static init(): Promise<boolean>
{
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<Record<string, Omit<LocalContent, 'content'>>>(overview);
Content._reverseMapping = Object.values(Content._overview).reduce((p, v) => { p[v.path] = v.id; return p; }, {} as Record<string, string>);
await Content.pull();
}
catch(e)
{
Content._overview = {};
await Content.pull(true);
}
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<LocalContent | undefined>
{
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<LocalContent, 'content'> | Recursive<Omit<LocalContent, 'content'>>)
{
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<LocalContent>;
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<FileType>[] | 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._reverseMapping[file.path] = file.id;
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 }));
}
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._reverseMapping[Content._overview[id]!.path];
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<string | undefined>
{
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<FileSystemDirectoryHandle | undefined>
{
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<void>
{
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<void>
{
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<StorageEstimate>
{
return Content._ready ? navigator.storage.estimate() : Promise.reject();
}
static toString<T extends FileType>(content: ProjectContent<T>): string
{
return handlers[content.type].toString(content.content);
}
static fromString<T extends FileType>(overview: Omit<ProjectContent<T>, 'content'>, content: string): ContentMap[T]
{
return handlers[overview.type].fromString(content);
}
static async render(parent: RedrawableHTML, path: string): Promise<Omit<LocalContent, 'content'> | 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<T extends FileType>(content: LocalContent<T>): 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<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;
};
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<K> } = {
canvas: {
toString: (content) => {
const mapping: Record<string, string> = {
'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: RedrawableHTML;
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: RedrawableHTML;
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<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: RedrawableHTML;
selected?: Recursive<LocalContent & { element?: RedrawableHTML }>;
private instruction: RedrawableHTML;
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 RedrawableHTML;
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(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<LocalContent & { element?: RedrawableHTML }> | 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<LocalContent>)
{
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<LocalContent>)
{
const count = Object.values(Content.files).filter(e => e.title.match(/^Nouveau( \(\d+\))?$/)).length;
const item: Recursive<Omit<LocalContent, 'path' | 'content'> & { element?: RedrawableHTML }> = { 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?: RedrawableHTML })
{
this.history.add('overview', 'remove', [{ element: item, from: item.order, to: undefined }], true);
}
private rename(item: LocalContent & { element?: RedrawableHTML })
{
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?: RedrawableHTML })
{
cancelPropagation(e);
this.history.add('overview', 'navigable', [{ element: item, from: item.navigable, to: !item.navigable }], true);
}
private togglePrivate(e: Event, item: LocalContent & { element?: RedrawableHTML })
{
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<LocalContent & { element?: RedrawableHTML, cleanup?: () => void }, "content">, depth: number, parent?: Omit<LocalContent & { element?: RedrawableHTML }, "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: item.id };
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?: RedrawableHTML })
{
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 RedrawableHTML);
}
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;
}