912 lines
39 KiB
TypeScript
912 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, popper } from "#shared/floating.util";
|
|
import { cancelPropagation, dom, icon, text, type Node } from "#shared/dom.util";
|
|
import { loading } from "#shared/components.util";
|
|
import prose, { h1, h2 } from "#shared/proses";
|
|
import { getID, ID_SIZE, 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);
|
|
|
|
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);
|
|
}
|
|
catch(e)
|
|
{
|
|
Content._overview = {};
|
|
await Content.pull();
|
|
}
|
|
Content._reverseMapping = Object.values(Content._overview).reduce((p, v) => {
|
|
p[v.path] = v.id;
|
|
return p;
|
|
}, {} as Record<string, string>);
|
|
|
|
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()
|
|
{
|
|
const overview = (await useRequestFetch()('/api/file/overview', { cache: 'no-cache' })) as ProjectContent<FileType>[] | undefined;
|
|
|
|
if(!overview)
|
|
{
|
|
//TODO: Cannot get data :'(
|
|
//Add a warning ?
|
|
return;
|
|
}
|
|
|
|
for(const file of overview)
|
|
{
|
|
const _overview = Content._overview[file.id];
|
|
if(_overview && _overview.localEdit)
|
|
{
|
|
//TODO: Ask what to do about this file.
|
|
}
|
|
else
|
|
{
|
|
Content._overview[file.id] = file;
|
|
|
|
Content.queue.queue(() => {
|
|
return useRequestFetch()(`/api/file/content/${file.id}`, { cache: 'no-cache' }).then(async (content: string | undefined) => {
|
|
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;
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
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
|
|
{
|
|
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}'`);
|
|
}
|
|
}
|
|
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>
|
|
{
|
|
const size = new TextEncoder().encode(content).byteLength;
|
|
console.time(`Writing ${size} bytes to '${path}'`);
|
|
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 file.write(content);
|
|
await file.close();
|
|
}
|
|
catch(e)
|
|
{
|
|
console.error(path, e);
|
|
}
|
|
console.timeEnd(`Writing ${size} bytes to '${path}'`);
|
|
}
|
|
|
|
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 render(parent: HTMLElement, path: string): Omit<LocalContent, 'content'> | undefined
|
|
{
|
|
const overview = Content.getFromPath(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.getContent(overview.id).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;
|
|
};
|
|
|
|
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',
|
|
};
|
|
content.edges?.forEach(e => e.color = e.color ? e.color.hex ?? (e.color.class ? mapping[e.color.class]! : undefined) : undefined);
|
|
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),
|
|
])
|
|
},
|
|
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<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(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<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(); });
|
|
|
|
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' })]);
|
|
|
|
this.select(this.tree.tree.find(useRouter().currentRoute.value.hash.substring(1)) as Recursive<LocalContent & { element?: HTMLElement }> | undefined);
|
|
}
|
|
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?: HTMLElement }> = { id: getID(ID_SIZE), 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<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: 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?: 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<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;
|
|
} |