Big rework to include OPFS API for local edits. Big components rework in vanilla JS to optimize. Unfinished, DO NOT SHIP YET !
This commit is contained in:
parent
f2d00097d6
commit
41c19b4bfb
137
app.vue
137
app.vue
|
|
@ -1,7 +1,6 @@
|
|||
<template>
|
||||
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
|
||||
<NuxtRouteAnnouncer/>
|
||||
<NuxtLoadingIndicator />
|
||||
<TooltipProvider>
|
||||
<NuxtLayout>
|
||||
<div class="xl:px-12 xl:py-8 lg:px-8 lg:py-6 px-6 py-3 flex flex-1 justify-center overflow-auto max-h-full relative">
|
||||
|
|
@ -14,8 +13,27 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Content } from './shared/content.util';
|
||||
import * as Floating from '#shared/floating.util';
|
||||
|
||||
provideToaster();
|
||||
|
||||
onBeforeMount(() => {
|
||||
Content.init();
|
||||
Floating.init();
|
||||
|
||||
const unmount = useRouter().afterEach((to, from, failure) => {
|
||||
if(failure) return;
|
||||
|
||||
document.querySelectorAll(`a[href="${from.path}"][data-active]`).forEach(e => e.classList.remove(e.getAttribute('data-active') ?? ''));
|
||||
document.querySelectorAll(`a[href="${to.path}"][data-active]`).forEach(e => e.classList.add(e.getAttribute('data-active') ?? ''));
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
unmount();
|
||||
})
|
||||
});
|
||||
|
||||
const { list } = useToast();
|
||||
</script>
|
||||
|
||||
|
|
@ -39,4 +57,121 @@ const { list } = useToast();
|
|||
@apply bg-light-50;
|
||||
@apply dark:bg-dark-50;
|
||||
}
|
||||
.callout[data-type="abstract"],
|
||||
.callout[data-type="summary"],
|
||||
.callout[data-type="tldr"]
|
||||
{
|
||||
@apply bg-light-cyan;
|
||||
@apply dark:bg-dark-cyan;
|
||||
@apply text-light-cyan;
|
||||
@apply dark:text-dark-cyan;
|
||||
}
|
||||
.callout[data-type="info"]
|
||||
{
|
||||
@apply bg-light-blue;
|
||||
@apply dark:bg-dark-blue;
|
||||
@apply text-light-blue;
|
||||
@apply dark:text-dark-blue;
|
||||
}
|
||||
.callout[data-type="todo"]
|
||||
{
|
||||
@apply bg-light-blue;
|
||||
@apply dark:bg-dark-blue;
|
||||
@apply text-light-blue;
|
||||
@apply dark:text-dark-blue;
|
||||
}
|
||||
.callout[data-type="important"]
|
||||
{
|
||||
@apply bg-light-cyan;
|
||||
@apply dark:bg-dark-cyan;
|
||||
@apply text-light-cyan;
|
||||
@apply dark:text-dark-cyan;
|
||||
}
|
||||
.callout[data-type="tip"],
|
||||
.callout[data-type="hint"]
|
||||
{
|
||||
@apply bg-light-cyan;
|
||||
@apply dark:bg-dark-cyan;
|
||||
@apply text-light-cyan;
|
||||
@apply dark:text-dark-cyan;
|
||||
}
|
||||
.callout[data-type="success"],
|
||||
.callout[data-type="check"],
|
||||
.callout[data-type="done"]
|
||||
{
|
||||
@apply bg-light-green;
|
||||
@apply dark:bg-dark-green;
|
||||
@apply text-light-green;
|
||||
@apply dark:text-dark-green;
|
||||
}
|
||||
.callout[data-type="question"],
|
||||
.callout[data-type="help"],
|
||||
.callout[data-type="faq"]
|
||||
{
|
||||
@apply bg-light-orange;
|
||||
@apply dark:bg-dark-orange;
|
||||
@apply text-light-orange;
|
||||
@apply dark:text-dark-orange;
|
||||
}
|
||||
.callout[data-type="warning"],
|
||||
.callout[data-type="caution"],
|
||||
.callout[data-type="attention"]
|
||||
{
|
||||
@apply bg-light-orange;
|
||||
@apply dark:bg-dark-orange;
|
||||
@apply text-light-orange;
|
||||
@apply dark:text-dark-orange;
|
||||
}
|
||||
.callout[data-type="failure"],
|
||||
.callout[data-type="fail"],
|
||||
.callout[data-type="missing"]
|
||||
{
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply text-light-red;
|
||||
@apply dark:text-dark-red;
|
||||
}
|
||||
.callout[data-type="danger"],
|
||||
.callout[data-type="error"]
|
||||
{
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply text-light-red;
|
||||
@apply dark:text-dark-red;
|
||||
}
|
||||
.callout[data-type="bug"]
|
||||
{
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply text-light-red;
|
||||
@apply dark:text-dark-red;
|
||||
}
|
||||
.callout[data-type="example"]
|
||||
{
|
||||
@apply bg-light-purple;
|
||||
@apply dark:bg-dark-purple;
|
||||
@apply text-light-purple;
|
||||
@apply dark:text-dark-purple;
|
||||
}
|
||||
.variant-cap
|
||||
{
|
||||
font-variant: small-caps;
|
||||
}
|
||||
.cm-editor
|
||||
{
|
||||
@apply bg-transparent;
|
||||
@apply flex-1 h-full;
|
||||
@apply font-sans;
|
||||
|
||||
@apply text-light-100 dark:text-dark-100;
|
||||
}
|
||||
.cm-editor .cm-content
|
||||
{
|
||||
@apply caret-light-100 dark:caret-dark-100;
|
||||
}
|
||||
.cm-line
|
||||
{
|
||||
@apply text-base;
|
||||
@apply font-sans;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -80,7 +80,6 @@ const viewport = computed<Box>(() => {
|
|||
const updateScaleVar = useDebounceFn(() => {
|
||||
if(transformRef.value)
|
||||
{
|
||||
console.log(zoom.value);
|
||||
transformRef.value.style.setProperty('--tw-scale', zoom.value.toString());
|
||||
}
|
||||
if(canvasRef.value)
|
||||
|
|
@ -100,10 +99,19 @@ const historyPos = ref(-1);
|
|||
const historyCursor = computed(() => history.value.length > 0 && historyPos.value > -1 ? history.value[historyPos.value] : undefined);
|
||||
|
||||
watch(props, () => {
|
||||
snapFinder = new SnapFinder(hints, viewport, { gridSize: 512, preferences: canvasSettings.value, threshold: 16, cellSize: 64 })
|
||||
canvas.value.nodes?.forEach((e) => snapFinder.update(e));
|
||||
snapFinder = new SnapFinder(hints, viewport, { gridSize: 512, preferences: canvasSettings.value, threshold: 16, cellSize: 64 });
|
||||
|
||||
canvas.value.nodes?.forEach((e) => {
|
||||
snapFinder.update(e);
|
||||
});
|
||||
|
||||
focusing.value = undefined;
|
||||
editing.value = undefined;
|
||||
|
||||
dispX.value = 0;
|
||||
dispY.value = 0;
|
||||
zoom.value = 0.5;
|
||||
|
||||
history.value = [];
|
||||
historyPos.value = -1;
|
||||
fakeEdge.value = {};
|
||||
|
|
@ -334,10 +342,10 @@ function edit(element: Element)
|
|||
}
|
||||
function createNode(e: MouseEvent)
|
||||
{
|
||||
let box = canvasRef.value?.getBoundingClientRect()!;
|
||||
const centerX = (viewportSize.right.value - viewportSize.left.value) / 2 + viewportSize.left.value, centerY = (viewportSize.bottom.value - viewportSize.top.value) / 2 + viewportSize.top.value;
|
||||
const width = 250, height = 100;
|
||||
const x = (e.layerX / zoom.value) - dispX.value - (width / 2);
|
||||
const y = (e.layerY / zoom.value) - dispY.value - (height / 2);
|
||||
const x = e.layerX / zoom.value - dispX.value - width / 2;
|
||||
const y = e.layerY / zoom.value - dispY.value - height / 2;
|
||||
const node: CanvasNode = { id: getID(16), x, y, width, height, type: 'text' };
|
||||
|
||||
if(!canvas.value.nodes)
|
||||
|
|
|
|||
|
|
@ -1,61 +1,205 @@
|
|||
<script lang="ts">
|
||||
import type { Editor, EditorConfiguration, EditorChange } from 'codemirror';
|
||||
import { fromTextArea } from 'hypermd';
|
||||
import { crosshairCursor, Decoration, dropCursor, EditorView, keymap, ViewPlugin, ViewUpdate, WidgetType, type DecorationSet } from '@codemirror/view';
|
||||
import { Annotation, EditorState, RangeValue, SelectionRange, type Range } from '@codemirror/state';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { bracketMatching, defaultHighlightStyle, foldKeymap, HighlightStyle, indentOnInput, syntaxHighlighting, syntaxTree } from '@codemirror/language';
|
||||
import { search, searchKeymap } from '@codemirror/search';
|
||||
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
|
||||
import { lintKeymap } from '@codemirror/lint';
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||
import { IterMode, Tree } from '@lezer/common';
|
||||
import { tags } from '@lezer/highlight';
|
||||
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' });
|
||||
const Blockquote = Decoration.line({ class: '*:hidden before:block !ps-4 relative before:absolute before:top-0 before:bottom-0 before:left-0 before:w-1 before:bg-none before:bg-light-30 dark:before:bg-dark-30' });
|
||||
|
||||
import '#shared/hypermd.extend';
|
||||
const TagTag = tags.special(tags.content);
|
||||
|
||||
const intersects = (a: {
|
||||
from: number;
|
||||
to: number;
|
||||
}, b: {
|
||||
from: number;
|
||||
to: number;
|
||||
}) => !(a.to < b.from || b.to < a.from);
|
||||
|
||||
const highlight = HighlightStyle.define([
|
||||
{ tag: tags.heading1, class: 'text-5xl pt-4 pb-2 after:hidden' },
|
||||
{ tag: tags.heading2, class: 'text-4xl pt-4 pb-2 ps-1 leading-loose after:hidden' },
|
||||
{ tag: tags.heading3, class: 'text-2xl font-bold pt-1 after:hidden' },
|
||||
{ tag: tags.heading4, class: 'text-xl font-semibold pt-1 after:hidden variant-cap' },
|
||||
{ tag: tags.meta, color: "#404740" },
|
||||
{ tag: tags.link, textDecoration: "underline" },
|
||||
{ tag: tags.heading, textDecoration: "underline", fontWeight: "bold" },
|
||||
{ tag: tags.emphasis, fontStyle: "italic" },
|
||||
{ tag: tags.strong, fontWeight: "bold" },
|
||||
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
||||
{ tag: tags.keyword, color: "#708" },
|
||||
{ tag: TagTag, class: 'cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30' }
|
||||
]);
|
||||
|
||||
class Decorator
|
||||
{
|
||||
static hiddenNodes: string[] = [
|
||||
'HardBreak',
|
||||
'LinkMark',
|
||||
'EmphasisMark',
|
||||
'CodeMark',
|
||||
'CodeInfo',
|
||||
'URL',
|
||||
]
|
||||
decorations: DecorationSet;
|
||||
constructor(view: EditorView)
|
||||
{
|
||||
this.decorations = Decoration.set(this.iterate(syntaxTree(view.state), view.visibleRanges, []), true);
|
||||
}
|
||||
update(update: ViewUpdate)
|
||||
{
|
||||
if(!update.docChanged && !update.viewportChanged && !update.selectionSet)
|
||||
return;
|
||||
|
||||
this.decorations = this.decorations.update({
|
||||
filter: (f, t, v) => false,
|
||||
add: this.iterate(syntaxTree(update.state), update.view.visibleRanges, update.state.selection.ranges),
|
||||
sort: true,
|
||||
});
|
||||
}
|
||||
iterate(tree: Tree, visible: readonly {
|
||||
from: number;
|
||||
to: number;
|
||||
}[], selection: readonly SelectionRange[]): Range<Decoration>[]
|
||||
{
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (let { from, to } of visible) {
|
||||
tree.iterate({
|
||||
from, to, mode: IterMode.IgnoreMounts,
|
||||
enter: node => {
|
||||
if(node.node.parent && selection.some(e => intersects(e, node.node.parent!)))
|
||||
return true;
|
||||
|
||||
else if(node.name === 'HeaderMark')
|
||||
decorations.push(Hidden.range(node.from, node.to + 1));
|
||||
|
||||
else if(Decorator.hiddenNodes.includes(node.name))
|
||||
decorations.push(Hidden.range(node.from, node.to));
|
||||
|
||||
else if(node.matchContext(['BulletList', 'ListItem']) && node.name === 'ListMark')
|
||||
decorations.push(Bullet.range(node.from, node.to + 1));
|
||||
|
||||
else if(node.matchContext(['Blockquote']))
|
||||
decorations.push(Blockquote.range(node.from, node.from));
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return decorations;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { placeholder, autofocus = false, gutters = true, format = 'd-any' } = defineProps<{
|
||||
const { autofocus = false } = defineProps<{
|
||||
placeholder?: string
|
||||
autofocus?: boolean
|
||||
gutters?: boolean
|
||||
format?: 'hypermd' | 'd-any'
|
||||
}>();
|
||||
const model = defineModel<string>();
|
||||
const editor = ref<Editor>();
|
||||
|
||||
const input = useTemplateRef('input');
|
||||
const editor = useTemplateRef('editor');
|
||||
const view = ref<EditorView>();
|
||||
|
||||
onMounted(() => {
|
||||
if(input.value)
|
||||
if(editor.value)
|
||||
{
|
||||
const e = editor.value = fromTextArea(input.value, {
|
||||
mode: {
|
||||
name: format,
|
||||
hashtag: true,
|
||||
toc: false,
|
||||
math: false,
|
||||
orgModeMarkup: false,
|
||||
tokenTypeOverrides: {
|
||||
hr: "line-HyperMD-hr line-background-HyperMD-hr-bg hr",
|
||||
list1: "list-1",
|
||||
list2: "list-2",
|
||||
list3: "list-3",
|
||||
code: "inline-code",
|
||||
hashtag: "hashtag meta"
|
||||
}
|
||||
},
|
||||
spellcheck: true,
|
||||
autofocus: autofocus,
|
||||
lineNumbers: false,
|
||||
showCursorWhenSelecting: true,
|
||||
indentUnit: 4,
|
||||
autoCloseBrackets: true,
|
||||
foldGutter: gutters,
|
||||
theme: 'custom'
|
||||
} as EditorConfiguration);
|
||||
view.value = new EditorView({
|
||||
doc: model.value,
|
||||
parent: editor.value,
|
||||
extensions: [
|
||||
markdown({
|
||||
base: markdownLanguage,
|
||||
extensions: {
|
||||
defineNodes: [
|
||||
{ name: "Tag", style: TagTag },
|
||||
{ name: "TagMark", style: tags.processingInstruction }
|
||||
],
|
||||
parseInline: [{
|
||||
name: "Tag",
|
||||
parse(cx, next, pos) {
|
||||
if (next != 35 || cx.char(pos + 1) == 35) return -1;
|
||||
let elts = [cx.elt("TagMark", pos, pos + 1)];
|
||||
for (let i = pos + 1; i < cx.end; i++) {
|
||||
let next = cx.char(i);
|
||||
if (next == 35)
|
||||
return cx.addElement(cx.elt("Tag", pos, i + 1, elts.concat(cx.elt("TagMark", i, i + 1))));
|
||||
if (next == 92)
|
||||
elts.push(cx.elt("Escape", i, i++ + 2));
|
||||
if (next == 32 || next == 9 || next == 10 || next == 13) break;
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}],
|
||||
}
|
||||
}),
|
||||
history(),
|
||||
search(),
|
||||
dropCursor(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
syntaxHighlighting(highlight),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
crosshairCursor(),
|
||||
EditorView.lineWrapping,
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...foldKeymap,
|
||||
...completionKeymap,
|
||||
...lintKeymap
|
||||
]),
|
||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||
if (viewUpdate.docChanged && !viewUpdate.transactions.some(tr => tr.annotation(External)))
|
||||
{
|
||||
model.value = viewUpdate.state.doc.toString();
|
||||
}
|
||||
}),
|
||||
EditorView.contentAttributes.of({spellcheck: "true"}),
|
||||
ViewPlugin.fromClass(Decorator, {
|
||||
decorations: e => e.decorations,
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
e.setValue(model.value ?? '');
|
||||
e.on('change', (cm: Editor, change: EditorChange) => model.value = cm.getValue());
|
||||
if(autofocus)
|
||||
{
|
||||
view.value.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (view.value)
|
||||
{
|
||||
view.value?.destroy();
|
||||
view.value = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
const value = editor.value?.getValue();
|
||||
if (editor.value && model.value !== value) {
|
||||
editor.value.setValue(model.value ?? '');
|
||||
editor.value.clearHistory();
|
||||
if (model.value === void 0) {
|
||||
return;
|
||||
}
|
||||
const currentValue = view.value ? view.value.state.doc.toString() : "";
|
||||
if (view.value && model.value !== currentValue) {
|
||||
view.value.dispatch({
|
||||
changes: { from: 0, to: currentValue.length, insert: model.value || "" },
|
||||
annotations: [External.of(true)],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -63,72 +207,5 @@ defineExpose({ focus: () => editor.value?.focus() });
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ 'cancel-gutters': !gutters }" class="flex flex-1 w-full justify-stretch items-stretch !font-sans !text-base">
|
||||
<textarea ref="input" class="hidden"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.CodeMirror
|
||||
{
|
||||
@apply bg-transparent;
|
||||
@apply flex-1 h-full;
|
||||
@apply font-sans;
|
||||
|
||||
@apply text-light-100 dark:text-dark-100;
|
||||
}
|
||||
.cancel-gutters .CodeMirror-gutters
|
||||
{
|
||||
@apply hidden;
|
||||
}
|
||||
.CodeMirror-sizer
|
||||
{
|
||||
@apply !px-3;
|
||||
}
|
||||
.cancel-gutters .CodeMirror-sizer
|
||||
{
|
||||
@apply ms-2;
|
||||
}
|
||||
.CodeMirror-gutters
|
||||
{
|
||||
@apply bg-transparent;
|
||||
@apply border-transparent;
|
||||
}
|
||||
.CodeMirror-gutter-wrapper
|
||||
{
|
||||
@apply absolute top-0 bottom-0;
|
||||
@apply flex justify-center items-center;
|
||||
}
|
||||
.CodeMirror-foldmarker
|
||||
{
|
||||
@apply text-light-100;
|
||||
@apply dark:text-dark-100;
|
||||
@apply ps-3;
|
||||
text-shadow: none;
|
||||
}
|
||||
.hmd-inactive-line .cm-formatting-header, .hmd-inactive-line .cm-formatting-link, .hmd-inactive-line .cm-link-has-alias, .hmd-inactive-line .cm-link-alias-pipe
|
||||
{
|
||||
@apply hidden;
|
||||
}
|
||||
.CodeMirror-line
|
||||
{
|
||||
@apply text-base;
|
||||
}
|
||||
.CodeMirror-cursor
|
||||
{
|
||||
@apply border-light-100 dark:border-dark-100;
|
||||
}
|
||||
.CodeMirror-selected
|
||||
{
|
||||
@apply bg-light-35 dark:bg-dark-35;
|
||||
}
|
||||
.HyperMD-list-line-1 {
|
||||
@apply !ps-0;
|
||||
}
|
||||
.HyperMD-list-line-2 {
|
||||
@apply !ps-6;
|
||||
}
|
||||
.HyperMD-list-line-3 {
|
||||
@apply !ps-12;
|
||||
}
|
||||
</style>
|
||||
<div ref="editor" class="flex flex-1 w-full justify-stretch items-stretch py-2 px-1.5 font-sans text-base"></div>
|
||||
</template>
|
||||
|
|
@ -1,27 +1,31 @@
|
|||
<template>
|
||||
<div v-if="content && content.length > 0">
|
||||
<ProsesRenderer #default v-if="data" :node="data" :proses="proses" />
|
||||
</div>
|
||||
<div ref="element"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
import { heading } from 'hast-util-heading';
|
||||
import { headingRank } from 'hast-util-heading-rank';
|
||||
import { parseId } from '~/shared/general.util';
|
||||
import type { Root } from 'hast';
|
||||
import { renderMarkdown } from '~/shared/markdown.util';
|
||||
import { tag, a, blockquote, h1, h2, h3, h4, h5, hr, li, small, table, td, th, type Prose } from '~/shared/proses';
|
||||
import { callout } from '../shared/proses';
|
||||
|
||||
const { content, proses, filter } = defineProps<{
|
||||
content: string
|
||||
proses?: Record<string, string | Component>
|
||||
proses?: Record<string, Prose>
|
||||
filter?: string
|
||||
}>();
|
||||
const element = useTemplateRef('element');
|
||||
|
||||
const parser = useMarkdown(), data = ref<Root>();
|
||||
const node = computed(() => content ? parser(content) : undefined);
|
||||
const node = computed(() => content ? parser.parseSync(content) : undefined);
|
||||
watch([node], () => {
|
||||
if(!node.value)
|
||||
{
|
||||
data.value = undefined;
|
||||
return;
|
||||
}
|
||||
else if(!filter)
|
||||
{
|
||||
data.value = node.value;
|
||||
|
|
@ -45,5 +49,32 @@ watch([node], () => {
|
|||
data.value = { ...node.value, children: node.value.children.slice(start, end) };
|
||||
}
|
||||
}
|
||||
|
||||
mount();
|
||||
}, { immediate: true, });
|
||||
|
||||
onMounted(() => {
|
||||
mount();
|
||||
})
|
||||
|
||||
function mount()
|
||||
{
|
||||
if(!element.value || !data.value)
|
||||
return;
|
||||
|
||||
const el = element.value!;
|
||||
|
||||
for(let i = el.childElementCount - 1; i >= 0; i--)
|
||||
{
|
||||
el.removeChild(el.children[i]);
|
||||
}
|
||||
|
||||
el.appendChild(renderMarkdown(data.value, { tag, a, blockquote, callout, h1, h2, h3, h4, h5, hr, li, small, table, td, th, ...proses }));
|
||||
|
||||
const hash = useRouter().currentRoute.value.hash;
|
||||
if(hash.length > 0)
|
||||
{
|
||||
document.getElementById(parseId(hash.substring(1))!)?.scrollIntoView({ behavior: 'instant' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
<div class="absolute" :style="{transform: `translate(${node.x}px, ${node.y}px)`, width: `${node.width}px`, height: `${node.height}px`, '--canvas-color': node.color?.hex}" :class="{'-z-10': node.type === 'group', 'z-10': node.type !== 'group'}">
|
||||
<div :class="[style.border]" class="outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full hover:outline-4">
|
||||
<div class="w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto" :class="style.bg">
|
||||
<div v-if="node.text?.length > 0" class="flex items-center">
|
||||
<div v-if="node.text && node.text.length > 0" class="flex items-center">
|
||||
<MarkdownRenderer :content="node.text" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<div class="absolute" ref="dom" :style="{transform: `translate(${node.x}px, ${node.y}px)`, width: `${node.width}px`, height: `${node.height}px`, '--canvas-color': node.color?.hex}" :class="{'-z-10': node.type === 'group', 'z-10': node.type !== 'group'}">
|
||||
<div v-if="!editing || node.type === 'group'" style="outline-style: solid;" :class="[style.border, style.outline, { '!outline-4 cursor-move': focusing }]" class="outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full hover:outline-4">
|
||||
<div class="w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto" :class="style.bg" @click.left="(e) => { if(node.type !== 'group') selectNode(e) }" @dblclick.left="(e) => { if(node.type !== 'group') editNode(e) }">
|
||||
<div v-if="node.text?.length > 0" class="flex items-center">
|
||||
<div v-if="node.text && node.text.length > 0" class="flex items-center">
|
||||
<MarkdownRenderer :content="node.text" :proses="{ a: FakeA }" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -127,7 +127,7 @@ function dragEdge(e: MouseEvent, direction: Direction) {
|
|||
function unselect() {
|
||||
if(editing.value)
|
||||
{
|
||||
const text = node.type === 'group' ? node.label : node.text;
|
||||
const text = node.type === 'group' ? node.label! : node.text!;
|
||||
|
||||
if(text !== oldText)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,28 +1,4 @@
|
|||
<script lang="ts">
|
||||
import { type Position } from '#shared/canvas.util';
|
||||
import { hasPermissions } from '~/shared/auth.util';
|
||||
|
||||
const cancelEvent = (e: Event) => e.preventDefault();
|
||||
function center(touches: TouchList): Position
|
||||
{
|
||||
const pos = { x: 0, y: 0 };
|
||||
|
||||
for(const touch of touches)
|
||||
{
|
||||
pos.x += touch.clientX;
|
||||
pos.y += touch.clientY;
|
||||
}
|
||||
|
||||
pos.x /= touches.length;
|
||||
pos.y /= touches.length;
|
||||
return pos;
|
||||
}
|
||||
function distance(touches: TouchList): number
|
||||
{
|
||||
const [A, B] = touches;
|
||||
return Math.hypot(B.clientX - A.clientX, B.clientY - A.clientY);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
stroke-light-red
|
||||
|
|
@ -90,9 +66,8 @@ dark:outline-dark-purple
|
|||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
import { clamp } from '#shared/general.util';
|
||||
import type { CanvasContent } from '~/types/content';
|
||||
import { Canvas } from '#shared/canvas.util';
|
||||
|
||||
const { path } = defineProps<{
|
||||
path: string
|
||||
|
|
@ -102,183 +77,31 @@ const { user } = useUserSession();
|
|||
const { content, get } = useContent();
|
||||
const overview = computed(() => content.value.find(e => e.path === path) as CanvasContent | undefined);
|
||||
const isOwner = computed(() => user.value?.id === overview.value?.owner);
|
||||
|
||||
const loading = ref(false);
|
||||
if(overview.value && !overview.value.content)
|
||||
{
|
||||
loading.value = true;
|
||||
await get(path);
|
||||
loading.value = false;
|
||||
}
|
||||
const canvas = computed(() => overview.value && overview.value.content ? overview.value.content : undefined);
|
||||
|
||||
const dispX = ref(0), dispY = ref(0), minZoom = ref(0.1), zoom = ref(0.5);
|
||||
const canvasRef = useTemplateRef('canvasRef'), transformRef = useTemplateRef('transformRef');
|
||||
|
||||
const reset = (_: MouseEvent) => {
|
||||
zoom.value = minZoom.value;
|
||||
|
||||
dispX.value = 0;
|
||||
dispY.value = 0;
|
||||
|
||||
updateTransform();
|
||||
}
|
||||
const element = useTemplateRef('element');
|
||||
|
||||
onMounted(() => {
|
||||
let lastX = 0, lastY = 0, lastDistance = 0;
|
||||
let box = canvasRef.value?.getBoundingClientRect()!;
|
||||
const dragMove = (e: MouseEvent) => {
|
||||
dispX.value = dispX.value - (lastX - e.clientX) / zoom.value;
|
||||
dispY.value = dispY.value - (lastY - e.clientY) / zoom.value;
|
||||
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
|
||||
updateTransform();
|
||||
};
|
||||
const dragEnd = (e: MouseEvent) => {
|
||||
window.removeEventListener('mouseup', dragEnd);
|
||||
window.removeEventListener('mousemove', dragMove);
|
||||
};
|
||||
canvasRef.value?.addEventListener('mouseenter', () => {
|
||||
window.addEventListener('wheel', cancelEvent, { passive: false });
|
||||
document.addEventListener('gesturestart', cancelEvent);
|
||||
document.addEventListener('gesturechange', cancelEvent);
|
||||
|
||||
canvasRef.value?.addEventListener('mouseleave', () => {
|
||||
window.removeEventListener('wheel', cancelEvent);
|
||||
document.removeEventListener('gesturestart', cancelEvent);
|
||||
document.removeEventListener('gesturechange', cancelEvent);
|
||||
});
|
||||
})
|
||||
window.addEventListener('resize', () => box = canvasRef.value?.getBoundingClientRect()!);
|
||||
canvasRef.value?.addEventListener('mousedown', (e) => {
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
|
||||
window.addEventListener('mouseup', dragEnd, { passive: true });
|
||||
window.addEventListener('mousemove', dragMove, { passive: true });
|
||||
}, { passive: true });
|
||||
canvasRef.value?.addEventListener('wheel', (e) => {
|
||||
if((zoom.value >= 3 && e.deltaY < 0) || (zoom.value <= minZoom.value && e.deltaY > 0))
|
||||
return;
|
||||
|
||||
const diff = Math.exp(e.deltaY * -0.001);
|
||||
const centerX = (box.x + box.width / 2), centerY = (box.y + box.height / 2);
|
||||
const mousex = centerX - e.clientX, mousey = centerY - e.clientY;
|
||||
|
||||
dispX.value = dispX.value - (mousex / (diff * zoom.value) - mousex / zoom.value);
|
||||
dispY.value = dispY.value - (mousey / (diff * zoom.value) - mousey / zoom.value);
|
||||
|
||||
zoom.value = clamp(zoom.value * diff, minZoom.value, 3)
|
||||
|
||||
updateTransform();
|
||||
}, { passive: true });
|
||||
canvasRef.value?.addEventListener('touchstart', (e) => {
|
||||
({ x: lastX, y: lastY } = center(e.touches));
|
||||
|
||||
if(e.touches.length > 1)
|
||||
{
|
||||
lastDistance = distance(e.touches);
|
||||
}
|
||||
|
||||
canvasRef.value?.addEventListener('touchend', touchend, { passive: true });
|
||||
canvasRef.value?.addEventListener('touchcancel', touchcancel, { passive: true });
|
||||
canvasRef.value?.addEventListener('touchmove', touchmove, { passive: true });
|
||||
}, { passive: true });
|
||||
const touchend = (e: TouchEvent) => {
|
||||
if(e.touches.length > 1)
|
||||
{
|
||||
({ x: lastX, y: lastY } = center(e.touches));
|
||||
}
|
||||
|
||||
canvasRef.value?.removeEventListener('touchend', touchend);
|
||||
canvasRef.value?.removeEventListener('touchcancel', touchcancel);
|
||||
canvasRef.value?.removeEventListener('touchmove', touchmove);
|
||||
};
|
||||
const touchcancel = (e: TouchEvent) => {
|
||||
if(e.touches.length > 1)
|
||||
{
|
||||
({ x: lastX, y: lastY } = center(e.touches));
|
||||
}
|
||||
|
||||
canvasRef.value?.removeEventListener('touchend', touchend);
|
||||
canvasRef.value?.removeEventListener('touchcancel', touchcancel);
|
||||
canvasRef.value?.removeEventListener('touchmove', touchmove);
|
||||
};
|
||||
const touchmove = (e: TouchEvent) => {
|
||||
const pos = center(e.touches);
|
||||
dispX.value = dispX.value - (lastX - pos.x) / zoom.value;
|
||||
dispY.value = dispY.value - (lastY - pos.y) / zoom.value;
|
||||
lastX = pos.x;
|
||||
lastY = pos.y;
|
||||
|
||||
if(e.touches.length === 2)
|
||||
{
|
||||
const dist = distance(e.touches);
|
||||
const diff = dist / lastDistance;
|
||||
|
||||
zoom.value = clamp(zoom.value * diff, minZoom.value, 3);
|
||||
}
|
||||
|
||||
updateTransform();
|
||||
};
|
||||
|
||||
updateTransform();
|
||||
mount();
|
||||
});
|
||||
|
||||
function updateTransform()
|
||||
if(overview.value && !overview.value.content)
|
||||
{
|
||||
if(transformRef.value)
|
||||
await get(path);
|
||||
mount();
|
||||
}
|
||||
|
||||
const canvas = computed(() => overview.value && overview.value.content ? overview.value.content : undefined);
|
||||
|
||||
function mount()
|
||||
{
|
||||
if(element.value && canvas.value)
|
||||
{
|
||||
transformRef.value.style.transform = `scale3d(${zoom.value}, ${zoom.value}, 1) translate3d(${dispX.value}px, ${dispY.value}px, 0)`;
|
||||
transformRef.value.style.setProperty('--tw-scale', zoom.value.toString());
|
||||
const c = new Canvas(canvas.value);
|
||||
element.value.appendChild(c.container);
|
||||
c.mount();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="canvasRef" class="absolute top-0 left-0 overflow-hidden w-full h-full touch-none" :style="{ '--zoom-multiplier': (1 / Math.pow(zoom, 0.7)) }">
|
||||
<div class="flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4">
|
||||
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
|
||||
<Tooltip message="Zoom avant" side="right">
|
||||
<div @click="zoom = clamp(zoom * 1.1, minZoom, 3); updateTransform()" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||
<Icon icon="radix-icons:plus" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip message="Reset" side="right">
|
||||
<div @click="zoom = 1; updateTransform();" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||
<Icon icon="radix-icons:reload" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip message="Tout contenir" side="right">
|
||||
<div @click="reset" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||
<Icon icon="radix-icons:corners" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip message="Zoom arrière" side="right">
|
||||
<div @click="zoom = clamp(zoom / 1.1, minZoom, 3); updateTransform()" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer">
|
||||
<Icon icon="radix-icons:minus" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<NuxtLink v-if="overview && isOwner || hasPermissions(user?.permissions ?? [], ['admin', 'editor'])" :to="{ name: 'explore-edit', hash: `#${encodeURIComponent(overview!.path)}` }" class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
|
||||
<Tooltip message="Modifier" side="right">
|
||||
<Icon icon="radix-icons:pencil-1" class="w-8 h-8 p-2" />
|
||||
</Tooltip>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div ref="transformRef" :style="{
|
||||
'transform-origin': 'center center',
|
||||
}" class="h-full">
|
||||
<div v-if="canvas" class="absolute top-0 left-0 w-full h-full pointer-events-none *:pointer-events-auto *:select-none touch-none">
|
||||
<div>
|
||||
<CanvasNode v-for="node of canvas.nodes" :key="node.id" ref="nodes" :node="node" :zoom="zoom" />
|
||||
</div>
|
||||
<div>
|
||||
<CanvasEdge v-for="edge of canvas.edges" :key="edge.id" ref="edges" :edge="edge" :nodes="canvas.nodes!" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="element"></div>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<NuxtLink class="text-accent-blue inline-flex items-center" :to="overview ? { name: 'explore-path', params: { path: overview.path }, hash: hash } : href" :class="class">
|
||||
<HoverCard nuxt-client class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': overview?.type === 'canvas'}" :disabled="!overview">
|
||||
<HoverCard nuxt-client class="min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': overview?.type === 'canvas'}" :disabled="!overview">
|
||||
<template #content>
|
||||
<Markdown v-if="overview?.type === 'markdown'" class="!px-6" :path="pathname" :filter="hash.substring(1)" popover />
|
||||
<template v-else-if="overview?.type === 'canvas'"><div class="w-[600px] h-[600px] relative"><Canvas :path="pathname" /></div></template>
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
<script setup lang="ts">
|
||||
import { parseURL } from 'ufo';
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
import { iconByType } from '#shared/general.util';
|
||||
import { iconByType } from '#shared/content.util';
|
||||
|
||||
const { href } = defineProps<{
|
||||
href: string
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ const defaultCalloutIcon = 'radix-icons:info-circled';
|
|||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
const { type, title, fold } = defineProps<{
|
||||
type: string;
|
||||
|
|
@ -43,104 +42,4 @@ const { type, title, fold } = defineProps<{
|
|||
fold?: boolean;
|
||||
}>();
|
||||
const disabled = computed(() => fold === undefined);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.callout[data-type="abstract"],
|
||||
.callout[data-type="summary"],
|
||||
.callout[data-type="tldr"]
|
||||
{
|
||||
@apply bg-light-cyan;
|
||||
@apply dark:bg-dark-cyan;
|
||||
@apply text-light-cyan;
|
||||
@apply dark:text-dark-cyan;
|
||||
}
|
||||
.callout[data-type="info"]
|
||||
{
|
||||
@apply bg-light-blue;
|
||||
@apply dark:bg-dark-blue;
|
||||
@apply text-light-blue;
|
||||
@apply dark:text-dark-blue;
|
||||
}
|
||||
.callout[data-type="todo"]
|
||||
{
|
||||
@apply bg-light-blue;
|
||||
@apply dark:bg-dark-blue;
|
||||
@apply text-light-blue;
|
||||
@apply dark:text-dark-blue;
|
||||
}
|
||||
.callout[data-type="important"]
|
||||
{
|
||||
@apply bg-light-cyan;
|
||||
@apply dark:bg-dark-cyan;
|
||||
@apply text-light-cyan;
|
||||
@apply dark:text-dark-cyan;
|
||||
}
|
||||
.callout[data-type="tip"],
|
||||
.callout[data-type="hint"]
|
||||
{
|
||||
@apply bg-light-cyan;
|
||||
@apply dark:bg-dark-cyan;
|
||||
@apply text-light-cyan;
|
||||
@apply dark:text-dark-cyan;
|
||||
}
|
||||
.callout[data-type="success"],
|
||||
.callout[data-type="check"],
|
||||
.callout[data-type="done"]
|
||||
{
|
||||
@apply bg-light-green;
|
||||
@apply dark:bg-dark-green;
|
||||
@apply text-light-green;
|
||||
@apply dark:text-dark-green;
|
||||
}
|
||||
.callout[data-type="question"],
|
||||
.callout[data-type="help"],
|
||||
.callout[data-type="faq"]
|
||||
{
|
||||
@apply bg-light-orange;
|
||||
@apply dark:bg-dark-orange;
|
||||
@apply text-light-orange;
|
||||
@apply dark:text-dark-orange;
|
||||
}
|
||||
.callout[data-type="warning"],
|
||||
.callout[data-type="caution"],
|
||||
.callout[data-type="attention"]
|
||||
{
|
||||
@apply bg-light-orange;
|
||||
@apply dark:bg-dark-orange;
|
||||
@apply text-light-orange;
|
||||
@apply dark:text-dark-orange;
|
||||
}
|
||||
.callout[data-type="failure"],
|
||||
.callout[data-type="fail"],
|
||||
.callout[data-type="missing"]
|
||||
{
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply text-light-red;
|
||||
@apply dark:text-dark-red;
|
||||
}
|
||||
.callout[data-type="danger"],
|
||||
.callout[data-type="error"]
|
||||
{
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply text-light-red;
|
||||
@apply dark:text-dark-red;
|
||||
}
|
||||
.callout[data-type="bug"]
|
||||
{
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply text-light-red;
|
||||
@apply dark:text-dark-red;
|
||||
}
|
||||
.callout[data-type="example"]
|
||||
{
|
||||
@apply bg-light-purple;
|
||||
@apply dark:bg-dark-purple;
|
||||
@apply text-light-purple;
|
||||
@apply dark:text-dark-purple;
|
||||
}
|
||||
|
||||
</style>
|
||||
</script>
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
<template>
|
||||
<span class="before:content-['#'] cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30">
|
||||
<slot></slot>
|
||||
</span>
|
||||
<HoverCard nuxt-client class="min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]">
|
||||
<template #content>
|
||||
<Markdown class="!px-6" path="tags" :filter="tag" popover />
|
||||
</template>
|
||||
<span class="before:content-['#'] cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</HoverCard>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
|
@ -13,4 +18,13 @@
|
|||
{
|
||||
@apply bg-accent-blue bg-opacity-10 text-accent-blue text-sm pb-0.5 pe-1 rounded-r-[12px] !rounded-se-none border border-l-0 border-accent-blue border-opacity-30;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { tag } = defineProps<{
|
||||
tag: string
|
||||
}>();
|
||||
|
||||
const { content } = useContent();
|
||||
const overview = computed(() => content.value.find(e => e.path === "tags"));
|
||||
</script>
|
||||
|
|
@ -1,40 +1,45 @@
|
|||
import { Content } from '~/shared/content.util';
|
||||
import type { ExploreContent, ContentComposable, TreeItem } from '~/types/content';
|
||||
|
||||
const useContentState = () => useState<ExploreContent[]>('content', () => []);
|
||||
|
||||
export function useContent(): ContentComposable {
|
||||
const contentState = useContentState();
|
||||
return {
|
||||
content: contentState,
|
||||
tree: computed(() => {
|
||||
const arr: TreeItem[] = [];
|
||||
for(const element of contentState.value)
|
||||
{
|
||||
addChild(arr, element);
|
||||
}
|
||||
return arr;
|
||||
}),
|
||||
fetch,
|
||||
get,
|
||||
}
|
||||
const contentState = useContentState();
|
||||
|
||||
return {
|
||||
content: contentState,
|
||||
tree: computed(() => {
|
||||
const arr: TreeItem[] = [];
|
||||
for(const element of contentState.value)
|
||||
{
|
||||
addChild(arr, element);
|
||||
}
|
||||
return arr;
|
||||
}),
|
||||
fetch,
|
||||
get,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetch(force: boolean) {
|
||||
async function fetch(force: boolean = false) {
|
||||
const content = useContentState();
|
||||
if(content.value.length === 0 || force)
|
||||
content.value = await useRequestFetch()('/api/file/overview');
|
||||
}
|
||||
|
||||
async function get(path: string) {
|
||||
async function get(path: string, force: boolean = false): Promise<ExploreContent | undefined> {
|
||||
const content = useContentState()
|
||||
const value = content.value;
|
||||
const item = value.find(e => e.path === path);
|
||||
if(item)
|
||||
|
||||
if(item && !item.content)
|
||||
{
|
||||
item.content = await useRequestFetch()(`/api/file/content/${encodeURIComponent(path)}`);
|
||||
}
|
||||
|
||||
content.value = value;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function addChild(arr: TreeItem[], e: ExploreContent): void {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,14 @@ import RemarkGfm from 'remark-gfm';
|
|||
import RemarkBreaks from 'remark-breaks';
|
||||
import RemarkFrontmatter from 'remark-frontmatter';
|
||||
|
||||
export default function useMarkdown(): (md: string) => Root
|
||||
interface Parser
|
||||
{
|
||||
let processor: Processor;
|
||||
parse: (md: string) => Promise<Root>;
|
||||
parseSync: (md: string) => Root
|
||||
}
|
||||
export default function useMarkdown(): Parser
|
||||
{
|
||||
let processor: Processor, processorSync: Processor;
|
||||
|
||||
const parse = (markdown: string) => {
|
||||
if (!processor)
|
||||
|
|
@ -19,9 +24,20 @@ export default function useMarkdown(): (md: string) => Root
|
|||
processor.use(RemarkRehype);
|
||||
}
|
||||
|
||||
const processed = processor.run(processor.parse(markdown)) as Promise<Root>;
|
||||
return processed;
|
||||
}
|
||||
|
||||
const parseSync = (markdown: string) => {
|
||||
if (!processor)
|
||||
{
|
||||
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter]);
|
||||
processor.use(RemarkRehype);
|
||||
}
|
||||
|
||||
const processed = processor.runSync(processor.parse(markdown)) as Root;
|
||||
return processed;
|
||||
}
|
||||
|
||||
return parse;
|
||||
return { parse, parseSync };
|
||||
}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
import type { UserSession, UserSessionComposable } from '~/types/auth'
|
||||
import { useContent } from './useContent'
|
||||
|
||||
const useSessionState = () => useState<UserSession>('nuxt-session', () => ({}))
|
||||
const useAuthReadyState = () => useState('nuxt-auth-ready', () => false)
|
||||
const useContentFetch = (force: boolean) => useContent().fetch(force);
|
||||
|
||||
/**
|
||||
* Composable to get back the user session and utils around it.
|
||||
|
|
|
|||
BIN
db.sqlite-shm
BIN
db.sqlite-shm
Binary file not shown.
BIN
db.sqlite-wal
BIN
db.sqlite-wal
Binary file not shown.
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<CollapsibleRoot class="flex flex-1 flex-col" v-model:open="open">
|
||||
<div class="z-50 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2">
|
||||
<div class="z-40 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2">
|
||||
<div class="flex items-center px-2 gap-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button icon class="!bg-transparent group md:hidden">
|
||||
|
|
@ -29,34 +29,22 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-row relative h-screen overflow-hidden">
|
||||
<CollapsibleContent asChild forceMount>
|
||||
<!-- <CollapsibleContent asChild forceMount> -->
|
||||
<div class="bg-light-0 dark:bg-dark-0 z-40 w-screen md:w-[18rem] border-r border-light-30 dark:border-dark-30 flex flex-col justify-between my-2 max-md:data-[state=closed]:hidden">
|
||||
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden" ref="treeParent">
|
||||
<div class="flex flex-row flex-1 justify-between items-center py-4 px-2">
|
||||
<NuxtLink :href="{ name: 'explore-path', params: { path: 'index' } }" class="flex flex-1 font-bold text-lg items-center border-light-35 dark:border-dark-35 hover:border-accent-blue" active-class="text-accent-blue border-s-2 !border-accent-blue">
|
||||
<span class="pl-3 py-1 flex-1 truncate">Projet</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="user && hasPermissions(user.permissions, ['admin', 'editor'])" :to="{ name: 'explore-edit' }"><Button icon><Icon icon="radix-icons:pencil-2" /></Button></NuxtLink>
|
||||
</div>
|
||||
<Tree v-if="pages" v-model="pages" :getKey="(item) => item.path" class="ps-4">
|
||||
<template #default="{ item, isExpanded }">
|
||||
<NuxtLink :href="item.value.path && !item.hasChildren ? { name: 'explore-path', params: { path: item.value.path } } : undefined" class="flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full" :class="{ 'font-medium': item.hasChildren }" active-class="text-accent-blue" :data-private="item.value.private">
|
||||
<Icon v-if="item.hasChildren" icon="radix-icons:chevron-right" :class="{ 'rotate-90': isExpanded }" class="h-4 w-4 transition-transform absolute" :style="{ 'left': `${item.level / 2 - 1.5}em` }" />
|
||||
<Icon v-else-if="iconByType[item.value.type]" :icon="iconByType[item.value.type]" class="w-5 h-5" />
|
||||
<div class="pl-1.5 py-1.5 flex-1 truncate">
|
||||
{{ item.value.title }}
|
||||
</div>
|
||||
<Tooltip message="Privé" side="right"><Icon v-show="item.value.private" class="mx-1" icon="radix-icons:lock-closed" /></Tooltip>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</Tree>
|
||||
</div>
|
||||
<div class="xl:px-12 px-6 pt-4 pb-2 text-center text-xs text-light-60 dark:text-dark-60">
|
||||
<NuxtLink class="hover:underline italic" :to="{ name: 'roadmap' }">Roadmap</NuxtLink> - <NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
|
||||
<p>Copyright Peaceultime - 2025</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
<!-- </CollapsibleContent> -->
|
||||
<slot></slot>
|
||||
</div>
|
||||
</CollapsibleRoot>
|
||||
|
|
@ -64,10 +52,13 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
import { iconByType } from '#shared/general.util';
|
||||
import type { DropdownOption } from '~/components/base/DropdownMenu.vue';
|
||||
import { hasPermissions } from '~/shared/auth.util';
|
||||
import type { TreeItem } from '~/types/content';
|
||||
import { hasPermissions } from '#shared/auth.util';
|
||||
import { TreeDOM } from '#shared/tree';
|
||||
import { Content, iconByType } from '#shared/content.util';
|
||||
import { dom, icon, text } from '#shared/dom.util';
|
||||
import { popper } from '#shared/floating.util';
|
||||
import { link } from '#shared/proses';
|
||||
|
||||
const options = ref<DropdownOption[]>([{
|
||||
type: 'item',
|
||||
|
|
@ -86,16 +77,43 @@ const { fetch } = useContent();
|
|||
await fetch(false);
|
||||
|
||||
const route = useRouter().currentRoute;
|
||||
const path = computed(() => route.value.params.path ? Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path : undefined);
|
||||
const path = computed(() => route.value.params.path ? decodeURIComponent(Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path) : undefined);
|
||||
|
||||
await Content.init();
|
||||
const 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 } }, [
|
||||
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 } }),
|
||||
item.private ? popper(dom('span', { class: 'flex' }, [icon('radix-icons:lock-closed', { class: 'mx-1' })]), { 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' }) : undefined,
|
||||
])]);
|
||||
}, (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` } }, [link({ class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full'], attributes: { 'data-private': item.private }, active: 'text-accent-blue' }, item.path ? { name: 'explore-path', params: { path: item.path } } : undefined, [
|
||||
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 } }),
|
||||
item.private ? popper(dom('span', { class: 'flex' }, [icon('radix-icons:lock-closed', { class: 'mx-1' })]), { 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' }) : undefined,
|
||||
])]);
|
||||
}, (item) => item.navigable);
|
||||
(path.value?.split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree.toggle(e, true));
|
||||
const treeParent = useTemplateRef('treeParent');
|
||||
|
||||
const unmount = useRouter().afterEach((to, from, failure) => {
|
||||
if(failure)
|
||||
return;
|
||||
|
||||
to.name === 'explore-path' && ((to.params.path as string).split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree.toggle(e, true));
|
||||
});
|
||||
|
||||
watch(route, () => {
|
||||
open.value = false;
|
||||
});
|
||||
|
||||
const { tree } = useContent();
|
||||
const pages = computed(() => transform(tree.value));
|
||||
function transform(list: TreeItem[] | undefined): TreeItem[] | undefined
|
||||
{
|
||||
return list?.filter(e => e.navigable)?.map(e => ({ ...e, open: path.value?.startsWith(e.path), children: transform(e.children) }));
|
||||
}
|
||||
onMounted(() => {
|
||||
if(treeParent.value)
|
||||
{
|
||||
treeParent.value.appendChild(tree.container);
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
unmount();
|
||||
})
|
||||
</script>
|
||||
|
|
@ -16,6 +16,11 @@ export default defineNuxtConfig({
|
|||
tailwindcss: {
|
||||
viewer: false,
|
||||
config: {
|
||||
content: {
|
||||
files: [
|
||||
"./shared/**/*.{vue,js,jsx,mjs,ts,tsx}"
|
||||
]
|
||||
},
|
||||
theme: {
|
||||
extend: {
|
||||
boxShadow: {
|
||||
|
|
@ -114,6 +119,7 @@ export default defineNuxtConfig({
|
|||
pageTransition: false,
|
||||
layoutTransition: false
|
||||
},
|
||||
ssr: false,
|
||||
components: [
|
||||
{
|
||||
path: '~/components',
|
||||
|
|
@ -135,6 +141,7 @@ export default defineNuxtConfig({
|
|||
runtimeConfig: {
|
||||
session: {
|
||||
password: '699c46bd-9aaa-4364-ad01-510ee4fe7013',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
},
|
||||
database: 'db.sqlite',
|
||||
mail: {
|
||||
|
|
@ -177,5 +184,10 @@ export default defineNuxtConfig({
|
|||
key: fs.readFileSync(path.resolve(__dirname, 'localhost+1-key.pem')).toString('utf-8'),
|
||||
cert: fs.readFileSync(path.resolve(__dirname, 'localhost+1.pem')).toString('utf-8'),
|
||||
}
|
||||
},
|
||||
vue: {
|
||||
compilerOptions: {
|
||||
isCustomElement: (tag) => tag === 'iconify-icon',
|
||||
}
|
||||
}
|
||||
})
|
||||
33
package.json
33
package.json
|
|
@ -7,30 +7,31 @@
|
|||
"dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 bunx --bun nuxi dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.5.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.5.2",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
||||
"@codemirror/lang-markdown": "^6.3.2",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@markdoc/markdoc": "^0.5.1",
|
||||
"@nuxtjs/color-mode": "^3.5.2",
|
||||
"@nuxtjs/sitemap": "^7.2.5",
|
||||
"@nuxtjs/tailwindcss": "^6.13.1",
|
||||
"@types/codemirror": "^5.60.15",
|
||||
"@nuxtjs/sitemap": "^7.2.9",
|
||||
"@nuxtjs/tailwindcss": "^6.13.2",
|
||||
"@vueuse/gesture": "^2.0.0",
|
||||
"@vueuse/math": "^12.7.0",
|
||||
"@vueuse/nuxt": "^12.7.0",
|
||||
"codemirror": "5.65.18",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"@vueuse/math": "^13.0.0",
|
||||
"@vueuse/nuxt": "^13.0.0",
|
||||
"codemirror": "^6.0.1",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"hast": "^1.0.0",
|
||||
"hast-util-heading": "^3.0.0",
|
||||
"hast-util-heading-rank": "^3.0.0",
|
||||
"hypermd": "^0.3.11",
|
||||
"iconify-icon": "^2.3.0",
|
||||
"lodash.capitalize": "^4.2.1",
|
||||
"mdast-util-find-and-replace": "^3.0.2",
|
||||
"nodemailer": "^6.10.0",
|
||||
"nuxt": "3.15.4",
|
||||
"nuxt-security": "^2.1.5",
|
||||
"radix-vue": "^1.9.15",
|
||||
"nuxt": "3.16.1",
|
||||
"nuxt-security": "^2.2.0",
|
||||
"radix-vue": "^1.9.17",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
|
|
@ -47,13 +48,13 @@
|
|||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.2",
|
||||
"@types/bun": "^1.2.8",
|
||||
"@types/lodash.capitalize": "^4.2.9",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/unist": "^3.0.3",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"bun-types": "^1.2.2",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"bun-types": "^1.2.7",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"rehype-stringify": "^10.0.1"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@
|
|||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { format, iconByType } from '~/shared/general.util';
|
||||
import { format } from '~/shared/general.util';
|
||||
import { iconByType } from '~/shared/content.util';
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
|
||||
interface File
|
||||
|
|
|
|||
|
|
@ -1,24 +1,23 @@
|
|||
<template>
|
||||
<div class="flex flex-1 justify-start items-start" v-if="overview">
|
||||
<div class="flex flex-1 justify-start items-start" ref="element">
|
||||
<Head>
|
||||
<Title>d[any] - {{ overview.title }}</Title>
|
||||
<Title>d[any] - {{ overview?.title ?? "Erreur" }}</Title>
|
||||
</Head>
|
||||
<Markdown v-if="overview.type === 'markdown'" :path="path" />
|
||||
<Canvas v-else-if="overview.type === 'canvas'" :path="path" />
|
||||
<ProseH2 v-else class="flex-1 text-center">Impossible d'afficher le contenu demandé</ProseH2>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Head>
|
||||
<Title>d[any] - Erreur</Title>
|
||||
</Head>
|
||||
<div><ProseH2>Impossible d'afficher le contenu demandé</ProseH2></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Content } from '~/shared/content.util';
|
||||
|
||||
const element = useTemplateRef('element'), overview = ref();
|
||||
const route = useRouter().currentRoute;
|
||||
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
|
||||
|
||||
const { content } = useContent();
|
||||
const overview = computed(() => content.value.find(e => e.path === path.value));
|
||||
onMounted(async () => {
|
||||
if(element.value && path.value)
|
||||
{
|
||||
await Content.init()
|
||||
overview.value = Content.render(element.value, path.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
@ -2,228 +2,81 @@
|
|||
<Head>
|
||||
<Title>d[any] - Modification</Title>
|
||||
</Head>
|
||||
<ClientOnly>
|
||||
<CollapsibleRoot asChild class="flex flex-1 flex-col xl:-mx-12 xl:-my-8 lg:-mx-8 lg:-my-6 -mx-6 -my-3 overflow-hidden" v-model="open">
|
||||
<div>
|
||||
<div class="z-50 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2">
|
||||
<div class="flex items-center px-2 gap-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button icon class="!bg-transparent group md:hidden">
|
||||
<Icon class="group-data-[state=open]:hidden" icon="radix-icons:hamburger-menu" />
|
||||
<Icon class="group-data-[state=closed]:hidden" icon="radix-icons:cross-1" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-opacity-70 m-2 flex items-center gap-4" aria-label="Accueil" :to="{ path: '/', force: true }">
|
||||
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
|
||||
<Avatar src="/logo.light.svg" class="block dark:hidden" />
|
||||
<span class="text-xl max-md:hidden">d[any]</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="flex items-center px-2 gap-4">
|
||||
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">{{ user!.username }}</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-row relative overflow-hidden">
|
||||
<CollapsibleContent asChild forceMount>
|
||||
<div class="bg-light-0 dark:bg-dark-0 z-40 w-screen md:w-[18rem] border-r border-light-30 dark:border-dark-30 flex flex-col justify-between my-2 max-md:data-[state=closed]:hidden">
|
||||
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex flex-row justify-between items-center pt-2 pb-4 mb-2 px-2 gap-4 border-b border-light-35 dark:border-dark-35">
|
||||
<Button @click="router.push({ name: 'explore-path', params: { path: selected ? getPath(selected) : 'index' } })">Quitter</Button>
|
||||
<Button @click="save(true);">Enregistrer</Button>
|
||||
<Tooltip side="top" message="Nouveau">
|
||||
<DropdownMenu align="end" side="bottom" :options="[{
|
||||
type: 'item',
|
||||
label: 'Markdown',
|
||||
kbd: 'Ctrl+N',
|
||||
icon: 'radix-icons:file-text',
|
||||
select: () => add('markdown'),
|
||||
}, {
|
||||
type: 'item',
|
||||
label: 'Dossier',
|
||||
kbd: 'Ctrl+Shift+N',
|
||||
icon: 'lucide:folder',
|
||||
select: () => add('folder'),
|
||||
}, {
|
||||
type: 'item',
|
||||
label: 'Canvas',
|
||||
icon: 'ph:graph-light',
|
||||
select: () => add('canvas'),
|
||||
}, {
|
||||
type: 'item',
|
||||
label: 'Carte',
|
||||
icon: 'lucide:map',
|
||||
select: () => add('map'),
|
||||
}, {
|
||||
type: 'item',
|
||||
label: 'Fichier',
|
||||
icon: 'radix-icons:file',
|
||||
select: () => add('file'),
|
||||
}]">
|
||||
<Button icon><Icon class="w-5 h-5" icon="radix-icons:plus" /></Button>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<DraggableTree class="ps-4 text-sm" :items="navigation ?? undefined" :get-key="(item: Partial<TreeItemEditable>) => item.path !== undefined ? getPath(item as TreeItemEditable) : ''" @updateTree="drop"
|
||||
v-model="selected" :defaultExpanded="defaultExpanded" :get-children="(item: Partial<TreeItemEditable>) => item.type === 'folder' ? item.children : undefined" >
|
||||
<template #default="{ handleToggle, handleSelect, isExpanded, isDragging, item }">
|
||||
<div class="flex flex-1 items-center overflow-hidden" :class="{ 'opacity-50': isDragging }" :style="{ 'padding-left': `${item.level / 2 - 0.5}em` }">
|
||||
<div class="flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple group-data-[selected]:text-accent-blue">
|
||||
<Icon @click="handleToggle" v-if="item.hasChildren" icon="radix-icons:chevron-right" :class="{ 'rotate-90': isExpanded }" class="h-4 w-4 transition-transform absolute" :style="{ 'left': `${item.level / 2 - 1.5}em` }" />
|
||||
<Icon v-else-if="iconByType[item.value.type]" :icon="iconByType[item.value.type]" class="w-5 h-5" @click="handleSelect" />
|
||||
<div class="pl-1.5 py-1.5 flex-1 truncate" :title="item.value.title" @click="handleSelect" :class="{ 'font-semibold': item.hasChildren }">
|
||||
{{ item.value.title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span @click="item.value.private = !item.value.private">
|
||||
<Icon v-if="item.value.private" icon="radix-icons:lock-closed" />
|
||||
<Icon v-else class="text-light-50 dark:text-dark-50" icon="radix-icons:lock-open-2" />
|
||||
</span>
|
||||
<span @click="item.value.navigable = !item.value.navigable">
|
||||
<Icon v-if="item.value.navigable" icon="radix-icons:eye-open" />
|
||||
<Icon v-else class="text-light-50 dark:text-dark-50" icon="radix-icons:eye-none" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #hint="{ instruction }">
|
||||
<div v-if="instruction" class="absolute h-full w-full top-0 right-0 border-light-50 dark:border-dark-50" :style="{
|
||||
width: `calc(100% - ${instruction.currentLevel / 2 - 1.5}em)`
|
||||
}" :class="{
|
||||
'!border-b-4': instruction?.type === 'reorder-below',
|
||||
'!border-t-4': instruction?.type === 'reorder-above',
|
||||
'!border-4': instruction?.type === 'make-child',
|
||||
}"></div>
|
||||
</template>
|
||||
</DraggableTree>
|
||||
</div>
|
||||
<div class="xl:px-12 px-6 pt-4 pb-2 text-center text-xs text-light-60 dark:text-dark-60">
|
||||
<NuxtLink class="hover:underline italic" :to="{ name: 'roadmap' }">Roadmap</NuxtLink> - <NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
|
||||
<p>Copyright Peaceultime - 2025</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
<div class="flex flex-1 flex-row max-h-full overflow-hidden">
|
||||
<div v-if="selected" class="flex flex-1 flex-col items-start justify-start max-h-full relative">
|
||||
<Head>
|
||||
<Title>d[any] - Modification de {{ selected.title }}</Title>
|
||||
</Head>
|
||||
<CollapsibleRoot v-model:open="topOpen" class="group data-[state=open]:mt-4 w-full relative">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button class="absolute left-1/2 -translate-x-1/2 group-data-[state=open]:-bottom-3 group-data-[state=closed]:-bottom-6 z-30" icon>
|
||||
<Icon v-if="topOpen" icon="radix-icons:caret-up" class="h-4 w-4" />
|
||||
<Icon v-else icon="radix-icons:caret-down" class="h-4 w-4" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent class="xl:px-12 lg:px-8 px-6">
|
||||
<div class="pb-2 grid lg:grid-cols-2 grid-cols-1 lg:items-center justify-between gap-x-4 flex-1 border-b border-light-35 dark:border-dark-35">
|
||||
<input type="text" v-model="selected.title" @input="() => {
|
||||
if(selected && !selected.customPath)
|
||||
{
|
||||
selected.name = parsePath(selected.title);
|
||||
rebuildPath(selected.children, getPath(selected));
|
||||
}
|
||||
}" placeholder="Titre" style="line-height: normal;" class="flex-1 md:text-5xl text-4xl md:h-14 h-12 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 appearance-none outline-none pb-3 font-thin bg-transparent"/>
|
||||
<div class="flex flex-row justify-between items-center gap-x-4">
|
||||
<div v-if="selected.customPath" class="flex lg:items-center truncate">
|
||||
<pre class="md:text-base text-sm truncate" style="direction: rtl">/{{ selected.parent !== '' ? selected.parent + '/' : '' }}</pre>
|
||||
<TextInput v-model="selected.name" @input="(e: Event) => {
|
||||
if(selected && selected.customPath)
|
||||
{
|
||||
selected.name = parsePath(selected.name);
|
||||
rebuildPath(selected.children, getPath(selected));
|
||||
}
|
||||
}" class="mx-0 font-mono"/>
|
||||
</div>
|
||||
<pre v-else class="md:text-base text-sm truncate" style="direction: rtl">{{ getPath(selected) }}/</pre>
|
||||
<div class="flex gap-4">
|
||||
<Dialog :title="`Supprimer '${selected.title}'${selected.children?.length ?? 0 > 0 ? ' et ses enfants' : ''}`">
|
||||
<template #trigger><Button icon class="bg-light-red dark:bg-dark-red !bg-opacity-40 border-light-red dark:border-dark-red hover:bg-light-red dark:hover:bg-dark-red hover:!bg-opacity-70 hover:border-light-red dark:hover:border-dark-red"><Icon icon="radix-icons:trash" /></Button></template>
|
||||
<template #default>
|
||||
<div class="flex gap-4">
|
||||
<DialogClose><Button @click="navigation = tree.remove(navigation, getPath(selected)); selected = undefined;" class="bg-light-red dark:bg-dark-red !bg-opacity-40 border-light-red dark:border-dark-red hover:bg-light-red dark:hover:bg-dark-red hover:!bg-opacity-70 hover:border-light-red dark:hover:border-dark-red">Oui</Button></DialogClose>
|
||||
<DialogClose><Button>Non</Button></DialogClose>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<Dialog title="Préférences Markdown" v-if="selected.type === 'markdown'">
|
||||
<template #trigger><Button icon><Icon icon="radix-icons:gear" /></Button></template>
|
||||
<template #default>
|
||||
<Select label="Editeur de markdown" :modelValue="preferences.markdown.editing" @update:model-value="v => preferences.markdown.editing = (v as 'reading' | 'editing' | 'split')">
|
||||
<SelectItem label="Mode lecture" value="reading" />
|
||||
<SelectItem label="Mode edition" value="editing" />
|
||||
<SelectItem label="Ecran partagé" value="split" />
|
||||
</Select>
|
||||
</template>
|
||||
</Dialog>
|
||||
<DropdownMenu align="end" :options="[{
|
||||
type: 'checkbox',
|
||||
label: 'URL custom',
|
||||
select: (state: boolean) => { selected!.customPath = state; if(!state) selected!.name = parsePath(selected!.title) },
|
||||
checked: selected.customPath
|
||||
}]">
|
||||
<Button icon><Icon icon="radix-icons:dots-vertical"/></Button>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
<div class="py-4 flex-1 w-full max-h-full flex overflow-hidden xl:px-12 lg:px-8 px-6 relative">
|
||||
<template v-if="selected.type === 'markdown'">
|
||||
<div v-if="contentStatus === 'pending'" class="flex flex-1 justify-center items-center">
|
||||
<Loading />
|
||||
</div>
|
||||
<span v-else-if="contentError">{{ contentError }}</span>
|
||||
<template v-else-if="preferences.markdown.editing === 'editing'">
|
||||
<Editor v-model="selected.content" autofocus class="flex-1 bg-transparent appearance-none outline-none max-h-full resize-none !overflow-y-auto lg:mx-16 xl:mx-32 2xl:mx-64" />
|
||||
</template>
|
||||
<template v-else-if="preferences.markdown.editing === 'reading'">
|
||||
<div class="flex-1 max-h-full !overflow-y-auto px-4 xl:px-32 2xl:px-64"><MarkdownRenderer :content="(debounced as string)" :proses="{ 'a': FakeA }" /></div>
|
||||
</template>
|
||||
<template v-else-if="preferences.markdown.editing === 'split'">
|
||||
<SplitterGroup direction="horizontal" class="flex-1 w-full flex">
|
||||
<SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }" :defaultSize="50">
|
||||
<Editor v-model="selected.content" autofocus class="flex-1 bg-transparent appearance-none outline-none max-h-full resize-none !overflow-y-auto" :class="{ 'hidden': isCollapsed }" />
|
||||
</SplitterPanel>
|
||||
<SplitterResizeHandle class="bg-light-35 dark:bg-dark-35 w-px xl!mx-4 mx-2" />
|
||||
<SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }">
|
||||
<div class="flex-1 max-h-full !overflow-y-auto px-8" :class="{ 'hidden': isCollapsed }"><MarkdownRenderer :content="(debounced as string)" :proses="{ 'a': FakeA }" /></div>
|
||||
</SplitterPanel>
|
||||
</SplitterGroup>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="selected.type === 'canvas'">
|
||||
<CanvasEditor v-if="selected.content" :modelValue="selected.content" :path="getPath(selected)" />
|
||||
</template>
|
||||
<template v-else-if="selected.type === 'map'">
|
||||
<span class="flex flex-1 justify-center items-center"><ProseH3>Editeur de carte en cours de développement</ProseH3></span>
|
||||
</template>
|
||||
<template v-else-if="selected.type === 'file'">
|
||||
<span>Modifier le contenu :</span><input type="file" @change="(e: Event) => console.log((e.target as HTMLInputElement).files?.length)" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col xl:-mx-12 xl:-my-8 lg:-mx-8 lg:-my-6 -mx-6 -my-3 overflow-hidden">
|
||||
<div class="z-50 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2">
|
||||
<div class="flex items-center px-2 gap-4">
|
||||
<!-- <CollapsibleTrigger asChild>
|
||||
<Button icon class="!bg-transparent group md:hidden">
|
||||
<Icon class="group-data-[state=open]:hidden" icon="radix-icons:hamburger-menu" />
|
||||
<Icon class="group-data-[state=closed]:hidden" icon="radix-icons:cross-1" />
|
||||
</Button>
|
||||
</CollapsibleTrigger> -->
|
||||
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-opacity-70 m-2 flex items-center gap-4" aria-label="Accueil" :to="{ path: '/', force: true }">
|
||||
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
|
||||
<Avatar src="/logo.light.svg" class="block dark:hidden" />
|
||||
<span class="text-xl max-md:hidden">d[any]</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="flex items-center px-2 gap-4">
|
||||
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">{{ user!.username }}</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-row relative h-screen overflow-hidden">
|
||||
<div class="bg-light-0 dark:bg-dark-0 z-40 w-screen md:w-[18rem] border-r border-light-30 dark:border-dark-30 flex flex-col justify-between my-2 max-md:data-[state=closed]:hidden">
|
||||
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden" ref="tree"></div>
|
||||
<div class="xl:px-12 px-6 pt-4 pb-2 text-center text-xs text-light-60 dark:text-dark-60">
|
||||
<NuxtLink class="hover:underline italic" :to="{ name: 'roadmap' }">Roadmap</NuxtLink> - <NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
|
||||
<p>Copyright Peaceultime - 2025</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleRoot>
|
||||
</ClientOnly>
|
||||
<div class="flex flex-1 flex-row max-h-full overflow-hidden" ref="container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/tree-item';
|
||||
import { iconByType, convertContentFromText, convertContentToText, DEFAULT_CONTENT,parsePath } from '#shared/general.util';
|
||||
import type { ExploreContent, FileType, TreeItem } from '~/types/content';
|
||||
import FakeA from '~/components/prose/FakeA.vue';
|
||||
import type { Preferences } from '~/types/general';
|
||||
import { Content, Editor } from '#shared/content.util';
|
||||
import { loading } from '#shared/proses';
|
||||
|
||||
definePageMeta({
|
||||
rights: ['admin', 'editor'],
|
||||
layout: 'null',
|
||||
});
|
||||
|
||||
export type TreeItemEditable = TreeItem &
|
||||
const { user } = useUserSession();
|
||||
const tree = useTemplateRef('tree'), container = useTemplateRef('container');
|
||||
let editor: Editor;
|
||||
|
||||
onMounted(async () => {
|
||||
if(tree.value && container.value && await Content.ready)
|
||||
{
|
||||
const load = loading('normal');
|
||||
tree.value.appendChild(load);
|
||||
|
||||
editor = new Editor();
|
||||
|
||||
tree.value.replaceChild(editor.tree.container, load);
|
||||
container.value.appendChild(editor.container);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor?.unmount();
|
||||
})
|
||||
/* import { Icon } from '@iconify/vue/dist/iconify.js';
|
||||
import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/tree-item';
|
||||
import type { FileType, LocalContent, TreeItem } from '#shared/content.util';
|
||||
import { DEFAULT_CONTENT, iconByType, Content, getPath } from '#shared/content.util';
|
||||
import type { Preferences } from '~/types/general';
|
||||
import { fakeA as proseA } from '#shared/proses';
|
||||
import { parsePath } from '~/shared/general.util';
|
||||
import type { CanvasContent } from '~/types/canvas';
|
||||
|
||||
export type TreeItemEditable = LocalContent &
|
||||
{
|
||||
parent: string;
|
||||
name: string;
|
||||
customPath: boolean;
|
||||
parent?: string;
|
||||
name?: string;
|
||||
customPath?: boolean;
|
||||
children?: TreeItemEditable[];
|
||||
}
|
||||
|
||||
|
|
@ -240,225 +93,73 @@ const open = ref(true), topOpen = ref(true);
|
|||
const toaster = useToast();
|
||||
const saveStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle');
|
||||
|
||||
const { content: complete, tree: project } = useContent();
|
||||
const navigation = ref<TreeItemEditable[]>(transform(JSON.parse(JSON.stringify(project.value)))!);
|
||||
const selected = ref<TreeItemEditable>(), edited = ref(false);
|
||||
const contentStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), contentError = ref<string>();
|
||||
let navigation = Content.tree as TreeItemEditable[];
|
||||
const selected = ref<TreeItemEditable>();
|
||||
|
||||
const preferences = useCookie<Preferences>('preferences', { default: () => ({ markdown: { editing: 'split' }, canvas: { snap: true, size: 32 } }), watch: true, maxAge: 60*60*24*31 });
|
||||
const preferences = useCookie<Preferences>('preferences', { default: () => ({ markdown: { editing: 'split' }, canvas: { gridSnap: true, neighborSnap: true, spacing: 32 } }), watch: true, maxAge: 60*60*24*31 });
|
||||
|
||||
watch(selected, async (value, old) => {
|
||||
if(selected.value)
|
||||
{
|
||||
if(!selected.value.content && selected.value.path)
|
||||
{
|
||||
contentStatus.value = 'pending';
|
||||
try
|
||||
{
|
||||
const storedEdit = sessionStorage.getItem(`editing:${encodeURIComponent(selected.value.path)}`);
|
||||
selected.value = await Content.content(selected.value.path);
|
||||
}
|
||||
|
||||
if(storedEdit)
|
||||
{
|
||||
selected.value.content = convertContentFromText(selected.value.type, storedEdit);
|
||||
contentStatus.value = 'success';
|
||||
}
|
||||
else
|
||||
{
|
||||
selected.value.content = (await $fetch(`/api/file/content/${encodeURIComponent(selected.value.path)}`, { query: { type: 'editing'} }));
|
||||
contentStatus.value = 'success';
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
debounced.value = selected.value.content ?? '';
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
contentError.value = (e as Error).message;
|
||||
contentStatus.value = 'error';
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
//@ts-ignore
|
||||
debounced.value = selected.value.content ?? '';
|
||||
}
|
||||
router.replace({ hash: '#' + encodeURIComponent(selected.value.path || getPath(selected.value)) });
|
||||
router.replace({ hash: '#' + encodeURIComponent(selected.value!.path || getPath(selected.value!)) });
|
||||
}
|
||||
else
|
||||
{
|
||||
router.replace({ hash: '' });
|
||||
}
|
||||
})
|
||||
const content = computed(() => selected.value?.content ?? '');
|
||||
const debounced = useDebounce(content, 250, { maxWait: 500 });
|
||||
const debouncedSave = useDebounceFn(save, 60000, { maxWait: 180000 });
|
||||
|
||||
watch(debounced, () => {
|
||||
if(selected.value && debounced.value)
|
||||
sessionStorage.setItem(`editing:${encodeURIComponent(selected.value.path)}`, typeof debounced.value === 'string' ? debounced.value : JSON.stringify(debounced.value));
|
||||
});
|
||||
useShortcuts({
|
||||
meta_s: { usingInput: true, handler: () => save(false), prevent: true },
|
||||
//meta_s: { usingInput: true, handler: () => save(), prevent: true },
|
||||
meta_n: { usingInput: true, handler: () => add('markdown'), prevent: true },
|
||||
meta_shift_n: { usingInput: true, handler: () => add('folder'), prevent: true },
|
||||
meta_shift_z: { usingInput: true, handler: () => router.push({ name: 'explore-path', params: { path: 'index' } }), prevent: true }
|
||||
})
|
||||
|
||||
const tree = {
|
||||
remove(data: TreeItemEditable[], id: string): TreeItemEditable[] {
|
||||
return data
|
||||
.filter(item => getPath(item) !== id)
|
||||
.map((item) => {
|
||||
if (tree.hasChildren(item)) {
|
||||
return {
|
||||
...item,
|
||||
children: tree.remove(item.children ?? [], id),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
},
|
||||
insertBefore(data: TreeItemEditable[], targetId: string, newItem: TreeItemEditable): TreeItemEditable[] {
|
||||
return data.flatMap((item) => {
|
||||
if (getPath(item) === targetId)
|
||||
return [newItem, item];
|
||||
|
||||
if (tree.hasChildren(item)) {
|
||||
return {
|
||||
...item,
|
||||
children: tree.insertBefore(item.children ?? [], targetId, newItem),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
},
|
||||
insertAfter(data: TreeItemEditable[], targetId: string, newItem: TreeItemEditable): TreeItemEditable[] {
|
||||
return data.flatMap((item) => {
|
||||
if (getPath(item) === targetId)
|
||||
return [item, newItem];
|
||||
|
||||
if (tree.hasChildren(item)) {
|
||||
return {
|
||||
...item,
|
||||
children: tree.insertAfter(item.children ?? [], targetId, newItem),
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
},
|
||||
insertChild(data: TreeItemEditable[], targetId: string, newItem: TreeItemEditable): TreeItemEditable[] {
|
||||
return data.flatMap((item) => {
|
||||
if (getPath(item) === targetId) {
|
||||
// already a parent: add as first child
|
||||
return {
|
||||
...item,
|
||||
// opening item so you can see where item landed
|
||||
isOpen: true,
|
||||
children: [newItem, ...item.children ?? []],
|
||||
};
|
||||
}
|
||||
|
||||
if (!tree.hasChildren(item))
|
||||
return item;
|
||||
|
||||
return {
|
||||
...item,
|
||||
children: tree.insertChild(item.children ?? [], targetId, newItem),
|
||||
};
|
||||
});
|
||||
},
|
||||
find(data: TreeItemEditable[], itemId: string): TreeItemEditable | undefined {
|
||||
for (const item of data) {
|
||||
if (getPath(item) === itemId)
|
||||
return item;
|
||||
|
||||
if (tree.hasChildren(item)) {
|
||||
const result = tree.find(item.children ?? [], itemId);
|
||||
if (result)
|
||||
return result;
|
||||
}
|
||||
}
|
||||
},
|
||||
search(data: TreeItemEditable[], prop: keyof TreeItemEditable, value: string): TreeItemEditable[] {
|
||||
const arr = [];
|
||||
|
||||
for (const item of data)
|
||||
{
|
||||
if (item[prop]?.toString().toLowerCase()?.startsWith(value.toLowerCase()))
|
||||
arr.push(item);
|
||||
|
||||
if (tree.hasChildren(item)) {
|
||||
arr.push(...tree.search(item.children ?? [], prop, value));
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
},
|
||||
getPathToItem({
|
||||
current,
|
||||
targetId,
|
||||
parentIds = [],
|
||||
}: {
|
||||
current: TreeItemEditable[]
|
||||
targetId: string
|
||||
parentIds?: string[]
|
||||
}): string[] | undefined {
|
||||
for (const item of current) {
|
||||
if (getPath(item) === targetId)
|
||||
return parentIds;
|
||||
|
||||
const nested = tree.getPathToItem({
|
||||
current: (item.children ?? []),
|
||||
targetId,
|
||||
parentIds: [...parentIds, getPath(item)],
|
||||
});
|
||||
if (nested)
|
||||
return nested;
|
||||
}
|
||||
},
|
||||
hasChildren(item: TreeItemEditable): boolean {
|
||||
return (item.children ?? []).length > 0;
|
||||
},
|
||||
}
|
||||
|
||||
function add(type: FileType): void
|
||||
{
|
||||
if(!navigation.value)
|
||||
if(!navigation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const news = [...tree.search(navigation.value, 'title', 'Nouveau')].filter((e, i, a) => a.indexOf(e) === i);
|
||||
const news = [...tree.search(navigation, 'title', 'Nouveau')].filter((e, i, a) => a.indexOf(e) === i);
|
||||
const title = `Nouveau${news.length > 0 ? ' (' + news.length +')' : ''}`;
|
||||
const item: TreeItemEditable = { navigable: true, private: false, parent: '', path: '', title: title, name: parsePath(title), type: type, order: 0, children: [], customPath: false, content: DEFAULT_CONTENT[type], owner: -1, timestamp: new Date(), visit: 0 };
|
||||
|
||||
if(!selected.value)
|
||||
{
|
||||
navigation.value = [...navigation.value, item];
|
||||
navigation = [...navigation, item];
|
||||
}
|
||||
else if(selected.value?.children)
|
||||
{
|
||||
item.parent = getPath(selected.value);
|
||||
navigation.value = tree.insertChild(navigation.value, item.parent, item);
|
||||
navigation = tree.insertChild(navigation, item.parent, item);
|
||||
}
|
||||
else
|
||||
{
|
||||
navigation.value = tree.insertAfter(navigation.value, getPath(selected.value), item);
|
||||
navigation = tree.insertAfter(navigation, getPath(selected.value), item);
|
||||
}
|
||||
}
|
||||
function updateTree(instruction: Instruction, itemId: string, targetId: string) : TreeItemEditable[] | undefined {
|
||||
if(!navigation.value)
|
||||
if(!navigation)
|
||||
return;
|
||||
|
||||
const item = tree.find(navigation.value, itemId);
|
||||
const target = tree.find(navigation.value, targetId);
|
||||
const item = tree.find(navigation, itemId);
|
||||
const target = tree.find(navigation, targetId);
|
||||
|
||||
if(!item)
|
||||
return;
|
||||
|
||||
if (instruction.type === 'reparent') {
|
||||
const path = tree.getPathToItem({
|
||||
current: navigation.value,
|
||||
current: navigation,
|
||||
targetId: targetId,
|
||||
});
|
||||
if (!path) {
|
||||
|
|
@ -467,23 +168,23 @@ function updateTree(instruction: Instruction, itemId: string, targetId: string)
|
|||
}
|
||||
|
||||
const desiredId = path[instruction.desiredLevel];
|
||||
let result = tree.remove(navigation.value, itemId);
|
||||
let result = tree.remove(navigation, itemId);
|
||||
result = tree.insertAfter(result, desiredId, item);
|
||||
return result;
|
||||
}
|
||||
|
||||
// the rest of the actions require you to drop on something else
|
||||
if (itemId === targetId)
|
||||
return navigation.value;
|
||||
return navigation;
|
||||
|
||||
if (instruction.type === 'reorder-above') {
|
||||
let result = tree.remove(navigation.value, itemId);
|
||||
let result = tree.remove(navigation, itemId);
|
||||
result = tree.insertBefore(result, targetId, item);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (instruction.type === 'reorder-below') {
|
||||
let result = tree.remove(navigation.value, itemId);
|
||||
let result = tree.remove(navigation, itemId);
|
||||
result = tree.insertAfter(result, targetId, item);
|
||||
return result;
|
||||
}
|
||||
|
|
@ -492,13 +193,13 @@ function updateTree(instruction: Instruction, itemId: string, targetId: string)
|
|||
if(!target || target.type !== 'folder')
|
||||
return;
|
||||
|
||||
let result = tree.remove(navigation.value, itemId);
|
||||
let result = tree.remove(navigation, itemId);
|
||||
result = tree.insertChild(result, targetId, item);
|
||||
rebuildPath([item], targetId);
|
||||
return result;
|
||||
}
|
||||
|
||||
return navigation.value;
|
||||
return navigation;
|
||||
}
|
||||
function transform(items: TreeItem[] | undefined): TreeItemEditable[] | undefined
|
||||
{
|
||||
|
|
@ -516,7 +217,7 @@ function flatten(items: TreeItemEditable[] | undefined): TreeItemEditable[]
|
|||
}
|
||||
function drop(instruction: Instruction, itemId: string, targetId: string)
|
||||
{
|
||||
navigation.value = updateTree(instruction, itemId, targetId) ?? navigation.value ?? [];
|
||||
navigation = updateTree(instruction, itemId, targetId) ?? navigation ?? [];
|
||||
}
|
||||
function rebuildPath(tree: TreeItemEditable[] | null | undefined, parentPath: string)
|
||||
{
|
||||
|
|
@ -528,38 +229,14 @@ function rebuildPath(tree: TreeItemEditable[] | null | undefined, parentPath: st
|
|||
rebuildPath(e.children, getPath(e));
|
||||
});
|
||||
}
|
||||
async function save(redirect: boolean): Promise<void>
|
||||
function save()
|
||||
{
|
||||
//@ts-ignore
|
||||
const map = (e: TreeItemEditable[]): TreeItemEditable[] => e.map(f => ({ ...f, content: f.content ? convertContentToText(f.type, f.content) : undefined, children: f.children ? map(f.children) : undefined }));
|
||||
saveStatus.value = 'pending';
|
||||
try {
|
||||
const result = await $fetch(`/api/project`, {
|
||||
method: 'post',
|
||||
body: map(navigation.value),
|
||||
});
|
||||
saveStatus.value = 'success';
|
||||
edited.value = false;
|
||||
sessionStorage.clear();
|
||||
|
||||
toaster.clear('error');
|
||||
toaster.add({ type: 'success', content: 'Contenu enregistré', timer: true, duration: 10000 });
|
||||
|
||||
//@ts-ignore
|
||||
complete.value = result as ExploreContent[];
|
||||
if(redirect) router.go(-1);
|
||||
} catch(e: any) {
|
||||
toaster.add({
|
||||
type: 'error', content: e.message, timer: true, duration: 10000
|
||||
})
|
||||
console.error(e);
|
||||
saveStatus.value = 'error';
|
||||
if(selected.value && selected.value.content)
|
||||
{
|
||||
selected.value.path = getPath(selected.value);
|
||||
Content.save(selected.value);
|
||||
}
|
||||
}
|
||||
function getPath(item: TreeItemEditable): string
|
||||
{
|
||||
return [item.parent, parsePath(item.customPath ? item.name : item.title)].filter(e => !!e).join('/');
|
||||
}
|
||||
|
||||
const defaultExpanded = computed(() => {
|
||||
if(router.currentRoute.value.hash)
|
||||
|
|
@ -568,11 +245,11 @@ const defaultExpanded = computed(() => {
|
|||
split.forEach((e, i) => { if(i !== 0) split[i] = split[i - 1] + '/' + e });
|
||||
return split;
|
||||
}
|
||||
})
|
||||
});
|
||||
watch(router.currentRoute, (value) => {
|
||||
if(value && value.hash && navigation.value)
|
||||
selected.value = tree.find(navigation.value, decodeURIComponent(value.hash.substring(1)));
|
||||
if(value && value.hash && navigation)
|
||||
selected.value = tree.find(navigation, decodeURIComponent(value.hash.substring(1)));
|
||||
else
|
||||
selected.value = undefined;
|
||||
}, { immediate: true });
|
||||
}, { immediate: true }); */
|
||||
</script>
|
||||
|
|
@ -1,57 +1,67 @@
|
|||
import { eq, sql } from 'drizzle-orm';
|
||||
import useDatabase from '~/composables/useDatabase';
|
||||
import { explorerContentTable } from '~/db/schema';
|
||||
import { convertContentFromText } from '~/shared/general.util';
|
||||
import { Content } from '~/shared/content.util';
|
||||
|
||||
export default defineEventHandler(async (e) => {
|
||||
const path = decodeURIComponent(getRouterParam(e, "path") ?? '');
|
||||
const query = getQuery(e);
|
||||
|
||||
if(!path)
|
||||
try
|
||||
{
|
||||
const path = decodeURIComponent(getRouterParam(e, "path") ?? '');
|
||||
const query = getQuery(e);
|
||||
|
||||
if(!path)
|
||||
{
|
||||
setResponseStatus(e, 404);
|
||||
return;
|
||||
}
|
||||
|
||||
const db = useDatabase();
|
||||
const content = db.select({
|
||||
'content': sql<string>`cast(${explorerContentTable.content} as TEXT)`.as('content'),
|
||||
'private': explorerContentTable.private,
|
||||
'type': explorerContentTable.type,
|
||||
'owner': explorerContentTable.owner,
|
||||
'visit': explorerContentTable.visit,
|
||||
}).from(explorerContentTable).where(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path });
|
||||
|
||||
if(content != undefined && content.content != undefined)
|
||||
{
|
||||
const session = await getUserSession(e);
|
||||
|
||||
if(!session || !session.user || session.user.id !== content.owner)
|
||||
{
|
||||
if(content.private)
|
||||
{
|
||||
setResponseStatus(e, 404);
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
content.content = content.content.replace(/%%(.+)%%/g, "");
|
||||
}
|
||||
}
|
||||
if(query?.type === 'view')
|
||||
{
|
||||
db.update(explorerContentTable).set({ visit: content.visit + 1 }).where(eq(explorerContentTable.path, path)).run();
|
||||
}
|
||||
if(query?.type === 'editing')
|
||||
{
|
||||
content.content = convertFromStorableLinks(content.content);
|
||||
}
|
||||
|
||||
return Content.fromString(content as any, content.content);
|
||||
}
|
||||
|
||||
console.log(content);
|
||||
setResponseStatus(e, 404);
|
||||
return;
|
||||
}
|
||||
|
||||
const db = useDatabase();
|
||||
const content = db.select({
|
||||
'content': sql<string>`cast(${explorerContentTable.content} as TEXT)`.as('content'),
|
||||
'private': explorerContentTable.private,
|
||||
'type': explorerContentTable.type,
|
||||
'owner': explorerContentTable.owner,
|
||||
'visit': explorerContentTable.visit,
|
||||
}).from(explorerContentTable).where(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path });
|
||||
|
||||
if(content !== undefined)
|
||||
catch(_e)
|
||||
{
|
||||
const session = await getUserSession(e);
|
||||
|
||||
if(!session || !session.user || session.user.id !== content.owner)
|
||||
{
|
||||
if(content.private)
|
||||
{
|
||||
setResponseStatus(e, 404);
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
content.content = content.content.replace(/%%(.+)%%/g, "");
|
||||
}
|
||||
}
|
||||
if(query.type === 'view')
|
||||
{
|
||||
db.update(explorerContentTable).set({ visit: content.visit + 1 }).where(eq(explorerContentTable.path, path)).run();
|
||||
}
|
||||
if(query.type === 'editing')
|
||||
{
|
||||
content.content = convertFromStorableLinks(content.content);
|
||||
}
|
||||
|
||||
return convertContentFromText(content.type, content.content);
|
||||
console.error(_e);
|
||||
setResponseStatus(e, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
setResponseStatus(e, 404);
|
||||
return;
|
||||
});
|
||||
|
||||
export function convertFromStorableLinks(content: string): string
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import useDatabase from '~/composables/useDatabase';
|
||||
|
||||
export default defineEventHandler(async (e) => {
|
||||
const query = getQuery(e);
|
||||
|
||||
if (query.search) {
|
||||
const db = useDatabase();
|
||||
|
||||
const files = db.query(`SELECT f.*, u.username, count(c.path) as comments FROM explorer_files f LEFT JOIN users u ON f.owner = u.id LEFT JOIN explorer_comments c ON c.project = f.project AND c.path = f.path WHERE title LIKE ?1 AND private = 0 AND type != "Folder" GROUP BY f.project, f.path`).all(query.search) as FileSearch[];
|
||||
const users = db.query(`SELECT id, username FROM users WHERE username LIKE ?1`).all(query.search) as UserSearch[];
|
||||
|
||||
return {
|
||||
projects,
|
||||
files,
|
||||
users
|
||||
} as Search;
|
||||
}
|
||||
|
||||
setResponseStatus(e, 404);
|
||||
});
|
||||
|
|
@ -15,19 +15,10 @@ export default defineTask({
|
|||
},
|
||||
async run(event) {
|
||||
try {
|
||||
const tree = await $fetch('https://git.peaceultime.com/api/v1/repos/peaceultime/system-aspect/git/trees/master', {
|
||||
method: 'get',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
},
|
||||
params: {
|
||||
recursive: true,
|
||||
per_page: 1000,
|
||||
}
|
||||
}) as any;
|
||||
|
||||
const db = useDatabase();
|
||||
const files = db.select().from(explorerContentTable).where(ne(explorerContentTable.type, 'folder')).all();
|
||||
|
||||
|
||||
|
||||
return { result: true };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import type { CanvasNode } from "~/types/canvas";
|
||||
import { clamp } from "#shared/general.util";
|
||||
import type { CanvasContent, CanvasEdge, CanvasNode } from "~/types/canvas";
|
||||
import { clamp, lerp } from "#shared/general.util";
|
||||
import { dom, icon, svg, text } from "./dom.util";
|
||||
import render from "./markdown.util";
|
||||
import { popper } from "#shared/floating.util";
|
||||
import { Content } from "./content.util";
|
||||
|
||||
export type Direction = 'bottom' | 'top' | 'left' | 'right';
|
||||
export type Position = { x: number, y: number };
|
||||
|
|
@ -100,4 +104,376 @@ export function getCenter(n: Position, i: Position, r: Position, o: Position, e:
|
|||
export function gridSnap(value: number, grid: number): number
|
||||
{
|
||||
return Math.round(value / grid) * grid;
|
||||
}
|
||||
|
||||
const cancelEvent = (e: Event) => e.preventDefault();
|
||||
function center(touches: TouchList): Position
|
||||
{
|
||||
const pos = { x: 0, y: 0 };
|
||||
|
||||
for(const touch of touches)
|
||||
{
|
||||
pos.x += touch.clientX;
|
||||
pos.y += touch.clientY;
|
||||
}
|
||||
|
||||
pos.x /= touches.length;
|
||||
pos.y /= touches.length;
|
||||
return pos;
|
||||
}
|
||||
function distance(touches: TouchList): number
|
||||
{
|
||||
const [A, B] = touches;
|
||||
return Math.hypot(B.clientX - A.clientX, B.clientY - A.clientY);
|
||||
}
|
||||
|
||||
export class Node
|
||||
{
|
||||
properties: CanvasNode;
|
||||
|
||||
nodeDom: HTMLDivElement;
|
||||
|
||||
constructor(properties: CanvasNode)
|
||||
{
|
||||
this.properties = properties;
|
||||
|
||||
const style = this.style;
|
||||
|
||||
this.nodeDom = dom('div', { class: ['absolute', {'-z-10': properties.type === 'group', 'z-10': properties.type !== 'group'}], style: { transform: `translate(${properties.x}px, ${properties.y}px)`, width: `${properties.width}px`, height: `${properties.height}px`, '--canvas-color': properties.color?.hex } }, [
|
||||
dom('div', { class: ['outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full hover:outline-4', style.border] }, [
|
||||
dom('div', { class: ['w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto', style.bg] }, [properties.text ? dom('div', { class: 'flex items-center' }, [render(properties.text)]) : undefined])
|
||||
])
|
||||
]);
|
||||
|
||||
if(properties.type === 'group')
|
||||
{
|
||||
if(properties.label !== undefined)
|
||||
{
|
||||
this.nodeDom.appendChild(dom('div', { class: ['origin-bottom-left tracking-wider border-4 truncate inline-block text-light-100 dark:text-dark-100 absolute bottom-[100%] mb-2 px-2 py-1 font-thin', style.border], style: 'max-width: 100%; font-size: calc(18px * var(--zoom-multiplier))', text: properties.label }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get style()
|
||||
{
|
||||
return this.properties.color ? this.properties.color?.class ?
|
||||
{ bg: `bg-light-${this.properties.color?.class} dark:bg-dark-${this.properties.color?.class}`, border: `border-light-${this.properties.color?.class} dark:border-dark-${this.properties.color?.class}` } :
|
||||
{ bg: `bg-colored`, border: `border-[color:var(--canvas-color)]` } :
|
||||
{ border: `border-light-40 dark:border-dark-40`, bg: `bg-light-40 dark:bg-dark-40` }
|
||||
}
|
||||
}
|
||||
export class Edge
|
||||
{
|
||||
properties: CanvasEdge;
|
||||
|
||||
edgeDom: HTMLDivElement;
|
||||
#from: CanvasNode;
|
||||
#to: CanvasNode;
|
||||
#path: Path;
|
||||
#labelPos: string;
|
||||
|
||||
constructor(properties: CanvasEdge, nodes: CanvasNode[])
|
||||
{
|
||||
this.properties = properties;
|
||||
|
||||
this.#from = nodes.find(f => f.id === properties.fromNode)!;
|
||||
this.#to = nodes.find(f => f.id === properties.toNode)!;
|
||||
this.#path = getPath(this.#from, properties.fromSide, this.#to, properties.toSide)!;
|
||||
this.#labelPos = labelCenter(this.#from, properties.fromSide, this.#to, properties.toSide);
|
||||
|
||||
const style = this.style;
|
||||
|
||||
/* <div class="absolute overflow-visible">
|
||||
<div v-if="edge.label" :style="{ transform: `${labelPos} translate(-50%, -50%)` }" class="relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 z-20">{{ edge.label }}</div>
|
||||
<svg class="absolute top-0 overflow-visible h-px w-px">
|
||||
<g :style="{'--canvas-color': edge.color?.hex}" class="z-0">
|
||||
<g :style="`transform: translate(${this.#path!.to.x}px, ${this.#path!.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[this.#path!.side]}deg);`">
|
||||
<polygon :class="style.fill" points="0,0 6.5,10.4 -6.5,10.4"></polygon>
|
||||
</g>
|
||||
<path :style="`stroke-width: calc(3px * var(--zoom-multiplier));`" style="stroke-linecap: butt;" :class="style.stroke" class="fill-none stroke-[4px]" :d="this.#path!.path"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</div> */
|
||||
this.edgeDom = dom('div', { class: 'absolute overflow-visible' }, [
|
||||
properties.label ? dom('div', { style: { transform: `${this.#labelPos} translate(-50%, -50%)` }, class: 'relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 z-20', text: properties.label }) : undefined,
|
||||
svg('svg', { class: 'absolute top-0 overflow-visible h-px w-px' }, [
|
||||
svg('g', { style: {'--canvas-color': properties.color?.hex}, class: 'z-0' }, [
|
||||
svg('g', { style: `transform: translate(${this.#path!.to.x}px, ${this.#path!.to.y}px) scale(var(--zoom-multiplier)) rotate(${rotation[this.#path!.side]}deg);` }, [
|
||||
svg('polygon', { class: style.fill, attributes: { points: '0,0 6.5,10.4 -6.5,10.4' } }),
|
||||
]),
|
||||
svg('path', { style: `stroke-width: calc(3px * var(--zoom-multiplier)); stroke-linecap: butt;`, class: [style.stroke, 'fill-none stroke-[4px]'], attributes: { d: this.#path!.path } }),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
get style()
|
||||
{
|
||||
return this.properties.color ? this.properties.color?.class ?
|
||||
{ fill: `fill-light-${this.properties.color?.class} dark:fill-dark-${this.properties.color?.class}`, stroke: `stroke-light-${this.properties.color?.class} dark:stroke-dark-${this.properties.color?.class}` } :
|
||||
{ fill: `fill-colored`, stroke: `stroke-[color:var(--canvas-color)]` } :
|
||||
{ stroke: `stroke-light-40 dark:stroke-dark-40`, fill: `fill-light-40 dark:fill-dark-40` }
|
||||
}
|
||||
}
|
||||
|
||||
export class Canvas
|
||||
{
|
||||
static minZoom: number = 0.3;
|
||||
static maxZoom: number = 3;
|
||||
|
||||
private content: Required<CanvasContent>;
|
||||
private zoom: number = 0.5;
|
||||
private x: number = 0;
|
||||
private y: number = 0;
|
||||
|
||||
private visualZoom: number = this.zoom;
|
||||
private visualX: number = this.x;
|
||||
private visualY: number = this.y;
|
||||
|
||||
private tweener: Tweener = new Tweener();
|
||||
private debouncedTimeout: Timer = setTimeout(() => {}, 0);
|
||||
|
||||
private transform: HTMLDivElement;
|
||||
container: HTMLDivElement;
|
||||
|
||||
constructor(content?: CanvasContent)
|
||||
{
|
||||
if(!content)
|
||||
content = { nodes: [], edges: [], groups: [] };
|
||||
|
||||
if(!content.nodes)
|
||||
content.nodes = [];
|
||||
|
||||
if(!content.edges)
|
||||
content.edges = [];
|
||||
|
||||
if(!content.groups)
|
||||
content.groups = [];
|
||||
|
||||
this.content = content as Required<CanvasContent>;
|
||||
|
||||
this.transform = dom('div', { class: 'origin-center h-full' }, [
|
||||
dom('div', { class: 'absolute top-0 left-0 w-full h-full pointer-events-none *:pointer-events-auto *:select-none touch-none' }, [
|
||||
...this.content.nodes.map(e => new Node(e).nodeDom), ...this.content.edges.map(e => new Edge(e, this.content.nodes).edgeDom),
|
||||
])
|
||||
]);
|
||||
//TODO: --zoom-multiplier dynamic
|
||||
this.container = dom('div', { class: 'absolute top-0 left-0 overflow-hidden w-full h-full touch-none' }, [
|
||||
dom('div', { class: 'flex flex-col absolute sm:top-2 top-10 left-2 z-[35] overflow-hidden gap-4' }, [
|
||||
dom('div', { class: 'border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10' }, [
|
||||
popper(dom('span', { class: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this.x, this.y, clamp(this.zoom * 1.1, Canvas.minZoom, Canvas.maxZoom)) } }, [icon('radix-icons:plus')]), {
|
||||
delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Zoom avant')], 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: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: (e: MouseEvent) => { this.reset(); } } }, [icon('radix-icons:reload')]), {
|
||||
delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Reset')], 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: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: (e: MouseEvent) => { this.zoomTo(this.x, this.y, Canvas.minZoom); } } }, [icon('radix-icons:corners')]), {
|
||||
delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Tout contenir')], 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: 'w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer', listeners: { click: () => this.zoomTo(this.x, this.y, clamp(this.zoom / 1.1, Canvas.minZoom, Canvas.maxZoom)) } }, [icon('radix-icons:minus')]), {
|
||||
delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Zoom arrière')], 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'
|
||||
}),
|
||||
]), //dom('a') Edition link
|
||||
]), this.transform,
|
||||
])
|
||||
/*
|
||||
<NuxtLink v-if="overview && isOwner || hasPermissions(user?.permissions ?? [], ['admin', 'editor'])" :to="{ name: 'explore-edit', hash: `#${encodeURIComponent(overview!.path)}` }" class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10">
|
||||
<Tooltip message="Modifier" side="right">
|
||||
<Icon icon="radix-icons:pencil-1" class="w-8 h-8 p-2" />
|
||||
</Tooltip>
|
||||
</NuxtLink>
|
||||
*/
|
||||
|
||||
this.mount();
|
||||
}
|
||||
|
||||
mount()
|
||||
{
|
||||
let lastX = 0, lastY = 0, lastDistance = 0;
|
||||
const dragMove = (e: MouseEvent) => {
|
||||
this.x = this.visualX = this.x - (lastX - e.layerX) / this.zoom;
|
||||
this.y = this.visualY = this.y - (lastY - e.layerY) / this.zoom;
|
||||
|
||||
lastX = e.layerX;
|
||||
lastY = e.layerY;
|
||||
|
||||
this.updateTransform();
|
||||
};
|
||||
const dragEnd = (e: MouseEvent) => {
|
||||
window.removeEventListener('mouseup', dragEnd);
|
||||
window.removeEventListener('mousemove', dragMove);
|
||||
};
|
||||
this.container.addEventListener('mouseenter', () => {
|
||||
window.addEventListener('wheel', cancelEvent, { passive: false });
|
||||
document.addEventListener('gesturestart', cancelEvent);
|
||||
document.addEventListener('gesturechange', cancelEvent);
|
||||
|
||||
this.container.addEventListener('mouseleave', () => {
|
||||
window.removeEventListener('wheel', cancelEvent);
|
||||
document.removeEventListener('gesturestart', cancelEvent);
|
||||
document.removeEventListener('gesturechange', cancelEvent);
|
||||
});
|
||||
})
|
||||
this.container.addEventListener('mousedown', (e) => {
|
||||
lastX = e.layerX;
|
||||
lastY = e.layerY;
|
||||
|
||||
window.addEventListener('mouseup', dragEnd, { passive: true });
|
||||
window.addEventListener('mousemove', dragMove, { passive: true });
|
||||
}, { passive: true });
|
||||
this.container.addEventListener('wheel', (e) => {
|
||||
if((this.zoom >= Canvas.maxZoom && e.deltaY < 0) || (this.zoom <= Canvas.minZoom && e.deltaY > 0))
|
||||
return;
|
||||
|
||||
let box = this.container.getBoundingClientRect()!;
|
||||
|
||||
const diff = Math.exp(e.deltaY * -0.001);
|
||||
const centerX = (box.x + box.width / 2), centerY = (box.y + box.height / 2);
|
||||
const mousex = centerX - e.clientX, mousey = centerY - e.clientY;
|
||||
|
||||
this.zoomTo(this.x - (mousex / (diff * this.zoom) - mousex / this.zoom), this.y - (mousey / (diff * this.zoom) - mousey / this.zoom), clamp(this.zoom * diff, Canvas.minZoom, Canvas.maxZoom));
|
||||
}, { passive: true });
|
||||
this.container.addEventListener('touchstart', (e) => {
|
||||
({ x: lastX, y: lastY } = center(e.touches));
|
||||
|
||||
if(e.touches.length > 1)
|
||||
{
|
||||
lastDistance = distance(e.touches);
|
||||
}
|
||||
|
||||
this.container.addEventListener('touchend', touchend, { passive: true });
|
||||
this.container.addEventListener('touchcancel', touchcancel, { passive: true });
|
||||
this.container.addEventListener('touchmove', touchmove, { passive: true });
|
||||
}, { passive: true });
|
||||
const touchend = (e: TouchEvent) => {
|
||||
if(e.touches.length > 1)
|
||||
{
|
||||
({ x: lastX, y: lastY } = center(e.touches));
|
||||
}
|
||||
|
||||
this.container.removeEventListener('touchend', touchend);
|
||||
this.container.removeEventListener('touchcancel', touchcancel);
|
||||
this.container.removeEventListener('touchmove', touchmove);
|
||||
};
|
||||
const touchcancel = (e: TouchEvent) => {
|
||||
if(e.touches.length > 1)
|
||||
{
|
||||
({ x: lastX, y: lastY } = center(e.touches));
|
||||
}
|
||||
|
||||
this.container.removeEventListener('touchend', touchend);
|
||||
this.container.removeEventListener('touchcancel', touchcancel);
|
||||
this.container.removeEventListener('touchmove', touchmove);
|
||||
};
|
||||
const touchmove = (e: TouchEvent) => {
|
||||
const pos = center(e.touches);
|
||||
this.x = this.visualX = this.x - (lastX - pos.x) / this.zoom;
|
||||
this.y = this.visualY = this.y - (lastY - pos.y) / this.zoom;
|
||||
lastX = pos.x;
|
||||
lastY = pos.y;
|
||||
|
||||
if(e.touches.length === 2)
|
||||
{
|
||||
const dist = distance(e.touches);
|
||||
const diff = dist / lastDistance;
|
||||
|
||||
this.zoom = clamp(this.zoom * diff, Canvas.minZoom, Canvas.maxZoom);
|
||||
}
|
||||
|
||||
this.updateTransform();
|
||||
};
|
||||
|
||||
this.updateTransform();
|
||||
}
|
||||
|
||||
private updateTransform()
|
||||
{
|
||||
this.transform.style.transform = `scale3d(${this.visualZoom}, ${this.visualZoom}, 1) translate3d(${this.visualX}px, ${this.visualY}px, 0)`;
|
||||
|
||||
clearTimeout(this.debouncedTimeout);
|
||||
this.debouncedTimeout = setTimeout(this.updateScale.bind(this), 150);
|
||||
}
|
||||
|
||||
private updateScale()
|
||||
{
|
||||
this.transform.style.setProperty('--tw-scale', this.visualZoom.toString());
|
||||
this.container.style.setProperty('--zoom-multiplier', (1 / Math.pow(this.visualZoom, 0.7)).toFixed(3));
|
||||
}
|
||||
|
||||
private zoomTo(x: number, y: number, zoom: number)
|
||||
{
|
||||
const oldX = this.x, oldY = this.y, oldZoom = this.zoom;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.zoom = zoom;
|
||||
|
||||
this.tweener.update((e) => {
|
||||
this.visualX = lerp(e, oldX, x);
|
||||
this.visualY = lerp(e, oldY, y);
|
||||
this.visualZoom = lerp(e, oldZoom, zoom);
|
||||
|
||||
this.updateTransform();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
private reset()
|
||||
{
|
||||
this.tweener.stop();
|
||||
|
||||
this.zoom = this.visualZoom = 0.5;
|
||||
this.x = this.visualX = 0;
|
||||
this.y = this.visualY = 0;
|
||||
|
||||
this.updateTransform();
|
||||
this.updateScale();
|
||||
}
|
||||
}
|
||||
|
||||
class Tweener
|
||||
{
|
||||
static linear = (progress: number) => progress;
|
||||
|
||||
private progress: number;
|
||||
private duration: number;
|
||||
private last: number;
|
||||
|
||||
private animationFrame: number = 0;
|
||||
|
||||
private animation: (progress: number) => number;
|
||||
private tick?: (progress: number) => void;
|
||||
|
||||
constructor(animation: (progress: number) => number = Tweener.linear)
|
||||
{
|
||||
this.progress = 0, this.duration = 0, this.last = 0;
|
||||
this.animation = animation;
|
||||
}
|
||||
private loop(t: DOMHighResTimeStamp)
|
||||
{
|
||||
const elapsed = t - this.last;
|
||||
this.progress = clamp(this.progress + elapsed, 0, this.duration);
|
||||
this.last = t;
|
||||
|
||||
const step = this.animation(clamp(this.progress / this.duration, 0, 1));
|
||||
this.tick!(step);
|
||||
|
||||
if(this.progress < this.duration)
|
||||
this.animationFrame = requestAnimationFrame(this.loop.bind(this));
|
||||
}
|
||||
update(tick: (progress: number) => void, duration: number)
|
||||
{
|
||||
this.duration = duration + this.duration - this.progress;
|
||||
this.progress = 0;
|
||||
this.last = performance.now();
|
||||
this.tick = tick;
|
||||
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
this.animationFrame = requestAnimationFrame(this.loop.bind(this));
|
||||
}
|
||||
stop()
|
||||
{
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
|
||||
this.duration = 0;
|
||||
this.progress = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,808 @@
|
|||
import { safeDestr as parse } from 'destr';
|
||||
import { Canvas } from "#shared/canvas.util";
|
||||
import render from "#shared/markdown.util";
|
||||
import { contextmenu, popper } from "#shared/floating.util";
|
||||
import { cancelPropagation, dom, icon, text, type Node } from "#shared/dom.util";
|
||||
import prose, { h1, h2, loading } from "#shared/proses";
|
||||
import { parsePath } from '#shared/general.util';
|
||||
import { Tree, TreeDOM, type Recursive } from '#shared/tree';
|
||||
import { History } from '#shared/history.util';
|
||||
import { MarkdownEditor } from '#shared/editor.util';
|
||||
import type { CanvasContent } from '~/types/canvas';
|
||||
|
||||
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
|
||||
import { draggable, dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { type Instruction, attachInstruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
|
||||
import type { CleanupFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types';
|
||||
|
||||
export type FileType = keyof ContentMap;
|
||||
export interface ContentMap
|
||||
{
|
||||
markdown: string;
|
||||
file: string;
|
||||
canvas: CanvasContent;
|
||||
map: string;
|
||||
folder: null;
|
||||
}
|
||||
export interface Overview<T extends FileType>
|
||||
{
|
||||
path: string;
|
||||
owner: number;
|
||||
title: string;
|
||||
timestamp: Date;
|
||||
navigable: boolean;
|
||||
private: boolean;
|
||||
order: number;
|
||||
visit: number;
|
||||
type: T;
|
||||
}
|
||||
export type ExploreContent<T extends FileType = FileType> = Overview<T> & { content: ContentMap[T] };
|
||||
|
||||
class AsyncQueue
|
||||
{
|
||||
private size: number;
|
||||
private count: number = 0;
|
||||
private _queue: Array<() => Promise<any>>;
|
||||
|
||||
promise: Promise<void> = Promise.resolve();
|
||||
finished: boolean = true;
|
||||
|
||||
private res: (value: void | PromiseLike<void>) => void = () => {};
|
||||
|
||||
constructor(size: number = 8)
|
||||
{
|
||||
this.size = size;
|
||||
this._queue = [];
|
||||
}
|
||||
|
||||
queue(fn: () => Promise<any>): Promise<void>
|
||||
{
|
||||
if(this.finished)
|
||||
{
|
||||
this.finished = false;
|
||||
|
||||
this.promise = new Promise((res, rej) => {
|
||||
this.res = res;
|
||||
});
|
||||
}
|
||||
|
||||
this._queue.push(fn);
|
||||
this.refresh();
|
||||
|
||||
return this.promise;
|
||||
}
|
||||
|
||||
private refresh()
|
||||
{
|
||||
for(let i = this.count; i < this.size && this._queue.length > 0; i++)
|
||||
{
|
||||
this.count++;
|
||||
|
||||
const fn = this._queue.shift()!;
|
||||
fn().finally(() => {
|
||||
this.count--;
|
||||
this.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
if(this.count === 0 && this._queue.length === 0 && !this.finished)
|
||||
{
|
||||
this.finished = true;
|
||||
this.res();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_CONTENT: Record<FileType, ContentMap[FileType]> = {
|
||||
map: {},
|
||||
canvas: { nodes: [], edges: []},
|
||||
markdown: '',
|
||||
file: '',
|
||||
folder: null,
|
||||
};
|
||||
export type LocalContent<T extends FileType = FileType> = ExploreContent<T> & {
|
||||
localEdit?: boolean;
|
||||
error?: boolean;
|
||||
};
|
||||
export class Content
|
||||
{
|
||||
private static _ready = false;
|
||||
private static initPromise?: Promise<boolean>;
|
||||
|
||||
private static root: FileSystemDirectoryHandle;
|
||||
|
||||
private static _overview: Omit<LocalContent, 'content'>[];
|
||||
private static dlQueue = new AsyncQueue();
|
||||
private static writeQueue = new AsyncQueue(1);
|
||||
|
||||
static init(): Promise<boolean>
|
||||
{
|
||||
if(Content._ready)
|
||||
return Promise.resolve(true);
|
||||
|
||||
Content.initPromise = new Promise(async (res, rej) => {
|
||||
try
|
||||
{
|
||||
if(!('storage' in navigator))
|
||||
return false;
|
||||
|
||||
Content.root = await navigator.storage.getDirectory();
|
||||
|
||||
const overview = await Content.read('overview', { create: true });
|
||||
try
|
||||
{
|
||||
Content._overview = parse<Omit<LocalContent, 'content'>[]>(overview);
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
Content._overview = [];
|
||||
await Content.pull();
|
||||
}
|
||||
|
||||
Content._ready = true;
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
res(Content._ready);
|
||||
});
|
||||
|
||||
return Content.initPromise;
|
||||
}
|
||||
static overview(path: string): Omit<LocalContent, 'content'> | undefined
|
||||
{
|
||||
return Content._overview.find(e => getPath(e) === path);
|
||||
}
|
||||
static async content(path: string): Promise<LocalContent | undefined>
|
||||
{
|
||||
const overview = Content._overview.find(e => getPath(e) === path);
|
||||
|
||||
return overview ? { ...overview, content: Content.fromString(overview, await Content.read(encodeURIComponent(path)) ?? '') } as LocalContent : undefined;
|
||||
}
|
||||
static update(item: Recursive<LocalContent>)
|
||||
{
|
||||
const index = Content._overview.findIndex(e => e.path === getPath(item));
|
||||
if(index !== -1)
|
||||
Content._overview[index] = item;
|
||||
|
||||
const overview = JSON.stringify(Content._overview, (k, v) => ['parent', 'children', 'content'].includes(k) ? undefined : v);
|
||||
return Content.writeQueue.queue(() => Content.write('overview', overview));
|
||||
}
|
||||
static rename(from: string, to: string)
|
||||
{
|
||||
const index = Content._overview.findIndex(e => getPath(e) === from);
|
||||
if(index !== -1)
|
||||
Content._overview[index].path = to;
|
||||
|
||||
return Content.writeQueue.queue(async () => {
|
||||
const content = await Content.read(encodeURIComponent(from));
|
||||
if(content !== undefined)
|
||||
{
|
||||
await Content.write(encodeURIComponent(to), content, { create: true });
|
||||
await Content.remove(encodeURIComponent(from));
|
||||
}
|
||||
});
|
||||
}
|
||||
static save(content?: Recursive<LocalContent>)
|
||||
{
|
||||
if(!content)
|
||||
return;
|
||||
|
||||
const string = Content.toString(content), path = getPath(content);
|
||||
return Content.writeQueue.queue(() => Content.write(encodeURIComponent(path), string, { create: true }));
|
||||
}
|
||||
private static async pull()
|
||||
{
|
||||
const overview = (await useRequestFetch()('/api/file/overview', { cache: 'no-cache' })) as ExploreContent<FileType>[] | undefined;
|
||||
|
||||
if(!overview)
|
||||
{
|
||||
//TODO: Cannot get data :'(
|
||||
//Add a warning ?
|
||||
return;
|
||||
}
|
||||
|
||||
for(const file of overview)
|
||||
{
|
||||
let index = Content._overview.findIndex(e => getPath(e) === file.path), _overview = (index === -1 ? undefined : Content._overview[index]);
|
||||
if(!_overview || _overview.localEdit)
|
||||
{
|
||||
const encoded = encodeURIComponent(file.path);
|
||||
if(!_overview)
|
||||
{
|
||||
index = Content._overview.push(file) - 1;
|
||||
}
|
||||
else
|
||||
Content._overview[index] = file;
|
||||
|
||||
_overview = file;
|
||||
|
||||
if(file.type === 'folder')
|
||||
continue;
|
||||
|
||||
Content.dlQueue.queue(() => {
|
||||
return useRequestFetch()(`/api/file/content/${encoded}`, { cache: 'no-cache' }).then(async (content: ContentMap[FileType] | undefined) => {
|
||||
if(content)
|
||||
await Content.write(encoded, Content.toString({ ...file, content }), { create: true });
|
||||
else
|
||||
Content._overview[index].error = true;
|
||||
}).catch(e => {
|
||||
Content._overview[index].error = true;
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Content.dlQueue.queue(() => {
|
||||
return Content.write('overview', JSON.stringify(Content._overview), { create: true });
|
||||
});
|
||||
|
||||
await Content.dlQueue.promise;
|
||||
}
|
||||
private static async push()
|
||||
{
|
||||
|
||||
}
|
||||
//Maybe store the file handles ? Is it safe to keep them ?
|
||||
private static async read(path: string, options?: FileSystemGetFileOptions): Promise<string | undefined>
|
||||
{
|
||||
try
|
||||
{
|
||||
console.time(`Reading '${path}'`);
|
||||
const handle = await Content.root.getFileHandle(path, options);
|
||||
const file = await handle.getFile();
|
||||
|
||||
const text = await file.text();
|
||||
console.timeEnd(`Reading '${path}'`);
|
||||
return text;
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
console.error(path, e);
|
||||
console.timeEnd(`Reading '${path}'`);
|
||||
}
|
||||
}
|
||||
//Easy to use, but not very performant.
|
||||
private static async write(path: string, content: string, options?: FileSystemGetFileOptions): Promise<void>
|
||||
{
|
||||
const size = new TextEncoder().encode(content).byteLength;
|
||||
console.time(`Writing ${size} bytes to '${path}'`);
|
||||
try
|
||||
{
|
||||
const handle = await Content.root.getFileHandle(path, options);
|
||||
const file = await handle.createWritable({ keepExistingData: false });
|
||||
|
||||
await file.write(content);
|
||||
await file.close();
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
console.error(path, e);
|
||||
}
|
||||
console.timeEnd(`Writing ${size} bytes to '${path}'`);
|
||||
}
|
||||
private static async remove(path: string): Promise<void>
|
||||
{
|
||||
console.time(`Removing '${path}'`);
|
||||
await Content.root.removeEntry(path)
|
||||
console.timeEnd(`Removing '${path}'`);
|
||||
}
|
||||
|
||||
static get estimate(): Promise<StorageEstimate>
|
||||
{
|
||||
return Content._ready ? navigator.storage.estimate() : Promise.reject();
|
||||
}
|
||||
|
||||
static toString<T extends FileType>(content: ExploreContent<T>): string
|
||||
{
|
||||
return handlers[content.type].toString(content.content);
|
||||
}
|
||||
|
||||
static fromString<T extends FileType>(overview: Omit<ExploreContent<T>, 'content'>, content: string): ContentMap[T]
|
||||
{
|
||||
return handlers[overview.type].fromString(content);
|
||||
}
|
||||
|
||||
static render(parent: HTMLElement, path: string): Omit<LocalContent, 'content'> | undefined
|
||||
{
|
||||
const overview = Content.overview(path);
|
||||
|
||||
if(!!overview)
|
||||
{
|
||||
const load = dom('div', { class: 'flex, flex-1 justify-center items-center' }, [loading('normal')]);
|
||||
parent.appendChild(load);
|
||||
|
||||
function _render<T extends FileType>(content: LocalContent<T>): void
|
||||
{
|
||||
const el = handlers[content.type].render(content);
|
||||
el && parent.replaceChild(el, load);
|
||||
}
|
||||
|
||||
Content.content(path).then(content => _render(content!));
|
||||
}
|
||||
else
|
||||
{
|
||||
parent.appendChild(dom('h2', { class: 'flex-1 text-center', text: "Impossible d'afficher le contenu demandé" }));
|
||||
}
|
||||
|
||||
return overview;
|
||||
}
|
||||
|
||||
static get files()
|
||||
{
|
||||
return Object.freeze(Content._overview);
|
||||
}
|
||||
static get tree()
|
||||
{
|
||||
const arr: Recursive<Omit<LocalContent, 'content'>>[] = [];
|
||||
|
||||
function addChild(arr: Recursive<Omit<LocalContent, 'content'>>[], overview: Omit<LocalContent, 'content'>): void {
|
||||
const parent = arr.find(f => overview.path.startsWith(f.path));
|
||||
|
||||
if(parent)
|
||||
{
|
||||
if(!parent.children)
|
||||
parent.children = [];
|
||||
|
||||
(overview as Recursive<typeof overview>).parent = parent;
|
||||
addChild(parent.children, overview);
|
||||
}
|
||||
else
|
||||
{
|
||||
arr.push({ ...overview });
|
||||
arr.sort((a, b) => {
|
||||
if(a.order !== b.order)
|
||||
return a.order - b.order;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for(const element of Object.values(Content._overview))
|
||||
{
|
||||
addChild(arr, element);
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
static get ready(): Promise<boolean>
|
||||
{
|
||||
return Content._ready ? Promise.resolve(true) : Content.initPromise ?? Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
|
||||
type ContentTypeHandler<T extends FileType> = {
|
||||
toString: (content: ContentMap[T]) => string;
|
||||
fromString: (str: string) => ContentMap[T];
|
||||
render: (content: LocalContent<T>) => Node;
|
||||
renderEditor: (content: LocalContent<T>) => Node;
|
||||
};
|
||||
|
||||
const handlers: { [K in FileType]: ContentTypeHandler<K> } = {
|
||||
canvas: {
|
||||
toString: (content) => JSON.stringify(content),
|
||||
fromString: (str) => JSON.parse(str),
|
||||
render: (content) => new Canvas(content.content).container,
|
||||
renderEditor: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }),
|
||||
},
|
||||
markdown: {
|
||||
toString: (content) => content,
|
||||
fromString: (str) => str,
|
||||
render: (content) => {
|
||||
return dom('div', { class: 'flex flex-1 justify-start items-start flex-col lg:px-16 xl:px-32 2xl:px-64 py-6' }, [
|
||||
dom('div', { class: 'flex flex-1 flex-row justify-between items-center' }, [
|
||||
prose('h1', h1, [text(content.title)]),
|
||||
dom('div', { class: 'flex gap-4' }, [
|
||||
//TODO: Edition link
|
||||
]),
|
||||
]),
|
||||
render(content.content),
|
||||
])
|
||||
},
|
||||
renderEditor: (content) => {
|
||||
let element: HTMLElement;
|
||||
if(content.hasOwnProperty('content'))
|
||||
{
|
||||
MarkdownEditor.singleton.content = content.content;
|
||||
element = MarkdownEditor.singleton.dom;
|
||||
}
|
||||
else
|
||||
{
|
||||
element = loading("large");
|
||||
Content.content(content.path).then(e => {
|
||||
if(!e)
|
||||
return element.parentElement?.replaceChild(dom('div', { class: '', text: '' }), element);
|
||||
|
||||
MarkdownEditor.singleton.content = content.content = (e as typeof content).content;
|
||||
element.parentElement?.replaceChild(MarkdownEditor.singleton.dom, element);
|
||||
});
|
||||
}
|
||||
MarkdownEditor.singleton.onChange = (value) => { content.content = value; };
|
||||
return dom('div', { class: 'flex flex-1 justify-start items-start flex-col lg:px-16 xl:px-32 2xl:px-64 py-6' }, [
|
||||
dom('div', { class: 'flex flex-row justify-between items-center' }, [ prose('h1', h1, [text(content.title)]) ]),
|
||||
dom('div', { class: 'flex flex-1 w-full justify-stretch items-stretch py-2 px-1.5 font-sans text-base' }, [element]),
|
||||
])
|
||||
},
|
||||
},
|
||||
file: {
|
||||
toString: (content) => content,
|
||||
fromString: (str) => str,
|
||||
render: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }),
|
||||
renderEditor: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }),
|
||||
},
|
||||
map: {
|
||||
toString: (content) => content,
|
||||
fromString: (str) => str,
|
||||
render: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }),
|
||||
renderEditor: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }),
|
||||
},
|
||||
folder: {
|
||||
toString: (_) => '',
|
||||
fromString: (_) => null,
|
||||
render: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }),
|
||||
renderEditor: (content) => prose('h2', h2, [text('En cours de développement')], { class: 'flex-1 text-center' }),
|
||||
}
|
||||
};
|
||||
|
||||
export const iconByType: Record<FileType, string> = {
|
||||
'folder': 'lucide:folder',
|
||||
'canvas': 'ph:graph-light',
|
||||
'file': 'radix-icons:file',
|
||||
'markdown': 'radix-icons:file-text',
|
||||
'map': 'lucide:map',
|
||||
}
|
||||
|
||||
export class Editor
|
||||
{
|
||||
tree: TreeDOM;
|
||||
container: HTMLDivElement;
|
||||
|
||||
selected?: Recursive<LocalContent & { element?: HTMLElement }>;
|
||||
|
||||
private instruction: HTMLDivElement;
|
||||
private cleanup: CleanupFn;
|
||||
|
||||
private history: History;
|
||||
|
||||
constructor()
|
||||
{
|
||||
this.history = new History();
|
||||
this.history.register('overview', {
|
||||
move: {
|
||||
undo: (action) => {
|
||||
this.tree.tree.remove(getPath(action.element));
|
||||
action.element.parent = action.from.parent;
|
||||
action.element.order = action.from.order;
|
||||
const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length;
|
||||
|
||||
this.tree.tree.insertAt(action.element, action.to.order as number);
|
||||
action.element.element = this.tree.render(action.element, depth);
|
||||
action.element?.cleanup();
|
||||
this.dragndrop(action.element, depth, action.element.parent);
|
||||
this.tree.update();
|
||||
|
||||
Content.rename(action.element.path, path);
|
||||
action.element.path = path;
|
||||
},
|
||||
redo: (action) => {
|
||||
this.tree.tree.remove(getPath(action.element));
|
||||
action.element.parent = action.to.parent;
|
||||
action.element.order = action.to.order;
|
||||
const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length;
|
||||
|
||||
this.tree.tree.insertAt(action.element, action.to.order as number);
|
||||
|
||||
action.element.element = this.tree.render(action.element, depth);
|
||||
action.element?.cleanup();
|
||||
this.dragndrop(action.element, depth, action.element.parent);
|
||||
this.tree.update();
|
||||
|
||||
Content.rename(action.element.path, path);
|
||||
action.element.path = path;
|
||||
},
|
||||
},
|
||||
add: {
|
||||
undo: (action) => {
|
||||
this.tree.tree.remove(getPath(action.element));
|
||||
if(this.selected === action.element) this.select();
|
||||
action.element.cleanup();
|
||||
action.element.remove();
|
||||
},
|
||||
redo: (action) => {
|
||||
if(!action.element)
|
||||
{
|
||||
const depth = getPath(action.element as LocalContent).split('/').length;
|
||||
action.element.element = this.tree.render(action.element as LocalContent, depth) as HTMLElement;
|
||||
this.dragndrop(action.element as LocalContent, depth, (action.element as Recursive<LocalContent>).parent);
|
||||
}
|
||||
this.tree.tree.insertAt(action.element as Recursive<LocalContent>, action.to as number);
|
||||
this.tree.update();
|
||||
},
|
||||
},
|
||||
remove: {
|
||||
undo: (action) => {
|
||||
this.tree.tree.insertAt(action.element, action.from as number);
|
||||
this.tree.update();
|
||||
},
|
||||
redo: (action) => {
|
||||
this.tree.tree.remove(getPath(action.element));
|
||||
if(this.selected === action.element) this.select();
|
||||
action.element.cleanup();
|
||||
action.element.remove();
|
||||
},
|
||||
},
|
||||
rename: {
|
||||
undo: (action) => {
|
||||
action.element.title = action.from;
|
||||
action.element.element!.children[0].children[1].textContent = action.from;
|
||||
action.element.element!.children[0].children[1].setAttribute('title', action.from);
|
||||
|
||||
const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length;
|
||||
action.element?.cleanup();
|
||||
this.dragndrop(action.element, depth, action.element.parent);
|
||||
Content.rename(action.element.path, path);
|
||||
action.element.path = path;
|
||||
},
|
||||
redo: (action) => {
|
||||
action.element.title = action.to;
|
||||
action.element.element!.children[0].children[1].textContent = action.to;
|
||||
action.element.element!.children[0].children[1].setAttribute('title', action.to);
|
||||
|
||||
const path = getPath(action.element), depth = path.split("/").filter(e => !!e).length;
|
||||
action.element?.cleanup();
|
||||
this.dragndrop(action.element, depth, action.element.parent);
|
||||
Content.rename(action.element.path, path);
|
||||
action.element.path = path;
|
||||
},
|
||||
},
|
||||
navigable: {
|
||||
undo: (action) => {
|
||||
action.element.navigable = action.from;
|
||||
action.element.element!.children[0].children[2].children[0].replaceWith(icon(action.element.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !action.element.navigable }] }));
|
||||
},
|
||||
redo: (action) => {
|
||||
action.element.navigable = action.to;
|
||||
action.element.element!.children[0].children[2].children[0].replaceWith(icon(action.element.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !action.element.navigable }] }));
|
||||
},
|
||||
},
|
||||
private: {
|
||||
undo: (action) => {
|
||||
action.element.private = action.from;
|
||||
action.element.element!.children[0].children[3].children[0].replaceWith(icon(action.element.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !action.element.private }] }));
|
||||
},
|
||||
redo: (action) => {
|
||||
action.element.private = action.to;
|
||||
action.element.element!.children[0].children[3].children[0].replaceWith(icon(action.element.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !action.element.private }] }));
|
||||
},
|
||||
},
|
||||
}, action => Content.update(action.element));
|
||||
|
||||
this.tree = new TreeDOM((item, depth) => {
|
||||
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-left': `${depth / 2 - 0.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full cursor-pointer font-medium'], attributes: { 'data-private': item.private }, listeners: { contextmenu: (e) => this.contextmenu(e, item as LocalContent)} }, [
|
||||
icon('radix-icons:chevron-right', { class: 'h-4 w-4 transition-transform absolute group-data-[state=open]:rotate-90', style: { 'left': `${depth / 2 - 1.5}em` } }),
|
||||
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
|
||||
popper(dom('span', { class: 'flex', listeners: { click: e => this.toggleNavigable(e, item as LocalContent) } }, [icon(item.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !item.navigable }] })]), { delay: 150, offset: 8, placement: 'left', arrow: true, content: [text('Navigable')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }),
|
||||
popper(dom('span', { class: 'flex', listeners: { click: e => this.togglePrivate(e, item as LocalContent) } }, [icon(item.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !item.private }] })]), { delay: 150, offset: 8, placement: 'right', arrow: true, content: [text('Privé')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }),
|
||||
])]);
|
||||
}, (item, depth) => {
|
||||
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-left': `${depth / 2 - 0.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full'], attributes: { 'data-private': item.private }, listeners: { contextmenu: (e) => this.contextmenu(e, item as LocalContent), click: () => this.select(item as LocalContent) } }, [
|
||||
icon(iconByType[item.type], { class: 'w-5 h-5', width: 20, height: 20 }),
|
||||
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
|
||||
popper(dom('span', { class: 'flex', listeners: { click: e => this.toggleNavigable(e, item as LocalContent) } }, [icon(item.navigable ? 'radix-icons:eye-open' : 'radix-icons:eye-none', { class: ['mx-1', { 'opacity-50': !item.navigable }] })]), { delay: 150, offset: 8, placement: 'left', arrow: true, content: [text('Navigable')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }),
|
||||
popper(dom('span', { class: 'flex', listeners: { click: e => this.togglePrivate(e, item as LocalContent) } }, [icon(item.private ? 'radix-icons:lock-closed' : 'radix-icons:lock-open-2', { class: ['mx-1', { 'opacity-50': !item.private }] })]), { delay: 150, offset: 8, placement: 'right', arrow: true, content: [text('Privé')], class: 'TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50' }),
|
||||
])]);
|
||||
});
|
||||
|
||||
this.instruction = dom('div', { class: 'absolute h-full w-full top-0 right-0 border-light-50 dark:border-dark-50' });
|
||||
|
||||
this.cleanup = this.setupDnD();
|
||||
|
||||
this.container = dom('div', { class: 'flex flex-1 flex-col items-start justify-start max-h-full relative' }, [dom('div', { class: 'py-4 flex-1 w-full max-h-full flex overflow-auto xl:px-12 lg:px-8 px-6 relative' })]);
|
||||
}
|
||||
private contextmenu(e: MouseEvent, item: LocalContent)
|
||||
{
|
||||
e.preventDefault();
|
||||
|
||||
const close = contextmenu(e.clientX, e.clientY, { placement: 'right-start', offset: 8, content: [
|
||||
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-100 dark:text-dark-100', listeners: { click: (e) => { this.add("markdown", item); close() }} }, [icon('radix-icons:plus'), text('Ajouter')]),
|
||||
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-100 dark:text-dark-100', listeners: { click: (e) => { this.rename(item); close() }} }, [icon('radix-icons:input'), text('Renommer')]),
|
||||
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-red dark:text-dark-red', listeners: { click: (e) => { this.remove(item); close() }} }, [icon('radix-icons:trash'), text('Supprimer')]),
|
||||
] });
|
||||
}
|
||||
private add(type: FileType, nextTo: Recursive<LocalContent>)
|
||||
{
|
||||
const count = Object.values(Content.files).filter(e => e.title.match(/^Nouveau( \(\d+\))?$/)).length;
|
||||
const item: Recursive<Omit<LocalContent, 'path' | 'content'> & { element?: HTMLElement }> = { navigable: true, private: false, owner: 0, order: nextTo.order + 1, timestamp: new Date(), title: count === 0 ? 'Nouveau' : `Nouveau (${count})`, type: type, visit: 0, parent: nextTo.parent };
|
||||
this.history.add('overview', 'add', [{ element: item, from: undefined, to: nextTo.order + 1 }]);
|
||||
}
|
||||
private remove(item: LocalContent & { element?: HTMLElement })
|
||||
{
|
||||
this.history.add('overview', 'remove', [{ element: item, from: item.order, to: undefined }], true);
|
||||
}
|
||||
private rename(item: LocalContent & { element?: HTMLElement })
|
||||
{
|
||||
let exists = true;
|
||||
const change = () =>
|
||||
{
|
||||
const value = input.value || item.title;
|
||||
|
||||
if(exists)
|
||||
{
|
||||
exists = false;
|
||||
|
||||
input.parentElement?.replaceChild(text, input);
|
||||
input.remove();
|
||||
if(value !== item.title) this.history.add('overview', 'rename', [{ element: item, from: item.title, to: value }], true);
|
||||
}
|
||||
}
|
||||
const text = item.element!.children[0].children[1];
|
||||
const input = dom('input', { attributes: { type: 'text', value: item.title }, class: 'bg-light-20 dark:bg-dark-20 outline outline-light-35 dark:outline-dark-35 outline-offset-0 pl-1.5 py-1.5 flex-1', listeners: { mousedown: cancelPropagation, click: cancelPropagation, blur: change, change: change } });
|
||||
|
||||
text.parentElement?.replaceChild(input, text);
|
||||
input.focus();
|
||||
}
|
||||
private toggleNavigable(e: Event, item: LocalContent & { element?: HTMLElement })
|
||||
{
|
||||
cancelPropagation(e);
|
||||
|
||||
this.history.add('overview', 'navigable', [{ element: item, from: item.navigable, to: !item.navigable }], true);
|
||||
}
|
||||
private togglePrivate(e: Event, item: LocalContent & { element?: HTMLElement })
|
||||
{
|
||||
cancelPropagation(e);
|
||||
|
||||
this.history.add('overview', 'private', [{ element: item, from: item.private, to: !item.private }], true);
|
||||
}
|
||||
private setupDnD(): CleanupFn
|
||||
{
|
||||
return combine(...this.tree.tree.accumulate(this.dragndrop.bind(this)), monitorForElements({
|
||||
onDrop: ({ location }) => {
|
||||
if (location.initial.dropTargets.length === 0)
|
||||
return;
|
||||
if (location.current.dropTargets.length === 0)
|
||||
return;
|
||||
|
||||
const target = location.current.dropTargets[0];
|
||||
const instruction = extractInstruction(target.data);
|
||||
|
||||
if (instruction !== null)
|
||||
this.updateTree(instruction, location.initial.dropTargets[0].data.id as string, target.data.id as string);
|
||||
},
|
||||
}));
|
||||
}
|
||||
private dragndrop(item: Omit<LocalContent & { element?: HTMLElement, cleanup?: () => void }, "content">, depth: number, parent?: Omit<LocalContent & { element?: HTMLElement }, "content">): CleanupFn
|
||||
{
|
||||
item.cleanup && item.cleanup();
|
||||
|
||||
let opened = false, draggedOpened = false;
|
||||
const element = item.element!;
|
||||
item.cleanup = combine(draggable({
|
||||
element,
|
||||
onDragStart: () => {
|
||||
element.classList.toggle('opacity-50', true);
|
||||
opened = this.tree.opened(item)!;
|
||||
this.tree.toggle(item, false);
|
||||
},
|
||||
onDrop: () => {
|
||||
element.classList.toggle('opacity-50', false);
|
||||
this.tree.toggle(item, opened);
|
||||
},
|
||||
canDrag: ({ element }) => {
|
||||
return !element.querySelector('input[type="text"]');
|
||||
}
|
||||
}),
|
||||
|
||||
dropTargetForElements({
|
||||
element,
|
||||
getData: ({ input }) => {
|
||||
const data = { id: getPath(item) };
|
||||
|
||||
return attachInstruction(data, {
|
||||
input,
|
||||
element,
|
||||
indentPerLevel: 16,
|
||||
currentLevel: depth,
|
||||
mode: !!(item as Recursive<typeof item>).children ? 'expanded' : parent ? ((parent as Recursive<typeof item>).children!.length === item.order + 1 ? 'last-in-group' : 'standard') : this.tree.tree.flatten.slice(-1)[0] === item ? 'last-in-group' : 'standard',
|
||||
block: [],
|
||||
})
|
||||
},
|
||||
canDrop: ({ source }) => {
|
||||
return source.data.id !== getPath(item);
|
||||
},
|
||||
onDrag: ({ self }) => {
|
||||
const instruction = extractInstruction(self.data) as Instruction;
|
||||
|
||||
if(instruction)
|
||||
{
|
||||
if('currentLevel' in instruction) this.instruction.style.width = `calc(100% - ${instruction.currentLevel / 2 - 1.5}em)`;
|
||||
|
||||
this.instruction.classList.toggle('!border-b-4', instruction?.type === 'reorder-below');
|
||||
this.instruction.classList.toggle('!border-t-4', instruction?.type === 'reorder-above');
|
||||
this.instruction.classList.toggle('!border-4', instruction?.type === 'make-child' || instruction?.type === 'reparent');
|
||||
|
||||
if(this.instruction.parentElement === null) element.appendChild(this.instruction);
|
||||
}
|
||||
},
|
||||
onDragEnter: () => {
|
||||
draggedOpened = this.tree.opened(item)!;
|
||||
this.tree.toggle(item, true);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
this.tree.toggle(item, draggedOpened);
|
||||
this.instruction.remove();
|
||||
},
|
||||
onDrop: () => {
|
||||
this.tree.toggle(item, true);
|
||||
this.instruction.remove();
|
||||
},
|
||||
getIsSticky: () => true,
|
||||
}));
|
||||
|
||||
return item.cleanup;
|
||||
}
|
||||
private updateTree(instruction: Instruction, source: string, target: string)
|
||||
{
|
||||
const sourceItem = this.tree.tree.find(source);
|
||||
const targetItem = this.tree.tree.find(target);
|
||||
|
||||
if(!sourceItem || !targetItem || instruction.type === 'instruction-blocked')
|
||||
return;
|
||||
|
||||
const from = { parent: (sourceItem as Recursive<typeof targetItem>).parent, order: sourceItem.order };
|
||||
|
||||
if (source === target)
|
||||
return;
|
||||
|
||||
if (instruction.type === 'reorder-above')
|
||||
this.history.add('overview', 'move', [{ element: sourceItem, from: from, to: { parent: (targetItem as Recursive<typeof targetItem>).parent, order: targetItem!.order }}], true);
|
||||
|
||||
if (instruction.type === 'reorder-below')
|
||||
this.history.add('overview', 'move', [{ element: sourceItem, from: from, to: { parent: (targetItem as Recursive<typeof targetItem>).parent, order: targetItem!.order + 1 }}], true);
|
||||
|
||||
if (instruction.type === 'make-child' && targetItem.type === 'folder')
|
||||
this.history.add('overview', 'move', [{ element: sourceItem, from: from, to: { parent: targetItem, order: 0 }}], true);
|
||||
}
|
||||
private render<T extends FileType>(item: LocalContent<T>): Node
|
||||
{
|
||||
return handlers[item.type].renderEditor(item);
|
||||
}
|
||||
private select(item?: LocalContent & { element?: HTMLElement })
|
||||
{
|
||||
if(this.selected && item)
|
||||
{
|
||||
Content.save(this.selected);
|
||||
}
|
||||
if(this.selected === item)
|
||||
{
|
||||
item?.element!.classList.remove('text-accent-blue');
|
||||
this.selected = undefined;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.selected?.element!.classList.remove('text-accent-blue');
|
||||
item?.element!.classList.add('text-accent-blue');
|
||||
this.selected = item;
|
||||
}
|
||||
|
||||
this.container.firstElementChild!.replaceChildren();
|
||||
this.selected && this.container.firstElementChild!.appendChild(this.render(this.selected) as HTMLElement);
|
||||
}
|
||||
unmount()
|
||||
{
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export function getPath(item: Recursive<Omit<LocalContent, 'content'>>): string
|
||||
export function getPath(item: Omit<LocalContent, 'content'>): string
|
||||
export function getPath(item: any): string
|
||||
{
|
||||
if(item.hasOwnProperty('parent') && item.parent !== undefined)
|
||||
return [getPath(item.parent), parsePath(item.title)].filter(e => !!e).join('/');
|
||||
else if(item.hasOwnProperty('parent'))
|
||||
return parsePath(item.title);
|
||||
else
|
||||
return parsePath(item.title) ?? item.path;
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import { iconExists, loadIcon } from 'iconify-icon';
|
||||
|
||||
export type Node = HTMLElement | SVGElement | Text | undefined;
|
||||
export type NodeChildren = Array<Node>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface NodeProperties
|
||||
{
|
||||
attributes?: Record<string, string | undefined | boolean>;
|
||||
text?: string;
|
||||
class?: Class;
|
||||
style?: Record<string, string | undefined | boolean | number> | string;
|
||||
listeners?: {
|
||||
[K in keyof HTMLElementEventMap]?: Listener<K>
|
||||
};
|
||||
}
|
||||
|
||||
export const cancelPropagation = (e: Event) => e.stopImmediatePropagation();
|
||||
export function dom<K extends keyof HTMLElementTagNameMap>(tag: K, properties?: NodeProperties, children?: NodeChildren): HTMLElementTagNameMap[K]
|
||||
{
|
||||
const element = document.createElement(tag);
|
||||
|
||||
if(children && children.length > 0)
|
||||
for(const c of children) if(c !== undefined) element.appendChild(c);
|
||||
|
||||
if(properties?.attributes)
|
||||
for(const [k, v] of Object.entries(properties.attributes))
|
||||
if(typeof v === 'string') element.setAttribute(k, v);
|
||||
else if(typeof v === 'boolean') element.toggleAttribute(k, v);
|
||||
|
||||
if(properties?.text)
|
||||
element.textContent = properties.text;
|
||||
|
||||
if(properties?.listeners)
|
||||
{
|
||||
for(let [k, v] of Object.entries(properties.listeners))
|
||||
{
|
||||
const key = k as keyof HTMLElementEventMap, value = v as Listener<typeof key>;
|
||||
if(typeof value === 'function')
|
||||
element.addEventListener(key, value);
|
||||
else
|
||||
element.addEventListener(key, value.listener, value.options);
|
||||
}
|
||||
}
|
||||
|
||||
styling(element, properties ?? {});
|
||||
|
||||
return element;
|
||||
}
|
||||
export function svg<K extends keyof SVGElementTagNameMap>(tag: K, properties?: NodeProperties, children?: Omit<NodeChildren, 'HTMLElement' | 'Text'>): SVGElementTagNameMap[K]
|
||||
{
|
||||
const element = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
||||
|
||||
if(children && children.length > 0)
|
||||
for(const c of children) if(c !== undefined) element.appendChild(c);
|
||||
|
||||
if(properties?.attributes)
|
||||
for(const [k, v] of Object.entries(properties.attributes))
|
||||
if(typeof v === 'string') element.setAttribute(k, v);
|
||||
else if(typeof v === 'boolean') element.toggleAttribute(k, v);
|
||||
|
||||
if(properties?.text)
|
||||
element.textContent = properties.text;
|
||||
|
||||
styling(element, properties ?? {});
|
||||
|
||||
return element;
|
||||
}
|
||||
export function text(data: string): Text
|
||||
{
|
||||
return document.createTextNode(data);
|
||||
}
|
||||
export function styling(element: SVGElement | HTMLElement, properties: {
|
||||
class?: Class;
|
||||
style?: Record<string, string | undefined | boolean | number> | string;
|
||||
}): SVGElement | HTMLElement
|
||||
{
|
||||
if(properties?.class)
|
||||
{
|
||||
element.setAttribute('class', mergeClasses(properties.class));
|
||||
}
|
||||
|
||||
if(properties?.style)
|
||||
{
|
||||
if(typeof properties.style === 'string')
|
||||
{
|
||||
element.setAttribute('style', properties.style);
|
||||
}
|
||||
else
|
||||
for(const [k, v] of Object.entries(properties.style)) if(v !== undefined && v !== false) element.attributeStyleMap.set(k, v);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
export interface IconProperties
|
||||
{
|
||||
mode?: string;
|
||||
inline?: boolean;
|
||||
noobserver?: boolean;
|
||||
width?: string|number;
|
||||
height?: string|number;
|
||||
flip?: string;
|
||||
rotate?: number|string;
|
||||
style?: Record<string, string | undefined> | string;
|
||||
class?: Class;
|
||||
}
|
||||
const iconCache: Map<IconProperties & { name: string }, HTMLElement> = new Map();
|
||||
export function icon(name: string, properties?: IconProperties): HTMLElement
|
||||
{
|
||||
const key = { ...properties, name };
|
||||
|
||||
if(iconCache.has(key))
|
||||
return iconCache.get(key)!.cloneNode() as HTMLElement;
|
||||
|
||||
const el = document.createElement('iconify-icon');
|
||||
|
||||
if(!iconExists(name))
|
||||
loadIcon(name);
|
||||
|
||||
el.setAttribute('icon', name);
|
||||
|
||||
properties?.mode && el.setAttribute('mode', properties?.mode.toString());
|
||||
properties?.inline && el.toggleAttribute('inline', properties?.inline);
|
||||
properties?.noobserver && el.toggleAttribute('noobserver', properties?.noobserver);
|
||||
properties?.width && el.setAttribute('width', properties?.width.toString());
|
||||
properties?.height && el.setAttribute('height', properties?.height.toString());
|
||||
properties?.flip && el.setAttribute('flip', properties?.flip.toString());
|
||||
properties?.rotate && el.setAttribute('rotate', properties?.rotate.toString());
|
||||
|
||||
if(properties?.class)
|
||||
{
|
||||
el.setAttribute('class', mergeClasses(properties.class));
|
||||
}
|
||||
if(properties?.style)
|
||||
{
|
||||
if(typeof properties.style === 'string')
|
||||
{
|
||||
el.setAttribute('style', properties.style);
|
||||
}
|
||||
else
|
||||
for(const [k, v] of Object.entries(properties.style)) if(v !== undefined) el.attributeStyleMap.set(k, v);
|
||||
}
|
||||
|
||||
iconCache.set(key, el.cloneNode() as HTMLElement);
|
||||
return el;
|
||||
}
|
||||
|
||||
export function mergeClasses(classes: Class): string
|
||||
{
|
||||
if(typeof classes === 'string')
|
||||
{
|
||||
return classes.trim();
|
||||
}
|
||||
else if(Array.isArray(classes))
|
||||
{
|
||||
return classes.map(e => mergeClasses(e)).join(' ');
|
||||
}
|
||||
else if(classes)
|
||||
{
|
||||
return Object.entries(classes).filter(e => e[1]).map(e => e[0].trim()).join(' ');
|
||||
}
|
||||
else
|
||||
{
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
import { crosshairCursor, Decoration, dropCursor, EditorView, keymap, ViewPlugin, ViewUpdate, type DecorationSet } from '@codemirror/view';
|
||||
import { Annotation, EditorState, SelectionRange, type Range } from '@codemirror/state';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { bracketMatching, foldKeymap, HighlightStyle, indentOnInput, syntaxHighlighting, syntaxTree } from '@codemirror/language';
|
||||
import { search, searchKeymap } from '@codemirror/search';
|
||||
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
|
||||
import { lintKeymap } from '@codemirror/lint';
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||
import { IterMode, Tree } from '@lezer/common';
|
||||
import { tags } from '@lezer/highlight';
|
||||
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' });
|
||||
const Blockquote = Decoration.line({ class: '*:hidden before:block !ps-4 relative before:absolute before:top-0 before:bottom-0 before:left-0 before:w-1 before:bg-none before:bg-light-30 dark:before:bg-dark-30' });
|
||||
|
||||
const TagTag = tags.special(tags.content);
|
||||
|
||||
const intersects = (a: {
|
||||
from: number;
|
||||
to: number;
|
||||
}, b: {
|
||||
from: number;
|
||||
to: number;
|
||||
}) => !(a.to < b.from || b.to < a.from);
|
||||
|
||||
const highlight = HighlightStyle.define([
|
||||
{ tag: tags.heading1, class: 'text-5xl pt-4 pb-2 after:hidden' },
|
||||
{ tag: tags.heading2, class: 'text-4xl pt-4 pb-2 ps-1 leading-loose after:hidden' },
|
||||
{ tag: tags.heading3, class: 'text-2xl font-bold pt-1 after:hidden' },
|
||||
{ tag: tags.heading4, class: 'text-xl font-semibold pt-1 after:hidden variant-cap' },
|
||||
{ tag: tags.meta, color: "#404740" },
|
||||
{ tag: tags.link, textDecoration: "underline" },
|
||||
{ tag: tags.heading, textDecoration: "underline", fontWeight: "bold" },
|
||||
{ tag: tags.emphasis, fontStyle: "italic" },
|
||||
{ tag: tags.strong, fontWeight: "bold" },
|
||||
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
||||
{ tag: tags.keyword, color: "#708" },
|
||||
{ tag: TagTag, class: 'cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30' }
|
||||
]);
|
||||
|
||||
class Decorator
|
||||
{
|
||||
static hiddenNodes: string[] = [
|
||||
'HardBreak',
|
||||
'LinkMark',
|
||||
'EmphasisMark',
|
||||
'CodeMark',
|
||||
'CodeInfo',
|
||||
'URL',
|
||||
]
|
||||
decorations: DecorationSet;
|
||||
constructor(view: EditorView)
|
||||
{
|
||||
this.decorations = Decoration.set(this.iterate(syntaxTree(view.state), view.visibleRanges, []), true);
|
||||
}
|
||||
update(update: ViewUpdate)
|
||||
{
|
||||
if(!update.docChanged && !update.viewportChanged && !update.selectionSet)
|
||||
return;
|
||||
|
||||
this.decorations = this.decorations.update({
|
||||
filter: (f, t, v) => false,
|
||||
add: this.iterate(syntaxTree(update.state), update.view.visibleRanges, update.state.selection.ranges),
|
||||
sort: true,
|
||||
});
|
||||
}
|
||||
iterate(tree: Tree, visible: readonly {
|
||||
from: number;
|
||||
to: number;
|
||||
}[], selection: readonly SelectionRange[]): Range<Decoration>[]
|
||||
{
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (let { from, to } of visible) {
|
||||
tree.iterate({
|
||||
from, to, mode: IterMode.IgnoreMounts,
|
||||
enter: node => {
|
||||
if(node.node.parent && selection.some(e => intersects(e, node.node.parent!)))
|
||||
return true;
|
||||
|
||||
else if(node.name === 'HeaderMark')
|
||||
decorations.push(Hidden.range(node.from, node.to + 1));
|
||||
|
||||
else if(Decorator.hiddenNodes.includes(node.name))
|
||||
decorations.push(Hidden.range(node.from, node.to));
|
||||
|
||||
else if(node.matchContext(['BulletList', 'ListItem']) && node.name === 'ListMark')
|
||||
decorations.push(Bullet.range(node.from, node.to + 1));
|
||||
|
||||
else if(node.matchContext(['Blockquote']))
|
||||
decorations.push(Blockquote.range(node.from, node.from));
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return decorations;
|
||||
}
|
||||
}
|
||||
|
||||
export class MarkdownEditor
|
||||
{
|
||||
private static _singleton: MarkdownEditor;
|
||||
|
||||
private view: EditorView;
|
||||
onChange?: (content: string) => void;
|
||||
constructor()
|
||||
{
|
||||
this.view = new EditorView({
|
||||
extensions: [
|
||||
markdown({
|
||||
base: markdownLanguage,
|
||||
extensions: {
|
||||
defineNodes: [
|
||||
{ name: "Tag", style: TagTag },
|
||||
{ name: "TagMark", style: tags.processingInstruction }
|
||||
],
|
||||
parseInline: [{
|
||||
name: "Tag",
|
||||
parse(cx, next, pos) {
|
||||
if (next != 35 || cx.char(pos + 1) == 35) return -1;
|
||||
let elts = [cx.elt("TagMark", pos, pos + 1)];
|
||||
for (let i = pos + 1; i < cx.end; i++) {
|
||||
let next = cx.char(i);
|
||||
if (next == 35)
|
||||
return cx.addElement(cx.elt("Tag", pos, i + 1, elts.concat(cx.elt("TagMark", i, i + 1))));
|
||||
if (next == 92)
|
||||
elts.push(cx.elt("Escape", i, i++ + 2));
|
||||
if (next == 32 || next == 9 || next == 10 || next == 13) break;
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}],
|
||||
}
|
||||
}),
|
||||
history(),
|
||||
search(),
|
||||
dropCursor(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
syntaxHighlighting(highlight),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
crosshairCursor(),
|
||||
EditorView.lineWrapping,
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...foldKeymap,
|
||||
...completionKeymap,
|
||||
...lintKeymap
|
||||
]),
|
||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||
if (viewUpdate.docChanged && !viewUpdate.transactions.some(tr => tr.annotation(External)))
|
||||
this.onChange && this.onChange(viewUpdate.state.doc.toString());
|
||||
}),
|
||||
EditorView.contentAttributes.of({spellcheck: "true"}),
|
||||
ViewPlugin.fromClass(Decorator, {
|
||||
decorations: e => e.decorations,
|
||||
})
|
||||
]
|
||||
});
|
||||
}
|
||||
focus()
|
||||
{
|
||||
this.view.focus();
|
||||
}
|
||||
|
||||
set content(value: string)
|
||||
{
|
||||
if (value === undefined)
|
||||
return;
|
||||
|
||||
const currentValue = this.view ? this.view.state.doc.toString() : "";
|
||||
if (this.view && value !== currentValue)
|
||||
{
|
||||
this.view.dispatch({
|
||||
changes: { from: 0, to: currentValue.length, insert: value || "" },
|
||||
annotations: [External.of(true)],
|
||||
});
|
||||
}
|
||||
}
|
||||
get content(): string
|
||||
{
|
||||
return this.view.state.doc.toString();
|
||||
}
|
||||
|
||||
get dom()
|
||||
{
|
||||
return this.view.dom;
|
||||
}
|
||||
|
||||
static get singleton(): MarkdownEditor
|
||||
{
|
||||
if(!MarkdownEditor._singleton)
|
||||
MarkdownEditor._singleton = new MarkdownEditor();
|
||||
return MarkdownEditor._singleton;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
import * as FloatingUI from "@floating-ui/dom";
|
||||
import { cancelPropagation, dom, svg, type Class, type NodeChildren } from "./dom.util";
|
||||
|
||||
export interface CommonProperties
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
let teleport: HTMLDivElement;
|
||||
export function init()
|
||||
{
|
||||
teleport = dom('div', { attributes: { id: 'popper-container' }, class: 'absolute top-0 left-0' });
|
||||
document.body.appendChild(teleport);
|
||||
}
|
||||
|
||||
export function popper(container: HTMLElement, properties?: PopperProperties): HTMLElement
|
||||
{
|
||||
let shown = false, timeout: Timer;
|
||||
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 content = dom('div', { class: ['fixed hidden', properties?.class], attributes: { 'data-state': 'closed' } }, [...(properties?.content ?? []), arrow]);
|
||||
|
||||
function update()
|
||||
{
|
||||
FloatingUI.computePosition(container, content, {
|
||||
placement: properties?.placement,
|
||||
strategy: 'fixed',
|
||||
middleware: [
|
||||
properties?.offset ? FloatingUI.offset(properties?.offset) : undefined,
|
||||
FloatingUI.flip(),
|
||||
properties?.offset ? FloatingUI.shift({ padding: properties?.offset }) : undefined,
|
||||
properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined,
|
||||
]
|
||||
}).then(({ x, y, placement, middlewareData }) => {
|
||||
Object.assign(content.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
|
||||
const side = placement.split('-')[0] as FloatingUI.Side;
|
||||
|
||||
content.setAttribute('data-side', side);
|
||||
|
||||
if(properties?.offset && properties?.arrow)
|
||||
{
|
||||
const { x: arrowX, y: arrowY } = middlewareData.arrow!;
|
||||
|
||||
const staticSide = {
|
||||
top: 'bottom',
|
||||
right: 'left',
|
||||
bottom: 'top',
|
||||
left: 'right',
|
||||
}[side]!;
|
||||
|
||||
const rotation = {
|
||||
top: "0",
|
||||
bottom: "180",
|
||||
left: "270",
|
||||
right: "90"
|
||||
}[side]!;
|
||||
|
||||
Object.assign(arrow.style, {
|
||||
left: arrowX != null ? `${arrowX}px` : '',
|
||||
top: arrowY != null ? `${arrowY}px` : '',
|
||||
right: '',
|
||||
bottom: '',
|
||||
[staticSide]: `-6px`,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let stop: () => void | undefined;
|
||||
function show()
|
||||
{
|
||||
if(shown || !properties?.onShow || properties?.onShow(content) !== false)
|
||||
{
|
||||
clearTimeout(timeout);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
if(!shown)
|
||||
{
|
||||
teleport!.appendChild(content);
|
||||
|
||||
content.setAttribute('data-state', 'open');
|
||||
content.classList.toggle('hidden', false);
|
||||
|
||||
update();
|
||||
stop = FloatingUI.autoUpdate(container, content, update, {
|
||||
animationFrame: true,
|
||||
layoutShift: false,
|
||||
elementResize: false,
|
||||
ancestorScroll: false,
|
||||
ancestorResize: false,
|
||||
});
|
||||
}
|
||||
shown = true;
|
||||
}, properties?.delay ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
function hide()
|
||||
{
|
||||
if(!properties?.onHide || properties?.onHide(content) !== false)
|
||||
{
|
||||
clearTimeout(timeout);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
content.remove();
|
||||
stop && stop();
|
||||
|
||||
shown = false;
|
||||
}, shown ? properties?.delay ?? 0 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
function link(element: HTMLElement) {
|
||||
Object.entries({
|
||||
'mouseenter': show,
|
||||
'mouseleave': hide,
|
||||
'focus': show,
|
||||
'blur': hide,
|
||||
} as Record<keyof HTMLElementEventMap, () => void>).forEach(([event, listener]) => {
|
||||
element.addEventListener(event, listener);
|
||||
});
|
||||
}
|
||||
|
||||
link(container);
|
||||
link(content);
|
||||
|
||||
return container;
|
||||
}
|
||||
export function contextmenu(x: number, y: number, properties?: CommonProperties): () => void
|
||||
{
|
||||
const virtual = {
|
||||
getBoundingClientRect() {
|
||||
return {
|
||||
x: x,
|
||||
y: y,
|
||||
top: y,
|
||||
left: x,
|
||||
bottom: y,
|
||||
right: x,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
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 ?? [])]);
|
||||
|
||||
function update()
|
||||
{
|
||||
FloatingUI.computePosition(virtual, container, {
|
||||
placement: properties?.placement,
|
||||
strategy: 'fixed',
|
||||
middleware: [
|
||||
properties?.offset ? FloatingUI.offset(properties?.offset) : undefined,
|
||||
FloatingUI.flip(),
|
||||
properties?.offset ? FloatingUI.shift({ padding: properties?.offset }) : undefined,
|
||||
properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined,
|
||||
]
|
||||
}).then(({ x, y, placement, middlewareData }) => {
|
||||
Object.assign(container.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
|
||||
const side = placement.split('-')[0] as FloatingUI.Side;
|
||||
|
||||
container.setAttribute('data-side', side);
|
||||
|
||||
if(properties?.offset && properties?.arrow)
|
||||
{
|
||||
const { x: arrowX, y: arrowY } = middlewareData.arrow!;
|
||||
|
||||
const staticSide = {
|
||||
top: 'bottom',
|
||||
right: 'left',
|
||||
bottom: 'top',
|
||||
left: 'right',
|
||||
}[side]!;
|
||||
|
||||
const rotation = {
|
||||
top: "0",
|
||||
bottom: "180",
|
||||
left: "270",
|
||||
right: "90"
|
||||
}[side]!;
|
||||
|
||||
Object.assign(arrow.style, {
|
||||
left: arrowX != null ? `${arrowX}px` : '',
|
||||
top: arrowY != null ? `${arrowY}px` : '',
|
||||
right: '',
|
||||
bottom: '',
|
||||
[staticSide]: `-6px`,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
update();
|
||||
|
||||
document.addEventListener('mousedown', close);
|
||||
container.addEventListener('mousedown', cancelPropagation);
|
||||
|
||||
const stop = FloatingUI.autoUpdate(virtual, container, update, {
|
||||
animationFrame: true,
|
||||
layoutShift: false,
|
||||
elementResize: false,
|
||||
ancestorScroll: false,
|
||||
ancestorResize: false,
|
||||
});
|
||||
teleport!.appendChild(container);
|
||||
|
||||
function close()
|
||||
{
|
||||
document.removeEventListener('mousedown', close);
|
||||
container.removeEventListener('mousedown', cancelPropagation);
|
||||
|
||||
container.remove();
|
||||
stop();
|
||||
}
|
||||
|
||||
return close;
|
||||
}
|
||||
//TODO
|
||||
export function modal()
|
||||
{
|
||||
}
|
||||
|
|
@ -1,13 +1,3 @@
|
|||
import type { CanvasContent } from '~/types/canvas';
|
||||
import type { ContentMap, FileType } from '~/types/content';
|
||||
|
||||
export const DEFAULT_CONTENT: Record<FileType, ContentMap[FileType]['content']> = {
|
||||
map: {},
|
||||
canvas: { nodes: [], edges: []},
|
||||
markdown: '',
|
||||
file: '',
|
||||
folder: null,
|
||||
}
|
||||
export function unifySlug(slug: string | string[]): string
|
||||
{
|
||||
return (Array.isArray(slug) ? slug.join('/') : slug);
|
||||
|
|
@ -54,40 +44,4 @@ export function clamp(x: number, min: number, max: number): number
|
|||
export function lerp(x: number, a: number, b: number): number
|
||||
{
|
||||
return (1-x)*a+x*b;
|
||||
}
|
||||
export function convertContentFromText(type: FileType, content: string): CanvasContent | string {
|
||||
switch(type)
|
||||
{
|
||||
case 'canvas':
|
||||
return JSON.parse(content) as CanvasContent;
|
||||
case 'map':
|
||||
case 'file':
|
||||
case 'folder':
|
||||
case 'markdown':
|
||||
return content;
|
||||
default:
|
||||
return content;
|
||||
}
|
||||
}
|
||||
export function convertContentToText(type: FileType, content: any): string {
|
||||
switch(type)
|
||||
{
|
||||
case 'canvas':
|
||||
return JSON.stringify(content);
|
||||
case 'map':
|
||||
case 'file':
|
||||
case 'folder':
|
||||
case 'markdown':
|
||||
return content;
|
||||
default:
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
export type HistoryHandler = {
|
||||
undo: (action: HistoryAction) => void;
|
||||
redo: (action: HistoryAction) => void;
|
||||
}
|
||||
|
||||
interface HistoryEvent
|
||||
{
|
||||
source: string;
|
||||
event: string;
|
||||
actions: HistoryAction[];
|
||||
}
|
||||
interface HistoryAction
|
||||
{
|
||||
element: any;
|
||||
from?: any;
|
||||
to?: any;
|
||||
}
|
||||
|
||||
export class History
|
||||
{
|
||||
private handlers: Record<string, { handlers: Record<string, HistoryHandler>, any?: (action: HistoryAction) => void }>;
|
||||
private history: HistoryEvent[];
|
||||
private position: number;
|
||||
|
||||
constructor()
|
||||
{
|
||||
this.handlers = {};
|
||||
this.history = [];
|
||||
this.position = -1;
|
||||
}
|
||||
get last()
|
||||
{
|
||||
return this.history.length > 0 && this.position > -1 ? this.history[this.position] : undefined;
|
||||
}
|
||||
get undoable()
|
||||
{
|
||||
return this.history && this.position !== -1;
|
||||
}
|
||||
get redoable()
|
||||
{
|
||||
return this.history && this.position < this.history.length - 1;
|
||||
}
|
||||
undo()
|
||||
{
|
||||
const last = this.last;
|
||||
if(!last)
|
||||
return;
|
||||
|
||||
last.actions.forEach(e => {
|
||||
this.handlers[last.source] && this.handlers[last.source].handlers[last.event]?.undo(e)
|
||||
this.handlers[last.source] && this.handlers[last.source].any && this.handlers[last.source].any!(e);
|
||||
});
|
||||
|
||||
this.position--;
|
||||
}
|
||||
redo()
|
||||
{
|
||||
if(!this.history || this.history.length - 1 <= this.position)
|
||||
return;
|
||||
|
||||
this.position++;
|
||||
|
||||
const last = this.last;
|
||||
if(!last)
|
||||
{
|
||||
this.position--;
|
||||
return;
|
||||
}
|
||||
|
||||
last.actions.forEach(e => {
|
||||
this.handlers[last.source] && this.handlers[last.source].handlers[last.event]?.redo(e)
|
||||
this.handlers[last.source] && this.handlers[last.source].any && this.handlers[last.source].any!(e);
|
||||
});
|
||||
}
|
||||
add(source: string, event: string, actions: HistoryAction[], apply: boolean = false)
|
||||
{
|
||||
this.position++;
|
||||
this.history.splice(this.position, history.length - this.position, { source, event, actions });
|
||||
|
||||
if(apply)
|
||||
actions.forEach(e => {
|
||||
this.handlers[source] && this.handlers[source].handlers[event]?.redo(e);
|
||||
this.handlers[source] && this.handlers[source].any && this.handlers[source].any(e);
|
||||
});
|
||||
}
|
||||
register(source: string, handlers: Record<string, HistoryHandler>, any?: (action: HistoryAction) => void)
|
||||
{
|
||||
this.handlers[source] = { handlers, any };
|
||||
}
|
||||
unregister(source: string)
|
||||
{
|
||||
delete this.handlers[source];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,670 +0,0 @@
|
|||
import { defineMode, type Mode, getMode, StringStream, startState } from "codemirror";
|
||||
import type { MarkdownState } from "hypermd/mode/hypermd";
|
||||
|
||||
const EN = /^(?:[*\-+]|^[0-9]+([.)]))\s+/, SN = /^(?:(?:(?:aaas?|about|acap|adiumxtra|af[ps]|aim|apt|attachment|aw|beshare|bitcoin|bolo|callto|cap|chrome(?:-extension)?|cid|coap|com-eventbrite-attendee|content|crid|cvs|data|dav|dict|dlna-(?:playcontainer|playsingle)|dns|doi|dtn|dvb|ed2k|facetime|feed|file|finger|fish|ftp|geo|gg|git|gizmoproject|go|gopher|gtalk|h323|hcp|https?|iax|icap|icon|im|imap|info|ipn|ipp|irc[6s]?|iris(?:\.beep|\.lwz|\.xpc|\.xpcs)?|itms|jar|javascript|jms|keyparc|lastfm|ldaps?|magnet|mailto|maps|market|message|mid|mms|ms-help|msnim|msrps?|mtqp|mumble|mupdate|mvn|news|nfs|nih?|nntp|notes|oid|opaquelocktoken|palm|paparazzi|platform|pop|pres|proxy|psyc|query|res(?:ource)?|rmi|rsync|rtmp|rtsp|secondlife|service|session|sftp|sgn|shttp|sieve|sips?|skype|sm[bs]|snmp|soap\.beeps?|soldat|spotify|ssh|steam|svn|tag|teamspeak|tel(?:net)?|tftp|things|thismessage|tip|tn3270|tv|udp|unreal|urn|ut2004|vemmi|ventrilo|view-source|webcal|wss?|wtai|wyciwyg|xcon(?:-userid)?|xfire|xmlrpc\.beeps?|xmpp|xri|ymsgr|z39\.50[rs]?):(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]|\([^\s()<>]*\))+(?:\([^\s()<>]*\)|[^\s`*!()\[\]{};:'".,<>?«»“”‘’]))/i, xN = /^(?:(?:[^<>()[\]\\.,;:\s@\"`]+(?:\.[^<>()[\]\\.,;:\s@\"]+)*)|(?:\".+\"))@(?:(?:\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(?:(?:[a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))\b/, TN = /^(?:[^\u2000-\u206F\u2E00-\u2E7F'!"#$%&()*+,.:;<=>?@^`{|}~\[\]\\\s])+/;
|
||||
const PN = /^\s*[^\|].*?\|.*[^|]\s*$/, IN = /^\s*[^\|].*\|/, FN = /^\|(?:[^|]+\|)+?\s*$/, ON = /^\|/, BN = /^\s*-+\s*:\s*$/, NN = /^\s*:\s*-+\s*$/, RN = /^\s*:\s*-+\s*:\s*$/, HN = /^\s*-+\s*$/;
|
||||
const readSideRegex = /(?:([\u200F\p{sc=Arabic}\p{sc=Hebrew}\p{sc=Syriac}\p{sc=Thaana}])|([\u200E\p{sc=Armenian}\p{sc=Bengali}\p{sc=Bopomofo}\p{sc=Braille}\p{sc=Buhid}\p{sc=Canadian_Aboriginal}\p{sc=Cherokee}\p{sc=Cyrillic}\p{sc=Devanagari}\p{sc=Ethiopic}\p{sc=Georgian}\p{sc=Greek}\p{sc=Gujarati}\p{sc=Gurmukhi}\p{sc=Han}\p{sc=Hangul}\p{sc=Hanunoo}\p{sc=Hiragana}\p{sc=Kannada}\p{sc=Katakana}\p{sc=Khmer}\p{sc=Lao}\p{sc=Latin}\p{sc=Limbu}\p{sc=Malayalam}\p{sc=Mongolian}\p{sc=Myanmar}\p{sc=Ogham}\p{sc=Oriya}\p{sc=Runic}\p{sc=Sinhala}\p{sc=Tagalog}\p{sc=Tagbanwa}\p{sc=Tamil}\p{sc=Telugu}\p{sc=Thai}\p{sc=Tibetan}\p{sc=Yi}]))/u;
|
||||
const readSide = function(e: string) {
|
||||
var t = e.match(readSideRegex);
|
||||
if (t) {
|
||||
if (t[1])
|
||||
return "rtl";
|
||||
if (t[2])
|
||||
return "ltr"
|
||||
}
|
||||
return "auto"
|
||||
}
|
||||
const isLetter = (e: string) => /[a-z]/i.test(e);
|
||||
const clearSubstringWords = (str: string, substring: string) => str.replace(new RegExp("\\s?[^\\s]*".concat(substring, "[^\\s]*","g")), "");
|
||||
const enum HashtagType {
|
||||
NONE = 0,
|
||||
NORMAL = 1,
|
||||
WITH_SPACE = 2
|
||||
}
|
||||
const enum TableType {
|
||||
NONE = 0,
|
||||
SIMPLE = 1,
|
||||
NORMAL = 2
|
||||
}
|
||||
const enum NextMaybe {
|
||||
NONE = 0,
|
||||
FRONT_MATTER = 1,
|
||||
FRONT_MATTER_END = 2
|
||||
}
|
||||
const enum LinkType {
|
||||
NONE = 0,
|
||||
BARELINK = 1,
|
||||
FOOTREF = 2,
|
||||
NORMAL = 3,
|
||||
FOOTNOTE = 4,
|
||||
MAYBE_FOOTNOTE_URL = 5,
|
||||
MAYBE_FOOTNOTE_URL_TITLE = 6,
|
||||
BARELINK2 = 7,
|
||||
FOOTREF2 = 8,
|
||||
INTERNAL_LINK = 9,
|
||||
EMBED = 10,
|
||||
}
|
||||
const CLASSES: Record<number, string> = {
|
||||
1: "hmd-barelink",
|
||||
7: "hmd-barelink2",
|
||||
2: "hmd-barelink footref",
|
||||
4: "hmd-barelink hmd-footnote line-HyperMD-footnote",
|
||||
8: "hmd-footref2",
|
||||
9: "hmd-internal-link",
|
||||
10: "hmd-internal-link hmd-embed",
|
||||
}
|
||||
|
||||
export declare type TokenFunc = (stream: CodeMirror.StringStream, state: HyperMDState) => string;
|
||||
export declare type InnerModeExitChecker = (stream: CodeMirror.StringStream, state: HyperMDState) => {
|
||||
endPos?: number;
|
||||
skipInnerMode?: boolean;
|
||||
style?: string;
|
||||
} | null;
|
||||
interface HyperMDState extends MarkdownState {
|
||||
hmdTableRTL: boolean;
|
||||
highlight: boolean;
|
||||
hasAlias: boolean;
|
||||
isAlias: boolean;
|
||||
comment: boolean;
|
||||
mathed: boolean;
|
||||
internalEmbed: any;
|
||||
internalLink: any;
|
||||
inFootnote: boolean;
|
||||
inlineFootnote: boolean;
|
||||
wasHeading: boolean;
|
||||
isHeading: boolean;
|
||||
hmdTable: TableType;
|
||||
hmdTableID: string | null;
|
||||
hmdTableColumns: string[];
|
||||
hmdTableCol: number;
|
||||
hmdTableRow: number;
|
||||
hmdOverride: TokenFunc | null;
|
||||
hmdHashtag: HashtagType | boolean;
|
||||
hmdInnerStyle: string;
|
||||
hmdInnerExitChecker: InnerModeExitChecker | null;
|
||||
hmdInnerMode: CodeMirror.Mode<any> | null;
|
||||
hmdInnerState: any;
|
||||
hmdLinkType: LinkType;
|
||||
hmdNextMaybe: NextMaybe;
|
||||
hmdNextState: HyperMDState | null;
|
||||
hmdNextStyle: string | null;
|
||||
hmdNextPos: number | null;
|
||||
}
|
||||
|
||||
function resetTable(state: HyperMDState)
|
||||
{
|
||||
state.hmdTable = TableType.NONE,
|
||||
state.hmdTableRTL = !1,
|
||||
state.hmdTableColumns = [],
|
||||
state.hmdTableID = null,
|
||||
state.hmdTableCol = state.hmdTableRow = 0
|
||||
}
|
||||
defineMode('d-any', function(cm, config) {
|
||||
const markdownMode: Mode<MarkdownState> = getMode(cm, { ...config, name: 'markdown' }) as any;
|
||||
const mode: Mode<HyperMDState> = getMode(cm, { ...config, name: 'hypermd' }) as any;
|
||||
|
||||
config = Object.assign({}, {
|
||||
front_matter: !0,
|
||||
math: !0,
|
||||
table: !0,
|
||||
toc: !0,
|
||||
orgModeMarkup: !0,
|
||||
hashtag: !0,
|
||||
fencedCodeBlockHighlighting: !0,
|
||||
highlightFormatting: !0,
|
||||
taskLists: !0,
|
||||
strikethrough: !0,
|
||||
emoji: !1,
|
||||
highlight: !0,
|
||||
headers: !0,
|
||||
blockquotes: !0,
|
||||
indentedCode: !0,
|
||||
lists: !0,
|
||||
hr: !0,
|
||||
blockId: !0
|
||||
}, config)
|
||||
|
||||
function modeOverride(stream: CodeMirror.StringStream, state: HyperMDState): string {
|
||||
const exit = state.hmdInnerExitChecker!(stream, state);
|
||||
const extraStyle = state.hmdInnerStyle;
|
||||
|
||||
let ans = (!exit || !exit.skipInnerMode) && state.hmdInnerMode!.token(stream, state.hmdInnerState) || "";
|
||||
|
||||
if (extraStyle) ans += " " + extraStyle;
|
||||
if (exit) {
|
||||
if (exit.style) ans += " " + exit.style;
|
||||
if (exit.endPos) stream.pos = exit.endPos;
|
||||
|
||||
state.hmdInnerExitChecker = null;
|
||||
state.hmdInnerMode = null;
|
||||
state.hmdInnerState = null;
|
||||
state.hmdOverride = null;
|
||||
}
|
||||
|
||||
return ans.trim();
|
||||
}
|
||||
|
||||
function advanceMarkdown(stream: CodeMirror.StringStream, state: HyperMDState) {
|
||||
if (stream.eol() || state.hmdNextState) return false;
|
||||
|
||||
let oldStart = stream.start;
|
||||
let oldPos = stream.pos;
|
||||
|
||||
stream.start = oldPos;
|
||||
let newState = { ...state };
|
||||
let newStyle = mode.token(stream, newState);
|
||||
|
||||
state.hmdNextPos = stream.pos;
|
||||
state.hmdNextState = newState;
|
||||
state.hmdNextStyle = newStyle;
|
||||
|
||||
stream.start = oldStart;
|
||||
stream.pos = oldPos;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function createDummyMode(endTag: string): CodeMirror.Mode<void> {
|
||||
return {
|
||||
token(stream) {
|
||||
let endTagSince = stream.string.indexOf(endTag, stream.start);
|
||||
if (endTagSince === -1) stream.skipToEnd(); // endTag not in this line
|
||||
else if (endTagSince === 0) stream.pos += endTag.length; // current token is endTag
|
||||
else {
|
||||
stream.pos = endTagSince;
|
||||
if (stream.string.charAt(endTagSince - 1) === "\\") stream.pos++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createSimpleInnerModeExitChecker(endTag: string, retInfo?: ReturnType<InnerModeExitChecker>): InnerModeExitChecker {
|
||||
if (!retInfo) retInfo = {};
|
||||
|
||||
return function (stream, state) {
|
||||
if (stream.string.substring(stream.start, stream.start + endTag.length) === endTag) {
|
||||
retInfo.endPos = stream.start + endTag.length;
|
||||
return retInfo;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface BasicInnerModeOptions {
|
||||
skipFirstToken?: boolean
|
||||
style?: string
|
||||
}
|
||||
|
||||
interface InnerModeOptions1 extends BasicInnerModeOptions {
|
||||
fallbackMode: () => CodeMirror.Mode<any>
|
||||
exitChecker: InnerModeExitChecker
|
||||
}
|
||||
|
||||
interface InnerModeOptions2 extends BasicInnerModeOptions {
|
||||
endTag: string
|
||||
}
|
||||
|
||||
type InnerModeOptions = InnerModeOptions1 | InnerModeOptions2
|
||||
|
||||
/**
|
||||
* switch to another mode
|
||||
*
|
||||
* After entering a mode, you can then set `hmdInnerExitStyle` and `hmdInnerState` of `state`
|
||||
*
|
||||
* @returns if `skipFirstToken` not set, returns `innerMode.token(stream, innerState)`, meanwhile, stream advances
|
||||
*/
|
||||
function enterMode(stream: StringStream, state: HyperMDState, mode: string | CodeMirror.Mode<any> | null, opt: InnerModeOptions): string {
|
||||
if (typeof mode === "string") mode = getMode(cm, mode);
|
||||
|
||||
if (!mode || mode["name"] === "null") {
|
||||
if ('endTag' in opt) mode = createDummyMode(opt.endTag);
|
||||
else if(typeof opt.fallbackMode === 'function') mode = opt.fallbackMode();
|
||||
|
||||
if (!mode) throw new Error("no mode");
|
||||
}
|
||||
|
||||
state.hmdInnerExitChecker = ('endTag' in opt) ? createSimpleInnerModeExitChecker(opt.endTag) : opt.exitChecker;
|
||||
state.hmdInnerStyle = opt.style ?? '';
|
||||
state.hmdInnerMode = mode;
|
||||
state.hmdOverride = modeOverride;
|
||||
state.hmdInnerState = startState(mode);
|
||||
|
||||
let ans = opt.style || "";
|
||||
if (!opt.skipFirstToken)
|
||||
ans += " " + mode.token(stream, state.hmdInnerState);
|
||||
|
||||
return ans.trim();
|
||||
}
|
||||
|
||||
const i: Record<string, any> = {
|
||||
htmlBlock: null,
|
||||
block: null
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'd-any',
|
||||
...mode,
|
||||
token(stream, _state) {
|
||||
const state = _state as HyperMDState;
|
||||
stream.tabSize = 4;
|
||||
if (state.hmdOverride)
|
||||
return state.hmdOverride(stream, state);
|
||||
|
||||
if (state.hmdTable && " " === stream.peek())
|
||||
{
|
||||
if ("|" === stream.string[stream.pos - 1] && "\\" !== stream.string[stream.pos - 2])
|
||||
return stream.match(/^ +/), "";
|
||||
if (stream.match(/^ +\|/))
|
||||
return stream.backUp(1), "";
|
||||
}
|
||||
if (state.hmdNextMaybe === NextMaybe.FRONT_MATTER)
|
||||
{
|
||||
if ("---" === stream.string)
|
||||
return state.hmdNextMaybe = NextMaybe.FRONT_MATTER_END, enterMode(stream, state, "yaml", {
|
||||
style: "hmd-frontmatter",
|
||||
fallbackMode: function() {
|
||||
return createDummyMode("---");
|
||||
},
|
||||
exitChecker: function(e, stream) {
|
||||
return e.string.startsWith("---") && "" === e.string.substring(3).trim() ? (stream.hmdNextMaybe = NextMaybe.NONE,
|
||||
{
|
||||
endPos: e.string.length
|
||||
}) : null;
|
||||
}
|
||||
});
|
||||
state.hmdNextMaybe = NextMaybe.NONE;
|
||||
}
|
||||
let a = state.f === i.htmlBlock, c = -1 === state.code, u = state.quote, h = 0 === stream.start;
|
||||
h && (state.inFootnote && state.hmdLinkType === LinkType.MAYBE_FOOTNOTE_URL || (state.hmdLinkType = LinkType.NONE),
|
||||
state.inlineFootnote = !1,
|
||||
state.wasHeading = state.isHeading,
|
||||
state.isHeading = !1,
|
||||
!state.code || 1 !== state.code && 2 !== state.code || (state.code = 0));
|
||||
let d, p, f = state.linkText, m = state.linkHref, v = !(c || a), g = v && !(state.code || state.indentedCode || state.linkHref), y = "", b = !1, w = -1, k = !1;
|
||||
if (v)
|
||||
{
|
||||
if (g && "\\" === stream.peek() && (k = !0), state.list && !state.header && "#" === stream.peek() && /^\s*[*\-+]\s$/.test(stream.string.substring(0, stream.pos)))
|
||||
{
|
||||
const C = stream.match(/^(#+)(?: |$)/, !0);
|
||||
if (C)
|
||||
{
|
||||
const M = C[1].length;
|
||||
return state.header = M, "formatting formatting-header formatting-header-" + M + " header header-" + M;
|
||||
}
|
||||
}
|
||||
if (config.math && g && "$" === stream.peek() && (state.hmdLinkType === LinkType.NONE || state.hmdLinkType === LinkType.MAYBE_FOOTNOTE_URL) && !state.internalLink && !state.internalEmbed)
|
||||
{
|
||||
let E: string[] | null = stream.match(/^(\$)[^\s$]/, !1), S = stream.match(/^(\${2})/, !1), x = E ? "$" : "$$";
|
||||
if (E)
|
||||
{
|
||||
let I = stream.string.slice(stream.pos + 1).match(/[^\\]\$(.|$)/)
|
||||
if(!I || !I[0].match(/^[^\s\\]\$([^0-9]|$)/))
|
||||
E = null;
|
||||
}
|
||||
let T = !1;
|
||||
if (E || S)
|
||||
{
|
||||
if (0 !== stream.pos || state.mathed)
|
||||
{
|
||||
let D = "math";
|
||||
state.quote && (D += " line-HyperMD-quote line-HyperMD-quote-" + state.quote + " line-HyperMD-quote-lazy");
|
||||
const A = getMode(cm, {
|
||||
name: "stex"
|
||||
}), L = "stex" !== A.name;
|
||||
return y += enterMode(stream, state, A, {
|
||||
style: D,
|
||||
skipFirstToken: L,
|
||||
fallbackMode: function() {
|
||||
return createDummyMode(x);
|
||||
},
|
||||
exitChecker: function(e, stream) {
|
||||
let n = e.start, i = e.string, r = "formatting formatting-math formatting-math-end math-";
|
||||
return stream.hmdTable && "|" === i[n] ? {
|
||||
endPos: n,
|
||||
style: r
|
||||
} : i.substring(n, n + x.length) === x ? {
|
||||
endPos: n + x.length,
|
||||
style: r
|
||||
} : null;
|
||||
}
|
||||
}),
|
||||
E ? (L && (stream.pos += E[1].length),
|
||||
y += " formatting formatting-math formatting-math-begin") : (L && (stream.pos += S[1].length),
|
||||
y += " formatting formatting-math formatting-math-begin math-block")
|
||||
}
|
||||
T = !0, w = 0;
|
||||
}
|
||||
state.mathed = T;
|
||||
}
|
||||
if (g) {
|
||||
state.internalLink ? (state.hmdLinkType = LinkType.INTERNAL_LINK,
|
||||
state.internalLink = !1) : state.internalEmbed && (state.hmdLinkType = LinkType.EMBED,
|
||||
state.internalEmbed = !1);
|
||||
let P = state.hmdLinkType === LinkType.INTERNAL_LINK || state.hmdLinkType === LinkType.EMBED;
|
||||
if (P)
|
||||
if ("|" === stream.peek())
|
||||
state.isAlias = !0,
|
||||
w = stream.pos + 1,
|
||||
y += " link-alias-pipe";
|
||||
else if ("]" === stream.peek() && stream.match("]]", !1))
|
||||
state.hmdLinkType = LinkType.NONE,
|
||||
state.linkText = !1,
|
||||
state.isAlias = !1,
|
||||
state.hasAlias = !1,
|
||||
w = stream.pos + 2,
|
||||
y += " formatting-link formatting-link-end";
|
||||
else {
|
||||
b = !0,
|
||||
state.isAlias && (y += " link-alias"),
|
||||
state.hasAlias && !state.isAlias && (y += " link-has-alias");
|
||||
let I = stream.match(/^([^|\]]*?)/, !1);
|
||||
w = stream.pos + Math.max(1, I[0].length)
|
||||
}
|
||||
else if("!" === stream.peek() || "[" === stream.peek())
|
||||
{
|
||||
const I = stream.match(/^(!?\[\[)(.*?)]]/, !1)
|
||||
if(I)
|
||||
"!" === I[1].charAt(0) ? (y += " formatting-link formatting-link-start formatting-embed", state.internalEmbed = !0) : (y += " formatting-link formatting-link-start", state.internalLink = !0), w = stream.pos + I[1].length, state.hasAlias = I[2].includes("|");
|
||||
}
|
||||
if (state.hmdLinkType === LinkType.FOOTREF)
|
||||
if (b = !0,
|
||||
"]" === stream.peek())
|
||||
state.hmdLinkType = LinkType.NONE,
|
||||
w = stream.pos + 1,
|
||||
y += " formatting formatting-link formatting-link-end " + CLASSES[LinkType.FOOTREF];
|
||||
else {
|
||||
let I = stream.match(/^([^\]]*?)/, !1);
|
||||
w = stream.pos + Math.max(1, I[0].length)
|
||||
}
|
||||
else
|
||||
{
|
||||
const I = stream.peek() === "[" && stream.match(/^\[\^([^\]\s]*?)\](:?)/, false);
|
||||
if(I && (h || I[2]))
|
||||
{
|
||||
stream.match("[^"),
|
||||
y += " formatting formatting-link formatting-link-start",
|
||||
state.hmdLinkType = LinkType.FOOTREF,
|
||||
w = stream.pos;
|
||||
}
|
||||
}
|
||||
if (config.blockId && "^" === stream.peek() && stream.match(/^\^([a-zA-Z0-9\-]+)$/))
|
||||
return y += " blockid";
|
||||
!state.inlineFootnote && "^" === stream.peek() && stream.match("^[", !1) ? (state.inlineFootnote = !0,
|
||||
y += " inline-footnote-start formatting-inline-footnote",
|
||||
w = stream.pos + 2) : state.inlineFootnote && !P && state.hmdLinkType === LinkType.NONE && !state.image && stream.match("]") && (state.inlineFootnote = !1,
|
||||
y += " footref inline-footnote inline-footnote-end formatting-inline-footnote",
|
||||
w = stream.pos),
|
||||
"%" === stream.peek() && stream.match("%%", !1) ? (state.comment ? (y += " comment formatting comment-end",
|
||||
state.comment = !1) : (y += " comment formatting comment-start",
|
||||
state.comment = !0),
|
||||
w = stream.pos + 2) : state.comment && (y += " comment")
|
||||
}
|
||||
if (g && (state.hmdLinkType || state.image || state.linkText || (isLetter(stream.peek()!) && stream.match(SN) || (p = stream.peek(),
|
||||
!/[\s<>()[\]\\.,;:\s@\"`]/.test(p!) && stream.match(xN))) && (y += " url",
|
||||
w = stream.pos)),
|
||||
h && state.inFootnote) {
|
||||
let F = stream.match(/^\s*/, !1)[0].replace(/\stream/g, " ").length;
|
||||
F && F % stream.tabSize == 0 ? y += " line-HyperMD-footnote" : state.inFootnote = !1
|
||||
}
|
||||
let O = h && "[" === stream.peek() && stream.match(/^\[((?:[^\]\\]|\\.)*)\]:/, !1);
|
||||
if (O) {
|
||||
let B = O[1];
|
||||
if ("^" !== B[0] || !/\s/.test(B))
|
||||
return stream.match(/\[\^?/),
|
||||
state.hmdLinkType = LinkType.FOOTNOTE,
|
||||
state.formatting = "link",
|
||||
state.linkText = !0,
|
||||
y += "formatting formatting-link link " + CLASSES[LinkType.FOOTNOTE]
|
||||
} else if (state.hmdLinkType === LinkType.FOOTNOTE) {
|
||||
if ("]" === stream.peek() && stream.match("]:"))
|
||||
return y += " formatting formatting-link link " + CLASSES[LinkType.FOOTNOTE],
|
||||
state.linkText = !1,
|
||||
state.inFootnote = !0,
|
||||
state.hmdLinkType = LinkType.MAYBE_FOOTNOTE_URL,
|
||||
//@ts-ignore
|
||||
state.f = state.inline = markdownMode.startState().inline,
|
||||
y;
|
||||
y += " link " + CLASSES[LinkType.FOOTNOTE]
|
||||
} else if (state.hmdLinkType === LinkType.MAYBE_FOOTNOTE_URL) {
|
||||
if (stream.eatSpace())
|
||||
return y;
|
||||
if (isLetter(stream.peek()!) && stream.match(SN))
|
||||
return y += " url hmd-footnote-url",
|
||||
state.hmdLinkType = LinkType.MAYBE_FOOTNOTE_URL_TITLE,
|
||||
y;
|
||||
state.hmdLinkType = LinkType.NONE
|
||||
} else if (state.hmdLinkType === LinkType.MAYBE_FOOTNOTE_URL_TITLE) {
|
||||
if (stream.eatSpace())
|
||||
return y;
|
||||
if (state.hmdLinkType = LinkType.NONE,
|
||||
stream.match(/^(["']).*?\1/) || stream.match(/^\([^)]*?\)/))
|
||||
return y += " hmd-footnote-url-title"
|
||||
}
|
||||
}
|
||||
if (state.hmdTable && "|" === stream.peek() && "\\" !== stream.string[stream.pos - 1] && function(e)
|
||||
{
|
||||
e.code = !1,
|
||||
e.comment = !1,
|
||||
e.em = !1,
|
||||
e.formatting = !1,
|
||||
e.highlight = !1,
|
||||
e.hmdHashtag = !1,
|
||||
e.hmdLinkType = LinkType.NONE,
|
||||
e.isAlias = !1,
|
||||
e.internalEmbed = !1,
|
||||
e.internalLink = !1,
|
||||
e.linkHref = !1,
|
||||
e.linkText = !1,
|
||||
e.linkTitle = !1,
|
||||
e.strikethrough = !1,
|
||||
e.strong = !1
|
||||
}(state),
|
||||
state.hmdNextState)
|
||||
stream.pos = state.hmdNextPos!,
|
||||
y += " " + (state.hmdNextStyle || ""),
|
||||
Object.assign(state, state.hmdNextState),
|
||||
state.hmdNextState = null,
|
||||
state.hmdNextStyle = null,
|
||||
state.hmdNextPos = null;
|
||||
else {
|
||||
let N = h && 0 !== stream.pos;
|
||||
if (b) {
|
||||
//@ts-ignore
|
||||
let R = markdownMode.copyState(state), H = stream.pos;
|
||||
y += " " + (markdownMode.token(stream, R) || ""),
|
||||
stream.pos = H
|
||||
} else
|
||||
y += " " + (markdownMode.token(stream, state) || "");
|
||||
//@ts-ignore
|
||||
N && state.f === state.block && (state.f = state.inline = markdownMode.startState().inline),
|
||||
state.inFootnote && (state.indentationDiff = 0)
|
||||
}
|
||||
y = function(e, text) {
|
||||
return text ? (!config.hr && e.hr && (text = clearSubstringWords(text, "hr"),
|
||||
e.hr = !1),
|
||||
!config.headers && e.header && (text = clearSubstringWords(text, "header"),
|
||||
e.header = 0),
|
||||
!config.indentedCode && e.indentedCode && (text = clearSubstringWords(text, "inline-code"),
|
||||
e.indentedCode = !1),
|
||||
!config.blockquotes && e.quote && (text = clearSubstringWords(text, "quote"),
|
||||
e.quote = 0),
|
||||
!config.lists && e.list && (text = clearSubstringWords(text, "list"),
|
||||
e.list = !1),
|
||||
text) : text
|
||||
}(state, y),
|
||||
y.includes("formatting-task") && (y += " line-HyperMD-task-line"),
|
||||
state.hmdHashtag && (y += " " + config.tokenTypeOverrides.hashtag),
|
||||
-1 !== w && (stream.pos = w),
|
||||
state.header && (state.isHeading = !0),
|
||||
!i.htmlBlock && state.htmlState && (i.htmlBlock = state.f);
|
||||
let V = state.f === i.htmlBlock
|
||||
, z = -1 === state.code;
|
||||
if (v = v && !(V || z),
|
||||
g = g && v && !(state.code || state.indentedCode || state.linkHref),
|
||||
state.hmdTable && V) {
|
||||
let q = stream.current();
|
||||
/(?:^|[^\\])\|/.test(q) && ("" === y.trim() || /string|attribute/.test(y)) && (V = !1,
|
||||
a = !1,
|
||||
state.htmlState = null,
|
||||
state.block = i.block,
|
||||
//@ts-ignore
|
||||
state.f = state.inline = markdownMode.startState().inline,
|
||||
stream.pos = "|" === q ? stream.start : stream.start + 1)
|
||||
}
|
||||
let U = stream.current();
|
||||
if (V !== a && (V ? (y += " hmd-html-begin",
|
||||
i.htmlBlock = state.f) : y += " hmd-html-end"),
|
||||
(c || z) && (state.localMode && c || (y = y.replace("inline-code", "")),
|
||||
y += " line-HyperMD-codeblock line-background-HyperMD-codeblock-bg hmd-codeblock",
|
||||
z !== c && (z ? c || (y += " line-HyperMD-codeblock-begin line-background-HyperMD-codeblock-begin-bg") : y += " line-HyperMD-codeblock-end line-background-HyperMD-codeblock-end-bg")),
|
||||
v) {
|
||||
let _ = state.hmdTable;
|
||||
if (h && _)
|
||||
(_ == TableType.SIMPLE ? IN : ON).test(stream.string) ? (state.hmdTableCol = 0,
|
||||
state.hmdTableRow++) : resetTable(state);
|
||||
if (h && state.header && (/^(?:---+|===+)\s*$/.test(stream.string) && state.prevLine && state.prevLine.header ? y += " line-HyperMD-header-line line-HyperMD-header-line-" + state.header : y += " line-HyperMD-header line-HyperMD-header-" + state.header),
|
||||
state.indentedCode && (y += " hmd-indented-code"),
|
||||
state.quote) {
|
||||
if (stream.match(/^\s*>/, !1) && !stream.eol() || (y += " line-HyperMD-quote line-HyperMD-quote-" + state.quote,
|
||||
/^ {0,3}\>/.test(stream.string) || (y += " line-HyperMD-quote-lazy")),
|
||||
h && (d = U.match(/^\s+/)))
|
||||
return stream.pos = d![0].length,
|
||||
(y += " hmd-indent-in-quote").trim();
|
||||
if (state.quote > u)
|
||||
{
|
||||
const I = "[" === stream.peek() && stream.match(/^\[!([^\]]+)\]([+\-]?)(?:\s|$)/);
|
||||
if(I)
|
||||
y += " line-HyperMD-callout hmd-callout line-HyperMD-quote line-HyperMD-quote-" + state.quote
|
||||
}
|
||||
}
|
||||
let W = (state.listStack[state.listStack.length - 1] || 0) + 3
|
||||
, j = h && /^\s+$/.test(U) && (!1 !== state.list || stream.indentation() <= W)
|
||||
, G = state.list && y.includes("formatting-list");
|
||||
if (G || j && (!1 !== state.list || stream.match(EN, !1))) {
|
||||
let K = state.listStack && state.listStack.length || 0;
|
||||
if (j) {
|
||||
if (stream.match(EN, !1))
|
||||
!1 === state.list && K++;
|
||||
else {
|
||||
for (; K > 0 && stream.pos < state.listStack[K - 1]; )
|
||||
K--;
|
||||
if (!K)
|
||||
return y.trim() || null;
|
||||
y += " line-HyperMD-list-line-nobullet line-HyperMD-list-line line-HyperMD-list-line-".concat(K.toString())
|
||||
}
|
||||
y += " hmd-list-indent hmd-list-indent-".concat(K.toString())
|
||||
} else
|
||||
G && (y += " line-HyperMD-list-line line-HyperMD-list-line-".concat(K.toString()))
|
||||
}
|
||||
if (f !== state.linkText && (f || state.internalLink || state.internalEmbed ? state.hmdLinkType !== LinkType.FOOTNOTE && (state.hmdLinkType in CLASSES && (y += " " + CLASSES[state.hmdLinkType]),
|
||||
state.hmdLinkType = LinkType.NONE) : (d = stream.match(/^([^\]]+)\](\(| ?\[|\:)?/, !1)) ? d[2] ? "[" !== d[2] && " [" !== d[2] || "]" !== stream.string.charAt(stream.pos + d[0].length) ? state.hmdLinkType = LinkType.NORMAL : state.hmdLinkType = LinkType.BARELINK2 : "^" !== d[1][0] || /\s/.test(d[1]) ? state.hmdLinkType = LinkType.BARELINK : state.hmdLinkType = LinkType.FOOTREF : state.hmdLinkType = LinkType.BARELINK),
|
||||
m !== state.linkHref && (m ? state.hmdLinkType && (y += " " + CLASSES[state.hmdLinkType],
|
||||
state.hmdLinkType = LinkType.NONE) : "[" === U && "]" !== stream.peek() && (state.hmdLinkType = LinkType.FOOTREF2)),
|
||||
state.hmdLinkType !== LinkType.NONE && state.hmdLinkType in CLASSES && (y += " " + CLASSES[state.hmdLinkType]),
|
||||
state.inlineFootnote && (y += " footref inline-footnote"),
|
||||
k && U.length > 1) {
|
||||
let Y = U.length - 1
|
||||
, Z = y.replace("formatting-escape", "escape") + " hmd-escape-char";
|
||||
return state.hmdOverride = function(e, stream) {
|
||||
return e.pos += Y,
|
||||
stream.hmdOverride = null,
|
||||
Z.trim()
|
||||
}
|
||||
,
|
||||
y += " hmd-escape-backslash",
|
||||
stream.pos -= Y,
|
||||
y
|
||||
}
|
||||
if (!y.trim() && config.table) {
|
||||
let X = !1;
|
||||
if ("|" === U.charAt(0) && (stream.pos = stream.start + 1,
|
||||
U = "|",
|
||||
X = !0),
|
||||
!_ && state.prevLine && state.prevLine.stream && state.prevLine.stream.string.trim() && !state.wasHeading && (X = !1),
|
||||
X) {
|
||||
if (!_) {
|
||||
PN.test(stream.string) ? _ = TableType.SIMPLE : FN.test(stream.string) && (_ = TableType.NORMAL);
|
||||
let $: string[] | undefined = void 0;
|
||||
if (_) {
|
||||
let Q = stream.lookAhead(1);
|
||||
if (_ === TableType.NORMAL ? FN.test(Q) ? Q = Q.replace(/^\s*\|/, "").replace(/\|\s*$/, "") : _ = TableType.NONE : _ === TableType.SIMPLE && (PN.test(Q) || (_ = TableType.NONE)),
|
||||
_) {
|
||||
$ = Q.split("|");
|
||||
for (let J = 0; J < $.length; J++) {
|
||||
let ee = $[J];
|
||||
if (BN.test(ee))
|
||||
ee = "right";
|
||||
else if (NN.test(ee))
|
||||
ee = "left";
|
||||
else if (RN.test(ee))
|
||||
ee = "center";
|
||||
else {
|
||||
if (!HN.test(ee)) {
|
||||
_ = TableType.NONE;
|
||||
break
|
||||
}
|
||||
ee = "default"
|
||||
}
|
||||
$[J] = ee
|
||||
}
|
||||
}
|
||||
}
|
||||
_ && (state.hmdTable = _,
|
||||
state.hmdTableColumns = $!,
|
||||
"rtl" === readSide(stream.string) && (state.hmdTableRTL = !0),
|
||||
state.hmdTableRow = state.hmdTableCol = 0)
|
||||
}
|
||||
if (_) {
|
||||
let te = state.hmdTableColumns.length - 1
|
||||
, ne = state.hmdTableCol
|
||||
, ee = state.hmdTableRow;
|
||||
0 == ne && (y += " line-HyperMD-table-".concat(_.toString(), " line-HyperMD-table-row line-HyperMD-table-row-").concat(ee.toString()),
|
||||
state.hmdTableRTL && (y += " line-HyperMD-table-rtl")),
|
||||
_ === TableType.NORMAL && (0 === state.hmdTableCol && /^\s*\|$/.test(stream.string.slice(0, stream.pos)) || stream.match(/^\s*$/, !1)) ? y += " hmd-table-sep hmd-table-sep-dummy" : state.hmdTableCol < te && (y += " hmd-table-sep hmd-table-sep-".concat(ne.toString()),
|
||||
state.hmdTableCol += 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_ && 1 === state.hmdTableRow && y.includes("emoji") && (y = ""),
|
||||
g && "<" === U) {
|
||||
let ie = null;
|
||||
if ("!" === stream.peek() && stream.match(/^\![A-Z]+/) ? ie = ">" : "!" === stream.peek() && stream.match("![CDATA[") ? ie = "]]>" : "?" === stream.peek() && (ie = "?>"),
|
||||
null != ie)
|
||||
return enterMode(stream, state, null, {
|
||||
endTag: ie,
|
||||
style: (y + " comment hmd-cdata-html").trim()
|
||||
})
|
||||
}
|
||||
if (config.hashtag && g)
|
||||
if (state.hmdHashtag) {
|
||||
let re = !1;
|
||||
if (!(y = y.replace(/((formatting )?formatting-em|em) /g, "")).includes("formatting") && !/^\s*$/.test(U)) {
|
||||
d = U.match(TN);
|
||||
let oe = U.length - (d ? d[0].length : 0);
|
||||
oe > 0 && (stream.backUp(oe),
|
||||
re = !0)
|
||||
}
|
||||
if (re || (re = stream.eol()),
|
||||
re || (re = !TN.test(stream.peek()!)),
|
||||
re)
|
||||
{
|
||||
let le = stream.current();
|
||||
y += " hashtag-end " + (le = "tag-" + le.replace(/[^_a-zA-Z0-9\-]/g, "")),
|
||||
state.hmdHashtag = !1
|
||||
}
|
||||
} else if ("#" === U && !state.linkText && !state.image && (h || /^\s*$/.test(stream.string.charAt(stream.start - 1)))) {
|
||||
let ae = stream.string.slice(stream.pos).replace(/\\./g, "")
|
||||
, se = TN.exec(ae);
|
||||
if (se && /[^0-9]/.test(se[0])) {
|
||||
let le = "tag-" + se[0].replace(/[^_a-zA-Z0-9\-]/g, "");
|
||||
state.hmdHashtag = !0,
|
||||
y += " formatting formatting-hashtag hashtag-begin " + config.tokenTypeOverrides.hashtag + " " + le
|
||||
}
|
||||
}
|
||||
}
|
||||
return y.trim() || null;
|
||||
},
|
||||
}
|
||||
}, 'd-any');
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import type { Root, RootContent } from "hast";
|
||||
import { dom, styling, text, type Class, type Node } from "./dom.util";
|
||||
import prose, { tag, a, blockquote, h1, h2, h3, h4, h5, hr, li, small, table, td, th, callout, type Prose } from "./proses";
|
||||
import { heading } from "hast-util-heading";
|
||||
import { headingRank } from "hast-util-heading-rank";
|
||||
import { parseId } from "./general.util";
|
||||
import { loading } from "#shared/proses";
|
||||
|
||||
export function renderMarkdown(markdown: Root, proses: Record<string, Prose>): HTMLDivElement
|
||||
{
|
||||
return dom('div', {}, markdown.children.map(e => renderContent(e, proses)));
|
||||
}
|
||||
|
||||
function renderContent(node: RootContent, proses: Record<string, Prose>): Node
|
||||
{
|
||||
if(node.type === 'text' && node.value.length > 0 && node.value !== '\n')
|
||||
{
|
||||
return text(node.value);
|
||||
}
|
||||
else if(node.type === 'comment' && node.value.length > 0 && node.value !== '\n')
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
else if(node.type === 'element')
|
||||
{
|
||||
const children = node.children.map(e => renderContent(e, proses)), properties = { ...node.properties, class: node.properties.className as string | string[] };
|
||||
if(node.tagName in proses)
|
||||
return prose(node.tagName, proses[node.tagName], children, properties);
|
||||
else
|
||||
return dom(node.tagName as keyof HTMLElementTagNameMap, properties, children);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface MDProperties
|
||||
{
|
||||
class?: Class;
|
||||
style?: string | Record<string, string>
|
||||
}
|
||||
export default function(content: string, filter?: string, properties?: MDProperties): HTMLElement
|
||||
{
|
||||
const load = loading('normal');
|
||||
|
||||
useMarkdown().parse(content).then(data => {
|
||||
if(filter)
|
||||
{
|
||||
const start = data?.children.findIndex(e => heading(e) && parseId(e.properties.id as string | undefined) === filter) ?? -1;
|
||||
|
||||
if(start !== -1)
|
||||
{
|
||||
let end = start;
|
||||
const rank = headingRank(data.children[start])!;
|
||||
while(end < data.children.length)
|
||||
{
|
||||
end++;
|
||||
if(heading(data.children[end]) && headingRank(data.children[end])! <= rank)
|
||||
break;
|
||||
}
|
||||
data = { ...data, children: data.children.slice(start, end) };
|
||||
}
|
||||
}
|
||||
|
||||
const el = renderMarkdown(data, { tag, a, blockquote, callout, h1, h2, h3, h4, h5, hr, li, small, table, td, th, });
|
||||
|
||||
if(properties) styling(el, properties);
|
||||
|
||||
load.parentElement?.replaceChild(el, load);
|
||||
});
|
||||
|
||||
return load;
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
import { dom, icon, type NodeChildren, type Node, type NodeProperties, type Class, mergeClasses } from "./dom.util";
|
||||
import { parseURL } from 'ufo';
|
||||
import render from "./markdown.util";
|
||||
import { popper } from "#shared/floating.util";
|
||||
import { Canvas } from "./canvas.util";
|
||||
import { Content, iconByType, type LocalContent } from "./content.util";
|
||||
import type { RouteLocationAsRelativeTyped, RouteMapGeneric } from "vue-router";
|
||||
|
||||
export type CustomProse = (properties: any, children: NodeChildren) => Node;
|
||||
export type Prose = { class: string } | { custom: CustomProse };
|
||||
export const tag: Prose = {
|
||||
custom(properties, children) {
|
||||
const tag = properties.tag as string;
|
||||
const el = dom('span', { class: "before:content-['#'] cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30" }, children);
|
||||
const overview = Content.overview('tags');
|
||||
|
||||
let rendered = false;
|
||||
|
||||
if(!!overview)
|
||||
{
|
||||
popper(el, {
|
||||
arrow: true,
|
||||
delay: 150,
|
||||
offset: 10,
|
||||
placement: 'bottom-start',
|
||||
class: 'data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 data-[state=open]:transition-transform text-light-100 dark:text-dark-100 max-w-[600px] max-h-[600px] w-full z-[45]',
|
||||
content: [loading("large")],
|
||||
onShow(content: HTMLDivElement) {
|
||||
if(!rendered)
|
||||
{
|
||||
Content.content('tags').then((overview) => {
|
||||
content.replaceChild(render((overview as LocalContent<'markdown'>).content ?? '', tag, { class: 'max-w-[600px] max-h-[600px] w-full overflow-auto py-4 px-6' }), content.children[0]);
|
||||
});
|
||||
rendered = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
}
|
||||
export const a: Prose = {
|
||||
custom(properties, children) {
|
||||
const href = properties.href as string;
|
||||
const { hash, pathname } = parseURL(href);
|
||||
const router = useRouter();
|
||||
|
||||
const overview = Content.overview(pathname === '' && hash.length > 0 ? router.currentRoute.value.params.path[0] : pathname);
|
||||
|
||||
const link = overview ? { name: 'explore-path', params: { path: overview.path }, hash: hash } : href, nav = router.resolve(link);
|
||||
|
||||
let rendered = false;
|
||||
|
||||
const el = dom('a', { class: 'text-accent-blue inline-flex items-center', attributes: { href: nav.href }, listeners: {
|
||||
'click': (e) => {
|
||||
e.preventDefault();
|
||||
router.push(link);
|
||||
}
|
||||
}}, [
|
||||
dom('span', {}, [
|
||||
...(children ?? []),
|
||||
overview && overview.type !== 'markdown' ? icon(iconByType[overview.type], { class: 'w-4 h-4 inline-block', inline: true }) : undefined
|
||||
])
|
||||
]);
|
||||
|
||||
if(!!overview)
|
||||
{
|
||||
popper(el, {
|
||||
arrow: true,
|
||||
delay: 150,
|
||||
offset: 12,
|
||||
placement: 'bottom-start',
|
||||
class: 'data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 data-[state=open]:transition-transform text-light-100 dark:text-dark-100 min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-full z-[45]',
|
||||
content: [loading("large")],
|
||||
onShow(content: HTMLDivElement) {
|
||||
if(!rendered)
|
||||
{
|
||||
Content.content(overview.path).then((overview) => {
|
||||
if(overview?.type === 'markdown')
|
||||
{
|
||||
content.replaceChild(render((overview as LocalContent<'markdown'>).content ?? '', hash.length > 0 ? hash.substring(1) : undefined, { class: 'min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-full overflow-auto py-4 px-6' }), content.children[0]);
|
||||
}
|
||||
if(overview?.type === 'canvas')
|
||||
{
|
||||
const canvas = new Canvas((overview as LocalContent<'canvas'>).content);
|
||||
content.replaceChild(dom('div', { class: 'w-[600px] h-[600px] relative' }, [canvas.container]), content.children[0]);
|
||||
canvas.mount();
|
||||
}
|
||||
});
|
||||
rendered = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
}
|
||||
export const fakeA: Prose = {
|
||||
custom(properties, children) {
|
||||
const href = properties.href as string;
|
||||
const { hash, pathname } = parseURL(href);
|
||||
const router = useRouter();
|
||||
|
||||
const overview = Content.overview(pathname === '' && hash.length > 0 ? router.currentRoute.value.params.path[0] : pathname);
|
||||
|
||||
const el = dom('span', { class: 'cursor-pointer text-accent-blue inline-flex items-center' }, [
|
||||
dom('span', {}, [
|
||||
...(children ?? []),
|
||||
overview && overview.type !== 'markdown' ? icon(iconByType[overview.type], { class: 'w-4 h-4 inline-block', inline: true }) : undefined
|
||||
])
|
||||
]);
|
||||
|
||||
if(!!overview)
|
||||
{
|
||||
const magicKeys = useMagicKeys();
|
||||
popper(el, {
|
||||
arrow: true,
|
||||
delay: 150,
|
||||
offset: 12,
|
||||
placement: 'bottom-start',
|
||||
class: 'data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 data-[state=open]:transition-transform text-light-100 dark:text-dark-100 min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-full z-[45]',
|
||||
content: [loading("large")],
|
||||
onShow(content: HTMLDivElement) {
|
||||
if(!magicKeys.current.has('control') || magicKeys.current.has('meta'))
|
||||
return false;
|
||||
|
||||
content.replaceChild(loading("large"), content.children[0]);
|
||||
Content.content(overview.path).then((overview) => {
|
||||
if(overview?.type === 'markdown')
|
||||
{
|
||||
content.replaceChild(render((overview as LocalContent<'markdown'>).content ?? '', hash.length > 0 ? hash.substring(1) : undefined, { class: 'min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-full overflow-auto py-4 px-6' }), content.children[0]);
|
||||
}
|
||||
if(overview?.type === 'canvas')
|
||||
{
|
||||
const canvas = new Canvas((overview as LocalContent<'canvas'>).content);
|
||||
content.replaceChild(dom('div', { class: 'w-[600px] h-[600px] relative' }, [canvas.container]), content.children[0]);
|
||||
canvas.mount();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
}
|
||||
export const callout: Prose = {
|
||||
custom(properties, children) {
|
||||
const calloutIconByType: Record<string, string> = {
|
||||
note: 'radix-icons:pencil-1',
|
||||
abstract: 'radix-icons:file-text',
|
||||
info: 'radix-icons:info-circled',
|
||||
todo: 'radix-icons:check-circled',
|
||||
tip: 'radix-icons:star',
|
||||
success: 'radix-icons:check',
|
||||
question: 'radix-icons:question-mark-circled',
|
||||
warning: 'radix-icons:exclamation-triangle',
|
||||
failure: 'radix-icons:cross-circled',
|
||||
danger: 'radix-icons:circle-backslash',
|
||||
bug: 'solar:bug-linear',
|
||||
example: 'radix-icons:list-bullet',
|
||||
quote: 'radix-icons:quote',
|
||||
};
|
||||
const defaultCalloutIcon = 'radix-icons:info-circled';
|
||||
|
||||
const { type, title, fold }: {
|
||||
type: string;
|
||||
title?: string;
|
||||
fold?: boolean;
|
||||
} = properties;
|
||||
|
||||
let open = fold;
|
||||
const trigger = dom('div', { class: [{'cursor-pointer': fold !== undefined}, 'flex flex-row items-center justify-start ps-2'] }, [icon(calloutIconByType[type] ?? defaultCalloutIcon, { inline: true, width: 24, height: 24, class: 'w-6 h-6 stroke-2 float-start me-2 flex-shrink-0' }), !!title ? dom('span', { class: 'block font-bold text-start', text: title }) : undefined, fold !== undefined ? icon('radix-icons:caret-right', { height: 24, width: 24, class: 'transition-transform group-data-[state=open]:rotate-90 w-6 h-6 mx-6' }) : undefined]);
|
||||
const container = dom('div', { class: 'callout group overflow-hidden my-4 p-3 ps-4 bg-blend-lighten !bg-opacity-25 border-l-4 inline-block pe-8 bg-light-blue dark:bg-dark-blue', attributes: { 'data-state': fold !== false ? 'closed' : 'open', 'data-type': type } }, [
|
||||
trigger,
|
||||
dom('div', { class: 'overflow-hidden group-data-[state=closed]:animate-[collapseClose_0.2s_ease-in-out] group-data-[state=open]:animate-[collapseOpen_0.2s_ease-in-out] group-data-[state=closed]:h-0' }, [
|
||||
dom('div', { class: 'px-2' }, children),
|
||||
])
|
||||
]);
|
||||
|
||||
trigger.addEventListener('click', e => {
|
||||
container.setAttribute('data-state', open ? 'open' : 'closed');
|
||||
open = !open;
|
||||
})
|
||||
|
||||
return container;
|
||||
},
|
||||
}
|
||||
export const blockquote: Prose = {
|
||||
class: 'empty:before:hidden ps-4 my-4 relative before:absolute before:-top-1 before:-bottom-1 before:left-0 before:w-1 before:bg-light-30 dark:before:bg-dark-30',
|
||||
}
|
||||
export const h1: Prose = {
|
||||
class: 'text-5xl font-thin mt-3 mb-8 first:pt-0 pt-2',
|
||||
}
|
||||
export const h2: Prose = {
|
||||
class: 'text-4xl font-semibold mt-3 mb-6 ms-1 first:pt-0 pt-2',
|
||||
}
|
||||
export const h3: Prose = {
|
||||
class: 'text-2xl font-bold mt-2 mb-4',
|
||||
}
|
||||
export const h4: Prose = {
|
||||
class: 'text-xl font-semibold my-2',
|
||||
}
|
||||
export const h5: Prose = {
|
||||
class: 'text-lg font-semibold my-1',
|
||||
}
|
||||
export const hr: Prose = {
|
||||
class: 'border-b border-light-35 dark:border-dark-35 m-4',
|
||||
}
|
||||
export const li: Prose = {
|
||||
class: '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',
|
||||
}
|
||||
export const small: Prose = {
|
||||
class: 'text-light-60 dark:text-dark-60 text-sm italic',
|
||||
}
|
||||
export const table: Prose = {
|
||||
class: 'mx-4 my-8 border-collapse border border-light-35 dark:border-dark-35',
|
||||
}
|
||||
export const td: Prose = {
|
||||
class: 'border border-light-35 dark:border-dark-35 py-1 px-2',
|
||||
}
|
||||
export const th: Prose = {
|
||||
class: 'border border-light-35 dark:border-dark-35 px-4 first:pt-0',
|
||||
}
|
||||
|
||||
export default function(tag: string, prose: Prose, children?: NodeChildren, properties?: any): Node
|
||||
{
|
||||
if('class' in prose)
|
||||
{
|
||||
return dom(tag as keyof HTMLElementTagNameMap, { class: [properties?.class, prose.class] }, children ?? []);
|
||||
}
|
||||
else
|
||||
{
|
||||
return prose.custom(properties, children ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
export function link(properties?: NodeProperties & { active?: Class }, link?: RouteLocationAsRelativeTyped<RouteMapGeneric, string>, children?: NodeChildren)
|
||||
{
|
||||
const router = useRouter();
|
||||
const nav = link ? router.resolve(link) : undefined;
|
||||
return dom('a', { ...properties, class: [properties?.class, properties?.active && router.currentRoute.value.fullPath === nav?.fullPath ? properties.active : undefined], attributes: { href: nav?.href, 'data-active': properties?.active ? mergeClasses(properties?.active) : undefined }, listeners: link ? {
|
||||
click: function(e)
|
||||
{
|
||||
e.preventDefault();
|
||||
router.push(link);
|
||||
}
|
||||
} : undefined }, children);
|
||||
}
|
||||
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'}] })
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import { Content, getPath, type LocalContent } from "./content.util";
|
||||
import { dom } from "./dom.util";
|
||||
import { clamp } from "./general.util";
|
||||
|
||||
export type Recursive<T> = T & {
|
||||
children?: T[];
|
||||
parent?: T;
|
||||
};
|
||||
export class Tree<T extends Omit<LocalContent, 'content'>>
|
||||
{
|
||||
private _data: Recursive<T>[];
|
||||
private _flatten: T[];
|
||||
|
||||
constructor(data: T[])
|
||||
{
|
||||
this._data = data;
|
||||
this._flatten = this.accumulate(e => e);
|
||||
}
|
||||
|
||||
remove(path: string)
|
||||
{
|
||||
const recursive = (data?: Recursive<T>[], parent?: T) => data?.filter(e => getPath(e) !== path)?.map((e, i) => {
|
||||
e.order = i;
|
||||
e.children = recursive(e.children as T[], e);
|
||||
return e;
|
||||
});
|
||||
|
||||
this._data = recursive(this._data)!;
|
||||
this._flatten = this.accumulate(e => e);
|
||||
}
|
||||
insertAt(item: Recursive<T>, pos: number)
|
||||
{
|
||||
const parent = item.parent ? getPath(item.parent) : undefined;
|
||||
const recursive = (data?: Recursive<T>[]) => data?.flatMap(e => {
|
||||
if(getPath(e) === parent)
|
||||
{
|
||||
e.children = e.children ?? [];
|
||||
e.children.splice(clamp(pos, 0, e.children.length), 0, item);
|
||||
}
|
||||
else if(e.type === 'folder')
|
||||
e.children = recursive(e.children as T[]);
|
||||
|
||||
return e;
|
||||
}).map((e, i) => { e.order = i; return e; });
|
||||
|
||||
if(!parent || parent === '')
|
||||
{
|
||||
this._data.splice(pos, 0, item);
|
||||
this._data.forEach((e, i) => e.order = i);
|
||||
}
|
||||
else
|
||||
this._data = recursive(this._data)!;
|
||||
|
||||
this._flatten = this.accumulate(e => e);
|
||||
}
|
||||
find(path: string): T | undefined
|
||||
{
|
||||
const recursive = (data?: Recursive<T>[]): T | undefined => {
|
||||
if(!data)
|
||||
return;
|
||||
|
||||
for(const e of data)
|
||||
{
|
||||
if(getPath(e) === path)
|
||||
return e;
|
||||
|
||||
const result = recursive(e.children as T[]);
|
||||
|
||||
if(result)
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
return recursive(this._data);
|
||||
}
|
||||
search(prop: keyof T, value: string): T[]
|
||||
{
|
||||
const recursive = (data?: Recursive<T>[]): T[] => {
|
||||
if(!data)
|
||||
return [];
|
||||
|
||||
const arr = [];
|
||||
|
||||
for(const e of data)
|
||||
{
|
||||
if(e[prop] === value)
|
||||
arr.push(e);
|
||||
else
|
||||
arr.push(...recursive(e.children as T[]));
|
||||
}
|
||||
|
||||
return arr;
|
||||
};
|
||||
|
||||
return recursive(this._data);
|
||||
}
|
||||
subset(path: string): Tree<T> | undefined
|
||||
{
|
||||
const subset = this.find(path);
|
||||
return subset ? new Tree([subset]) : undefined;
|
||||
}
|
||||
each(callback: (item: T, depth: number, parent?: T) => void)
|
||||
{
|
||||
const recursive = (depth: number, data?: Recursive<T>[], parent?: T) => data?.forEach(e => { callback(e, depth, parent); recursive(depth + 1, e.children as T[], e) });
|
||||
|
||||
recursive(1, this._data);
|
||||
}
|
||||
accumulate(callback: (item: T, depth: number, parent?: T) => any): any[]
|
||||
{
|
||||
const recursive = (depth: number, data?: Recursive<T>[], parent?: T): any[] => data?.flatMap(e => [callback(e, depth, parent), ...recursive(depth + 1, e.children as T[], e)]) ?? [];
|
||||
|
||||
return recursive(1, this._data);
|
||||
}
|
||||
get data()
|
||||
{
|
||||
return this._data;
|
||||
}
|
||||
get flatten()
|
||||
{
|
||||
return this._flatten;
|
||||
}
|
||||
}
|
||||
export class TreeDOM
|
||||
{
|
||||
container: HTMLElement;
|
||||
tree: Tree<Omit<LocalContent & { element?: HTMLElement }, "content">>;
|
||||
|
||||
private filter?: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => boolean | undefined;
|
||||
private folder: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => HTMLElement;
|
||||
private leaf: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => HTMLElement;
|
||||
|
||||
constructor(folder: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => HTMLElement, leaf: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => HTMLElement, filter?: (item: Recursive<Omit<LocalContent, 'content'>>, depth: number) => boolean | undefined)
|
||||
{
|
||||
this.tree = new Tree(Content.tree);
|
||||
|
||||
this.filter = filter;
|
||||
this.folder = folder;
|
||||
this.leaf = leaf;
|
||||
|
||||
const elements = this.tree.accumulate(this.render.bind(this));
|
||||
this.container = dom('div', { class: 'list-none select-none text-light-100 dark:text-dark-100 text-sm ps-2' }, elements);
|
||||
}
|
||||
render(item: Recursive<Omit<LocalContent & { element?: HTMLElement }, "content">>, depth: number): HTMLElement | undefined
|
||||
{
|
||||
if(this.filter && !(this.filter(item, depth) ?? true))
|
||||
return;
|
||||
|
||||
if(item.type === 'folder')
|
||||
{
|
||||
let folded = false;
|
||||
if(item.element)
|
||||
folded = item.element.getAttribute('data-state') === 'open';
|
||||
item.element = this.folder(item, depth);
|
||||
|
||||
if(!!item.parent) item.element.classList.toggle('hidden', item.parent.element!.getAttribute('data-state') === 'closed' || item.parent.element!.classList.contains('hidden'));
|
||||
item.element.setAttribute('data-state', folded ? 'open' : 'closed');
|
||||
item.element.addEventListener('click', () => this.toggle(item));
|
||||
|
||||
return item.element;
|
||||
}
|
||||
else
|
||||
{
|
||||
item.element = this.leaf(item, depth);
|
||||
|
||||
if(!!item.parent) item.element.classList.toggle('hidden', item.parent.element!.getAttribute('data-state') === 'closed' || item.parent.element!.classList.contains('hidden'));
|
||||
|
||||
return item.element;
|
||||
}
|
||||
}
|
||||
update()
|
||||
{
|
||||
this.container.replaceChildren(...this.tree.flatten.map(e => e.element!));
|
||||
}
|
||||
toggle(item?: Omit<LocalContent & { element?: HTMLElement }, 'content'>, state?: boolean)
|
||||
{
|
||||
if(item && item.type === 'folder')
|
||||
{
|
||||
const open = state ?? item.element!.getAttribute('data-state') !== 'open';
|
||||
item.element!.setAttribute('data-state', open ? 'open' : 'closed');
|
||||
|
||||
new Tree([item]).each((e, _, parent) => {
|
||||
if(!parent)
|
||||
return;
|
||||
|
||||
e.element!.classList.toggle('hidden', !this.opened(parent) || parent.element!.classList.contains('hidden'));
|
||||
});
|
||||
}
|
||||
}
|
||||
opened(item?: Omit<LocalContent & { element?: HTMLElement }, 'content'>): boolean | undefined
|
||||
{
|
||||
return item ? item.element!.getAttribute('data-state') === 'open' : undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ComputedRef, Ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import type { SessionConfig } from 'h3'
|
||||
|
||||
import 'vue-router';
|
||||
declare module 'vue-router'
|
||||
|
|
@ -13,8 +14,8 @@ declare module 'vue-router'
|
|||
}
|
||||
}
|
||||
|
||||
import 'nuxt';
|
||||
declare module 'nuxt'
|
||||
import '@nuxt/schema';
|
||||
declare module '@nuxt/schema'
|
||||
{
|
||||
interface RuntimeConfig
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export interface CanvasNode {
|
|||
height: number;
|
||||
color?: CanvasColor;
|
||||
label?: string;
|
||||
text?: any;
|
||||
text?: string;
|
||||
};
|
||||
export interface CanvasEdge {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
import type { CanvasContent as Canvas } from "./canvas";
|
||||
import type { MapContent as Map } from "./map";
|
||||
|
||||
|
||||
export type FileType = keyof ContentMap;
|
||||
export interface Overview {
|
||||
path: string;
|
||||
owner: number;
|
||||
title: string;
|
||||
timestamp: Date;
|
||||
navigable: boolean;
|
||||
private: boolean;
|
||||
order: number;
|
||||
visit: number;
|
||||
}
|
||||
export interface CanvasContent extends Overview {
|
||||
type: 'canvas';
|
||||
content?: Canvas;
|
||||
}
|
||||
export interface MapContent extends Overview {
|
||||
type: 'map';
|
||||
content?: string;
|
||||
}
|
||||
export interface MarkdownContent extends Overview {
|
||||
type: 'markdown';
|
||||
content?: string;
|
||||
}
|
||||
export interface FileContent extends Overview {
|
||||
type: 'file';
|
||||
content?: string;
|
||||
}
|
||||
export interface FolderContent extends Overview {
|
||||
type: 'folder';
|
||||
content?: null;
|
||||
}
|
||||
export interface ContentMap
|
||||
{
|
||||
markdown: MarkdownContent;
|
||||
file: FileContent;
|
||||
canvas: CanvasContent;
|
||||
map: MapContent;
|
||||
folder: FolderContent;
|
||||
}
|
||||
|
||||
export type ExploreContent = ContentMap[FileType];
|
||||
|
||||
export type TreeItem = ExploreContent & {
|
||||
children?: TreeItem[];
|
||||
}
|
||||
|
||||
export interface ContentComposable {
|
||||
content: Ref<ExploreContent[]>
|
||||
tree: ComputedRef<TreeItem[]>
|
||||
/**
|
||||
* Fetch the overview of every content from the server.
|
||||
*/
|
||||
fetch: (force: boolean) => Promise<void>
|
||||
/**
|
||||
* Get the given content from the server.
|
||||
*/
|
||||
get: (path: string) => Promise<void>
|
||||
}
|
||||
Loading…
Reference in New Issue