Update DB schema to include an ID and split overview and content. Progressing on ContentEditor with the ID fixing many issues. Adding modal and sync features.

This commit is contained in:
2025-03-31 01:19:58 +02:00
parent 227d7224e5
commit 1d41514b26
48 changed files with 922 additions and 1156 deletions

View File

@@ -1,11 +1,11 @@
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 { confirm, 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 { 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';
@@ -13,6 +13,7 @@ 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;
@@ -26,6 +27,7 @@ export interface ContentMap
}
export interface Overview<T extends FileType>
{
id: string;
path: string;
owner: number;
title: string;
@@ -33,10 +35,9 @@ export interface Overview<T extends FileType>
navigable: boolean;
private: boolean;
order: number;
visit: number;
type: T;
}
export type ExploreContent<T extends FileType = FileType> = Overview<T> & { content: ContentMap[T] };
export type ProjectContent<T extends FileType = FileType> = Overview<T> & { content: ContentMap[T] };
class AsyncQueue
{
@@ -48,6 +49,7 @@ class AsyncQueue
finished: boolean = true;
private res: (value: void | PromiseLike<void>) => void = () => {};
private rej: (value: void | PromiseLike<void>) => void = () => {};
constructor(size: number = 8)
{
@@ -63,6 +65,7 @@ class AsyncQueue
this.promise = new Promise((res, rej) => {
this.res = res;
this.rej = rej;
});
}
@@ -79,7 +82,7 @@ class AsyncQueue
this.count++;
const fn = this._queue.shift()!;
fn().finally(() => {
fn().catch(e => this.rej(e)).then(() => {
this.count--;
this.refresh();
});
@@ -100,7 +103,7 @@ export const DEFAULT_CONTENT: Record<FileType, ContentMap[FileType]> = {
file: '',
folder: null,
};
export type LocalContent<T extends FileType = FileType> = ExploreContent<T> & {
export type LocalContent<T extends FileType = FileType> = ProjectContent<T> & {
localEdit?: boolean;
error?: boolean;
};
@@ -111,16 +114,15 @@ export class Content
private static root: FileSystemDirectoryHandle;
private static _overview: Omit<LocalContent, 'content'>[];
private static dlQueue = new AsyncQueue();
private static writeQueue = new AsyncQueue(1);
private static _overview: Record<string, Omit<LocalContent, 'content'>>;
private static queue = new AsyncQueue();
static init(): Promise<boolean>
{
if(Content._ready)
return Promise.resolve(true);
Content.initPromise = new Promise(async (res, rej) => {
Content.initPromise = new Promise(async (res) => {
try
{
if(!('storage' in navigator))
@@ -131,11 +133,11 @@ export class Content
const overview = await Content.read('overview', { create: true });
try
{
Content._overview = parse<Omit<LocalContent, 'content'>[]>(overview);
Content._overview = parse<Record<string, Omit<LocalContent, 'content'>>>(overview);
}
catch(e)
{
Content._overview = [];
Content._overview = {};
await Content.pull();
}
@@ -151,51 +153,61 @@ export class Content
return Content.initPromise;
}
static overview(path: string): Omit<LocalContent, 'content'> | undefined
static async get(id: string, content: boolean): Promise<LocalContent | 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);
const overview = Content._overview[id];
return overview ? { ...overview, content: Content.fromString(overview, await Content.read(encodeURIComponent(path)) ?? '') } as LocalContent : undefined;
return overview ? { ...overview, content: content ? Content.fromString(overview, await Content.read(id) ?? '') : undefined } as LocalContent : undefined;
}
static update(item: Recursive<LocalContent>)
static async set(id: string, overview?: Omit<LocalContent, 'content'> | Recursive<Omit<LocalContent, 'content'>>)
{
const index = Content._overview.findIndex(e => e.path === getPath(item));
if(index !== -1)
Content._overview[index] = item;
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));
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;
if(content && content.content)
{
const contentAsString = Content.toString(content);
Content.queue.queue(() => Content.write(content.id, contentAsString));
}
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));
}
});
return Content.queue.promise;
}
static save(content?: Recursive<LocalContent>)
static async pull()
{
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;
const overview = (await useRequestFetch()('/api/file/overview', { cache: 'no-cache' })) as ProjectContent<FileType>[] | undefined;
if(!overview)
{
@@ -206,44 +218,50 @@ export class Content
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 _overview = Content._overview[file.id];
if(_overview && _overview.localEdit)
{
const encoded = encodeURIComponent(file.path);
if(!_overview)
{
index = Content._overview.push(file) - 1;
}
else
Content._overview[index] = file;
_overview = file;
//TODO: Ask what to do about this file.
}
else
{
Content._overview[file.id] = file;
if(file.type === 'folder')
continue;
Content.dlQueue.queue(() => {
return useRequestFetch()(`/api/file/content/${encoded}`, { cache: 'no-cache' }).then(async (content: ContentMap[FileType] | undefined) => {
Content.queue.queue(() => {
return useRequestFetch()(`/api/file/content/${file.id}`, { cache: 'no-cache' }).then(async (content: ContentMap[FileType] | undefined) => {
if(content)
await Content.write(encoded, Content.toString({ ...file, content }), { create: true });
Content.queue.queue(() => Content.write(file.id, Content.toString({ ...file, content }), { create: true }));
else
Content._overview[index].error = true;
Content._overview[file.id].error = true;
}).catch(e => {
Content._overview[index].error = true;
})
Content._overview[file.id].error = true;
});
});
}
}
Content.dlQueue.queue(() => {
return Content.queue.queue(() => {
return Content.write('overview', JSON.stringify(Content._overview), { create: true });
});
await Content.dlQueue.promise;
}
private static async push()
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>
@@ -283,24 +301,18 @@ export class Content
}
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
static toString<T extends FileType>(content: ProjectContent<T>): string
{
return handlers[content.type].toString(content.content);
}
static fromString<T extends FileType>(overview: Omit<ExploreContent<T>, 'content'>, content: string): ContentMap[T]
static fromString<T extends FileType>(overview: Omit<ProjectContent<T>, 'content'>, content: string): ContentMap[T]
{
return handlers[overview.type].fromString(content);
}
@@ -362,7 +374,7 @@ export class Content
for(const element of Object.values(Content._overview))
{
addChild(arr, element);
addChild(arr, {...element});
}
return arr;
@@ -411,9 +423,9 @@ const handlers: { [K in FileType]: ContentTypeHandler<K> } = {
else
{
element = loading("large");
Content.content(content.path).then(e => {
Content.get(content.id, true).then(e => {
if(!e)
return element.parentElement?.replaceChild(dom('div', { class: '', text: '' }), element);
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);
@@ -452,7 +464,7 @@ export const iconByType: Record<FileType, string> = {
'file': 'radix-icons:file',
'markdown': 'radix-icons:file-text',
'map': 'lucide:map',
}
};
export class Editor
{
@@ -472,7 +484,7 @@ export class Editor
this.history.register('overview', {
move: {
undo: (action) => {
this.tree.tree.remove(getPath(action.element));
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;
@@ -483,11 +495,10 @@ export class Editor
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));
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;
@@ -499,16 +510,16 @@ export class Editor
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));
this.tree.tree.remove(action.element.id);
if(this.selected === action.element) this.select();
action.element.cleanup();
action.element.remove();
action.element.element?.remove();
},
redo: (action) => {
if(!action.element)
@@ -527,10 +538,10 @@ export class Editor
this.tree.update();
},
redo: (action) => {
this.tree.tree.remove(getPath(action.element));
this.tree.tree.remove(action.element.id);
if(this.selected === action.element) this.select();
action.element.cleanup();
action.element.remove();
action.element.element?.remove();
},
},
rename: {
@@ -542,7 +553,6 @@ export class Editor
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) => {
@@ -553,7 +563,6 @@ export class Editor
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;
},
},
@@ -577,7 +586,7 @@ export class Editor
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.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)} }, [
@@ -601,20 +610,20 @@ export class Editor
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)
private contextmenu(e: MouseEvent, item: Recursive<LocalContent>)
{
e.preventDefault();
const close = contextmenu(e.clientX, e.clientY, { placement: 'right-start', offset: 8, content: [
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) => { this.remove(item); close() }} }, [icon('radix-icons:trash'), text('Supprimer')]),
] });
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 }> = { 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 };
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 })
@@ -670,6 +679,8 @@ export class Editor
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
@@ -697,7 +708,7 @@ export class Editor
dropTargetForElements({
element,
getData: ({ input }) => {
const data = { id: getPath(item) };
const data = { id: item.id };
return attachInstruction(data, {
input,

View File

@@ -7,7 +7,7 @@ export type Class = string | Array<Class> | Record<string, boolean> | undefined;
type Listener<K extends keyof HTMLElementEventMap> = | ((ev: HTMLElementEventMap[K]) => any) | {
options?: boolean | AddEventListenerOptions;
listener: (ev: HTMLElementEventMap[K]) => any;
}
} | undefined;
export interface NodeProperties
{
@@ -43,7 +43,7 @@ export function dom<K extends keyof HTMLElementTagNameMap>(tag: K, properties?:
const key = k as keyof HTMLElementEventMap, value = v as Listener<typeof key>;
if(typeof value === 'function')
element.addEventListener(key, value);
else
else if(value)
element.addEventListener(key, value.listener, value.options);
}
}

View File

@@ -8,6 +8,7 @@ import { lintKeymap } from '@codemirror/lint';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { IterMode, Tree } from '@lezer/common';
import { tags } from '@lezer/highlight';
import { dom } from './dom.util';
const External = Annotation.define<boolean>();
const Hidden = Decoration.mark({ class: 'hidden' });
const Bullet = Decoration.mark({ class: '*:hidden before:absolute before:top-2 before:left-0 before:inline-block before:w-2 before:h-2 before:rounded before:bg-light-40 dark:before:bg-dark-40 relative ps-4' });
@@ -199,4 +200,52 @@ export class MarkdownEditor
MarkdownEditor._singleton = new MarkdownEditor();
return MarkdownEditor._singleton;
}
}
export class FramedEditor
{
editor: MarkdownEditor;
dom: HTMLIFrameElement;
private static _singleton: FramedEditor;
static get singleton()
{
if(!FramedEditor._singleton)
FramedEditor._singleton = new FramedEditor();
return FramedEditor._singleton;
}
private constructor()
{
this.editor = MarkdownEditor.singleton;
this.dom = dom('iframe');
this.dom.addEventListener('load', () => {
if(!this.dom.contentDocument)
return;
this.dom.contentDocument.documentElement.setAttribute('class', document.documentElement.getAttribute('class') ?? '');
this.dom.contentDocument.documentElement.setAttribute('style', document.documentElement.getAttribute('style') ?? '');
const base = this.dom.contentDocument.head.appendChild(this.dom.contentDocument.createElement('base'));
base.setAttribute('href', window.location.href);
for(let element of document.getElementsByTagName('link'))
{
if(element.getAttribute('rel') === 'stylesheet')
this.dom.contentDocument.head.appendChild(element.cloneNode(true));
}
for(let element of document.getElementsByTagName('style'))
{
this.dom.contentDocument.head.appendChild(element.cloneNode(true));
}
this.dom.contentDocument.body.setAttribute('class', document.body.getAttribute('class') ?? '');
this.dom.contentDocument.body.setAttribute('style', document.body.getAttribute('style') ?? '');
this.dom.contentDocument.body.appendChild(this.editor.dom);
this.editor.focus();
})
}
}

View File

@@ -1,22 +1,33 @@
import * as FloatingUI from "@floating-ui/dom";
import { cancelPropagation, dom, svg, type Class, type NodeChildren } from "./dom.util";
import { cancelPropagation, dom, svg, text, type Class, type NodeChildren } from "./dom.util";
import { button } from "./proses";
export interface CommonProperties
export interface ContextProperties
{
placement?: FloatingUI.Placement;
offset?: number;
arrow?: boolean;
class?: Class;
}
export interface PopperProperties
{
placement?: FloatingUI.Placement;
offset?: number;
arrow?: boolean;
class?: Class;
content?: NodeChildren;
}
export interface PopperProperties extends CommonProperties
{
delay?: number;
onShow?: (element: HTMLDivElement) => boolean | void;
onHide?: (element: HTMLDivElement) => boolean | void;
}
export interface ModalProperties
{
priority?: boolean;
closeWhenOutside?: boolean;
}
let teleport: HTMLDivElement;
export function init()
{
@@ -141,7 +152,7 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
return container;
}
export function contextmenu(x: number, y: number, properties?: CommonProperties): () => void
export function contextmenu(x: number, y: number, content: NodeChildren, properties?: ContextProperties): () => void
{
const virtual = {
getBoundingClientRect() {
@@ -159,7 +170,7 @@ export function contextmenu(x: number, y: number, properties?: CommonProperties)
};
const arrow = svg('svg', { class: 'absolute fill-light-35 dark:fill-dark-35', attributes: { width: "10", height: "7", viewBox: "0 0 30 10" } }, [svg('polygon', { attributes: { points: "0,0 30,0 15,10" } })]);
const container = dom('div', { class: ['fixed bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 z-50', properties?.class] }, [...(properties?.content ?? [])]);
const container = dom('div', { class: ['fixed bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 z-50', properties?.class] }, content);
function update()
{
@@ -236,7 +247,26 @@ export function contextmenu(x: number, y: number, properties?: CommonProperties)
return close;
}
//TODO
export function modal()
export function modal(content: NodeChildren, properties?: ModalProperties)
{
const _modalBlocker = dom('div', { class: [' absolute top-0 left-0 bottom-0 right-0 z-0', { 'bg-light-0 dark:bg-dark-0 opacity-70': properties?.priority ?? false }], listeners: { click: properties?.closeWhenOutside ? (() => _modal.remove()) : undefined } });
const _closer = properties?.priority ? undefined : dom('span', { class: 'absolute top-4 right-4', text: '×', listeners: { click: () => _modal.remove() } });
const _modal = dom('div', { class: 'fixed flex justify-center items-center top-0 left-0 bottom-0 right-0 inset-0 z-40' }, [ _modalBlocker, dom('div', { class: 'max-h-[85vh] max-w-[450px] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 text-light-100 dark:text-dark-100 z-10 relative' }, content)])
teleport.appendChild(_modal);
return {
close: () => _modal.remove(),
}
}
export function confirm(title: string): Promise<boolean>
{
return new Promise(res => {
const mod = modal([ dom('div', { class: 'flex flex-col justify-start gap-4' }, [ dom('div', { class: 'text-xl' }, [ text(title) ]), dom('div', { class: 'flex flex-row gap-2' }, [ button(text("Non"), () => (mod.close(), res(false)), 'h-[35px] px-[15px]'), button(text("Oui"), () => (mod.close(), res(true)), 'h-[35px] px-[15px] !border-light-red dark:!border-dark-red text-light:red dark:text-dark-red') ]) ]) ], {
priority: true,
closeWhenOutside: false,
});
})
}

View File

@@ -1,7 +1,15 @@
export const ID_SIZE = 32;
export function unifySlug(slug: string | string[]): string
{
return (Array.isArray(slug) ? slug.join('/') : slug);
}
export function getID(length: number)
{
for (var id = [], i = 0; i < length; i++)
id.push((16 * Math.random() | 0).toString(16));
return id.join("");
}
export function parsePath(path: string): string
{
return path.toLowerCase().replaceAll(" ", "-").normalize("NFD").replaceAll(/[\u0300-\u036f]/g, "").replaceAll('(', '').replaceAll(')', '');

View File

@@ -18,7 +18,7 @@ interface HistoryAction
export class History
{
private handlers: Record<string, { handlers: Record<string, HistoryHandler>, any?: (action: HistoryAction) => void }>;
private handlers: Record<string, { handlers: Record<string, HistoryHandler>, any?: (action: HistoryAction) => any }>;
private history: HistoryEvent[];
private position: number;
@@ -83,7 +83,7 @@ export class History
this.handlers[source] && this.handlers[source].any && this.handlers[source].any(e);
});
}
register(source: string, handlers: Record<string, HistoryHandler>, any?: (action: HistoryAction) => void)
register(source: string, handlers: Record<string, HistoryHandler>, any?: (action: HistoryAction) => any)
{
this.handlers[source] = { handlers, any };
}

View File

@@ -252,4 +252,10 @@ export function link(properties?: NodeProperties & { active?: Class }, link?: Ro
export function loading(size: 'small' | 'normal' | 'large' = 'normal'): HTMLElement
{
return dom('span', { class: ["after:block after:relative after:rounded-full after:border-transparent after:border-t-accent-purple after:animate-spin", {'w-6 h-6 border-4 border-transparent after:-top-[4px] after:-left-[4px] after:w-6 after:h-6 after:border-4': size === 'normal', 'w-4 h-4 after:-top-[2px] after:-left-[2px] after:w-4 after:h-4 after:border-2': size === 'small', 'w-12 h-12 after:-top-[6px] after:-left-[6px] after:w-12 after:h-12 after:border-[6px]': size === 'large'}] })
}
export function button(content: Node, onClick?: () => void, cls?: Class)
{
return dom('button', { class: [`text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-none
border border-light-25 dark:border-dark-25 hover:border-light-30 dark:hover:border-dark-30 active:border-light-40 dark:active:border-dark-40 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50`, cls], listeners: { click: onClick } }, [ content ]);
}

View File

@@ -1,4 +1,4 @@
import { Content, getPath, type LocalContent } from "./content.util";
import { Content, type LocalContent } from "./content.util";
import { dom } from "./dom.util";
import { clamp } from "./general.util";
@@ -17,9 +17,9 @@ export class Tree<T extends Omit<LocalContent, 'content'>>
this._flatten = this.accumulate(e => e);
}
remove(path: string)
remove(id: string)
{
const recursive = (data?: Recursive<T>[], parent?: T) => data?.filter(e => getPath(e) !== path)?.map((e, i) => {
const recursive = (data?: Recursive<T>[], parent?: T) => data?.filter(e => e.id !== id)?.map((e, i) => {
e.order = i;
e.children = recursive(e.children as T[], e);
return e;
@@ -30,9 +30,9 @@ export class Tree<T extends Omit<LocalContent, 'content'>>
}
insertAt(item: Recursive<T>, pos: number)
{
const parent = item.parent ? getPath(item.parent) : undefined;
const parent = item.parent ? item.parent.id : undefined;
const recursive = (data?: Recursive<T>[]) => data?.flatMap(e => {
if(getPath(e) === parent)
if(e.id === parent)
{
e.children = e.children ?? [];
e.children.splice(clamp(pos, 0, e.children.length), 0, item);
@@ -53,7 +53,7 @@ export class Tree<T extends Omit<LocalContent, 'content'>>
this._flatten = this.accumulate(e => e);
}
find(path: string): T | undefined
find(id: string): T | undefined
{
const recursive = (data?: Recursive<T>[]): T | undefined => {
if(!data)
@@ -61,7 +61,7 @@ export class Tree<T extends Omit<LocalContent, 'content'>>
for(const e of data)
{
if(getPath(e) === path)
if(e.id === id)
return e;
const result = recursive(e.children as T[]);
@@ -94,9 +94,9 @@ export class Tree<T extends Omit<LocalContent, 'content'>>
return recursive(this._data);
}
subset(path: string): Tree<T> | undefined
subset(id: string): Tree<T> | undefined
{
const subset = this.find(path);
const subset = this.find(id);
return subset ? new Tree([subset]) : undefined;
}
each(callback: (item: T, depth: number, parent?: T) => void)