From 5bc8886010c9d7dacdfcfaeb307e13b58c865c36 Mon Sep 17 00:00:00 2001
From: michi <michael@adornis.de>
Date: Wed, 18 Dec 2024 14:51:39 +0000
Subject: [PATCH 01/14] fix: parseDOM for paragraph extension

---
 lab/html-based-buildify/schema/nodes/paragraph.ts | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/lab/html-based-buildify/schema/nodes/paragraph.ts b/lab/html-based-buildify/schema/nodes/paragraph.ts
index 6dee717faf..3b376b1925 100644
--- a/lab/html-based-buildify/schema/nodes/paragraph.ts
+++ b/lab/html-based-buildify/schema/nodes/paragraph.ts
@@ -1,10 +1,12 @@
 import { DesignSystem } from '@adornis/chemistry/elements/theming/design.js';
 import type { NodeSpec } from 'prosemirror-model';
 
-const getAttrs = node => {
+const getAttrs = (node: HTMLElement) => {
+  const style = node.getAttribute('style') || '';
+  const textAlignMatch = /text-align:\s*(\w+)/.exec(style);
   return {
     slot: node.getAttribute('slot'),
-    style: node.getAttribute('style'),
+    textAlign: textAlignMatch ? textAlignMatch[1] : 'left',
   };
 };
 
-- 
GitLab


From 50eb17f77084aca00e8ecfcdf5c5339e17de4024 Mon Sep 17 00:00:00 2001
From: michi <michael@adornis.de>
Date: Wed, 18 Dec 2024 14:51:55 +0000
Subject: [PATCH 02/14] fix: better breakpoints for screen sizes

---
 lab/html-based-buildify/client/prosemirror-editor.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/lab/html-based-buildify/client/prosemirror-editor.ts b/lab/html-based-buildify/client/prosemirror-editor.ts
index 5cea640f61..c9a6434755 100644
--- a/lab/html-based-buildify/client/prosemirror-editor.ts
+++ b/lab/html-based-buildify/client/prosemirror-editor.ts
@@ -83,8 +83,8 @@ export class ProsemirrorEditor extends FormField<string> {
   @property({ attribute: false }) globalSettingsClassName = new RXController(this, BuildifyGlobalSettings._class);
   @property({ attribute: false }) sizeBreakpoints = new RXController<Record<Size, number>>(this, {
     [Size.DESKTOP]: 1200,
-    [Size.TABLET]: 992,
-    [Size.MOBILE]: 768,
+    [Size.TABLET]: 750,
+    [Size.MOBILE]: 400,
   });
   @state() protected readonly _globalSettings = new RXController<any>(
     this,
-- 
GitLab


From 42e4f2b491142550fe79bd915ce1d39f53ed9130 Mon Sep 17 00:00:00 2001
From: michi <michael@adornis.de>
Date: Thu, 19 Dec 2024 09:08:34 +0000
Subject: [PATCH 03/14] feat: add more heading menu items

---
 .../client/variables/ALL_MENU_ITEMS.ts                    | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/lab/html-based-buildify/client/variables/ALL_MENU_ITEMS.ts b/lab/html-based-buildify/client/variables/ALL_MENU_ITEMS.ts
index 4d67d46ef6..6e903e0df1 100644
--- a/lab/html-based-buildify/client/variables/ALL_MENU_ITEMS.ts
+++ b/lab/html-based-buildify/client/variables/ALL_MENU_ITEMS.ts
@@ -69,6 +69,14 @@ export const ALL_MENU_ITEMS: Record<string, BuildifyMenuItem> = {
     element: HEADING_MENU_ITEM(3),
     group: MenuItemGroup.TEXT_STYLE,
   },
+  heading4: {
+    element: HEADING_MENU_ITEM(4),
+    group: MenuItemGroup.TEXT_STYLE,
+  },
+  heading5: {
+    element: HEADING_MENU_ITEM(5),
+    group: MenuItemGroup.TEXT_STYLE,
+  },
   link: {
     element: LINK_MENU_ITEM,
     group: MenuItemGroup.TEXT_STYLE,
-- 
GitLab


From 0d61860878343626b69a802fcde216ae2e3adb18 Mon Sep 17 00:00:00 2001
From: michi <michael@adornis.de>
Date: Thu, 19 Dec 2024 20:36:53 +0000
Subject: [PATCH 04/14] feat!: api refactor

---
 .../client/HighlighSelectionPlugin.ts         |  32 --
 .../client/helper/runInsertNode.ts            |   8 +-
 .../client/helper/toggleMark.ts               |  67 ++--
 lab/html-based-buildify/client/marks/bold.ts  |  44 ++-
 lab/html-based-buildify/client/marks/color.ts |  38 +-
 .../client/marks/font-size.ts                 |  85 +++--
 .../client/marks/italic.ts                    |  46 ++-
 lab/html-based-buildify/client/marks/link.ts  |  48 ++-
 .../client/marks/strike-through.ts            |  40 ++-
 .../client/marks/text-align.ts                |  44 ++-
 .../client/marks/underline.ts                 |  38 +-
 .../client/menu-items/DeleteSelectedNode.ts   |  19 +-
 .../client/menu-items/EditGlobalSettings.ts   |  20 +-
 .../client/menu-items/EditSelectedNode.ts     |  69 ++--
 .../client/nodes/accordeon.ts                 | 113 +++---
 .../client/nodes/button.ts                    |  44 +--
 lab/html-based-buildify/client/nodes/doc.ts   |   9 +
 .../client/nodes/excalidraw.ts                |  75 ++--
 lab/html-based-buildify/client/nodes/file.ts  |  47 ++-
 lab/html-based-buildify/client/nodes/flex.ts  |  43 ++-
 lab/html-based-buildify/client/nodes/grid.ts  |  58 +--
 .../client/nodes/hard-break.ts                |  10 +-
 .../client/nodes/headings.ts                  |  48 ++-
 lab/html-based-buildify/client/nodes/icon.ts  |  33 +-
 .../client/nodes/iconText.ts                  |  69 ++--
 lab/html-based-buildify/client/nodes/image.ts |  39 +-
 lab/html-based-buildify/client/nodes/list.ts  | 162 ++++++---
 .../client/nodes/paragraph.ts                 |  36 +-
 .../client/nodes/section.ts                   |  46 ++-
 .../client/nodes/spacing.ts                   |  37 +-
 lab/html-based-buildify/client/nodes/tag.ts   |  28 +-
 lab/html-based-buildify/client/nodes/text.ts  |   9 +
 lab/html-based-buildify/client/nodes/vimeo.ts |  33 +-
 .../client/nodes/youtube.ts                   |  38 +-
 .../client/plugins/currentPathBarPlugin.ts    |   1 +
 .../plugins/handleTransactionMetaPlugin.ts    |  10 +
 .../client/plugins/quotePlugin.ts             |   2 +-
 .../client/prosemirror-editor.ts              | 340 +++++++++++-------
 lab/html-based-buildify/client/types.ts       |  73 ++--
 .../client/variables/ALL_EDITORS.ts           |  72 ++--
 .../client/variables/ALL_MENU_ITEMS.ts        | 318 ++++++++--------
 .../client/x-prosemirror-toolbar.ts           |  98 +++++
 .../schema/nodes/accordeon.ts                 |   8 +-
 lab/html-based-buildify/schema/nodes/file.ts  |   2 +-
 .../schema/nodes/icon-text.ts                 |   6 +-
 45 files changed, 1484 insertions(+), 1021 deletions(-)
 delete mode 100644 lab/html-based-buildify/client/HighlighSelectionPlugin.ts
 create mode 100644 lab/html-based-buildify/client/nodes/text.ts
 create mode 100644 lab/html-based-buildify/client/plugins/handleTransactionMetaPlugin.ts
 create mode 100644 lab/html-based-buildify/client/x-prosemirror-toolbar.ts

diff --git a/lab/html-based-buildify/client/HighlighSelectionPlugin.ts b/lab/html-based-buildify/client/HighlighSelectionPlugin.ts
deleted file mode 100644
index ac60826e9c..0000000000
--- a/lab/html-based-buildify/client/HighlighSelectionPlugin.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Plugin, PluginKey } from 'prosemirror-state';
-import { Decoration, DecorationSet } from 'prosemirror-view';
-
-export const highlightPlugin = new Plugin({
-  key: new PluginKey('highlight'),
-  state: {
-    init() {
-      return DecorationSet.empty;
-    },
-    apply(tr, set) {
-      // Entfernen Sie alle vorherigen Hervorhebungen
-      set = set.remove(set.find());
-
-      // Fügen Sie Hervorhebungen für alle selektierten Nodes hinzu
-      const selection = tr.selection;
-      if (selection.from !== selection.to) {
-        tr.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
-          if (node.type.name === 'icon') {
-            // Ersetzen Sie "icon" durch Ihren Node-Typ-Namen
-            set = set.add(tr.doc, [Decoration.node(pos, pos + node.nodeSize, { class: 'ProseMirror-selectednode' })]);
-          }
-        });
-      }
-      return set;
-    },
-  },
-  props: {
-    decorations(state) {
-      return this.getState(state);
-    },
-  },
-});
diff --git a/lab/html-based-buildify/client/helper/runInsertNode.ts b/lab/html-based-buildify/client/helper/runInsertNode.ts
index c044fe1987..78a8e1e6e0 100644
--- a/lab/html-based-buildify/client/helper/runInsertNode.ts
+++ b/lab/html-based-buildify/client/helper/runInsertNode.ts
@@ -1,15 +1,17 @@
 import type { Node as ProsemirrorNode } from 'prosemirror-model';
 import type { EditorState } from 'prosemirror-state';
+import type { EditorView } from 'prosemirror-view';
 
 export function runInsertNode(
   identifier: string,
   getOptions?: (state: EditorState) => { attrs?: {}; content?: readonly ProsemirrorNode[] },
 ) {
-  return (state: EditorState, dispatch, view, event) => {
+  return (state: EditorState, view: EditorView) => {
     const { tr } = view.state;
     const { attrs, content } = getOptions?.(state) ?? {};
-    if (content) console.log(':: content', content);
-    tr.insert(view.state.selection.from, state.schema.nodes[identifier]?.create(attrs, content));
+    const nodeType = state.schema.nodes[identifier];
+    if (!nodeType) throw new Error('node type ' + identifier + ' not found.');
+    tr.insert(view.state.selection.from, nodeType.create(attrs, content));
     view.dispatch(tr);
   };
 }
diff --git a/lab/html-based-buildify/client/helper/toggleMark.ts b/lab/html-based-buildify/client/helper/toggleMark.ts
index 994791f9e2..51a624d7b0 100644
--- a/lab/html-based-buildify/client/helper/toggleMark.ts
+++ b/lab/html-based-buildify/client/helper/toggleMark.ts
@@ -1,59 +1,76 @@
 import type { EditorState, Transaction } from 'prosemirror-state';
+import type { EditorView } from 'prosemirror-view';
 import { doesMarkMatchAttributes } from './doesMarkMatchAttributes.js';
 
-export function toggleMark({ markIdentifier, attrs }: { markIdentifier: string; attrs: {} }) {
+export function toggleMark({
+  markName,
+  view,
+  attrs,
+}: {
+  markName: string;
+  view: EditorView;
+  attrs: Record<string, any>;
+}) {
   return (state: EditorState, dispatch?: (transaction: Transaction) => void) => {
     const { schema, tr, selection } = state;
     const { from, to, empty } = selection;
-    const markType = schema.marks[markIdentifier];
+    const markType = schema.marks[markName];
 
     if (!markType) {
-      throw new Error(`No mark with identifier '${markIdentifier} 'found in schema`);
+      throw new Error(`No mark with identifier '${markName}' found in schema`);
     }
 
-    let attrsMatch = false;
-
-    if (!empty) {
-      // Prüfe den Bereich, ob die gleiche Farbe bereits vorhanden ist
-      state.doc.nodesBetween(from, to, node => {
-        if (node.isText) {
-          const mark = node.marks.find(mark => mark.type === markType);
-          if (mark && doesMarkMatchAttributes(mark, attrs)) {
-            attrsMatch = true;
+    // Prüfe, ob die Markierungen im aktuellen Kontext bereits vorhanden sind
+    const isMarkActive = (): boolean => {
+      if (empty) {
+        // Prüfe die gespeicherten Markierungen oder die aktuellen Marks an der Cursorposition
+        const marks = state.storedMarks || selection.$from.marks();
+        return marks.some(mark => mark.type === markType && doesMarkMatchAttributes(mark, attrs));
+      } else {
+        // Prüfe Marks im ausgewählten Bereich
+        let match = false;
+        state.doc.nodesBetween(from, to, node => {
+          if (node.isText) {
+            const mark = node.marks.find(m => m.type === markType);
+            if (mark && doesMarkMatchAttributes(mark, attrs)) {
+              match = true;
+            }
           }
-        }
-      });
-    } else {
-      // Prüfe die Marks an der Cursor-Position, wenn keine Auswahl vorhanden ist
-      const marks = state.storedMarks || selection.$from.marks();
-      attrsMatch = marks.some(mark => mark.type === markType && doesMarkMatchAttributes(mark, attrs));
-    }
+        });
+        return match;
+      }
+    };
+
+    const attrsMatch = isMarkActive();
 
     if (!dispatch) {
-      // Wenn keine `dispatch`-Funktion vorhanden ist, geben wir nur den Status zurück
+      // Wenn keine Dispatch-Funktion vorhanden ist, nur den Status zurückgeben
       return attrsMatch;
     }
 
     if (attrsMatch) {
       if (empty) {
-        // Entferne die `storedMark` bei leerer Auswahl
+        // Entferne die gespeicherten Marks, wenn keine Auswahl vorhanden ist
         tr.removeStoredMark(markType);
       } else {
-        // Entferne die Markierung im Bereich
+        // Entferne Marks aus dem ausgewählten Bereich
         tr.removeMark(from, to, markType);
       }
     } else {
-      // Setze die Markierung, wenn keine übereinstimmende Farbe vorhanden ist
       if (empty) {
-        // Setze die Markierung als storedMark für die nächste Eingabe
+        // Füge die Markierung für zukünftigen Text hinzu
         tr.addStoredMark(markType.create(attrs));
       } else {
-        // Setze die Markierung im Bereich
+        // Füge die Markierung im ausgewählten Bereich hinzu
         tr.addMark(from, to, markType.create(attrs));
       }
     }
 
     dispatch(tr);
+
+    // Setze den Fokus zurück, um sicherzustellen, dass der Editor weiterhin aktiv ist
+    view.focus();
+
     return true;
   };
 }
diff --git a/lab/html-based-buildify/client/marks/bold.ts b/lab/html-based-buildify/client/marks/bold.ts
index 8756318eae..61b2bf00ed 100644
--- a/lab/html-based-buildify/client/marks/bold.ts
+++ b/lab/html-based-buildify/client/marks/bold.ts
@@ -1,20 +1,34 @@
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
 import '@adornis/chemistry/elements/components/x-icon.js';
-import { MenuItem } from 'prosemirror-menu';
-import { BOLD_KEY } from '../../schema/marks/bold.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
+import type { EditorView } from 'prosemirror-view';
+import { BOLD_KEY, BOLD_SCHEMA } from '../../schema/marks/bold.js';
 import { toggleMark } from '../helper/toggleMark.js';
-import { isInText, isMarkActive } from '../util.js';
+import { MenuItemGroup, type IMarkConfig } from '../types.js';
+import { isInText } from '../util.js';
 
-export const BOLD_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'format_bold', tooltip: 'Fett' }),
-  run(state, dispatch, view, event) {
-    toggleMark({ markIdentifier: BOLD_KEY, attrs: {} })(state, dispatch);
+export const bold: IMarkConfig = {
+  mark: {
+    name: BOLD_KEY,
+    schema: BOLD_SCHEMA,
   },
-  active(state) {
-    return isMarkActive(state, state.schema.marks[BOLD_KEY]!);
-  },
-  select(state) {
-    return isInText(state);
-  },
-});
+  menuItems: [
+    {
+      group: MenuItemGroup.TEXT_STYLE,
+      label: 'Fett',
+      icon: 'format_bold',
+      run: (editorView: EditorView) => {
+        toggleMark({ markName: bold.mark.name, view: editorView, attrs: {} })(editorView.state, editorView.dispatch);
+      },
+      isActive: editorView => {
+        return toggleMark({
+          markName: bold.mark.name,
+          view: editorView,
+          attrs: {},
+        })(editorView.state);
+      },
+      shouldVisualize: (view: EditorView) => {
+        return isInText(view.state);
+      },
+    },
+  ],
+};
diff --git a/lab/html-based-buildify/client/marks/color.ts b/lab/html-based-buildify/client/marks/color.ts
index 72d50cf04a..f476f25c0c 100644
--- a/lab/html-based-buildify/client/marks/color.ts
+++ b/lab/html-based-buildify/client/marks/color.ts
@@ -1,26 +1,32 @@
 import '@adornis/chemistry/elements/components/x-flex';
 import '@adornis/chemistry/elements/components/x-icon';
-import { MenuItem } from 'prosemirror-menu';
-import { COLOR_KEY } from '../../schema/marks/color.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
+import type { EditorView } from 'prosemirror-view';
+import { COLOR_KEY, COLOR_SCHEMA } from '../../schema/marks/color.js';
 import { toggleMark } from '../helper/toggleMark.js';
+import { MenuItemGroup, type IMarkConfig, type IMenuItem } from '../types.js';
 import { isInText } from '../util.js';
 
-export const COLOR_MENU_ITEM = (color: string) => createColorMenuItem(color);
+export const color: IMarkConfig = {
+  mark: {
+    name: COLOR_KEY,
+    schema: COLOR_SCHEMA,
+  },
+};
 
-function createColorMenuItem(color: string | undefined): MenuItem {
-  return new MenuItem({
+export const colorMenuItem = (color: string): IMenuItem => {
+  return {
+    group: MenuItemGroup.COLOR,
     label: color,
-    title: color,
-    run(state, dispatch, view, event) {
-      toggleMark({ markIdentifier: COLOR_KEY, attrs: { color } })(state, dispatch);
+    color,
+    icon: 'opacity',
+    run: (view: EditorView) => {
+      toggleMark({ markName: COLOR_KEY, view, attrs: { color } })(view.state, view.dispatch);
     },
-    select(state) {
-      return isInText(state);
+    shouldVisualize: (view: EditorView) => {
+      return isInText(view.state);
     },
-    active(state) {
-      return toggleMark({ markIdentifier: COLOR_KEY, attrs: { color } })(state);
+    isActive: (view: EditorView) => {
+      return toggleMark({ markName: COLOR_KEY, view, attrs: { color } })(view.state);
     },
-    render: view => createMenuItemButton({ icon: 'opacity', tooltip: color, color }),
-  });
-}
+  };
+};
diff --git a/lab/html-based-buildify/client/marks/font-size.ts b/lab/html-based-buildify/client/marks/font-size.ts
index 35f20ddb60..b5bd1ad42a 100644
--- a/lab/html-based-buildify/client/marks/font-size.ts
+++ b/lab/html-based-buildify/client/marks/font-size.ts
@@ -1,48 +1,63 @@
 import '@adornis/chemistry/elements/components/x-icon';
 import '@adornis/fonts/fonts';
-import { html } from 'lit';
-import { Dropdown, MenuItem } from 'prosemirror-menu';
-import { FONT_SIZE_KEY } from '../../schema/marks/font-size.js';
+import type { EditorView } from 'prosemirror-view';
+import { FONT_SIZE_KEY, FONT_SIZE_SCHEMA } from '../../schema/marks/font-size.js';
 import { toggleMark } from '../helper/toggleMark.js';
-import { createHTMLElementByTemplate, isInText } from '../util.js';
+import { MenuItemGroup, type IMarkConfig, type IMenuItem } from '../types.js';
+import { isInText } from '../util.js';
+
+export const fontSize: IMarkConfig = {
+  mark: {
+    name: FONT_SIZE_KEY,
+    schema: FONT_SIZE_SCHEMA,
+  },
+  menuItems: [
+    // createFontSizeMenuItem('8px'),
+    // createFontSizeMenuItem('12px'),
+    // createFontSizeMenuItem('16px'),
+    // createFontSizeMenuItem('20px'),
+    // createFontSizeMenuItem('24px'),
+  ],
+};
 
 export const FONT_SIZE_MENU_ITEM = (fontSize: string) => createFontSizeMenuItem(fontSize);
 
-function createFontSizeMenuItem(fontSize: string | undefined): MenuItem {
-  return new MenuItem({
-    render: view => createHTMLElementByTemplate(html` <x-text> ${fontSize ?? 'reset fontsize'} </x-text> `),
-    run(state, dispatch, view, event) {
-      toggleMark({ markIdentifier: FONT_SIZE_KEY, attrs: { fontSize } })(state, dispatch);
+function createFontSizeMenuItem(fontSize: string): IMenuItem {
+  return {
+    group: MenuItemGroup.TEXT_STYLE,
+    label: fontSize,
+    run: (view: EditorView) => {
+      toggleMark({ markName: FONT_SIZE_KEY, view, attrs: { fontSize } })(view.state, view.dispatch);
     },
-    select(state) {
-      return isInText(state);
+    shouldVisualize: (view: EditorView) => {
+      return isInText(view.state);
     },
-    active(state) {
-      return toggleMark({ markIdentifier: FONT_SIZE_KEY, attrs: { fontSize } })(state);
+    isActive: (view: EditorView) => {
+      return toggleMark({ markName: FONT_SIZE_KEY, view, attrs: { fontSize } })(view.state);
     },
-  });
+  };
 }
 
-export function fontSizeMenu() {
-  const fontSizes = [
-    '8px',
-    '10px',
-    '12px',
-    '14px',
-    '16px',
-    '18px',
-    '20px',
-    '24px',
-    '30px',
-    '36px',
-    '48px',
-    '60px',
-    '72px',
-  ];
+// export function fontSizeMenu() {
+//   const fontSizes = [
+//     '8px',
+//     '10px',
+//     '12px',
+//     '14px',
+//     '16px',
+//     '18px',
+//     '20px',
+//     '24px',
+//     '30px',
+//     '36px',
+//     '48px',
+//     '60px',
+//     '72px',
+//   ];
 
-  const items = fontSizes.map(size => createFontSizeMenuItem(size));
+//   const items = fontSizes.map(size => createFontSizeMenuItem(size));
 
-  return new Dropdown(items, {
-    label: '',
-  });
-}
+//   return new Dropdown(items, {
+//     label: '',
+//   });
+// }
diff --git a/lab/html-based-buildify/client/marks/italic.ts b/lab/html-based-buildify/client/marks/italic.ts
index ce170ab329..ee581675a2 100644
--- a/lab/html-based-buildify/client/marks/italic.ts
+++ b/lab/html-based-buildify/client/marks/italic.ts
@@ -1,20 +1,34 @@
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
 import '@adornis/chemistry/elements/components/x-icon.js';
-import { toggleMark } from 'prosemirror-commands';
-import { MenuItem } from 'prosemirror-menu';
-import { ITALIC_KEY } from '../../schema/marks/italic.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
-import { isInText, isMarkActive } from '../util.js';
+import type { EditorView } from 'prosemirror-view';
+import { ITALIC_KEY, ITALIC_SCHEMA } from '../../schema/marks/italic.js';
+import { toggleMark } from '../helper/toggleMark.js';
+import { MenuItemGroup, type IMarkConfig } from '../types.js';
+import { isInText } from '../util.js';
 
-export const ITALIC_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'format_italic', tooltip: 'Kursiv' }),
-  run(state, dispatch, view, event) {
-    toggleMark(state.schema.marks[ITALIC_KEY]!)(state, dispatch);
+export const italic: IMarkConfig = {
+  mark: {
+    name: ITALIC_KEY,
+    schema: ITALIC_SCHEMA,
   },
-  active(state) {
-    return isMarkActive(state, state.schema.marks[ITALIC_KEY]!);
-  },
-  select(state) {
-    return isInText(state);
-  },
-});
+  menuItems: [
+    {
+      group: MenuItemGroup.TEXT_STYLE,
+      label: 'Kursiv',
+      icon: 'format_italic',
+      run: (view: EditorView) => {
+        toggleMark({ markName: italic.mark.name, view, attrs: {} })(view.state, view.dispatch);
+      },
+      isActive: editorView => {
+        return toggleMark({
+          markName: italic.mark.name,
+          view: editorView,
+          attrs: {},
+        })(editorView.state);
+      },
+      shouldVisualize: (view: EditorView) => {
+        return isInText(view.state);
+      },
+    },
+  ],
+};
diff --git a/lab/html-based-buildify/client/marks/link.ts b/lab/html-based-buildify/client/marks/link.ts
index 9a3db90a0b..f666c3d09a 100644
--- a/lab/html-based-buildify/client/marks/link.ts
+++ b/lab/html-based-buildify/client/marks/link.ts
@@ -1,24 +1,38 @@
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
 import '@adornis/chemistry/elements/components/x-icon';
 import { XDialog } from '@adornis/dialog/x-dialog.js';
-import { MenuItem } from 'prosemirror-menu';
-import { LINK_KEY } from '../../schema/marks/link.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
+import type { EditorView } from 'prosemirror-view';
+import { LINK_KEY, LINK_SCHEMA } from '../../schema/marks/link.js';
 import { toggleMark } from '../helper/toggleMark.js';
+import { MenuItemGroup, type IMarkConfig } from '../types.js';
 import { isInText, isMarkActive } from '../util.js';
 
-export const LINK_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'link', tooltip: 'Link' }),
-  async run(state, dispatch, view, event) {
-    const href = isMarkActive(state, state.schema.marks[LINK_KEY]!)
-      ? null
-      : await XDialog.prompt('Link', { placeholder: 'href' });
-    toggleMark({ markIdentifier: LINK_KEY, attrs: { href } })(state, dispatch);
+export const link: IMarkConfig = {
+  mark: {
+    name: LINK_KEY,
+    schema: LINK_SCHEMA,
   },
-  active(state) {
-    return isMarkActive(state, state.schema.marks[LINK_KEY]!);
-  },
-  select(state) {
-    return isInText(state);
-  },
-});
+  menuItems: [
+    {
+      group: MenuItemGroup.TEXT_STYLE,
+      label: 'Link',
+      icon: 'link',
+      run: async (view: EditorView) => {
+        const href = isMarkActive(view.state, view.state.schema.marks[LINK_KEY]!)
+          ? null
+          : await XDialog.prompt('Link', { placeholder: 'href' });
+        toggleMark({ markName: LINK_KEY, view, attrs: { href } })(view.state, view.dispatch);
+      },
+      isActive: editorView => {
+        return toggleMark({
+          markName: link.mark.name,
+          view: editorView,
+          attrs: {},
+        })(editorView.state);
+      },
+      shouldVisualize: (view: EditorView) => {
+        return isInText(view.state);
+      },
+    },
+  ],
+};
diff --git a/lab/html-based-buildify/client/marks/strike-through.ts b/lab/html-based-buildify/client/marks/strike-through.ts
index e06cd1807d..365a68f911 100644
--- a/lab/html-based-buildify/client/marks/strike-through.ts
+++ b/lab/html-based-buildify/client/marks/strike-through.ts
@@ -1,20 +1,30 @@
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
 import '@adornis/chemistry/elements/components/x-icon';
-import { toggleMark } from 'prosemirror-commands';
-import { MenuItem } from 'prosemirror-menu';
-import { STRIKE_THROUGH_KEY } from '../../schema/marks/strike-through.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
+import type { EditorView } from 'prosemirror-view';
+import { STRIKE_THROUGH_KEY, STRIKE_THROUGH_SCHEMA } from '../../schema/marks/strike-through.js';
+import { toggleMark } from '../helper/toggleMark.js';
+import { MenuItemGroup, type IMarkConfig } from '../types.js';
 import { isInText, isMarkActive } from '../util.js';
 
-export const STRIKE_THROUGH_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'format_strikethrough', tooltip: 'Durchstreichen' }),
-  run(state, dispatch, view, event) {
-    toggleMark(state.schema.marks[STRIKE_THROUGH_KEY]!)(state, dispatch);
+export const strikeThrough: IMarkConfig = {
+  mark: {
+    name: STRIKE_THROUGH_KEY,
+    schema: STRIKE_THROUGH_SCHEMA,
   },
-  active(state) {
-    return isMarkActive(state, state.schema.marks[STRIKE_THROUGH_KEY]!);
-  },
-  select(state) {
-    return isInText(state);
-  },
-});
+  menuItems: [
+    {
+      group: MenuItemGroup.TEXT_STYLE,
+      label: 'Durchstreichen',
+      icon: 'format_strikethrough',
+      run: (view: EditorView) => {
+        toggleMark({ markName: strikeThrough.mark.name, attrs: {}, view })(view.state, view.dispatch);
+      },
+      isActive: (view: EditorView) => {
+        return isMarkActive(view.state, view.state.schema.marks[STRIKE_THROUGH_KEY]!);
+      },
+      shouldVisualize: (view: EditorView) => {
+        return isInText(view.state);
+      },
+    },
+  ],
+};
diff --git a/lab/html-based-buildify/client/marks/text-align.ts b/lab/html-based-buildify/client/marks/text-align.ts
index 3f63ac1721..a5541aac1f 100644
--- a/lab/html-based-buildify/client/marks/text-align.ts
+++ b/lab/html-based-buildify/client/marks/text-align.ts
@@ -1,6 +1,6 @@
-import { MenuItem } from 'prosemirror-menu';
 import type { EditorState, Transaction } from 'prosemirror-state';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
+import type { EditorView } from 'prosemirror-view';
+import { MenuItemGroup, type IMenuItem } from '../types.js';
 import { isInText, updateNodeAttrs } from '../util.js';
 
 type Alignment = 'left' | 'right' | 'center' | 'justify';
@@ -26,30 +26,28 @@ export function toggleTextAlign(alignment: Alignment) {
   };
 }
 
-export const TEXT_ALIGN_MENU_ITEM = (alignment: Alignment) => createTextAlignMenuItem(alignment);
+export function createTextAlignMenuItem(alignment: Alignment): IMenuItem {
+  const icon = `format_align_${alignment}`;
 
-function createTextAlignMenuItem(alignment: Alignment): MenuItem {
-  return new MenuItem({
-    render(view) {
-      const icon = `format_align_${alignment}`;
-
-      const translation = {
-        format_align_left: 'Linksbündig',
-        format_align_right: 'Rechtsbündig',
-        format_align_center: 'Zentriert',
-        format_align_justify: 'Blocksatz',
-      };
+  const translation = {
+    format_align_left: 'Linksbündig',
+    format_align_right: 'Rechtsbündig',
+    format_align_center: 'Zentriert',
+    format_align_justify: 'Blocksatz',
+  };
 
-      return createMenuItemButton({ icon, tooltip: translation[icon] });
+  return {
+    group: MenuItemGroup.TEXT_ALIGNMENT,
+    label: translation[icon],
+    icon,
+    isActive: (view: EditorView) => {
+      return !toggleTextAlign(alignment)(view.state);
     },
-    enable(state) {
-      return toggleTextAlign(alignment)(state);
+    run: (view: EditorView) => {
+      toggleTextAlign(alignment)(view.state, view.dispatch);
     },
-    run(state, dispatch, view, event) {
-      toggleTextAlign(alignment)(state, dispatch);
+    shouldVisualize: (view: EditorView) => {
+      return isInText(view.state);
     },
-    select(state) {
-      return isInText(state);
-    },
-  });
+  };
 }
diff --git a/lab/html-based-buildify/client/marks/underline.ts b/lab/html-based-buildify/client/marks/underline.ts
index 22d1dbd303..1b3564ffb5 100644
--- a/lab/html-based-buildify/client/marks/underline.ts
+++ b/lab/html-based-buildify/client/marks/underline.ts
@@ -1,20 +1,30 @@
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
 import '@adornis/chemistry/elements/components/x-icon';
-import { MenuItem } from 'prosemirror-menu';
-import { UNDERLINE_KEY } from '../../schema/marks/underline.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
+import type { EditorView } from 'prosemirror-view';
+import { UNDERLINE_KEY, UNDERLINE_SCHEMA } from '../../schema/marks/underline.js';
 import { toggleMark } from '../helper/toggleMark.js';
+import { MenuItemGroup, type IMarkConfig } from '../types.js';
 import { isInText, isMarkActive } from '../util.js';
 
-export const UNDERLINE_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'format_underlined', tooltip: 'Unterstreichen' }),
-  run(state, dispatch, view, event) {
-    toggleMark({ markIdentifier: UNDERLINE_KEY, attrs: {} })(state, dispatch);
+export const underline: IMarkConfig = {
+  mark: {
+    name: UNDERLINE_KEY,
+    schema: UNDERLINE_SCHEMA,
   },
-  active(state) {
-    return isMarkActive(state, state.schema.marks[UNDERLINE_KEY]!);
-  },
-  select(state) {
-    return isInText(state);
-  },
-});
+  menuItems: [
+    {
+      group: MenuItemGroup.TEXT_STYLE,
+      label: 'Unterstreichen',
+      icon: 'format_underlined',
+      run: (view: EditorView) => {
+        toggleMark({ markName: UNDERLINE_KEY, view, attrs: {} })(view.state, view.dispatch);
+      },
+      isActive: (view: EditorView) => {
+        return isMarkActive(view.state, view.state.schema.marks[UNDERLINE_KEY]!);
+      },
+      shouldVisualize: (view: EditorView) => {
+        return isInText(view.state);
+      },
+    },
+  ],
+};
diff --git a/lab/html-based-buildify/client/menu-items/DeleteSelectedNode.ts b/lab/html-based-buildify/client/menu-items/DeleteSelectedNode.ts
index a131bc20ec..1d88316997 100644
--- a/lab/html-based-buildify/client/menu-items/DeleteSelectedNode.ts
+++ b/lab/html-based-buildify/client/menu-items/DeleteSelectedNode.ts
@@ -1,12 +1,13 @@
 import '@adornis/chemistry/elements/components/x-icon';
 import { XDialog } from '@adornis/dialog/x-dialog.js';
-import { MenuItem } from 'prosemirror-menu';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
+import type { IMenuItem } from '../types.js';
 import { isNodeSelection } from '../util.js';
 
-export const DeleteSelectedNodeMenuItem = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'delete', tooltip: 'Löschen' }),
-  run: async (state, dispatch, view, event) => {
+export const DeleteSelectedNodeMenuItem: IMenuItem = {
+  label: 'Löschen',
+  icon: 'delete',
+  run: async view => {
+    const { state, dispatch } = view;
     if (!isNodeSelection(state.selection) || !(await XDialog.confirm('Willst du dieses Element wirklich löschen?'))) {
       return;
     }
@@ -15,9 +16,7 @@ export const DeleteSelectedNodeMenuItem = new MenuItem({
     tr.delete(from, to);
     dispatch(tr);
   },
-  select(state) {
-    return isNodeSelection(state.selection);
+  shouldVisualize: view => {
+    return isNodeSelection(view.state.selection);
   },
-  label: 'Löschen',
-  title: 'Löschen',
-});
+};
diff --git a/lab/html-based-buildify/client/menu-items/EditGlobalSettings.ts b/lab/html-based-buildify/client/menu-items/EditGlobalSettings.ts
index 1f6e6aaaaf..912538433a 100644
--- a/lab/html-based-buildify/client/menu-items/EditGlobalSettings.ts
+++ b/lab/html-based-buildify/client/menu-items/EditGlobalSettings.ts
@@ -1,17 +1,11 @@
 import '@adornis/chemistry/elements/components/x-icon';
-import { MenuItem } from 'prosemirror-menu';
 import { GlobalSettingsDrawerPanel } from '../global-settings-drawer-panel.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
-import type { BuildifyMenuItem } from '../menu.js';
+import type { IMenuItem } from '../types.js';
 
-export const EditGlobalSettingsMenuItem = (globalSettingsClassName: string): BuildifyMenuItem => ({
-  element: new MenuItem({
-    label: 'Globale Einstellungen',
-    title: 'Globale Einstellungen',
-    run(state, dispatch, view, event) {
-      return GlobalSettingsDrawerPanel.showPopup({ modal: true, props: { globalSettingsClassName } });
-    },
-    render: view => createMenuItemButton({ icon: 'settings', tooltip: 'Globale Einstellungen' }),
-  }),
-  group: 'global-settings',
+export const EditGlobalSettingsMenuItem = (globalSettingsClassName: string): IMenuItem => ({
+  label: 'Globale Einstellungen',
+  icon: 'settings',
+  run: view => {
+    return GlobalSettingsDrawerPanel.showPopup({ modal: true, props: { globalSettingsClassName } });
+  },
 });
diff --git a/lab/html-based-buildify/client/menu-items/EditSelectedNode.ts b/lab/html-based-buildify/client/menu-items/EditSelectedNode.ts
index 1f38fa0478..3a46b2f1d1 100644
--- a/lab/html-based-buildify/client/menu-items/EditSelectedNode.ts
+++ b/lab/html-based-buildify/client/menu-items/EditSelectedNode.ts
@@ -1,48 +1,43 @@
 import type { Maybe } from '@adornis/base/utilTypes.js';
 import { constructValue } from '@adornis/baseql/entities/construct.js';
 import '@adornis/chemistry/elements/components/x-icon.js';
-import { MenuItem } from 'prosemirror-menu';
+import type { EditorView } from 'prosemirror-view';
 import type { HTMLBase } from '../../db/HTMLBase.js';
 import { EditorDrawerPanel } from '../editor-drawer-panel.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
-import type { BuildifyMenuItem } from '../menu.js';
-import { MenuItemGroup, type EditorFunc } from '../types.js';
+import { type EditorFunc, type IMenuItem } from '../types.js';
 import { isNodeSelection, updateNodeAttrs } from '../util.js';
 
 export const EditSelectedNodeMenuItem = (
   editors: Record<string, EditorFunc<HTMLBase>>,
   globalSettingsClassName: string,
-): BuildifyMenuItem => ({
-  element: new MenuItem({
-    render: view => createMenuItemButton({ icon: 'edit', tooltip: 'Bearbeiten' }),
-    run: async (state, dispatch, view, event) => {
-      if (!isNodeSelection(state.selection)) return;
-      const node = state.selection.node;
-      const editor = editors[node.type.name];
-      if (!editor) throw new Error(`no editor for name ${node.type.name} found`);
-      const dataString = node.attrs.data as Maybe<string>;
-      if (!dataString) throw new Error('no data on item type ' + node.type.name + ' found.');
-      const dataContent = constructValue(JSON.parse(dataString));
-      await EditorDrawerPanel.showPopup<Maybe<HTMLBase>>({
-        props: {
-          dataContent,
-          editor,
-          globalSettingsClassName,
-        },
-      }).then(res => {
-        if (!res) return;
-        dispatch(
-          updateNodeAttrs(state, state.tr.setMeta('onNodeChange', true), () => state.selection.from, {
-            data: JSON.stringify(res.toObject()),
-          }),
-        );
-      });
-    },
-    select: state => {
-      return isNodeSelection(state.selection) && !!editors[state.selection.node.type.name];
-    },
-    label: 'Bearbeiten',
-    title: 'Bearbeiten',
-  }),
-  group: MenuItemGroup.SETTINGS,
+): IMenuItem => ({
+  label: 'Bearbeiten',
+  icon: 'edit',
+  run: async (view: EditorView) => {
+    const { state, dispatch } = view;
+    if (!isNodeSelection(state.selection)) return;
+    const node = state.selection.node;
+    const editor = editors[node.type.name];
+    if (!editor) throw new Error(`no editor for name ${node.type.name} found`);
+    const dataString = node.attrs.data as Maybe<string>;
+    if (!dataString) throw new Error('no data on item type ' + node.type.name + ' found.');
+    const dataContent = constructValue(JSON.parse(dataString));
+    await EditorDrawerPanel.showPopup<Maybe<HTMLBase>>({
+      props: {
+        dataContent,
+        editor,
+        globalSettingsClassName,
+      },
+    }).then(res => {
+      if (!res) return;
+      dispatch(
+        updateNodeAttrs(state, state.tr.setMeta('onNodeChange', true), () => state.selection.from, {
+          data: JSON.stringify(res.toObject()),
+        }),
+      );
+    });
+  },
+  shouldVisualize: (view: EditorView) => {
+    return isNodeSelection(view.state.selection) && !!editors[view.state.selection.node.type.name];
+  },
 });
diff --git a/lab/html-based-buildify/client/nodes/accordeon.ts b/lab/html-based-buildify/client/nodes/accordeon.ts
index a5452398cd..0d2a78b785 100644
--- a/lab/html-based-buildify/client/nodes/accordeon.ts
+++ b/lab/html-based-buildify/client/nodes/accordeon.ts
@@ -12,28 +12,26 @@ import '@adornis/fonts/fonts';
 import '@adornis/forms/x-input';
 import { html, type PropertyValues } from 'lit';
 import { customElement, property, state } from 'lit/decorators.js';
-import { MenuItem } from 'prosemirror-menu';
 import { NodeSelection, type Command } from 'prosemirror-state';
+import type { EditorView } from 'prosemirror-view';
 import { filter } from 'rxjs';
 import { type HTMLBaseAccordeon } from '../../db/HTMLBaseAccordeon.js';
 import {
   ACCORDEON_CONTAINER_KEY,
+  ACCORDEON_CONTAINER_SCHEMA,
   ACCORDEON_CONTENT_KEY,
+  ACCORDEON_CONTENT_SCHEMA,
   ACCORDEON_KEY,
+  ACCORDEON_SCHEMA,
   ACCORDEON_TITLE_KEY,
+  ACCORDEON_TITLE_SCHEMA,
 } from '../../schema/nodes/accordeon.js';
 import { BuildifyLitElement } from '../BuildifyLitElement.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
 import { runInsertNode } from '../helper/runInsertNode.js';
-import { type EditorFunc } from '../types.js';
-import { createHTMLElementByTemplate, isNodeSelection } from '../util.js';
-
-export const ACCORDEON_EDITOR: EditorFunc<HTMLBaseAccordeon> = ({
-  content,
-  contentController,
-  controllerBaseKeyPath,
-  host,
-}) => {
+import { MenuItemGroup, type EditorFunc, type INodeConfig } from '../types.js';
+import { isNodeSelection } from '../util.js';
+
+const editor: EditorFunc = ({ contentController, controllerBaseKeyPath }) => {
   return html`
     <x-flex space="md">
       <x-infobox>
@@ -49,38 +47,71 @@ export const ACCORDEON_EDITOR: EditorFunc<HTMLBaseAccordeon> = ({
   `;
 };
 
-export const ACCORDEON_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'keyboard_arrow_down', tooltip: 'Accordeon' }),
-  run: runInsertNode(ACCORDEON_CONTAINER_KEY, state => {
-    return {
-      content: [
-        state.schema.nodes[ACCORDEON_KEY]!.create({}, [
-          state.schema.nodes[ACCORDEON_TITLE_KEY]!.create({}, [
-            state.schema.nodes.paragraph!.create({}, [state.schema.text('Titel')]),
-          ]),
-          state.schema.nodes[ACCORDEON_CONTENT_KEY]!.create({}, [
-            state.schema.nodes.paragraph!.create({}, [state.schema.text('Content')]),
-          ]),
-        ]),
-      ],
-    };
-  }),
-  select(state) {
-    return !isNodeSelection(state.selection);
+export const accordeonContainer: INodeConfig = {
+  node: {
+    name: ACCORDEON_CONTAINER_KEY,
+    schema: ACCORDEON_CONTAINER_SCHEMA,
+  },
+  menuItems: [
+    {
+      group: MenuItemGroup.LAYOUT,
+      label: 'Accordeon',
+      icon: 'keyboard_arrow_down',
+      run: (view: EditorView) =>
+        runInsertNode(ACCORDEON_CONTAINER_KEY, state => {
+          return {
+            content: [
+              state.schema.nodes[ACCORDEON_KEY]!.create({}, [
+                state.schema.nodes[ACCORDEON_TITLE_KEY]!.create({}, [
+                  state.schema.nodes.paragraph!.create({}, [state.schema.text('Titel')]),
+                ]),
+                state.schema.nodes[ACCORDEON_CONTENT_KEY]!.create({}, [
+                  state.schema.nodes.paragraph!.create({}, [state.schema.text('Content')]),
+                ]),
+              ]),
+            ],
+          };
+        })(view.state, view),
+      shouldVisualize: (view: EditorView) => {
+        return !isNodeSelection(view.state.selection);
+      },
+    },
+  ],
+  editor,
+};
+
+export const accordeon: INodeConfig = {
+  node: {
+    name: ACCORDEON_KEY,
+    schema: ACCORDEON_SCHEMA,
+  },
+  menuItems: [
+    {
+      label: 'Accordeon hinzufügen',
+      icon: 'add',
+      run: (view: EditorView) => addAccordion()(view.state, view.dispatch),
+      shouldVisualize: (view: EditorView) => {
+        return isNodeSelection(view.state.selection) && view.state.selection.node.type.name === ACCORDEON_CONTAINER_KEY;
+      },
+    },
+  ],
+};
+
+export const accordeonTitle: INodeConfig = {
+  node: {
+    name: ACCORDEON_TITLE_KEY,
+    schema: ACCORDEON_TITLE_SCHEMA,
   },
-  label: 'Accordeon',
-  title: 'Accordeon',
-});
-
-export const ADD_ACCORDEON_MENU_ITEM = new MenuItem({
-  render: view => createHTMLElementByTemplate(html` <x-icon> add </x-icon> `),
-  run: addAccordion(),
-  select(state) {
-    return isNodeSelection(state.selection) && state.selection.node.type.name === ACCORDEON_CONTAINER_KEY;
+};
+
+export const accordeonContent: INodeConfig = {
+  node: {
+    name: ACCORDEON_CONTENT_KEY,
+    schema: ACCORDEON_CONTENT_SCHEMA,
   },
-  label: 'Accordeon hinzufügen',
-  title: 'Accordeon hinzufügen',
-});
+};
+
+export const accordeonConfigs = [accordeonContainer, accordeon, accordeonTitle, accordeonContent];
 
 export function addAccordion(): Command {
   return (state, dispatch) => {
diff --git a/lab/html-based-buildify/client/nodes/button.ts b/lab/html-based-buildify/client/nodes/button.ts
index 984ecc3499..d3f0e17828 100644
--- a/lab/html-based-buildify/client/nodes/button.ts
+++ b/lab/html-based-buildify/client/nodes/button.ts
@@ -11,22 +11,16 @@ import '@adornis/popover/x-dropdown-selection.js';
 import { goTo } from '@adornis/router/client/open-href.js';
 import { html } from 'lit';
 import { customElement } from 'lit/decorators.js';
-import { MenuItem } from 'prosemirror-menu';
+import type { EditorView } from 'prosemirror-view';
 import type { HTMLBaseButton } from '../../db/HTMLBaseButton.js';
-import { BUTTON_KEY } from '../../schema/nodes/button.js';
+import { BUTTON_KEY, BUTTON_SCHEMA } from '../../schema/nodes/button.js';
 import { PARAGRAPH_KEY } from '../../schema/nodes/paragraph.js';
 import { BuildifyLitElement } from '../BuildifyLitElement.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
 import { runInsertNode } from '../helper/runInsertNode.js';
-import type { EditorFunc } from '../types.js';
+import type { EditorFunc, INodeConfig } from '../types.js';
 import { isNodeSelection } from '../util.js';
 
-export const BUTTON_EDITOR: EditorFunc<HTMLBaseButton> = ({
-  host,
-  content,
-  contentController,
-  controllerBaseKeyPath,
-}) => {
+const editor: EditorFunc<HTMLBaseButton> = ({ host, content, contentController, controllerBaseKeyPath }) => {
   return html`
     <x-flex space="md">
       <x-input
@@ -61,17 +55,27 @@ export const BUTTON_EDITOR: EditorFunc<HTMLBaseButton> = ({
   `;
 };
 
-export const BUTTON_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'videogame_asset', tooltip: 'Knopf' }),
-  run: runInsertNode(BUTTON_KEY, state => ({
-    content: [state.schema.nodes[PARAGRAPH_KEY]!.create(null, state.schema.text('Button text'))],
-  })),
-  label: 'Knopf',
-  title: 'Knopf',
-  select(state) {
-    return !isNodeSelection(state.selection);
+export const button: INodeConfig = {
+  node: {
+    name: BUTTON_KEY,
+    schema: BUTTON_SCHEMA,
   },
-});
+  menuItems: [
+    {
+      label: 'Knopf',
+      icon: 'videogame_asset',
+      run: (view: EditorView) => {
+        runInsertNode(BUTTON_KEY, state => ({
+          content: [state.schema.nodes[PARAGRAPH_KEY]!.create(null, state.schema.text('Button text'))],
+        }))(view.state, view);
+      },
+      shouldVisualize: (view: EditorView) => {
+        return !isNodeSelection(view.state.selection);
+      },
+    },
+  ],
+  editor,
+};
 
 @customElement('node-button')
 export class NodeButton extends BuildifyLitElement<HTMLBaseButton> {
diff --git a/lab/html-based-buildify/client/nodes/doc.ts b/lab/html-based-buildify/client/nodes/doc.ts
index b9c08588b8..f5dd8e96a1 100644
--- a/lab/html-based-buildify/client/nodes/doc.ts
+++ b/lab/html-based-buildify/client/nodes/doc.ts
@@ -10,6 +10,15 @@ import { html } from 'lit';
 import { customElement, property } from 'lit/decorators.js';
 import { debounceTime, distinctUntilChanged, filter, map } from 'rxjs';
 import { Contexts, Size } from '../../db/enums.js';
+import { DOC_SCHEMA } from '../../schema/nodes/doc.js';
+import type { INodeConfig } from '../types.js';
+
+export const doc: INodeConfig = {
+  node: {
+    name: 'doc',
+    schema: DOC_SCHEMA,
+  },
+};
 
 @customElement('node-doc')
 export class NodeDoc extends ChemistryLitElement {
diff --git a/lab/html-based-buildify/client/nodes/excalidraw.ts b/lab/html-based-buildify/client/nodes/excalidraw.ts
index ed622926c9..7b6d72838f 100644
--- a/lab/html-based-buildify/client/nodes/excalidraw.ts
+++ b/lab/html-based-buildify/client/nodes/excalidraw.ts
@@ -10,48 +10,55 @@ import { type ExcalidrawData } from '@adornis/excalidraw/db/ExcalidrawData.js';
 import '@adornis/forms/x-input';
 import { html, nothing } from 'lit';
 import { customElement, property } from 'lit/decorators.js';
-import { MenuItem } from 'prosemirror-menu';
-import type { EditorState } from 'prosemirror-state';
 import type { EditorView } from 'prosemirror-view';
 import { HTMLBaseExcalidraw } from '../../db/HTMLBaseExcalidraw.js';
-import { EXCALIDRAW_KEY } from '../../schema/nodes/excalidraw.js';
+import { EXCALIDRAW_KEY, EXCALIDRAW_SCHEMA } from '../../schema/nodes/excalidraw.js';
 import { BuildifyLitElement } from '../BuildifyLitElement.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
 import { runInsertNode } from '../helper/runInsertNode.js';
+import { MenuItemGroup, type INodeConfig } from '../types.js';
 import { isNodeSelection, updateNodeAttrs } from '../util.js';
 
-export const EXCALIDRAW_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'brush', tooltip: 'Excalidraw' }),
-  run: runInsertNode(EXCALIDRAW_KEY),
-  label: 'Excalidraw',
-  title: 'Excalidraw',
-  select(state) {
-    return !isNodeSelection(state.selection);
+export const excalidraw: INodeConfig = {
+  node: {
+    name: EXCALIDRAW_KEY,
+    schema: EXCALIDRAW_SCHEMA,
   },
-});
-
-export const PAINT_EXCALIDRAW_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'brush', tooltip: 'Bearbeiten' }),
-  run: async (state: EditorState, dispatch: any, view: EditorView, event: any) => {
-    if (!isNodeSelection(state.selection)) return;
-    let data = constructValue(JSON.parse(state.selection.node.attrs.data));
-    if (!data) data = new HTMLBaseExcalidraw({});
-    // eslint-disable-next-line @typescript-eslint/no-floating-promises
-    EditExcalidrawDialog.showPopup({
-      props: {
-        data,
+  menuItems: [
+    {
+      group: MenuItemGroup.MEDIA,
+      label: 'Excalidraw',
+      icon: 'brush',
+      run: (view: EditorView) => runInsertNode(EXCALIDRAW_KEY)(view.state, view),
+      shouldVisualize: (view: EditorView) => {
+        return !isNodeSelection(view.state.selection);
       },
-    }).then(res => {
-      if (!(res instanceof HTMLBaseExcalidraw)) return;
-      dispatch(updateNodeAttrs(state, state.tr, () => state.selection.from, { data: JSON.stringify(res.toObject()) }));
-    });
-  },
-  label: 'Excalidraw editieren',
-  title: 'Excalidraw editieren',
-  select(state) {
-    return isNodeSelection(state.selection) && state.selection.node.type.name === EXCALIDRAW_KEY;
-  },
-});
+    },
+    {
+      label: 'Excalidraw editieren',
+      icon: 'brush',
+      run: async (view: EditorView) => {
+        if (!isNodeSelection(view.state.selection)) return;
+        let data = constructValue(JSON.parse(view.state.selection.node.attrs.data));
+        if (!data) data = new HTMLBaseExcalidraw({});
+        return EditExcalidrawDialog.showPopup({
+          props: {
+            data,
+          },
+        }).then(res => {
+          if (!(res instanceof HTMLBaseExcalidraw)) return;
+          view.dispatch(
+            updateNodeAttrs(view.state, view.state.tr, () => view.state.selection.from, {
+              data: JSON.stringify(res.toObject()),
+            }),
+          );
+        });
+      },
+      shouldVisualize: (view: EditorView) => {
+        return isNodeSelection(view.state.selection) && view.state.selection.node.type.name === EXCALIDRAW_KEY;
+      },
+    },
+  ],
+};
 
 @customElement('node-excalidraw')
 export class NodeExcalidraw extends BuildifyLitElement<HTMLBaseExcalidraw> {
diff --git a/lab/html-based-buildify/client/nodes/file.ts b/lab/html-based-buildify/client/nodes/file.ts
index c6b81cdbf6..b25bde1ed3 100644
--- a/lab/html-based-buildify/client/nodes/file.ts
+++ b/lab/html-based-buildify/client/nodes/file.ts
@@ -1,6 +1,7 @@
 /* eslint-disable @typescript-eslint/no-unnecessary-condition */
 /* eslint-disable complexity */
 /* eslint-disable @typescript-eslint/no-unsafe-argument */
+import type { Styles } from '@adornis/ass/style.js';
 import '@adornis/buildify/client/components/x-buildify-file-selection.js';
 import { BuildifyFile } from '@adornis/buildify/db/BuildifyFile.js';
 import { RXController } from '@adornis/chemistry/controllers/RXController.js';
@@ -11,17 +12,16 @@ import '@adornis/chemistry/elements/components/x-icon';
 import '@adornis/forms/x-input';
 import { html } from 'lit';
 import { customElement, state } from 'lit/decorators.js';
-import { MenuItem } from 'prosemirror-menu';
+import type { EditorView } from 'prosemirror-view';
 import { distinctUntilChanged, filter, map, switchMap } from 'rxjs';
 import type { HTMLBaseFile } from '../../db/HTMLBaseFile.js';
-import { FILE_KEY } from '../../schema/nodes/file.js';
+import { FILE_KEY, FILE_SCHEMA } from '../../schema/nodes/file.js';
 import { BuildifyLitElement } from '../BuildifyLitElement.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
 import { runInsertNode } from '../helper/runInsertNode.js';
-import type { EditorFunc } from '../types.js';
+import { MenuItemGroup, type EditorFunc, type INodeConfig } from '../types.js';
 import { isNodeSelection } from '../util.js';
 
-export const FILE_EDITOR: EditorFunc<HTMLBaseFile> = ({ content, contentController, controllerBaseKeyPath, host }) => {
+const editor: EditorFunc<HTMLBaseFile> = ({ content, contentController, controllerBaseKeyPath, host }) => {
   return html`
     <x-buildify-file-selection
       ${contentController.field(...controllerBaseKeyPath, 'fileID')}
@@ -29,15 +29,24 @@ export const FILE_EDITOR: EditorFunc<HTMLBaseFile> = ({ content, contentControll
   `;
 };
 
-export const FILE_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'description', tooltip: 'Datei' }),
-  run: runInsertNode(FILE_KEY),
-  label: 'Datei',
-  title: 'Datei',
-  select(state) {
-    return !isNodeSelection(state.selection);
+export const file: INodeConfig = {
+  node: {
+    name: FILE_KEY,
+    schema: FILE_SCHEMA(),
   },
-});
+  menuItems: [
+    {
+      group: MenuItemGroup.MEDIA,
+      label: 'Datei',
+      icon: 'description',
+      run: (view: EditorView) => runInsertNode(FILE_KEY)(view.state, view),
+      shouldVisualize: (view: EditorView) => {
+        return !isNodeSelection(view.state.selection);
+      },
+    },
+  ],
+  editor,
+};
 
 @customElement('node-file')
 export class NodeFile extends BuildifyLitElement<HTMLBaseFile> {
@@ -96,4 +105,16 @@ export class NodeFile extends BuildifyLitElement<HTMLBaseFile> {
   protected getFileImage() {
     return html` <x-icon ${css({ fontSize: '50px', color: '#4d4d4d' })}> description </x-icon> `;
   }
+
+  override styles() {
+    return [
+      ...super.styles(),
+      {
+        ':host': {
+          display: 'inline-block',
+          verticalAlign: 'middle',
+        },
+      },
+    ] as Styles[];
+  }
 }
diff --git a/lab/html-based-buildify/client/nodes/flex.ts b/lab/html-based-buildify/client/nodes/flex.ts
index 37e59366fe..8ac97ca893 100644
--- a/lab/html-based-buildify/client/nodes/flex.ts
+++ b/lab/html-based-buildify/client/nodes/flex.ts
@@ -7,18 +7,17 @@ import '@adornis/chemistry/elements/components/x-icon';
 import '@adornis/popover/x-dropdown-selection';
 import { html } from 'lit';
 import { customElement } from 'lit/decorators.js';
-import { MenuItem } from 'prosemirror-menu';
+import type { EditorView } from 'prosemirror-view';
 import type { HTMLBaseContainerFlex } from '../../db/HTMLBaseContainerFlex.js';
-import { FLEX_KEY } from '../../schema/nodes/flex.js';
+import { FLEX_KEY, FLEX_SCHEMA } from '../../schema/nodes/flex.js';
 import { PARAGRAPH_KEY } from '../../schema/nodes/paragraph.js';
 import { BuildifyLitElement } from '../BuildifyLitElement.js';
 import { ContainerEditor } from '../editors/Container.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
 import { runInsertNode } from '../helper/runInsertNode.js';
-import { type EditorFunc } from '../types.js';
+import { MenuItemGroup, type EditorFunc, type INodeConfig } from '../types.js';
 import { isNodeSelection } from '../util.js';
 
-export const FLEX_EDITOR: EditorFunc<HTMLBaseContainerFlex> = ({
+export const flexEditor: EditorFunc<HTMLBaseContainerFlex> = ({
   content,
   contentController,
   controllerBaseKeyPath,
@@ -71,19 +70,29 @@ export const FLEX_EDITOR: EditorFunc<HTMLBaseContainerFlex> = ({
   `;
 };
 
-export const FLEX_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'flex_wrap', tooltip: 'Flex' }),
-  run: runInsertNode(FLEX_KEY, state => {
-    return {
-      content: [state.schema.nodes[PARAGRAPH_KEY]!.create(null, [state.schema.text('Hier tippen...')])],
-    };
-  }),
-  label: 'Flex',
-  title: 'Flex',
-  select(state) {
-    return !isNodeSelection(state.selection);
+export const flex: INodeConfig = {
+  node: {
+    name: FLEX_KEY,
+    schema: FLEX_SCHEMA,
   },
-});
+  menuItems: [
+    {
+      group: MenuItemGroup.LAYOUT,
+      label: 'Flex',
+      icon: 'flex_wrap',
+      run: (view: EditorView) =>
+        runInsertNode(FLEX_KEY, state => {
+          return {
+            content: [state.schema.nodes[PARAGRAPH_KEY]!.create(null, [state.schema.text('Hier tippen...')])],
+          };
+        })(view.state, view),
+      shouldVisualize: (view: EditorView) => {
+        return !isNodeSelection(view.state.selection);
+      },
+    },
+  ],
+  editor: flexEditor,
+};
 
 @customElement('node-flex')
 export class NodeFlex extends BuildifyLitElement<HTMLBaseContainerFlex> {
diff --git a/lab/html-based-buildify/client/nodes/grid.ts b/lab/html-based-buildify/client/nodes/grid.ts
index a51fb8fd61..603a7ffc2d 100644
--- a/lab/html-based-buildify/client/nodes/grid.ts
+++ b/lab/html-based-buildify/client/nodes/grid.ts
@@ -13,19 +13,18 @@ import '@adornis/forms/x-checkbox.js';
 import '@adornis/forms/x-input';
 import { html, nothing } from 'lit';
 import { customElement, property } from 'lit/decorators.js';
-import { MenuItem } from 'prosemirror-menu';
+import type { EditorView } from 'prosemirror-view';
 import { Size } from '../../db/enums.js';
 import { HTMLBaseContainerGridTableView, type HTMLBaseContainerGrid } from '../../db/HTMLBaseContainerGrid.js';
-import { GRID_CELL_KEY, GRID_KEY } from '../../schema/nodes/grid.js';
+import { GRID_CELL_KEY, GRID_CELL_SCHEMA, GRID_KEY, GRID_SCHEMA } from '../../schema/nodes/grid.js';
 import { PARAGRAPH_KEY } from '../../schema/nodes/paragraph.js';
 import { BuildifyLitElement } from '../BuildifyLitElement.js';
 import { ContainerEditor } from '../editors/Container.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
 import { runInsertNode } from '../helper/runInsertNode.js';
-import { type EditorFunc } from '../types.js';
+import { MenuItemGroup, type EditorFunc, type INodeConfig } from '../types.js';
 import { isNodeSelection } from '../util.js';
 
-export const GRID_EDITOR: EditorFunc<HTMLBaseContainerGrid> = ({
+export const editor: EditorFunc<HTMLBaseContainerGrid> = ({
   content,
   contentController,
   controllerBaseKeyPath,
@@ -155,23 +154,40 @@ export const GRID_EDITOR: EditorFunc<HTMLBaseContainerGrid> = ({
   `;
 };
 
-export const GRID_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'grid_on', tooltip: 'Grid' }),
-  run: runInsertNode(GRID_KEY, state => {
-    return {
-      content: [
-        state.schema.nodes[GRID_CELL_KEY]!.create(null, [
-          state.schema.nodes[PARAGRAPH_KEY]!.create(null, [state.schema.text('Hier tippen...')]),
-        ]),
-      ],
-    };
-  }),
-  select(state) {
-    return !isNodeSelection(state.selection);
+export const grid: INodeConfig = {
+  node: {
+    name: GRID_KEY,
+    schema: GRID_SCHEMA,
   },
-  label: 'Grid',
-  title: 'Grid',
-});
+  menuItems: [
+    {
+      group: MenuItemGroup.LAYOUT,
+      label: 'Grid',
+      icon: 'grid_on',
+      run: (view: EditorView) =>
+        runInsertNode(GRID_KEY, state => {
+          return {
+            content: [
+              state.schema.nodes[GRID_CELL_KEY]!.create(null, [
+                state.schema.nodes[PARAGRAPH_KEY]!.create(null, [state.schema.text('Hier tippen...')]),
+              ]),
+            ],
+          };
+        })(view.state, view),
+      shouldVisualize: (view: EditorView) => {
+        return !isNodeSelection(view.state.selection);
+      },
+    },
+  ],
+  editor,
+};
+
+export const gridCell: INodeConfig = {
+  node: {
+    name: GRID_CELL_KEY,
+    schema: GRID_CELL_SCHEMA,
+  },
+};
 
 @customElement('node-grid')
 export class NodeGrid extends BuildifyLitElement<HTMLBaseContainerGrid> {
diff --git a/lab/html-based-buildify/client/nodes/hard-break.ts b/lab/html-based-buildify/client/nodes/hard-break.ts
index 33be6dcba9..4dde70cd52 100644
--- a/lab/html-based-buildify/client/nodes/hard-break.ts
+++ b/lab/html-based-buildify/client/nodes/hard-break.ts
@@ -1 +1,9 @@
-import '@adornis/chemistry/elements/components/x-button.js';
+import { HARD_BREAK_KEY, HARD_BREAK_SCHEMA } from '../../schema/nodes/hard-break.js';
+import type { INodeConfig } from '../types.js';
+
+export const hardBreak: INodeConfig = {
+  node: {
+    name: HARD_BREAK_KEY,
+    schema: HARD_BREAK_SCHEMA,
+  },
+};
diff --git a/lab/html-based-buildify/client/nodes/headings.ts b/lab/html-based-buildify/client/nodes/headings.ts
index 43d7dedb3c..48684cf503 100644
--- a/lab/html-based-buildify/client/nodes/headings.ts
+++ b/lab/html-based-buildify/client/nodes/headings.ts
@@ -1,31 +1,42 @@
 import '@adornis/chemistry/elements/components/x-icon';
 import { textblockTypeInputRule } from 'prosemirror-inputrules';
-import { MenuItem } from 'prosemirror-menu';
 import type { NodeType } from 'prosemirror-model';
 import type { EditorState, Transaction } from 'prosemirror-state';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
+import type { EditorView } from 'prosemirror-view';
+import { HEADING_KEY, HEADING_SCHEMA } from '../../schema/nodes/heading.js';
+import { MenuItemGroup, type IMenuItem, type INodeConfig } from '../types.js';
 import { isInHeading, isInParagraph } from '../util.js';
 
-export const HEADING_MENU_ITEM = (level: number) => createHeadingMenuItem(level);
+export const heading: INodeConfig = {
+  node: {
+    name: HEADING_KEY,
+    schema: HEADING_SCHEMA,
+  },
+  menuItems: [
+    createHeadingMenuItem(1),
+    createHeadingMenuItem(2),
+    createHeadingMenuItem(3),
+    createHeadingMenuItem(4),
+    createHeadingMenuItem(5),
+  ],
+};
 
-function createHeadingMenuItem(level: number) {
-  return new MenuItem({
+function createHeadingMenuItem(level: number): IMenuItem {
+  const icon = `format_h${level}`;
+  return {
+    group: MenuItemGroup.HEADINGS,
     label: `h${level}`,
-    title: `h${level}`,
-    render(view) {
-      const icon = `format_h${level}`;
-      return createMenuItemButton({ icon, tooltip: `Überschrift ${level}` });
+    icon,
+    run: (view: EditorView) => {
+      toggleHeading(level, view)(view.state, view.dispatch);
     },
-    run(state, dispatch, view, event) {
-      toggleHeading(level)(state, dispatch);
+    shouldVisualize: (view: EditorView) => {
+      return isInParagraph(view.state) || isInHeading(view.state);
     },
-    select(state) {
-      return isInParagraph(state) || isInHeading(state);
+    isActive: (view: EditorView) => {
+      return toggleHeading(level, view)(view.state);
     },
-    active(state) {
-      return toggleHeading(level)(state);
-    },
-  });
+  };
 }
 
 export function headingRule(nodeType: NodeType) {
@@ -34,7 +45,7 @@ export function headingRule(nodeType: NodeType) {
   }));
 }
 
-function toggleHeading(level: number) {
+function toggleHeading(level: number, view: EditorView) {
   return (state: EditorState, dispatch?: (tr: Transaction) => void): boolean => {
     const { schema, selection } = state;
     const headingType = schema.nodes.heading;
@@ -64,6 +75,7 @@ function toggleHeading(level: number) {
       }
 
       dispatch(tr);
+      view.focus();
     }
 
     return isActive;
diff --git a/lab/html-based-buildify/client/nodes/icon.ts b/lab/html-based-buildify/client/nodes/icon.ts
index d6b98715ea..66b864d8ac 100644
--- a/lab/html-based-buildify/client/nodes/icon.ts
+++ b/lab/html-based-buildify/client/nodes/icon.ts
@@ -3,16 +3,15 @@ import { css } from '@adornis/chemistry/directives/css.js';
 import '@adornis/chemistry/elements/components/x-icon';
 import { html } from 'lit';
 import { customElement } from 'lit/decorators.js';
-import { MenuItem } from 'prosemirror-menu';
+import type { EditorView } from 'prosemirror-view';
 import { type HTMLBaseIcon } from '../../db/HTMLBaseIcon.js';
-import { ICON_KEY } from '../../schema/nodes/icon.js';
+import { ICON_KEY, ICON_SCHEMA } from '../../schema/nodes/icon.js';
 import { BuildifyLitElement } from '../BuildifyLitElement.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
 import { runInsertNode } from '../helper/runInsertNode.js';
-import { type EditorFunc } from '../types.js';
+import { type EditorFunc, type INodeConfig } from '../types.js';
 import { isNodeSelection } from '../util.js';
 
-export const ICON_EDITOR: EditorFunc<HTMLBaseIcon> = ({ content, contentController, controllerBaseKeyPath, host }) => {
+const editor: EditorFunc<HTMLBaseIcon> = ({ content, contentController, controllerBaseKeyPath, host }) => {
   return html`
     <x-flex space="sm">
       <x-infobox>
@@ -37,15 +36,23 @@ export const ICON_EDITOR: EditorFunc<HTMLBaseIcon> = ({ content, contentControll
   `;
 };
 
-export const ICON_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'emoticon', tooltip: 'Icon' }),
-  run: runInsertNode(ICON_KEY),
-  label: 'Icon',
-  title: 'Icon',
-  select(state) {
-    return !isNodeSelection(state.selection);
+export const icon: INodeConfig = {
+  node: {
+    name: ICON_KEY,
+    schema: ICON_SCHEMA,
   },
-});
+  menuItems: [
+    {
+      label: 'Icon',
+      icon: 'emoticon',
+      run: (view: EditorView) => runInsertNode(ICON_KEY)(view.state, view),
+      shouldVisualize: (view: EditorView) => {
+        return !isNodeSelection(view.state.selection);
+      },
+    },
+  ],
+  editor,
+};
 
 @customElement('node-icon')
 export class NodeIcon extends BuildifyLitElement<HTMLBaseIcon> {
diff --git a/lab/html-based-buildify/client/nodes/iconText.ts b/lab/html-based-buildify/client/nodes/iconText.ts
index 668862a5db..fe541d5cc4 100644
--- a/lab/html-based-buildify/client/nodes/iconText.ts
+++ b/lab/html-based-buildify/client/nodes/iconText.ts
@@ -5,34 +5,63 @@ import '@adornis/chemistry/elements/components/x-flex';
 import '@adornis/chemistry/elements/components/x-icon';
 import { html } from 'lit';
 import { customElement } from 'lit/decorators.js';
-import { MenuItem } from 'prosemirror-menu';
 import type { EditorState } from 'prosemirror-state';
+import type { EditorView } from 'prosemirror-view';
 import type { HTMLBaseIcon } from '../../db/HTMLBaseIcon.js';
-import { ICON_TEXT_KEY, ICON_WRAPPER_KEY, TEXT_WRAPPER_KEY } from '../../schema/nodes/icon-text.js';
+import {
+  ICON_TEXT_KEY,
+  ICON_TEXT_SCHEMA,
+  ICON_WRAPPER_KEY,
+  ICON_WRAPPER_SCHEMA,
+  TEXT_WRAPPER_KEY,
+  TEXT_WRAPPER_SCHEMA,
+} from '../../schema/nodes/icon-text.js';
 import { ICON_KEY } from '../../schema/nodes/icon.js';
 import { BuildifyLitElement } from '../BuildifyLitElement.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
 import { runInsertNode } from '../helper/runInsertNode.js';
+import type { INodeConfig } from '../types.js';
 import { isNodeSelection } from '../util.js';
 
-export const ICON_TEXT_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'skull_list', tooltip: 'Icon mit Text' }),
-  run: runInsertNode(ICON_TEXT_KEY, (state: EditorState) => {
-    return {
-      content: [
-        state.schema.nodes[ICON_WRAPPER_KEY]!.create(null, [state.schema.nodes[ICON_KEY]!.create()]),
-        state.schema.nodes[TEXT_WRAPPER_KEY]!.create(null, [
-          state.schema.nodes.paragraph!.create(null, [state.schema.text('Ändere mich...')]),
-        ]),
-      ],
-    };
-  }),
-  label: 'Icon mit Text',
-  title: 'Icon mit Text',
-  select(state) {
-    return !isNodeSelection(state.selection);
+export const iconText: INodeConfig = {
+  node: {
+    name: ICON_TEXT_KEY,
+    schema: ICON_TEXT_SCHEMA,
   },
-});
+  menuItems: [
+    {
+      label: 'Icon mit Text',
+      icon: 'skull_list',
+      run: (view: EditorView) =>
+        runInsertNode(ICON_TEXT_KEY, (state: EditorState) => {
+          return {
+            content: [
+              state.schema.nodes[ICON_WRAPPER_KEY]!.create(null, [state.schema.nodes[ICON_KEY]!.create()]),
+              state.schema.nodes[TEXT_WRAPPER_KEY]!.create(null, [
+                state.schema.nodes.paragraph!.create(null, [state.schema.text('Ändere mich...')]),
+              ]),
+            ],
+          };
+        })(view.state, view),
+      shouldVisualize: (view: EditorView) => {
+        return !isNodeSelection(view.state.selection);
+      },
+    },
+  ],
+};
+
+export const iconWrapper: INodeConfig = {
+  node: {
+    name: ICON_WRAPPER_KEY,
+    schema: ICON_WRAPPER_SCHEMA,
+  },
+};
+
+export const textWrapper: INodeConfig = {
+  node: {
+    name: TEXT_WRAPPER_KEY,
+    schema: TEXT_WRAPPER_SCHEMA,
+  },
+};
 
 @customElement('node-icon-text')
 export class NodeIconText extends BuildifyLitElement<HTMLBaseIcon> {
diff --git a/lab/html-based-buildify/client/nodes/image.ts b/lab/html-based-buildify/client/nodes/image.ts
index 0cbbb9cb1e..dd39c78ce4 100644
--- a/lab/html-based-buildify/client/nodes/image.ts
+++ b/lab/html-based-buildify/client/nodes/image.ts
@@ -14,21 +14,15 @@ import '@adornis/popover/x-dropdown-selection';
 import { goTo } from '@adornis/router/client/open-href.js';
 import { html, nothing } from 'lit';
 import { customElement, property } from 'lit/decorators.js';
-import { MenuItem } from 'prosemirror-menu';
+import type { EditorView } from 'prosemirror-view';
 import type { HTMLBaseImage } from '../../db/HTMLBaseImage.js';
-import { IMAGE_KEY } from '../../schema/nodes/image.js';
+import { IMAGE_KEY, IMAGE_SCHEMA } from '../../schema/nodes/image.js';
 import { BuildifyLitElement } from '../BuildifyLitElement.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
 import { runInsertNode } from '../helper/runInsertNode.js';
-import { type EditorFunc } from '../types.js';
+import { MenuItemGroup, type EditorFunc, type INodeConfig } from '../types.js';
 import { isNodeSelection } from '../util.js';
 
-export const IMAGE_EDITOR: EditorFunc<HTMLBaseImage> = ({
-  content,
-  contentController,
-  controllerBaseKeyPath,
-  host,
-}) => {
+export const editor: EditorFunc<HTMLBaseImage> = ({ content, contentController, controllerBaseKeyPath, host }) => {
   return html`
     <x-flex space="md">
       <x-input
@@ -83,15 +77,24 @@ export const IMAGE_EDITOR: EditorFunc<HTMLBaseImage> = ({
   `;
 };
 
-export const IMAGE_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'image', tooltip: 'Bild' }),
-  run: runInsertNode(IMAGE_KEY),
-  label: 'Bild',
-  title: 'Bild',
-  select(state) {
-    return !isNodeSelection(state.selection);
+export const image: INodeConfig = {
+  node: {
+    name: IMAGE_KEY,
+    schema: IMAGE_SCHEMA,
   },
-});
+  menuItems: [
+    {
+      group: MenuItemGroup.MEDIA,
+      label: 'Bild',
+      icon: 'image',
+      run: (view: EditorView) => runInsertNode(IMAGE_KEY)(view.state, view),
+      shouldVisualize: (view: EditorView) => {
+        return !isNodeSelection(view.state.selection);
+      },
+    },
+  ],
+  editor,
+};
 
 @customElement('node-image')
 export class NodeImage extends BuildifyLitElement<HTMLBaseImage> {
diff --git a/lab/html-based-buildify/client/nodes/list.ts b/lab/html-based-buildify/client/nodes/list.ts
index da8cb953fe..50151ad296 100644
--- a/lab/html-based-buildify/client/nodes/list.ts
+++ b/lab/html-based-buildify/client/nodes/list.ts
@@ -1,47 +1,117 @@
-import { wrappingInputRule } from 'prosemirror-inputrules';
-import { MenuItem } from 'prosemirror-menu';
-import type { NodeType } from 'prosemirror-model';
-import { liftListItem, wrapInList } from 'prosemirror-schema-list';
-import type { EditorState, Transaction } from 'prosemirror-state';
-import { LIST_ITEM_KEY, LIST_KEY } from '../../schema/nodes/list.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
-
-export function bulletListInputRule(nodeType: NodeType) {
-  return wrappingInputRule(/^\s*([-+*])\s$/, nodeType);
-}
-
-export function orderedListInputRule(nodeType) {
-  return wrappingInputRule(/^(\d+)\.\s$/, nodeType, match => ({ order: +match[1]! }));
-}
-
-export const LIST_MENU_ITEM = new MenuItem({
-  title: 'Unordered List',
-  render: view => createMenuItemButton({ icon: 'list', tooltip: 'Liste' }),
-  run: (state, dispatch, view, event) => toggleBulletList()(state, dispatch),
-  select(state) {
-    return toggleBulletList()(state);
+import { keymap } from 'prosemirror-keymap';
+import { Schema } from 'prosemirror-model';
+import { liftListItem, sinkListItem, splitListItem, wrapInList } from 'prosemirror-schema-list';
+import { Plugin } from 'prosemirror-state';
+import type { EditorView } from 'prosemirror-view';
+import type { INodeConfig } from '../types.js';
+
+export const listItem: INodeConfig = {
+  node: {
+    name: 'list_item',
+    schema: {
+      group: 'block',
+      content: 'paragraph block*',
+      toDOM() {
+        return ['li', 0];
+      },
+      parseDOM: [{ tag: 'li' }],
+    },
+  },
+};
+
+export const bulletList: INodeConfig = {
+  node: {
+    name: 'bullet_list',
+    schema: {
+      group: 'block',
+      content: `${listItem.node.name}+`,
+      toDOM() {
+        return ['ul', 0];
+      },
+      parseDOM: [{ tag: 'ul' }],
+    },
+  },
+  plugins: (schema: Schema) => [
+    new Plugin({
+      props: {
+        handleTextInput(view: EditorView, from, to, text) {
+          const { state, dispatch } = view;
+          const { $from } = state.selection;
+          const paragraphNode = $from.parent;
+
+          // checken ob der Input valid ist
+          const checkText = paragraphNode.textBetween(0, $from.parentOffset, null, '\n');
+          const shouldWrapInList = checkText === '-';
+          if (!shouldWrapInList) return false;
+
+          const tr = state.tr;
+
+          // Lösche das eingegebene Zeichen "-"
+          tr.delete(from - 1, to);
+
+          // Erstelle eine Bullet-Liste
+          dispatch(tr);
+          wrapInList(state.schema.nodes.bullet_list)(view.state, dispatch);
+          return true;
+        },
+      },
+    }),
+    listKeymapPlugin(schema),
+  ],
+};
+
+export const orderedList: INodeConfig = {
+  node: {
+    name: 'ordered_list',
+    schema: {
+      group: 'block',
+      content: `${listItem.node.name}+`,
+      attrs: { order: { default: 1 } },
+      toDOM(node) {
+        return ['ol', { start: node.attrs.order }, 0];
+      },
+      parseDOM: [
+        {
+          tag: 'ol',
+          getAttrs: dom => ({
+            order: dom.hasAttribute('start') ? +dom.getAttribute('start')! : 1,
+          }),
+        },
+      ],
+    },
   },
-});
-
-function toggleBulletList(): (state: EditorState, dispatch?: (tr: Transaction) => void) => boolean {
-  return (state, dispatch) => {
-    const listType = state.schema.nodes[LIST_KEY];
-    const itemType = state.schema.nodes[LIST_ITEM_KEY];
-    if (!listType || !itemType) throw new Error('no list type or list item type defined');
-
-    const { $from, $to } = state.selection;
-    const range = $from.blockRange($to);
-
-    if (!range) return false;
-
-    const parentList = range.depth > 0 ? $from.node(range.depth - 1) : null;
-
-    if (parentList?.type === listType) {
-      // Wenn wir bereits in einer Liste sind: zurück in Absatz konvertieren
-      return liftListItem(itemType)(state, dispatch);
-    } else {
-      // Liste erstellen
-      return wrapInList(listType)(state, dispatch);
-    }
-  };
-}
+  plugins: (schema: Schema) => [
+    new Plugin({
+      props: {
+        handleTextInput(view, from, to, text) {
+          const { state, dispatch } = view;
+          const { $from } = state.selection;
+          const paragraphNode = $from.parent;
+
+          // Prüfe auf "1. ", "2. " usw. (Ordered List)
+          const orderedListMatch = paragraphNode.textBetween(0, $from.parentOffset, null, '\n').match(/^(\d+)\.$/);
+          if (!orderedListMatch) return false; // Wenn kein Muster erkannt wurde
+
+          const tr = state.tr;
+
+          // Lösche die Eingabe "1."
+          tr.delete(from - orderedListMatch[0].length, to);
+
+          // Erstelle eine Ordered-Liste
+          dispatch(tr);
+          wrapInList(state.schema.nodes.ordered_list)(view.state, dispatch);
+          return true;
+        },
+      },
+    }),
+    listKeymapPlugin(schema),
+  ],
+};
+
+const listKeymapPlugin = (schema: Schema) => {
+  return keymap({
+    Enter: splitListItem(schema.nodes.list_item),
+    Tab: sinkListItem(schema.nodes.list_item),
+    'Shift-Tab': liftListItem(schema.nodes.list_item),
+  });
+};
diff --git a/lab/html-based-buildify/client/nodes/paragraph.ts b/lab/html-based-buildify/client/nodes/paragraph.ts
index c9383d4f7f..b993e826b4 100644
--- a/lab/html-based-buildify/client/nodes/paragraph.ts
+++ b/lab/html-based-buildify/client/nodes/paragraph.ts
@@ -1,29 +1,9 @@
-import '@adornis/fonts/fonts';
+import { PARAGRAPH_KEY, PARAGRAPH_SCHEMA } from '../../schema/nodes/paragraph.js';
+import type { INodeConfig } from '../types.js';
 
-// @customElement('node-paragraph')
-// export class NodeParagraph extends ChemistryLitElement {
-//   override render() {
-//     return html`
-//       <x-text>
-//         <slot></slot>
-//       </x-text>
-//     `;
-//   }
-
-//   override styles() {
-//     return [
-//       ...super.styles(),
-//       {
-//         ':host': {
-//           display: 'block',
-//           boxSizinh: 'border-box',
-//           marginBlock: '1em',
-//         },
-//         ':host(:empty):after': {
-//           content: "' '",
-//           display: 'inline-block',
-//         },
-//       },
-//     ] as Styles[];
-//   }
-// }
+export const paragraph: INodeConfig = {
+  node: {
+    name: PARAGRAPH_KEY,
+    schema: PARAGRAPH_SCHEMA,
+  },
+};
diff --git a/lab/html-based-buildify/client/nodes/section.ts b/lab/html-based-buildify/client/nodes/section.ts
index 5c8f9d0c6d..5c7521fdba 100644
--- a/lab/html-based-buildify/client/nodes/section.ts
+++ b/lab/html-based-buildify/client/nodes/section.ts
@@ -3,18 +3,16 @@ import '@adornis/buildify/client/components/x-buildify-width-picker.js';
 import { css } from '@adornis/chemistry/directives/css.js';
 import { html } from 'lit';
 import { customElement } from 'lit/decorators.js';
-import { MenuItem } from 'prosemirror-menu';
 import type { HTMLBaseContainerSection } from '../../db/HTMLBaseContainerSection.js';
 import { PARAGRAPH_KEY } from '../../schema/nodes/paragraph.js';
-import { SECTION_KEY } from '../../schema/nodes/section.js';
+import { SECTION_KEY, SECTION_SCHEMA } from '../../schema/nodes/section.js';
 import { BuildifyLitElement } from '../BuildifyLitElement.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
 import { runInsertNode } from '../helper/runInsertNode.js';
-import { type EditorFunc } from '../types.js';
+import { MenuItemGroup, type EditorFunc, type INodeConfig } from '../types.js';
 import { isNodeSelection } from '../util.js';
-import { FLEX_EDITOR } from './flex.js';
+import { flexEditor } from './flex.js';
 
-export const SECTION_EDITOR: EditorFunc<HTMLBaseContainerSection> = ({
+export const editor: EditorFunc<HTMLBaseContainerSection> = ({
   content,
   contentController,
   controllerBaseKeyPath,
@@ -27,7 +25,7 @@ export const SECTION_EDITOR: EditorFunc<HTMLBaseContainerSection> = ({
         placeholder="Inhaltsbreite"
         ${contentController.field(...controllerBaseKeyPath, 'contentWidthID')}
       ></x-buildify-width-picker>
-      ${FLEX_EDITOR({
+      ${flexEditor({
         content,
         // @ts-expect-error its the same type
         contentController,
@@ -38,19 +36,29 @@ export const SECTION_EDITOR: EditorFunc<HTMLBaseContainerSection> = ({
   `;
 };
 
-export const SECTION_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'fit_width', tooltip: 'Section' }),
-  run: runInsertNode(SECTION_KEY, state => {
-    return {
-      content: [state.schema.nodes[PARAGRAPH_KEY]!.create(null, [state.schema.text('Hier tippen...')])],
-    };
-  }),
-  select(state) {
-    return !isNodeSelection(state.selection);
+export const section: INodeConfig = {
+  node: {
+    name: SECTION_KEY,
+    schema: SECTION_SCHEMA,
   },
-  label: 'Section',
-  title: 'Section',
-});
+  menuItems: [
+    {
+      group: MenuItemGroup.LAYOUT,
+      label: 'Section',
+      icon: 'fit_width',
+      run: view =>
+        runInsertNode(SECTION_KEY, state => {
+          return {
+            content: [state.schema.nodes[PARAGRAPH_KEY]!.create(null, [state.schema.text('Hier tippen...')])],
+          };
+        })(view.state, view),
+      shouldVisualize: view => {
+        return !isNodeSelection(view.state.selection);
+      },
+    },
+  ],
+  editor,
+};
 
 @customElement('node-section')
 export class NodeSection extends BuildifyLitElement<HTMLBaseContainerSection> {
diff --git a/lab/html-based-buildify/client/nodes/spacing.ts b/lab/html-based-buildify/client/nodes/spacing.ts
index 735c292955..cc85412dde 100644
--- a/lab/html-based-buildify/client/nodes/spacing.ts
+++ b/lab/html-based-buildify/client/nodes/spacing.ts
@@ -6,21 +6,14 @@ import '@adornis/chemistry/elements/components/x-icon';
 import '@adornis/forms/x-checkbox.js';
 import { html, nothing } from 'lit';
 import { customElement } from 'lit/decorators.js';
-import { MenuItem } from 'prosemirror-menu';
 import type { HTMLBaseSpacing } from '../../db/HTMLBaseSpacing.js';
-import { SPACING_KEY } from '../../schema/nodes/spacing.js';
+import { SPACING_KEY, SPACING_SCHEMA } from '../../schema/nodes/spacing.js';
 import { BuildifyLitElement } from '../BuildifyLitElement.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
 import { runInsertNode } from '../helper/runInsertNode.js';
-import { type EditorFunc } from '../types.js';
+import { type EditorFunc, type INodeConfig } from '../types.js';
 import { isNodeSelection } from '../util.js';
 
-export const SPACING_EDITOR: EditorFunc<HTMLBaseSpacing> = ({
-  content,
-  contentController,
-  controllerBaseKeyPath,
-  host,
-}) => {
+export const editor: EditorFunc<HTMLBaseSpacing> = ({ content, contentController, controllerBaseKeyPath, host }) => {
   return html`
     <x-flex space="md">
       <x-buildify-spacing-picker
@@ -50,15 +43,23 @@ export const SPACING_EDITOR: EditorFunc<HTMLBaseSpacing> = ({
   `;
 };
 
-export const SPACING_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'format_line_spacing', tooltip: 'Abstand' }),
-  run: runInsertNode(SPACING_KEY),
-  select(state) {
-    return !isNodeSelection(state.selection);
+export const spacing: INodeConfig = {
+  node: {
+    name: SPACING_KEY,
+    schema: SPACING_SCHEMA,
   },
-  label: 'Abstand',
-  title: 'Abstand',
-});
+  menuItems: [
+    {
+      label: 'Abstand',
+      icon: 'format_line_spacing',
+      run: view => runInsertNode(SPACING_KEY)(view.state, view),
+      shouldVisualize: view => {
+        return !isNodeSelection(view.state.selection);
+      },
+    },
+  ],
+  editor,
+};
 
 @customElement('node-spacing')
 export class NodeSpacing extends BuildifyLitElement<HTMLBaseSpacing> {
diff --git a/lab/html-based-buildify/client/nodes/tag.ts b/lab/html-based-buildify/client/nodes/tag.ts
index 8e891c543d..46f95901cf 100644
--- a/lab/html-based-buildify/client/nodes/tag.ts
+++ b/lab/html-based-buildify/client/nodes/tag.ts
@@ -4,23 +4,29 @@ import { acss } from '@adornis/chemistry/directives/acss.js';
 import { css } from '@adornis/chemistry/directives/css.js';
 import { html, type PropertyValues } from 'lit';
 import { customElement } from 'lit/decorators.js';
-import { MenuItem } from 'prosemirror-menu';
 import type { HTMLBase } from '../../db/HTMLBase.js';
-import { TAG_KEY } from '../../schema/nodes/tag.js';
+import { TAG_KEY, TAG_SCHEMA } from '../../schema/nodes/tag.js';
 import { BuildifyLitElement } from '../BuildifyLitElement.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
 import { runInsertNode } from '../helper/runInsertNode.js';
+import type { INodeConfig } from '../types.js';
 import { isNodeSelection } from '../util.js';
 
-export const TAG_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'tag', tooltip: '#Tag' }),
-  run: runInsertNode(TAG_KEY),
-  label: 'Tag',
-  title: 'Tag',
-  select(state) {
-    return !isNodeSelection(state.selection);
+export const tag: INodeConfig = {
+  node: {
+    name: TAG_KEY,
+    schema: TAG_SCHEMA,
   },
-});
+  menuItems: [
+    {
+      label: 'Tag',
+      icon: 'tag',
+      run: view => runInsertNode(TAG_KEY)(view.state, view),
+      shouldVisualize: view => {
+        return !isNodeSelection(view.state.selection);
+      },
+    },
+  ],
+};
 
 @customElement('node-tag')
 export class NodeTag extends BuildifyLitElement<HTMLBase> {
diff --git a/lab/html-based-buildify/client/nodes/text.ts b/lab/html-based-buildify/client/nodes/text.ts
new file mode 100644
index 0000000000..6da4bfdaf3
--- /dev/null
+++ b/lab/html-based-buildify/client/nodes/text.ts
@@ -0,0 +1,9 @@
+import { TEXT_KEY, TEXT_SCHEMA } from '../../schema/nodes/text.js';
+import type { INodeConfig } from '../types.js';
+
+export const text: INodeConfig = {
+  node: {
+    name: TEXT_KEY,
+    schema: TEXT_SCHEMA,
+  },
+};
diff --git a/lab/html-based-buildify/client/nodes/vimeo.ts b/lab/html-based-buildify/client/nodes/vimeo.ts
index 21d39d0c86..8c77269e30 100644
--- a/lab/html-based-buildify/client/nodes/vimeo.ts
+++ b/lab/html-based-buildify/client/nodes/vimeo.ts
@@ -5,16 +5,14 @@ import '@adornis/forms/x-input';
 import { html } from 'lit';
 import { customElement } from 'lit/decorators.js';
 import { createRef, ref } from 'lit/directives/ref.js';
-import { MenuItem } from 'prosemirror-menu';
 import type { HTMLBaseVimeo } from '../../db/HTMLBaseVimeo.js';
-import { VIMEO_KEY } from '../../schema/nodes/vimeo.js';
+import { VIMEO_KEY, VIMEO_SCHEMA } from '../../schema/nodes/vimeo.js';
 import { BuildifyLitElement } from '../BuildifyLitElement.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
 import { runInsertNode } from '../helper/runInsertNode.js';
-import { type EditorFunc } from '../types.js';
+import { MenuItemGroup, type EditorFunc, type INodeConfig } from '../types.js';
 import { isNodeSelection } from '../util.js';
 
-export const VIMEO_EDITOR: EditorFunc<HTMLBaseVimeo> = ({ content, contentController, controllerBaseKeyPath }) => {
+export const editor: EditorFunc<HTMLBaseVimeo> = ({ content, contentController, controllerBaseKeyPath }) => {
   return html`
     <x-flex space="md">
       <x-input placeholder="Vimeo ID" ${contentController.field(...controllerBaseKeyPath, 'vimeoID')}></x-input>
@@ -22,15 +20,24 @@ export const VIMEO_EDITOR: EditorFunc<HTMLBaseVimeo> = ({ content, contentContro
   `;
 };
 
-export const VIMEO_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'skip_next', tooltip: 'Vimeo' }),
-  run: runInsertNode(VIMEO_KEY),
-  label: 'Vimeo',
-  title: 'Vimeo',
-  select(state) {
-    return !isNodeSelection(state.selection);
+export const vimeo: INodeConfig = {
+  node: {
+    name: VIMEO_KEY,
+    schema: VIMEO_SCHEMA,
   },
-});
+  menuItems: [
+    {
+      group: MenuItemGroup.MEDIA,
+      label: 'Vimeo',
+      icon: 'skip_next',
+      run: view => runInsertNode(VIMEO_KEY)(view.state, view),
+      shouldVisualize: view => {
+        return !isNodeSelection(view.state.selection);
+      },
+    },
+  ],
+  editor,
+};
 
 @customElement('node-vimeo')
 export class NodeVimeo extends BuildifyLitElement<HTMLBaseVimeo> {
diff --git a/lab/html-based-buildify/client/nodes/youtube.ts b/lab/html-based-buildify/client/nodes/youtube.ts
index bf73b05dc2..e9ce9fec86 100644
--- a/lab/html-based-buildify/client/nodes/youtube.ts
+++ b/lab/html-based-buildify/client/nodes/youtube.ts
@@ -4,21 +4,14 @@ import '@adornis/chemistry/elements/components/x-icon';
 import '@adornis/forms/x-input';
 import { html } from 'lit';
 import { customElement } from 'lit/decorators.js';
-import { MenuItem } from 'prosemirror-menu';
 import type { HTMLBaseYoutube } from '../../db/HTMLBaseYoutube.js';
-import { YOUTUBE_KEY } from '../../schema/nodes/youtube.js';
+import { YOUTUBE_KEY, YOUTUBE_SCHEMA } from '../../schema/nodes/youtube.js';
 import { BuildifyLitElement } from '../BuildifyLitElement.js';
-import { createMenuItemButton } from '../helper/createMenuItemIcon.js';
 import { runInsertNode } from '../helper/runInsertNode.js';
-import { type EditorFunc } from '../types.js';
+import { MenuItemGroup, type EditorFunc, type INodeConfig } from '../types.js';
 import { isNodeSelection } from '../util.js';
 
-export const YOUTUBE_EDITOR: EditorFunc<HTMLBaseYoutube> = ({
-  content,
-  contentController,
-  controllerBaseKeyPath,
-  host,
-}) => {
+const editor: EditorFunc<HTMLBaseYoutube> = ({ content, contentController, controllerBaseKeyPath, host }) => {
   return html`
     <x-flex space="md">
       <x-input placeholder="Youtube ID" ${contentController.field(...controllerBaseKeyPath, 'youtubeID')}></x-input>
@@ -26,15 +19,24 @@ export const YOUTUBE_EDITOR: EditorFunc<HTMLBaseYoutube> = ({
   `;
 };
 
-export const YOUTUBE_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'youtube_activity', tooltip: 'YouTube' }),
-  run: runInsertNode(YOUTUBE_KEY),
-  label: 'YouTube',
-  title: 'YouTube',
-  select(state) {
-    return !isNodeSelection(state.selection);
+export const youtube: INodeConfig = {
+  node: {
+    name: YOUTUBE_KEY,
+    schema: YOUTUBE_SCHEMA,
   },
-});
+  menuItems: [
+    {
+      group: MenuItemGroup.MEDIA,
+      label: 'YouTube',
+      icon: 'youtube_activity',
+      run: view => runInsertNode(YOUTUBE_KEY)(view.state, view),
+      shouldVisualize: view => {
+        return !isNodeSelection(view.state.selection);
+      },
+    },
+  ],
+  editor,
+};
 
 @customElement('node-youtube')
 export class NodeYoutube extends BuildifyLitElement<HTMLBaseYoutube> {
diff --git a/lab/html-based-buildify/client/plugins/currentPathBarPlugin.ts b/lab/html-based-buildify/client/plugins/currentPathBarPlugin.ts
index 1b15df2fa3..25e53571ac 100644
--- a/lab/html-based-buildify/client/plugins/currentPathBarPlugin.ts
+++ b/lab/html-based-buildify/client/plugins/currentPathBarPlugin.ts
@@ -58,6 +58,7 @@ export function getCurrentPathPlugin() {
       breadcrumb.style.fontSize = '14px';
       breadcrumb.style.color = '#333';
       breadcrumb.style.zIndex = '1000';
+      breadcrumb.style.boxSizing = 'border-box';
 
       // Breadcrumb aktualisieren
       const updateBreadcrumb = () => {
diff --git a/lab/html-based-buildify/client/plugins/handleTransactionMetaPlugin.ts b/lab/html-based-buildify/client/plugins/handleTransactionMetaPlugin.ts
new file mode 100644
index 0000000000..d55c1ba1e4
--- /dev/null
+++ b/lab/html-based-buildify/client/plugins/handleTransactionMetaPlugin.ts
@@ -0,0 +1,10 @@
+import { Plugin, PluginKey } from 'prosemirror-state';
+
+export const handleTransactionMetaPlugin = (key: string, handler: (meta: any) => void) =>
+  new Plugin({
+    key: new PluginKey(key),
+    filterTransaction: (_, state) => {
+      handler(state.tr.getMeta(key));
+      return true;
+    },
+  });
diff --git a/lab/html-based-buildify/client/plugins/quotePlugin.ts b/lab/html-based-buildify/client/plugins/quotePlugin.ts
index a2f4872652..b953ca14a8 100644
--- a/lab/html-based-buildify/client/plugins/quotePlugin.ts
+++ b/lab/html-based-buildify/client/plugins/quotePlugin.ts
@@ -1,7 +1,7 @@
 import { InputRule, inputRules } from 'prosemirror-inputrules';
 
 // Input Rules für deutsche Anführungszeichen
-export function germanSmartQuotesInputRules() {
+export function germanSmartQuotesPlugin() {
   return inputRules({
     rules: [
       // Regel für geschlossene doppelte Anführungszeichen: „...“
diff --git a/lab/html-based-buildify/client/prosemirror-editor.ts b/lab/html-based-buildify/client/prosemirror-editor.ts
index c9a6434755..03aed0235a 100644
--- a/lab/html-based-buildify/client/prosemirror-editor.ts
+++ b/lab/html-based-buildify/client/prosemirror-editor.ts
@@ -1,9 +1,6 @@
 /* eslint-disable @typescript-eslint/no-dupe-class-members */
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
 // styles
-import GapcursorStyles from 'text:prosemirror-gapcursor/style/gapcursor.css';
-import MenuStyles from 'text:prosemirror-menu/style/menu.css';
-import ViewStyles from 'text:prosemirror-view/style/prosemirror.css';
 // others
 import type { Styles } from '@adornis/ass/style.js';
 import type { Maybe, ValueEvent } from '@adornis/base/utilTypes.js';
@@ -23,33 +20,48 @@ import { baseKeymap } from 'prosemirror-commands';
 import { dropCursor } from 'prosemirror-dropcursor';
 import { gapCursor } from 'prosemirror-gapcursor';
 import { history, redo, undo } from 'prosemirror-history';
-import { inputRules } from 'prosemirror-inputrules';
 import { keymap } from 'prosemirror-keymap';
 import { DOMParser, DOMSerializer, Schema, type MarkSpec, type NodeSpec } from 'prosemirror-model';
-import { EditorState, Plugin, PluginKey } from 'prosemirror-state';
+import { EditorState, Plugin } from 'prosemirror-state';
 import { EditorView } from 'prosemirror-view';
-import { combineLatest, distinctUntilChanged, filter, map, switchMap, takeUntil } from 'rxjs';
+import { combineLatest, debounceTime, distinctUntilChanged, filter, switchMap, takeUntil } from 'rxjs';
 import { Contexts, Size } from '../db/enums.js';
-import type { HTMLBase } from '../db/HTMLBase.js';
-import { MARKS, NODES } from '../schema/defaultSchema.js';
-import { DOC } from '../schema/nodes/doc.js';
-import { HEADING_KEY } from '../schema/nodes/heading.js';
-import { LIST_KEY, ORDERED_LIST_KEY } from '../schema/nodes/list.js';
-import { PARAGRAPH } from '../schema/nodes/paragraph.js';
-import { TEXT } from '../schema/nodes/text.js';
-import { COLOR_MENU_ITEM } from './marks/color.js';
+import { bold } from './marks/bold.js';
+import { color, colorMenuItem } from './marks/color.js';
+import { fontSize } from './marks/font-size.js';
+import { italic } from './marks/italic.js';
+import { link } from './marks/link.js';
+import { strikeThrough } from './marks/strike-through.js';
+import { createTextAlignMenuItem } from './marks/text-align.js';
+import { underline } from './marks/underline.js';
+import { DeleteSelectedNodeMenuItem } from './menu-items/DeleteSelectedNode.js';
 import { EditGlobalSettingsMenuItem } from './menu-items/EditGlobalSettings.js';
 import { EditSelectedNodeMenuItem } from './menu-items/EditSelectedNode.js';
-import { getMenu, type BuildifyMenuItem } from './menu.js';
-import './nodes/doc.js';
-import { headingRule } from './nodes/headings.js';
-import { bulletListInputRule, orderedListInputRule } from './nodes/list.js';
-import './nodes/paragraph.js';
+import { accordeonConfigs } from './nodes/accordeon.js';
+import { button } from './nodes/button.js';
+import { doc } from './nodes/doc.js';
+import { excalidraw } from './nodes/excalidraw.js';
+import { file } from './nodes/file.js';
+import { flex } from './nodes/flex.js';
+import { grid, gridCell } from './nodes/grid.js';
+import { hardBreak } from './nodes/hard-break.js';
+import { heading } from './nodes/headings.js';
+import { icon } from './nodes/icon.js';
+import { iconText, iconWrapper, textWrapper } from './nodes/iconText.js';
+import { image } from './nodes/image.js';
+import { bulletList, listItem, orderedList } from './nodes/list.js';
+import { paragraph } from './nodes/paragraph.js';
+import { section } from './nodes/section.js';
+import { spacing } from './nodes/spacing.js';
+import { tag } from './nodes/tag.js';
+import { text } from './nodes/text.js';
+import { vimeo } from './nodes/vimeo.js';
+import { youtube } from './nodes/youtube.js';
 import { getCurrentPathPlugin } from './plugins/currentPathBarPlugin.js';
-import { germanSmartQuotesInputRules } from './plugins/quotePlugin.js';
-import { MenuItemGroup, type EditorFunc } from './types.js';
-import { ALL_EDITORS } from './variables/ALL_EDITORS.js';
-import { ALL_MENU_ITEMS } from './variables/ALL_MENU_ITEMS.js';
+import { handleTransactionMetaPlugin } from './plugins/handleTransactionMetaPlugin.js';
+import { germanSmartQuotesPlugin } from './plugins/quotePlugin.js';
+import { MenuItemGroup, type EditorFunc, type IMarkConfig, type IMenuItem, type INodeConfig } from './types.js';
+import './x-prosemirror-toolbar';
 
 const EDITOR_ID = 'editor';
 
@@ -61,25 +73,61 @@ function getHtmlString(doc) {
   return wrapper.innerHTML;
 }
 
-const handleMetaPlugin = (key: string, handler: (meta: any) => void) =>
-  new Plugin({
-    key: new PluginKey(key),
-    filterTransaction: (_, state) => {
-      handler(state.tr.getMeta(key));
-      return true;
-    },
-  });
-
 @customElement('prosemirror-editor')
 export class ProsemirrorEditor extends FormField<string> {
-  // * nodes and marks
-  private readonly _defaultNodes = { ...DOC, ...PARAGRAPH, ...TEXT };
-  @property({ attribute: false }) marks: Record<string, MarkSpec> = MARKS;
-  @property({ attribute: false }) nodes: Record<string, NodeSpec> = NODES;
-  @property({ attribute: false }) editors = new RXController<Record<string, EditorFunc<HTMLBase>>>(this, ALL_EDITORS);
-  @property({ attribute: false }) menuItems = new RXController<BuildifyMenuItem[]>(this, Object.values(ALL_MENU_ITEMS));
-
-  // * properties
+  @property({ attribute: false }) configs = new RXController<Array<INodeConfig | IMarkConfig>>(this, [
+    // default nodes
+    doc,
+    paragraph,
+    text,
+    // marks
+    bold,
+    color,
+    fontSize,
+    italic,
+    link,
+    strikeThrough,
+    underline,
+    // nodes
+    ...accordeonConfigs,
+    button,
+    excalidraw,
+    file,
+    flex,
+    grid,
+    gridCell,
+    hardBreak,
+    heading,
+    icon,
+    iconText,
+    iconWrapper,
+    textWrapper,
+    image,
+    listItem,
+    bulletList,
+    orderedList,
+    section,
+    spacing,
+    tag,
+    vimeo,
+    youtube,
+  ]);
+
+  @property({ attribute: false }) menuItemGroupOrder: string[] = [
+    MenuItemGroup.TEXT_STYLE,
+    MenuItemGroup.TEXT_ALIGNMENT,
+    MenuItemGroup.HEADINGS,
+    MenuItemGroup.LAYOUT,
+    MenuItemGroup.MEDIA,
+    MenuItemGroup.COLOR,
+  ];
+
+  @property({ attribute: false }) additionalMenuItems = new RXController(this, [
+    createTextAlignMenuItem('left'),
+    createTextAlignMenuItem('center'),
+    createTextAlignMenuItem('right'),
+    createTextAlignMenuItem('justify'),
+  ]);
   @property({ attribute: false }) globalSettingsClassName = new RXController(this, BuildifyGlobalSettings._class);
   @property({ attribute: false }) sizeBreakpoints = new RXController<Record<Size, number>>(this, {
     [Size.DESKTOP]: 1200,
@@ -97,24 +145,6 @@ export class ProsemirrorEditor extends FormField<string> {
       takeUntil(this.disconnected),
     ),
   );
-  @property({ attribute: false }) _menuItems = new RXController(
-    this,
-    combineLatest([this._globalSettings.observable, this.menuItems.observable, this.editors.observable]).pipe(
-      map(([settings, menuItems, editors]) => {
-        if (!settings || !(settings instanceof BuildifyGlobalSettings)) return menuItems;
-
-        return [
-          ...menuItems,
-          ...(settings.colors ?? []).map(color => ({
-            element: COLOR_MENU_ITEM(color),
-            group: MenuItemGroup.COLOR,
-          })),
-          EditSelectedNodeMenuItem(editors, settings._class),
-          EditGlobalSettingsMenuItem(settings._class),
-        ];
-      }),
-    ),
-  );
 
   // * reactive attributes
 
@@ -122,18 +152,7 @@ export class ProsemirrorEditor extends FormField<string> {
 
   // * attributes
   private _editor: Maybe<EditorView>;
-
-  buildSchema() {
-    return new Schema({
-      marks: {
-        ...this.marks,
-      },
-      nodes: {
-        ...this._defaultNodes,
-        ...this.nodes,
-      },
-    });
-  }
+  private _schema: Maybe<Schema<any, any>>;
 
   protected _globalSettingsClassNameProvider = new ContextProvider<
     Context<Contexts.GLOBAL_SETTINGS_CLASS_NAME, string>
@@ -146,21 +165,111 @@ export class ProsemirrorEditor extends FormField<string> {
     context: createContext(Contexts.SIZE_BREAKPOINT),
   });
 
+  getPlugins(schema: Schema): Plugin[] {
+    const plugins: Array<Plugin<any>> = [];
+
+    // add config plugins
+    for (const config of this.configs.value) {
+      if (!config.plugins) continue;
+      plugins.push(...config.plugins(schema));
+    }
+
+    // add default plugins
+    plugins.push(history());
+    plugins.push(keymap(baseKeymap));
+    plugins.push(
+      keymap({
+        'Mod-z': undo,
+        'Mod-y': redo,
+        'Mod-Shift-z': redo,
+      }),
+    );
+    plugins.push(germanSmartQuotesPlugin());
+    plugins.push(gapCursor());
+    plugins.push(dropCursor({ color: '#333', width: 2 }));
+    plugins.push(getCurrentPathPlugin());
+    plugins.push(
+      handleTransactionMetaPlugin('onNodeChange', () => {
+        setTimeout(() => this.throwValuePicked());
+      }),
+    );
+
+    return plugins;
+  }
+
+  generateSchema() {
+    const nodes: Record<string, NodeSpec> = {};
+    const marks: Record<string, MarkSpec> = {};
+
+    for (const config of this.configs.value) {
+      if ('mark' in config) {
+        marks[config.mark.name] = config.mark.schema;
+      }
+      if ('node' in config) {
+        nodes[config.node.name] = config.node.schema;
+      }
+    }
+
+    return new Schema({
+      nodes,
+      marks,
+    });
+  }
+
+  getEditors() {
+    const editors: Record<string, EditorFunc> = {};
+    for (const config of this.configs.value) {
+      if (!config.editor) continue;
+      if ('node' in config) {
+        editors[config.node.name] = config.editor;
+      }
+      if ('mark' in config) {
+        editors[config.mark.name] = config.editor;
+      }
+    }
+    return editors;
+  }
+
+  getMenuItems() {
+    const menuItems: IMenuItem[] = [];
+
+    for (const config of this.configs.value) {
+      if (!config.menuItems) continue;
+      menuItems.push(...config.menuItems);
+    }
+
+    const settings = this._globalSettings.value;
+    if (settings && settings instanceof BuildifyGlobalSettings && settings.colors) {
+      for (const color of settings.colors) {
+        menuItems.push(colorMenuItem(color));
+      }
+    }
+
+    menuItems.push(...this.additionalMenuItems.value);
+    menuItems.push(EditSelectedNodeMenuItem(this.getEditors(), this._globalSettingsClassNameProvider.value));
+    menuItems.push(DeleteSelectedNodeMenuItem);
+    menuItems.push(EditGlobalSettingsMenuItem(this._globalSettingsClassNameProvider.value));
+
+    return menuItems;
+  }
+
   // * lifecycle hooks
   override connectedCallback(): void {
     super.connectedCallback();
 
-    this.globalSettingsClassName.observable.pipe(filter(Boolean), distinctUntilChanged()).subscribe(className => {
-      this._globalSettingsClassNameProvider.setValue(className, true);
-    });
-
     this.sizeBreakpoints.observable.pipe(filter(Boolean), distinctUntilChanged()).subscribe(breakpoints => {
       this._sizeBreakpointProvider.setValue(breakpoints, true);
     });
 
-    this._menuItems.observable.pipe(filter(Boolean), distinctUntilChanged()).subscribe(items => {
-      this.updateEditor();
-    });
+    combineLatest([
+      this.configs.observable,
+      this.additionalMenuItems.observable,
+      this.globalSettingsClassName.observable,
+    ])
+      .pipe(debounceTime(300))
+      .subscribe(() => {
+        this.updateEditor();
+      });
 
     this.addEventListener('blur', () => {
       this.throwValuePicked();
@@ -170,7 +279,7 @@ export class ProsemirrorEditor extends FormField<string> {
   protected override update(changedProperties: PropertyValues): void {
     super.update(changedProperties);
 
-    if (changedProperties.has('value') && this._editor) {
+    if (changedProperties.has('value') && this._editor && this._schema) {
       const html = this.value.value || '';
 
       const node = document.createElement('div');
@@ -180,7 +289,7 @@ export class ProsemirrorEditor extends FormField<string> {
         return;
       }
 
-      const newDoc = DOMParser.fromSchema(this.buildSchema()).parse(node);
+      const newDoc = DOMParser.fromSchema(this._schema).parse(node);
       this._editor.updateState(
         EditorState.create({
           doc: newDoc,
@@ -194,7 +303,7 @@ export class ProsemirrorEditor extends FormField<string> {
   protected override firstUpdated(_changedProperties: PropertyValues): void {
     super.firstUpdated(_changedProperties);
 
-    const schema = this.buildSchema();
+    this._schema = this.generateSchema();
     const editorElement = this.renderRoot.querySelector(`#${EDITOR_ID}`);
     if (!editorElement) throw new Error('editor or content not found');
 
@@ -203,8 +312,9 @@ export class ProsemirrorEditor extends FormField<string> {
     node.innerHTML = html;
 
     const state = EditorState.create({
-      schema,
-      doc: DOMParser.fromSchema(schema).parse(node),
+      schema: this._schema,
+      plugins: this.getPlugins(this._schema),
+      doc: DOMParser.fromSchema(this._schema).parse(node),
     });
     this._editor = new EditorView(editorElement, { state });
   }
@@ -223,36 +333,12 @@ export class ProsemirrorEditor extends FormField<string> {
   }
 
   protected updateEditor() {
-    console.log('update editor');
     const editor = this._editor;
     if (!editor) return;
     const schema = editor.state.schema;
 
     const newState = editor.state.reconfigure({
-      plugins: [
-        germanSmartQuotesInputRules(),
-        inputRules({
-          rules: [
-            bulletListInputRule(schema.nodes[LIST_KEY]!),
-            orderedListInputRule(schema.nodes[ORDERED_LIST_KEY]!),
-            headingRule(schema.nodes[HEADING_KEY]!),
-          ],
-        }),
-        keymap({
-          ...baseKeymap,
-          'Mod-z': undo,
-          'Mod-y': redo,
-          'Mod-Shift-z': redo,
-        }),
-        history(),
-        gapCursor(),
-        dropCursor({ color: '#333', width: 2 }),
-        getCurrentPathPlugin(),
-        getMenu({ view: editor, menuItems: this._menuItems.value ?? [] }),
-        handleMetaPlugin('onNodeChange', () => {
-          setTimeout(() => this.throwValuePicked());
-        }),
-      ],
+      plugins: this.getPlugins(schema),
     });
 
     editor.updateState(newState);
@@ -266,22 +352,6 @@ export class ProsemirrorEditor extends FormField<string> {
 
     return html`
       <style>
-        /* prosemirror styling */
-        ${MenuStyles}
-        ${ViewStyles}
-        ${GapcursorStyles}
-
-        /* custom styling */
-        .ProseMirror-menubar {
-          display: flex;
-          flex-wrap: wrap;
-          gap: 4px;
-          position: sticky;
-          top: 0;
-          z-index: 101;
-          padding: 16px;
-        }
-
         ::selection {
           color: inherit !important;
           background-color: #eee;
@@ -300,20 +370,20 @@ export class ProsemirrorEditor extends FormField<string> {
           position: relative;
           outline: 2px solid ${this.colors.accent};
         }
-
-        .ProseMirror-menubar x-icon {
-          font-size: 32px;
-          cursor: pointer;
-        }
       </style>
+      <x-buildify-size-picker
+        ${css({ marginBottom: '16px', display: 'block' })}
+        .value=${this._selectedSize}
+        @value-picked=${(e: ValueEvent<Maybe<Size>>) => {
+          this._selectedSize = e.detail.value;
+        }}
+      ></x-buildify-size-picker>
+      <x-prosemirror-toolbar
+        .menuItemGroupOrder=${this.menuItemGroupOrder}
+        .view=${this._editor}
+        .menuItems=${this.getMenuItems()}
+      ></x-prosemirror-toolbar>
       <node-doc mode="edit" id=${EDITOR_ID} ${css({ width: maxWidth, maxWidth, margin: '0 auto' })} spellcheck="false">
-        <x-buildify-size-picker
-          ${css({ marginBottom: '16px', display: 'block' })}
-          .value=${this._selectedSize}
-          @value-picked=${(e: ValueEvent<Maybe<Size>>) => {
-            this._selectedSize = e.detail.value;
-          }}
-        ></x-buildify-size-picker>
       </node-doc>
     `;
   }
diff --git a/lab/html-based-buildify/client/types.ts b/lab/html-based-buildify/client/types.ts
index 876343aea2..9ecd6898a2 100644
--- a/lab/html-based-buildify/client/types.ts
+++ b/lab/html-based-buildify/client/types.ts
@@ -3,15 +3,18 @@ import '@adornis/chemistry/elements/components/x-icon';
 import type { TranslationController } from '@adornis/translation-core/client/translation-controller.js';
 import type { TranslationDictionary } from '@adornis/translation-core/translation.js';
 import type { TemplateResult } from 'lit';
-import type { MarkSpec, NodeSpec } from 'prosemirror-model';
+import type { MarkSpec, NodeSpec, Schema } from 'prosemirror-model';
+import type { Plugin } from 'prosemirror-state';
+import type { EditorView } from 'prosemirror-view';
 import type { HTMLBase } from '../db/HTMLBase.js';
 import type { RenderableConfigFormController } from './RenderableConfigFormController.js';
 
-export type EditorFunc<T extends HTMLBase, TranslationDict extends TranslationDictionary = {}> = ({
+export type EditorFunc<T extends HTMLBase = HTMLBase, TranslationDict extends TranslationDictionary = {}> = ({
   content,
   contentController,
   controllerBaseKeyPath,
   host,
+  translation,
 }: {
   content: T;
   contentController: RenderableConfigFormController<T>;
@@ -20,54 +23,40 @@ export type EditorFunc<T extends HTMLBase, TranslationDict extends TranslationDi
   translation: TranslationController<TranslationDict>;
 }) => TemplateResult;
 
-export abstract class SchemaDefinition<T = MarkSpec | NodeSpec> {
-  protected _identifier: string;
-  protected _extension: T;
-
-  constructor({ identifier, extension }: { identifier: string; extension: T }) {
-    this._identifier = identifier;
-    this._extension = extension;
-  }
-
-  get identifier() {
-    return this._identifier;
-  }
-
-  get extension() {
-    return this._extension;
-  }
+export interface IMenuItem {
+  label: string;
+  group?: string;
+  icon?: string;
+  color?: string;
+  run: (editorView: EditorView) => void;
+  isActive?: (editorView: EditorView) => boolean;
+  shouldVisualize?: (editorView: EditorView) => boolean;
 }
 
-export class SchemaNodeDefinition<U extends HTMLBase = HTMLBase> extends SchemaDefinition<NodeSpec> {
-  protected _editor?: EditorFunc<U>;
-  protected _dataClass?: typeof HTMLBase;
+export interface IEditorConfigBase {
+  menuItems?: IMenuItem[];
+  plugins?: (schema: Schema) => Array<Plugin<any>>;
+  editor?: EditorFunc;
+}
 
-  constructor({
-    extension,
-    identifier,
-    editor,
-    dataClass,
-  }: { editor?: EditorFunc<U>; dataClass?: typeof HTMLBase } & ConstructorParameters<
-    typeof SchemaDefinition<NodeSpec>
-  >[0]) {
-    super({ extension, identifier });
-    this._editor = editor;
-    this._dataClass = dataClass;
-  }
+export interface INodeConfig extends IEditorConfigBase {
+  node: {
+    name: string;
+    schema: NodeSpec;
+  };
+}
 
-  get Editor() {
-    if (!this._editor) throw new Error('no editor defined');
-    return this._editor;
-  }
-  get DataClass() {
-    if (!this._dataClass) throw new Error('no data class defined');
-    return this._dataClass;
-  }
+export interface IMarkConfig extends IEditorConfigBase {
+  mark: {
+    name: string;
+    schema: MarkSpec;
+  };
 }
-export class SchemaMarkDefinition extends SchemaDefinition<MarkSpec> {}
 
 export enum MenuItemGroup {
   TEXT_STYLE = 'text-style',
+  TEXT_ALIGNMENT = 'text-aignment',
+  HEADINGS = 'headings',
   COLOR = 'color',
   LAYOUT = 'layout',
   MEDIA = 'media',
diff --git a/lab/html-based-buildify/client/variables/ALL_EDITORS.ts b/lab/html-based-buildify/client/variables/ALL_EDITORS.ts
index d18238343d..8079427150 100644
--- a/lab/html-based-buildify/client/variables/ALL_EDITORS.ts
+++ b/lab/html-based-buildify/client/variables/ALL_EDITORS.ts
@@ -1,37 +1,37 @@
-import { ACCORDEON_CONTAINER_KEY } from '../../schema/nodes/accordeon.js';
-import { BUTTON_KEY } from '../../schema/nodes/button.js';
-import { FILE_KEY } from '../../schema/nodes/file.js';
-import { FLEX_KEY } from '../../schema/nodes/flex.js';
-import { GRID_KEY } from '../../schema/nodes/grid.js';
-import { ICON_KEY } from '../../schema/nodes/icon.js';
-import { IMAGE_KEY } from '../../schema/nodes/image.js';
-import { SECTION_KEY } from '../../schema/nodes/section.js';
-import { SPACING_KEY } from '../../schema/nodes/spacing.js';
-import { VIMEO_KEY } from '../../schema/nodes/vimeo.js';
-import { YOUTUBE_KEY } from '../../schema/nodes/youtube.js';
-import { ACCORDEON_EDITOR } from '../nodes/accordeon.js';
-import { BUTTON_EDITOR } from '../nodes/button.js';
-import { FILE_EDITOR } from '../nodes/file.js';
-import { FLEX_EDITOR } from '../nodes/flex.js';
-import { GRID_EDITOR } from '../nodes/grid.js';
-import { ICON_EDITOR } from '../nodes/icon.js';
-import { IMAGE_EDITOR } from '../nodes/image.js';
-import { SECTION_EDITOR } from '../nodes/section.js';
-import { SPACING_EDITOR } from '../nodes/spacing.js';
-import { VIMEO_EDITOR } from '../nodes/vimeo.js';
-import { YOUTUBE_EDITOR } from '../nodes/youtube.js';
-import type { EditorFunc } from '../types.js';
+// import { ACCORDEON_CONTAINER_KEY } from '../../schema/nodes/accordeon.js';
+// import { BUTTON_KEY } from '../../schema/nodes/button.js';
+// import { FILE_KEY } from '../../schema/nodes/file.js';
+// import { FLEX_KEY } from '../../schema/nodes/flex.js';
+// import { GRID_KEY } from '../../schema/nodes/grid.js';
+// import { ICON_KEY } from '../../schema/nodes/icon.js';
+// import { IMAGE_KEY } from '../../schema/nodes/image.js';
+// import { SECTION_KEY } from '../../schema/nodes/section.js';
+// import { SPACING_KEY } from '../../schema/nodes/spacing.js';
+// import { VIMEO_KEY } from '../../schema/nodes/vimeo.js';
+// import { YOUTUBE_KEY } from '../../schema/nodes/youtube.js';
+// import { accordeonEditor } from '../nodes/accordeon.js';
+// import { BUTTON_EDITOR } from '../nodes/button.js';
+// import { FILE_EDITOR } from '../nodes/file.js';
+// import { FLEX_EDITOR } from '../nodes/flex.js';
+// import { GRID_EDITOR } from '../nodes/grid.js';
+// import { ICON_EDITOR } from '../nodes/icon.js';
+// import { IMAGE_EDITOR } from '../nodes/image.js';
+// import { SECTION_EDITOR } from '../nodes/section.js';
+// import { SPACING_EDITOR } from '../nodes/spacing.js';
+// import { VIMEO_EDITOR } from '../nodes/vimeo.js';
+// import { YOUTUBE_EDITOR } from '../nodes/youtube.js';
+// import type { EditorFunc } from '../types.js';
 
-export const ALL_EDITORS: Record<string, EditorFunc<any>> = {
-  [ACCORDEON_CONTAINER_KEY]: ACCORDEON_EDITOR,
-  [ICON_KEY]: ICON_EDITOR,
-  [BUTTON_KEY]: BUTTON_EDITOR,
-  [SPACING_KEY]: SPACING_EDITOR,
-  [FILE_KEY]: FILE_EDITOR,
-  [VIMEO_KEY]: VIMEO_EDITOR,
-  [YOUTUBE_KEY]: YOUTUBE_EDITOR,
-  [IMAGE_KEY]: IMAGE_EDITOR,
-  [SECTION_KEY]: SECTION_EDITOR,
-  [FLEX_KEY]: FLEX_EDITOR,
-  [GRID_KEY]: GRID_EDITOR,
-};
+// export const ALL_EDITORS: Record<string, EditorFunc<any>> = {
+//   [ACCORDEON_CONTAINER_KEY]: accordeonEditor,
+//   [ICON_KEY]: ICON_EDITOR,
+//   [BUTTON_KEY]: BUTTON_EDITOR,
+//   [SPACING_KEY]: SPACING_EDITOR,
+//   [FILE_KEY]: FILE_EDITOR,
+//   [VIMEO_KEY]: VIMEO_EDITOR,
+//   [YOUTUBE_KEY]: YOUTUBE_EDITOR,
+//   [IMAGE_KEY]: IMAGE_EDITOR,
+//   [SECTION_KEY]: SECTION_EDITOR,
+//   [FLEX_KEY]: FLEX_EDITOR,
+//   [GRID_KEY]: GRID_EDITOR,
+// };
diff --git a/lab/html-based-buildify/client/variables/ALL_MENU_ITEMS.ts b/lab/html-based-buildify/client/variables/ALL_MENU_ITEMS.ts
index 6e903e0df1..8fd6af0c5d 100644
--- a/lab/html-based-buildify/client/variables/ALL_MENU_ITEMS.ts
+++ b/lab/html-based-buildify/client/variables/ALL_MENU_ITEMS.ts
@@ -1,163 +1,163 @@
-import { BOLD_MENU_ITEM } from '../marks/bold.js';
-import { fontSizeMenu } from '../marks/font-size.js';
-import { ITALIC_MENU_ITEM } from '../marks/italic.js';
-import { LINK_MENU_ITEM } from '../marks/link.js';
-import { STRIKE_THROUGH_MENU_ITEM } from '../marks/strike-through.js';
-import { TEXT_ALIGN_MENU_ITEM } from '../marks/text-align.js';
-import { UNDERLINE_MENU_ITEM } from '../marks/underline.js';
-import { DeleteSelectedNodeMenuItem } from '../menu-items/DeleteSelectedNode.js';
-import type { BuildifyMenuItem } from '../menu.js';
-import { ACCORDEON_MENU_ITEM, ADD_ACCORDEON_MENU_ITEM } from '../nodes/accordeon.js';
-import { BUTTON_MENU_ITEM } from '../nodes/button.js';
-import { EXCALIDRAW_MENU_ITEM, PAINT_EXCALIDRAW_MENU_ITEM } from '../nodes/excalidraw.js';
-import { FILE_MENU_ITEM } from '../nodes/file.js';
-import { FLEX_MENU_ITEM } from '../nodes/flex.js';
-import { GRID_MENU_ITEM } from '../nodes/grid.js';
-import { HEADING_MENU_ITEM } from '../nodes/headings.js';
-import { ICON_MENU_ITEM } from '../nodes/icon.js';
-import { ICON_TEXT_MENU_ITEM } from '../nodes/iconText.js';
-import { IMAGE_MENU_ITEM } from '../nodes/image.js';
-import { SECTION_MENU_ITEM } from '../nodes/section.js';
-import { SPACING_MENU_ITEM } from '../nodes/spacing.js';
-import { TAG_MENU_ITEM } from '../nodes/tag.js';
-import { VIMEO_MENU_ITEM } from '../nodes/vimeo.js';
-import { YOUTUBE_MENU_ITEM } from '../nodes/youtube.js';
-import { MenuItemGroup } from '../types.js';
+// import { BOLD_MENU_ITEM } from '../marks/bold.js';
+// import { fontSizeMenu } from '../marks/font-size.js';
+// import { ITALIC_MENU_ITEM } from '../marks/italic.js';
+// import { LINK_MENU_ITEM } from '../marks/link.js';
+// import { STRIKE_THROUGH_MENU_ITEM } from '../marks/strike-through.js';
+// import { TEXT_ALIGN_MENU_ITEM } from '../marks/text-align.js';
+// import { UNDERLINE_MENU_ITEM } from '../marks/underline.js';
+// import { DeleteSelectedNodeMenuItem } from '../menu-items/DeleteSelectedNode.js';
+// import type { BuildifyMenuItem } from '../menu.js';
+// import { ACCORDEON_MENU_ITEM, ADD_ACCORDEON_MENU_ITEM } from '../nodes/accordeon.js';
+// import { BUTTON_MENU_ITEM } from '../nodes/button.js';
+// import { EXCALIDRAW_MENU_ITEM, PAINT_EXCALIDRAW_MENU_ITEM } from '../nodes/excalidraw.js';
+// import { FILE_MENU_ITEM } from '../nodes/file.js';
+// import { FLEX_MENU_ITEM } from '../nodes/flex.js';
+// import { GRID_MENU_ITEM } from '../nodes/grid.js';
+// import { HEADING_MENU_ITEM } from '../nodes/headings.js';
+// import { ICON_MENU_ITEM } from '../nodes/icon.js';
+// import { ICON_TEXT_MENU_ITEM } from '../nodes/iconText.js';
+// import { IMAGE_MENU_ITEM } from '../nodes/image.js';
+// import { SECTION_MENU_ITEM } from '../nodes/section.js';
+// import { SPACING_MENU_ITEM } from '../nodes/spacing.js';
+// import { TAG_MENU_ITEM } from '../nodes/tag.js';
+// import { VIMEO_MENU_ITEM } from '../nodes/vimeo.js';
+// import { YOUTUBE_MENU_ITEM } from '../nodes/youtube.js';
+// import { MenuItemGroup } from '../types.js';
 
-export const ALL_MENU_ITEMS: Record<string, BuildifyMenuItem> = {
-  bold: {
-    element: BOLD_MENU_ITEM,
-    group: MenuItemGroup.TEXT_STYLE,
-  },
-  underline: {
-    element: UNDERLINE_MENU_ITEM,
-    group: MenuItemGroup.TEXT_STYLE,
-  },
-  italic: {
-    element: ITALIC_MENU_ITEM,
-    group: MenuItemGroup.TEXT_STYLE,
-  },
-  strikeThrough: {
-    element: STRIKE_THROUGH_MENU_ITEM,
-    group: MenuItemGroup.TEXT_STYLE,
-  },
-  textAlignLeft: {
-    element: TEXT_ALIGN_MENU_ITEM('left'),
-    group: MenuItemGroup.TEXT_STYLE,
-  },
-  textAlignCenter: {
-    element: TEXT_ALIGN_MENU_ITEM('center'),
-    group: MenuItemGroup.TEXT_STYLE,
-  },
-  textAlignRight: {
-    element: TEXT_ALIGN_MENU_ITEM('right'),
-    group: MenuItemGroup.TEXT_STYLE,
-  },
-  textAlignJustify: {
-    element: TEXT_ALIGN_MENU_ITEM('justify'),
-    group: MenuItemGroup.TEXT_STYLE,
-  },
-  heading1: {
-    element: HEADING_MENU_ITEM(1),
-    group: MenuItemGroup.TEXT_STYLE,
-  },
-  heading2: {
-    element: HEADING_MENU_ITEM(2),
-    group: MenuItemGroup.TEXT_STYLE,
-  },
-  heading3: {
-    element: HEADING_MENU_ITEM(3),
-    group: MenuItemGroup.TEXT_STYLE,
-  },
-  heading4: {
-    element: HEADING_MENU_ITEM(4),
-    group: MenuItemGroup.TEXT_STYLE,
-  },
-  heading5: {
-    element: HEADING_MENU_ITEM(5),
-    group: MenuItemGroup.TEXT_STYLE,
-  },
-  link: {
-    element: LINK_MENU_ITEM,
-    group: MenuItemGroup.TEXT_STYLE,
-  },
-  fontSize: {
-    element: fontSizeMenu(),
-    group: MenuItemGroup.TEXT_STYLE,
-  },
+// export const ALL_MENU_ITEMS: Record<string, BuildifyMenuItem> = {
+//   bold: {
+//     element: BOLD_MENU_ITEM,
+//     group: MenuItemGroup.TEXT_STYLE,
+//   },
+//   underline: {
+//     element: UNDERLINE_MENU_ITEM,
+//     group: MenuItemGroup.TEXT_STYLE,
+//   },
+//   italic: {
+//     element: ITALIC_MENU_ITEM,
+//     group: MenuItemGroup.TEXT_STYLE,
+//   },
+//   strikeThrough: {
+//     element: STRIKE_THROUGH_MENU_ITEM,
+//     group: MenuItemGroup.TEXT_STYLE,
+//   },
+//   textAlignLeft: {
+//     element: TEXT_ALIGN_MENU_ITEM('left'),
+//     group: MenuItemGroup.TEXT_STYLE,
+//   },
+//   textAlignCenter: {
+//     element: TEXT_ALIGN_MENU_ITEM('center'),
+//     group: MenuItemGroup.TEXT_STYLE,
+//   },
+//   textAlignRight: {
+//     element: TEXT_ALIGN_MENU_ITEM('right'),
+//     group: MenuItemGroup.TEXT_STYLE,
+//   },
+//   textAlignJustify: {
+//     element: TEXT_ALIGN_MENU_ITEM('justify'),
+//     group: MenuItemGroup.TEXT_STYLE,
+//   },
+//   heading1: {
+//     element: HEADING_MENU_ITEM(1),
+//     group: MenuItemGroup.TEXT_STYLE,
+//   },
+//   heading2: {
+//     element: HEADING_MENU_ITEM(2),
+//     group: MenuItemGroup.TEXT_STYLE,
+//   },
+//   heading3: {
+//     element: HEADING_MENU_ITEM(3),
+//     group: MenuItemGroup.TEXT_STYLE,
+//   },
+//   heading4: {
+//     element: HEADING_MENU_ITEM(4),
+//     group: MenuItemGroup.TEXT_STYLE,
+//   },
+//   heading5: {
+//     element: HEADING_MENU_ITEM(5),
+//     group: MenuItemGroup.TEXT_STYLE,
+//   },
+//   link: {
+//     element: LINK_MENU_ITEM,
+//     group: MenuItemGroup.TEXT_STYLE,
+//   },
+//   fontSize: {
+//     element: fontSizeMenu(),
+//     group: MenuItemGroup.TEXT_STYLE,
+//   },
 
-  // basics
-  tag: {
-    element: TAG_MENU_ITEM,
-    group: MenuItemGroup.BASIC,
-  },
-  icon: {
-    element: ICON_MENU_ITEM,
-    group: MenuItemGroup.BASIC,
-  },
-  iconText: {
-    element: ICON_TEXT_MENU_ITEM,
-    group: MenuItemGroup.BASIC,
-  },
-  button: {
-    element: BUTTON_MENU_ITEM,
-    group: MenuItemGroup.BASIC,
-  },
-  // layout
-  grid: {
-    element: GRID_MENU_ITEM,
-    group: MenuItemGroup.LAYOUT,
-  },
-  // accordeon
-  accordeon: {
-    element: ACCORDEON_MENU_ITEM,
-    group: MenuItemGroup.LAYOUT,
-  },
-  addAccordeon: {
-    element: ADD_ACCORDEON_MENU_ITEM,
-  },
-  // flex
-  flex: {
-    element: FLEX_MENU_ITEM,
-    group: MenuItemGroup.LAYOUT,
-  },
-  section: {
-    element: SECTION_MENU_ITEM,
-    group: MenuItemGroup.LAYOUT,
-  },
-  spacing: {
-    element: SPACING_MENU_ITEM,
-    group: MenuItemGroup.LAYOUT,
-  },
+//   // basics
+//   tag: {
+//     element: TAG_MENU_ITEM,
+//     group: MenuItemGroup.BASIC,
+//   },
+//   icon: {
+//     element: ICON_MENU_ITEM,
+//     group: MenuItemGroup.BASIC,
+//   },
+//   iconText: {
+//     element: ICON_TEXT_MENU_ITEM,
+//     group: MenuItemGroup.BASIC,
+//   },
+//   button: {
+//     element: BUTTON_MENU_ITEM,
+//     group: MenuItemGroup.BASIC,
+//   },
+//   // layout
+//   grid: {
+//     element: GRID_MENU_ITEM,
+//     group: MenuItemGroup.LAYOUT,
+//   },
+//   // accordeon
+//   accordeon: {
+//     element: ACCORDEON_MENU_ITEM,
+//     group: MenuItemGroup.LAYOUT,
+//   },
+//   addAccordeon: {
+//     element: ADD_ACCORDEON_MENU_ITEM,
+//   },
+//   // flex
+//   flex: {
+//     element: FLEX_MENU_ITEM,
+//     group: MenuItemGroup.LAYOUT,
+//   },
+//   section: {
+//     element: SECTION_MENU_ITEM,
+//     group: MenuItemGroup.LAYOUT,
+//   },
+//   spacing: {
+//     element: SPACING_MENU_ITEM,
+//     group: MenuItemGroup.LAYOUT,
+//   },
 
-  // media
-  image: {
-    element: IMAGE_MENU_ITEM,
-    group: MenuItemGroup.MEDIA,
-  },
-  youtube: {
-    element: YOUTUBE_MENU_ITEM,
-    group: MenuItemGroup.MEDIA,
-  },
-  vimeo: {
-    element: VIMEO_MENU_ITEM,
-    group: MenuItemGroup.MEDIA,
-  },
-  excalidraw: {
-    element: EXCALIDRAW_MENU_ITEM,
-    group: MenuItemGroup.MEDIA,
-  },
-  paintExcalidraw: {
-    element: PAINT_EXCALIDRAW_MENU_ITEM,
-  },
-  file: {
-    element: FILE_MENU_ITEM,
-    group: MenuItemGroup.MEDIA,
-  },
+//   // media
+//   image: {
+//     element: IMAGE_MENU_ITEM,
+//     group: MenuItemGroup.MEDIA,
+//   },
+//   youtube: {
+//     element: YOUTUBE_MENU_ITEM,
+//     group: MenuItemGroup.MEDIA,
+//   },
+//   vimeo: {
+//     element: VIMEO_MENU_ITEM,
+//     group: MenuItemGroup.MEDIA,
+//   },
+//   excalidraw: {
+//     element: EXCALIDRAW_MENU_ITEM,
+//     group: MenuItemGroup.MEDIA,
+//   },
+//   paintExcalidraw: {
+//     element: PAINT_EXCALIDRAW_MENU_ITEM,
+//   },
+//   file: {
+//     element: FILE_MENU_ITEM,
+//     group: MenuItemGroup.MEDIA,
+//   },
 
-  // others
-  deleteSelectedNode: {
-    element: DeleteSelectedNodeMenuItem,
-    group: MenuItemGroup.SETTINGS,
-  },
-};
+//   // others
+//   deleteSelectedNode: {
+//     element: DeleteSelectedNodeMenuItem,
+//     group: MenuItemGroup.SETTINGS,
+//   },
+// };
diff --git a/lab/html-based-buildify/client/x-prosemirror-toolbar.ts b/lab/html-based-buildify/client/x-prosemirror-toolbar.ts
new file mode 100644
index 0000000000..3e117a4c96
--- /dev/null
+++ b/lab/html-based-buildify/client/x-prosemirror-toolbar.ts
@@ -0,0 +1,98 @@
+import type { Styles } from '@adornis/ass/style.js';
+import { ChemistryLitElement } from '@adornis/chemistry/chemistry-lit-element.js';
+import { css } from '@adornis/chemistry/directives/css.js';
+import '@adornis/chemistry/elements/components/x-flex';
+import '@adornis/chemistry/elements/components/x-icon';
+import '@adornis/popover/x-tooltip';
+import { html, nothing, type TemplateResult } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { EditorView } from 'prosemirror-view';
+import type { IMenuItem } from './types.js';
+
+@customElement('x-prosemirror-toolbar')
+export class XProsemirrorToolbar extends ChemistryLitElement {
+  @property({ attribute: false }) menuItems: IMenuItem[] = [];
+  @property({ attribute: false }) menuItemGroupOrder: string[] = [];
+  @property({ attribute: false }) view: EditorView | undefined;
+
+  override render() {
+    const view = this.view;
+    if (!view) return nothing;
+
+    return html` <x-flex horizontal crossaxis-center wrap space="sm"> ${this._buildMenu(view)} </x-flex> `;
+  }
+
+  private _buildMenu(view: EditorView) {
+    const groups = this.menuItemGroupOrder;
+    const result: Array<TemplateResult | typeof nothing> = [];
+
+    // prio menu items
+    for (const group of groups) {
+      const items = this._getAllMenuItemsForGroup(group);
+      for (const item of items) {
+        result.push(this._renderMenuItem(item, view));
+      }
+    }
+
+    // append other items
+    const notGroupedItems = this.menuItems.filter(item => !item.group || !groups.includes(item.group));
+    for (const item of notGroupedItems) {
+      result.push(this._renderMenuItem(item, view));
+    }
+
+    return result;
+  }
+
+  private _renderMenuItem(item: IMenuItem, view: EditorView) {
+    if (item.shouldVisualize && !item.shouldVisualize(view)) return nothing;
+
+    const isActive = item.isActive?.(view);
+    const style = isActive ? 'background: #4b6584; color: #fff;' : '';
+    return html`
+      ${item.icon
+        ? html`
+            <x-icon
+              ${css({
+                fontSize: '28px',
+                cursor: 'pointer',
+                padding: '2px',
+                background: isActive ? '#F2F2F1' : 'transparent',
+                borderRadius: '4px',
+                color: item.color ?? '#333',
+              })}
+              @click=${() => {
+                if (!item.run) return;
+                item.run(view);
+              }}
+            >
+              ${item.icon}
+              <x-tooltip> ${item.label} </x-tooltip>
+            </x-icon>
+          `
+        : html`
+            <button
+              style=${style}
+              @click=${() => {
+                if (!item.run) return;
+                item.run(view);
+              }}
+            >
+              ${item.label}
+            </button>
+          `}
+    `;
+  }
+
+  private _getAllMenuItemsForGroup(group?: string) {
+    return this.menuItems.filter(item => item.group === group);
+  }
+
+  override styles() {
+    return [
+      ...super.styles(),
+      {
+        ':host': {},
+      },
+    ] as Styles[];
+  }
+}
diff --git a/lab/html-based-buildify/schema/nodes/accordeon.ts b/lab/html-based-buildify/schema/nodes/accordeon.ts
index c0d41a113f..394e47b9b7 100644
--- a/lab/html-based-buildify/schema/nodes/accordeon.ts
+++ b/lab/html-based-buildify/schema/nodes/accordeon.ts
@@ -1,7 +1,7 @@
 import type { NodeSpec } from 'prosemirror-model';
 import { HTMLBaseAccordeon } from '../../db/HTMLBaseAccordeon.js';
 
-const ACCORDEON_CONTAINER_SCHEMA: NodeSpec = {
+export const ACCORDEON_CONTAINER_SCHEMA: NodeSpec = {
   draggable: true,
   atom: true,
   isolating: true,
@@ -26,7 +26,7 @@ const ACCORDEON_CONTAINER_SCHEMA: NodeSpec = {
   },
 };
 
-const ACCORDEON_TITLE_SCHEMA: NodeSpec = {
+export const ACCORDEON_TITLE_SCHEMA: NodeSpec = {
   draggable: true,
   atom: true,
   selectable: false,
@@ -43,7 +43,7 @@ const ACCORDEON_TITLE_SCHEMA: NodeSpec = {
   },
 };
 
-const ACCORDEON_CONTENT_SCHEMA: NodeSpec = {
+export const ACCORDEON_CONTENT_SCHEMA: NodeSpec = {
   draggable: true,
   atom: true,
   selectable: false,
@@ -60,7 +60,7 @@ const ACCORDEON_CONTENT_SCHEMA: NodeSpec = {
   },
 };
 
-const ACCORDEON_SCHEMA: NodeSpec = {
+export const ACCORDEON_SCHEMA: NodeSpec = {
   draggable: true,
   selectable: false,
   atom: true,
diff --git a/lab/html-based-buildify/schema/nodes/file.ts b/lab/html-based-buildify/schema/nodes/file.ts
index ca4b69bbbe..9dd35d062e 100644
--- a/lab/html-based-buildify/schema/nodes/file.ts
+++ b/lab/html-based-buildify/schema/nodes/file.ts
@@ -1,7 +1,7 @@
 import type { NodeSpec } from 'prosemirror-model';
 import { HTMLBaseFile } from '../../db/HTMLBaseFile.js';
 
-export const FILE_SCHEMA = (tagName: string): NodeSpec => ({
+export const FILE_SCHEMA = (tagName: string = 'node-file'): NodeSpec => ({
   draggable: true,
   atom: true,
   group: 'inline',
diff --git a/lab/html-based-buildify/schema/nodes/icon-text.ts b/lab/html-based-buildify/schema/nodes/icon-text.ts
index 498c5f540e..04df313960 100644
--- a/lab/html-based-buildify/schema/nodes/icon-text.ts
+++ b/lab/html-based-buildify/schema/nodes/icon-text.ts
@@ -1,6 +1,6 @@
 import type { NodeSpec } from 'prosemirror-model';
 
-const ICON_TEXT_SCHEMA: NodeSpec = {
+export const ICON_TEXT_SCHEMA: NodeSpec = {
   draggable: true,
   atom: true,
   inline: true,
@@ -21,7 +21,7 @@ const ICON_TEXT_SCHEMA: NodeSpec = {
   },
 };
 
-const ICON_WRAPPER_SCHEMA: NodeSpec = {
+export const ICON_WRAPPER_SCHEMA: NodeSpec = {
   draggable: false,
   atom: true,
   isolating: true,
@@ -38,7 +38,7 @@ const ICON_WRAPPER_SCHEMA: NodeSpec = {
   },
 };
 
-const TEXT_WRAPPER_SCHEMA: NodeSpec = {
+export const TEXT_WRAPPER_SCHEMA: NodeSpec = {
   draggable: false,
   atom: true,
   isolating: true,
-- 
GitLab


From fca5a7ef827bccbb468d3af012d84559a2c43abd Mon Sep 17 00:00:00 2001
From: michi <michael@adornis.de>
Date: Fri, 20 Dec 2024 10:34:04 +0000
Subject: [PATCH 05/14] feat: add obsidian link plugin

---
 lab/html-based-buildify/client/marks/link.ts  | 132 +++++++++++--
 .../ObsidianLinkPlugin/ObsidianLinkPlugin.ts  | 172 +++++++++++++++++
 .../helper/ensureLinkMark.ts                  |  10 +
 .../helper/getFilteredLinks.ts                |  13 ++
 .../helper/getSearchInput.ts                  |  48 +++++
 .../ObsidianLinkPlugin/helper/index.ts        |  21 +++
 .../helper/parseLinkToText.ts                 |  46 +++++
 .../helper/parseTextToLink.ts                 |  94 ++++++++++
 .../x-obsidian-link-selection.ts              | 176 ++++++++++++++++++
 .../client/prosemirror-editor.ts              |   1 +
 10 files changed, 698 insertions(+), 15 deletions(-)
 create mode 100644 lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/ObsidianLinkPlugin.ts
 create mode 100644 lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/ensureLinkMark.ts
 create mode 100644 lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getFilteredLinks.ts
 create mode 100644 lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getSearchInput.ts
 create mode 100644 lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/index.ts
 create mode 100644 lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseLinkToText.ts
 create mode 100644 lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseTextToLink.ts
 create mode 100644 lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/x-obsidian-link-selection.ts

diff --git a/lab/html-based-buildify/client/marks/link.ts b/lab/html-based-buildify/client/marks/link.ts
index f666c3d09a..6d306e5cfe 100644
--- a/lab/html-based-buildify/client/marks/link.ts
+++ b/lab/html-based-buildify/client/marks/link.ts
@@ -1,11 +1,10 @@
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
 import '@adornis/chemistry/elements/components/x-icon';
 import { XDialog } from '@adornis/dialog/x-dialog.js';
+import { NodeSelection } from 'prosemirror-state';
 import type { EditorView } from 'prosemirror-view';
 import { LINK_KEY, LINK_SCHEMA } from '../../schema/marks/link.js';
-import { toggleMark } from '../helper/toggleMark.js';
 import { MenuItemGroup, type IMarkConfig } from '../types.js';
-import { isInText, isMarkActive } from '../util.js';
 
 export const link: IMarkConfig = {
   mark: {
@@ -16,23 +15,126 @@ export const link: IMarkConfig = {
     {
       group: MenuItemGroup.TEXT_STYLE,
       label: 'Link',
-      icon: 'link',
-      run: async (view: EditorView) => {
-        const href = isMarkActive(view.state, view.state.schema.marks[LINK_KEY]!)
-          ? null
-          : await XDialog.prompt('Link', { placeholder: 'href' });
-        toggleMark({ markName: LINK_KEY, view, attrs: { href } })(view.state, view.dispatch);
+      icon: 'add_link',
+      shouldVisualize: view => {
+        // Aktiviert das Menü, wenn eine Selektion existiert und kein Link vorhanden ist
+        const { state } = view;
+        const { from, to } = state.selection;
+        const linkMark = state.schema.marks[LINK_KEY];
+        if (!linkMark) return false;
+
+        return (
+          !state.selection.empty &&
+          !(state.selection instanceof NodeSelection) &&
+          !state.doc.rangeHasMark(from, to, linkMark)
+        );
+      },
+      run: view => {
+        const { state, dispatch } = view;
+        const linkMark = state.schema.marks[LINK_KEY];
+        if (!linkMark) return false;
+
+        const href = prompt('Enter the URL', 'http://');
+        if (href) {
+          toggleLinkMark(linkMark, { href })(state, dispatch);
+        }
       },
-      isActive: editorView => {
-        return toggleMark({
-          markName: link.mark.name,
-          view: editorView,
-          attrs: {},
-        })(editorView.state);
+    },
+    {
+      group: MenuItemGroup.TEXT_STYLE,
+      label: 'Link bearbeiten',
+      icon: 'edit_square',
+      shouldVisualize: view => {
+        const { state } = view;
+        const linkMark = state.schema.marks[LINK_KEY];
+        if (!linkMark) return false;
+        // Aktiviert das Menü, wenn der Cursor in einem Link ist oder ein Bereich ausgewählt ist
+        const { from, empty } = state.selection;
+        if (empty) {
+          // Prüft, ob der Cursor in einer Mark ist
+          return !!linkMark.isInSet(state.storedMarks || state.doc.resolve(from).marks());
+        } else {
+          // Prüft, ob eine Selektion einen Link enthält
+          const { from, to } = state.selection;
+          return !!state.doc.rangeHasMark(from, to, linkMark);
+        }
+      },
+      run: view => {
+        const { state, dispatch } = view;
+        const linkMark = state.schema.marks[LINK_KEY];
+        if (!linkMark) return false;
+
+        const { from, to } = state.selection;
+        const mark = state.doc.rangeHasMark(from, to, linkMark)
+          ? linkMark.isInSet(state.doc.resolve(from).marks())
+          : null;
+
+        const currentHref = mark ? mark.attrs.href : '';
+        const newHref = prompt('Edit the URL', currentHref);
+
+        if (newHref) {
+          toggleLinkMark(linkMark, { href: newHref })(state, dispatch);
+        }
       },
+    },
+    {
+      group: MenuItemGroup.TEXT_STYLE,
+      label: 'Link löschen',
+      icon: 'link_off',
       shouldVisualize: (view: EditorView) => {
-        return isInText(view.state);
+        const { state } = view;
+        const linkMark = state.schema.marks[LINK_KEY];
+        if (!linkMark) return false;
+        // Aktiviert das Menü, wenn der Cursor in einem Link ist oder ein Bereich ausgewählt ist
+        const { from, empty } = state.selection;
+        if (empty) {
+          // Prüft, ob der Cursor in einer Mark ist
+          return !!linkMark.isInSet(state.storedMarks || state.doc.resolve(from).marks());
+        } else {
+          // Prüft, ob eine Selektion einen Link enthält
+          const { from, to } = state.selection;
+          return !!state.doc.rangeHasMark(from, to, linkMark);
+        }
+      },
+      run: async view => {
+        if (!(await XDialog.confirm('Wollen Sie diesen Link wirklich entfernen?'))) return;
+        const { state, dispatch } = view;
+        const { from, to, empty } = state.selection;
+        const linkMark = state.schema.marks[LINK_KEY];
+        if (!linkMark) return false;
+
+        if (empty) {
+          // Wenn der Cursor in einem Link ist, finde die gesamte Mark und entferne sie
+          const $pos = state.doc.resolve(from);
+          const isInSet = linkMark.isInSet($pos.marks());
+          if (isInSet) {
+            const linkStart = $pos.nodeBefore ? from - $pos.nodeBefore.nodeSize : from;
+            const linkEnd = $pos.nodeAfter ? from + $pos.nodeAfter.nodeSize : from;
+            dispatch(state.tr.removeMark(linkStart, linkEnd, linkMark));
+          }
+        } else {
+          // Entfernt die Markierung im ausgewählten Bereich
+          dispatch(state.tr.removeMark(from, to, linkMark));
+        }
       },
     },
   ],
 };
+
+// Toggle-Logic Command
+function toggleLinkMark(markType, attrs) {
+  return (state, dispatch) => {
+    const { from, to } = state.selection;
+    const hasMark = state.doc.rangeHasMark(from, to, markType);
+
+    if (hasMark) {
+      // Wenn der Link existiert, entfernen
+      dispatch(state.tr.removeMark(from, to, markType));
+    } else {
+      // Wenn kein Link existiert, hinzufügen
+      dispatch(state.tr.addMark(from, to, markType.create(attrs)));
+    }
+
+    return true;
+  };
+}
diff --git a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/ObsidianLinkPlugin.ts b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/ObsidianLinkPlugin.ts
new file mode 100644
index 0000000000..dda771cce2
--- /dev/null
+++ b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/ObsidianLinkPlugin.ts
@@ -0,0 +1,172 @@
+import type { Maybe } from '@adornis/base/utilTypes.js';
+import { XNativeDialog } from '@adornis/dialog/x-native-dialog.js';
+import { Plugin, PluginKey, TextSelection } from 'prosemirror-state';
+import type { EditorView } from 'prosemirror-view';
+import { GlobalLinkHelper } from './helper/index.js';
+import type { XObsidianLinkSelection } from './x-obsidian-link-selection.js';
+
+export type LinkListItem = { href: string; name: string };
+
+export const obsidianLinkPlugin = (searchFn: (search: string) => Promise<LinkListItem[]>) => {
+  // ensure stuff
+  //   GlobalLinkHelper.Ensure.linkMark(view);
+
+  // actual plugin
+  let component: Maybe<XObsidianLinkSelection>;
+
+  const removeComponent = () => {
+    if (component) {
+      component.remove();
+      component = null;
+    }
+    return false;
+  };
+  const insertComponent = (
+    coords: { left: number; right: number; top: number; bottom: number },
+    view: EditorView,
+    search?: string,
+  ) => {
+    if (component) return;
+
+    component = document.createElement('x-obsidian-link-selection') as XObsidianLinkSelection;
+    component.editorView = view;
+    component.searchFn = searchFn;
+
+    component.addEventListener('close', () => {
+      removeComponent();
+    });
+
+    const attachTarget = view.dom ? _lookForDialogElementInParents(view.dom) : document.body;
+
+    const attachTargetBoundingClientRect =
+      attachTarget instanceof XNativeDialog
+        ? attachTarget.renderRoot.querySelector('dialog')?.getBoundingClientRect() ??
+          attachTarget.getBoundingClientRect()
+        : attachTarget.getBoundingClientRect();
+
+    const OFFSET = 20;
+    const left = coords.left + OFFSET - attachTargetBoundingClientRect.x;
+    const top = coords.top + OFFSET - attachTargetBoundingClientRect.y;
+
+    component.style.left = `${left}px`;
+    component.style.top = `${top}px`;
+    attachTarget.appendChild(component);
+  };
+  const updateSearch = (view: EditorView) => {
+    const input = GlobalLinkHelper.HandleInput.search(view, '');
+    if (input === undefined) return removeComponent();
+
+    // example search könnte wie folgt aussehen.
+    // 1. https://www.adornis.de/
+    // 2. https://www.adornis.de/|Adornis
+    // in beiden Fällen wollen wir für die Suche nur den Teil vor | beachten
+    const searchParts = input.split('|');
+    if (searchParts.length > 2) throw new Error(`you shouldn't be able to use more than one pipe o.O`);
+    const search = searchParts[0];
+    if (!search && typeof search !== 'string') throw new Error('search not given');
+
+    if (!component) insertComponent(view.coordsAtPos(view.state.selection.$from.pos), view);
+    if (!component) throw new Error('how can this be?');
+
+    component.search = search;
+  };
+
+  return new Plugin({
+    key: new PluginKey('obsidianLinkPlugin'),
+    state: {
+      init() {
+        return null;
+      },
+      apply(tr, value) {
+        return value;
+      },
+    },
+    view(editorView) {
+      const handleCursorMovement = () => {
+        const parsedText = GlobalLinkHelper.ParseNode.TextToLink({ view: editorView });
+        if (!parsedText) GlobalLinkHelper.ParseNode.LinkToText({ view: editorView });
+      };
+
+      editorView.dom.addEventListener('click', handleCursorMovement);
+      editorView.dom.addEventListener('keyup', handleCursorMovement);
+
+      return {
+        update(view) {
+          updateSearch(view);
+          //   handleCursorMovement();
+        },
+        destroy() {
+          editorView.dom.removeEventListener('click', handleCursorMovement);
+          editorView.dom.removeEventListener('keyup', handleCursorMovement);
+        },
+      };
+    },
+
+    props: {
+      handleTextInput(view, from, to, text) {
+        if (text === '[' && view.state.doc.textBetween(from - 1, from) === '[') {
+          const { tr } = view.state;
+          tr.insertText('[]]', from, from);
+          tr.setSelection(TextSelection.create(tr.doc, from + 1));
+          view.dispatch(tr);
+
+          const coords = view.coordsAtPos(from + 1);
+          insertComponent(coords, view, '');
+          return true;
+        }
+
+        if (text === '|' && GlobalLinkHelper.HandleInput.isInputAlreadyPiped(view)) return true;
+        return false;
+      },
+
+      handleKeyDown(view, event) {
+        const component = document.querySelector('x-obsidian-link-selection') as Maybe<XObsidianLinkSelection>;
+
+        if (event.key === 'ArrowUp' && component) {
+          component.onArrowUp();
+          return true;
+        }
+
+        if (event.key === 'ArrowDown' && component) {
+          component.onArrowDown();
+          return true;
+        }
+
+        if (event.key === 'Enter' && component) {
+          component.onItemSelect();
+          return true;
+        }
+
+        if (event.key === 'Escape' && component) {
+          component.remove();
+          return true;
+        }
+
+        if (event.ctrlKey && event.code === 'Space') {
+          const input = GlobalLinkHelper.HandleInput.search(view, '');
+          if (input) {
+            const coords = view.coordsAtPos(view.state.selection.from);
+            insertComponent(coords, view, input);
+            return true;
+          }
+        }
+
+        return false;
+      },
+    },
+  });
+};
+
+function _lookForDialogElementInParents(start: Element) {
+  let curr = start;
+  while (
+    // @ts-expect-error host exists on rootNode in this context
+    (curr = curr.parentElement || curr.getRootNode()?.host) &&
+    !(curr instanceof HTMLDialogElement) &&
+    !(curr instanceof XNativeDialog)
+  ) {}
+
+  if (curr instanceof HTMLDialogElement || curr instanceof XNativeDialog) return curr;
+
+  return window.document.body;
+}
diff --git a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/ensureLinkMark.ts b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/ensureLinkMark.ts
new file mode 100644
index 0000000000..29ea1e6a9c
--- /dev/null
+++ b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/ensureLinkMark.ts
@@ -0,0 +1,10 @@
+import type { EditorView } from 'prosemirror-view';
+
+export const ensureLinkMark = (view: EditorView) => {
+  const extensionManager = host.editor.extensionManager;
+  if (!extensionManager) throw new Error('extension manager not found');
+  const extensions = extensionManager.extensions;
+  if (!extensions || !Array.isArray(extensions)) throw new Error('invalid extensions');
+  const linkMark = extensions.find(e => e.type === 'mark' && e.name === 'link');
+  if (!linkMark) throw new Error('the link extension has to be added, when you want to use the obsidian links.');
+};
diff --git a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getFilteredLinks.ts b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getFilteredLinks.ts
new file mode 100644
index 0000000000..6d73f2d28f
--- /dev/null
+++ b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getFilteredLinks.ts
@@ -0,0 +1,13 @@
+import type { LinkListItem } from '../ObsidianLink.js';
+
+export const getFilteredLinks = (linkList: LinkListItem[], search: string) => {
+  const filteredLinks =
+    search === ''
+      ? linkList
+      : linkList.filter(
+          link =>
+            link.href.toLowerCase().includes(search.toLowerCase()) ||
+            link.name.toLowerCase().includes(search.toLowerCase()),
+        );
+  return filteredLinks;
+};
diff --git a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getSearchInput.ts b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getSearchInput.ts
new file mode 100644
index 0000000000..b53a7d4ce8
--- /dev/null
+++ b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getSearchInput.ts
@@ -0,0 +1,48 @@
+import type { EditorView } from '@tiptap/pm/view';
+
+export const getSearchInput = (view: EditorView, text: string) => {
+  const texts = getTextBeforeAndAfterInBrackets(view);
+  if (!texts) return;
+
+  // zusammensetzen des Gesamt-Suchwortes
+  const searchString = `${texts.before}${text}${texts.after}`;
+  const parsedSearchString = searchString.split('|')[0];
+  if (!parsedSearchString && typeof parsedSearchString !== 'string') throw new Error('invalid search');
+  return parsedSearchString;
+};
+
+export const hasInputAlreadyPipe = (view: EditorView) => {
+  const texts = getTextBeforeAndAfterInBrackets(view);
+  if (!texts) return false;
+  return texts.before.includes('|') || texts.before.includes('|');
+};
+
+export const getTextBeforeAndAfterInBrackets = (view: EditorView): { before: string; after: string } | undefined => {
+  const { state } = view;
+  const { $from, $to } = state.selection;
+
+  // wenn $from & $to nicht gleich sind, bedeuted das, dass wir eine selection haben und bei einer normalen
+  // Eingabe sollte dies nicht der Fall sein.
+  if ($from !== $to) return;
+
+  // nodes, welches sich vor & hinter dem Cursor befinden.
+  const nodeBefore = $from.nodeBefore;
+  const nodeAfter = $from.nodeAfter;
+
+  // check if nodeBefore & nodeAfter are Text-Nodes
+  if (nodeBefore?.type.name !== 'text' || nodeAfter?.type.name !== 'text') return;
+
+  // wenn wir uns in keinen eckigen Klammern befinden, darf ebenfalls nichts weiter getan werden
+  if (!nodeBefore.text?.includes('[[') || !nodeAfter.text?.includes(']]')) return;
+
+  // wir strippen den Text innerhalb der Klammern raus
+  const textBeforeSplitted = nodeBefore.text.split('[[');
+  const textAfterSplitted = nodeAfter.text.split(']]');
+  const textBefore = textBeforeSplitted[textBeforeSplitted.length - 1];
+  const textAfter = textAfterSplitted[0];
+
+  return {
+    before: textBefore ? textBefore : '',
+    after: textAfter ? textAfter : '',
+  };
+};
diff --git a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/index.ts b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/index.ts
new file mode 100644
index 0000000000..bb05bb9bc2
--- /dev/null
+++ b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/index.ts
@@ -0,0 +1,21 @@
+import { ensureLinkMark } from './ensureLinkMark.js';
+import { getFilteredLinks } from './getFilteredLinks.js';
+import { getSearchInput, getTextBeforeAndAfterInBrackets, hasInputAlreadyPipe } from './getSearchInput.js';
+import { parseLinkToTextNode } from './parseLinkToText.js';
+import { parseTextToLinkNode } from './parseTextToLink.js';
+
+export namespace GlobalLinkHelper {
+  export namespace ParseNode {
+    export const LinkToText = parseLinkToTextNode;
+    export const TextToLink = parseTextToLinkNode;
+  }
+  export namespace HandleInput {
+    export const search = getSearchInput;
+    export const isInputAlreadyPiped = hasInputAlreadyPipe;
+  }
+  export namespace Ensure {
+    export const linkMark = ensureLinkMark;
+  }
+  export const filterLinks = getFilteredLinks;
+  export const textBeforeAndAfterInBrackets = getTextBeforeAndAfterInBrackets;
+}
diff --git a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseLinkToText.ts b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseLinkToText.ts
new file mode 100644
index 0000000000..7f2f4c4589
--- /dev/null
+++ b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseLinkToText.ts
@@ -0,0 +1,46 @@
+import { TextSelection } from '@tiptap/pm/state';
+import type { EditorView } from '@tiptap/pm/view';
+import { findTextNodesWithPositions } from './parseTextToLink.js';
+
+export const parseLinkToTextNode = ({ view }: { view: EditorView }): boolean => {
+  let transaction = view.state.tr;
+  if (transaction.selection.from !== transaction.selection.to) return false;
+  const { state } = view;
+  const pos = transaction.selection.from;
+
+  const textNodesWithPositions = findTextNodesWithPositions(transaction.doc);
+  const node = transaction.doc.nodeAt(pos - 1);
+  const nodeWithPositions = textNodesWithPositions.find(n => n.node === node);
+
+  if (!node || !nodeWithPositions) return false;
+  if (!node.marks) return false;
+  if (!node.marks.some(mark => mark.type.name === 'link')) return false;
+
+  const linkMark = node.marks.find(mark => mark.type.name === 'link');
+  const text = node.text;
+  const href = linkMark?.attrs.href;
+
+  if (!linkMark) return false;
+  if (!text) return false;
+
+  const newNodeText = `[[${href}${href !== text && text ? `|${text}` : ''}]]`;
+  const newNode = state.schema.text(newNodeText);
+
+  transaction = transaction.replaceWith(
+    nodeWithPositions.offset - 1,
+    nodeWithPositions.offset + text.length - 1,
+    newNode,
+  );
+
+  if (view.state.selection.to <= nodeWithPositions.offset) {
+    transaction = transaction.setSelection(TextSelection.create(transaction.doc, nodeWithPositions.offset - 1));
+  } else {
+    transaction = transaction.setSelection(
+      TextSelection.create(transaction.doc, nodeWithPositions.offset + newNodeText.length - 1),
+    );
+  }
+
+  view.dispatch(transaction);
+
+  return true;
+};
diff --git a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseTextToLink.ts b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseTextToLink.ts
new file mode 100644
index 0000000000..ae967bec55
--- /dev/null
+++ b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseTextToLink.ts
@@ -0,0 +1,94 @@
+import { Node } from '@tiptap/pm/model';
+import type { EditorView } from '@tiptap/pm/view';
+
+export const findTextNodesWithPositions = (doc: Node) => {
+  const node = doc;
+  const result: Array<{ node: Node; offset: number }> = [];
+
+  const findPositions = (node, pos = 0) => {
+    if (node.type.name === 'text') {
+      result.push({ node, offset: pos });
+    }
+
+    node.forEach((child, offset) => {
+      findPositions(child, pos + offset + 1);
+    });
+  };
+
+  findPositions(node);
+  return result;
+};
+
+export const parseTextToLinkNode = ({ view }: { view: EditorView }): boolean => {
+  const transaction = view.state.tr;
+  if (transaction.selection.from !== transaction.selection.to) return false;
+  const { state } = view;
+
+  const textNodesWithOffset = findTextNodesWithPositions(transaction.doc);
+
+  for (const textNodeWithOffset of textNodesWithOffset) {
+    const textNode = textNodeWithOffset.node;
+    const offset = textNodeWithOffset.offset;
+
+    const text = textNode.text as string | undefined;
+    if (text === undefined) continue;
+
+    let isOpen = false;
+    let openedAt: number | null = null;
+
+    // i = 1 & i < text.length - 1 damit der erste und letzte Char ausgelassen wird
+    for (let i = 1; i < text.length - 1; i++) {
+      // if (i === 0) continue;
+      const prevChar = text[i - 1];
+      const currChar = text[i];
+      const nextChar = text[i + 1];
+
+      if (!prevChar || !currChar || !nextChar) continue;
+
+      const isBracketsOpening = prevChar === '[' && currChar === '[';
+      if (isBracketsOpening) {
+        isOpen = true;
+        openedAt = i - 1;
+        continue;
+      }
+      if (!isOpen || (!openedAt && typeof openedAt !== 'number')) continue;
+
+      const isBracketsClosing = currChar === ']' && nextChar === ']';
+      if (!isBracketsClosing) continue;
+
+      const { from, to } = transaction.selection;
+
+      const transformText = text.substring(openedAt, i + 2);
+      const textNodeFrom = offset + openedAt - 1;
+      const textNodeTo = offset + openedAt + transformText.length - 1;
+
+      // wenn sich unser Cursor aktuell noch im Bereich dieses Texts befindet, wollen wir es noch nicht zu einem Link parsen.
+      if (from >= textNodeFrom && to <= textNodeTo) {
+        isOpen = false;
+        openedAt = null;
+        continue;
+      }
+
+      const strippedTransformText = transformText.replace('[[', '').replace(']]', '');
+
+      const strippedTransformTextParts = strippedTransformText.split('|');
+      const linkMark = state.schema.marks.link?.create({ href: strippedTransformTextParts[0], target: '_self' });
+
+      if (!linkMark) throw new Error('mark link not found');
+      const linkText = strippedTransformTextParts[1]
+        ? strippedTransformTextParts[1]
+        : strippedTransformTextParts[0]
+        ? strippedTransformTextParts[0]
+        : null;
+
+      if (!linkText) continue;
+
+      const newLinkNode = state.schema.text(linkText, [linkMark]);
+      view.dispatch(transaction.replaceWith(textNodeFrom, textNodeTo, newLinkNode));
+
+      // damit immer nur eine Node geupdated wird, weil sich nach der Änderung alles verschoben hat, returnen wir.
+      return true;
+    }
+  }
+  return false;
+};
diff --git a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/x-obsidian-link-selection.ts b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/x-obsidian-link-selection.ts
new file mode 100644
index 0000000000..3c70067531
--- /dev/null
+++ b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/x-obsidian-link-selection.ts
@@ -0,0 +1,176 @@
+import type { Styles } from '@adornis/ass/style.js';
+import type { Maybe } from '@adornis/base/utilTypes.js';
+import { ChemistryLitElement } from '@adornis/chemistry/chemistry-lit-element.js';
+import { css } from '@adornis/chemistry/directives/css.js';
+import { TextSelection } from '@tiptap/pm/state';
+import { EditorView } from '@tiptap/pm/view';
+import { html, type PropertyValueMap, type TemplateResult } from 'lit';
+import { customElement, property, state } from 'lit/decorators.js';
+import { repeat } from 'lit/directives/repeat.js';
+import { getTextBeforeAndAfterInBrackets } from './helper/getSearchInput.js';
+import type { LinkListItem } from './ObsidianLink.js';
+
+@customElement('x-obsidian-link-selection')
+export class XObsidianLinkSelection extends ChemistryLitElement {
+  @property({ type: Array }) searchFn?: (search: string) => Promise<LinkListItem[]>;
+  @property({ type: Array }) search = '';
+
+  @property({ type: Array }) links: LinkListItem[] = [];
+  @property({ type: Object }) editorView: Maybe<EditorView> = null;
+  @state() private _selectedIndex = 0;
+
+  override updated(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
+    super.updated(changedProperties);
+
+    if ((changedProperties.has('search') || changedProperties.has('searchFn')) && this.searchFn) {
+      this.searchFn(this.search).then(links => (this.links = links));
+    }
+
+    requestAnimationFrame(() => {
+      const activeItem = this.renderRoot.querySelector('.active-item');
+      if (!activeItem) return;
+      activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
+    });
+  }
+
+  protected override willUpdate(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
+    super.willUpdate(_changedProperties);
+
+    // damit bei update der links liste von außen man nicht plötzliche einen selected index
+    // außerhalb der aktuellen Liste haben kann, weil sie durch die Suche beispielsweise
+    // stark kürzer geworden ist, setzen wir den Index präventiv immer auf 0 bei Änderungen.
+    if (_changedProperties.has('links')) {
+      this._selectedIndex = 0;
+    }
+  }
+
+  override render(): TemplateResult<1> {
+    return html`
+      <x-flex ${css({ overflow: 'hidden', maxHeight: '250px' })}>
+        <x-flex
+          ${css({
+            margin: `${this.spacing.xs} 0 0 0`,
+            padding: `0 ${this.spacing.sm}`,
+            flex: '1',
+            overflow: 'auto',
+          })}
+        >
+          ${this.links.length === 0
+            ? html` <x-text ${css({ padding: this.spacing.sm })}> Kein passender Eintrag gefunden. </x-text> `
+            : html`
+                ${repeat(
+                  this.links,
+                  link => link.href,
+                  (link, index) => {
+                    const isActive = index === this._selectedIndex;
+                    return html`
+                      <x-flex
+                        space="xs"
+                        class=${isActive ? 'active-item' : ' passive-item'}
+                        ${css({
+                          padding: this.spacing.sm,
+                          borderRadius: '5px',
+                          ...(isActive ? { background: '#DADADB', color: '#333' } : {}),
+                        })}
+                        @click=${() => this.onItemSelect(index)}
+                      >
+                        <x-text> ${link.name} </x-text>
+                        <x-text ${css({ fontSize: '10px' })}> ${link.href} </x-text>
+                      </x-flex>
+                    `;
+                  },
+                )}
+              `}
+        </x-flex>
+        <x-hr ${css({ margin: `${this.spacing.xs} 0 0 0` })}></x-hr>
+        <x-flex ${css({ padding: `${this.spacing.sm} ${this.spacing.xs}` })} center crossaxis-center>
+          <x-text><b>Tippe |</b> um den Anzeigetext zu ändern </x-text>
+        </x-flex>
+      </x-flex>
+    `;
+  }
+
+  onArrowUp() {
+    if (this._selectedIndex === 0) {
+      this._selectedIndex = this.links.length - 1;
+    } else {
+      this._selectedIndex--;
+    }
+  }
+
+  onArrowDown() {
+    if (this._selectedIndex === this.links.length - 1) {
+      this._selectedIndex = 0;
+    } else {
+      this._selectedIndex++;
+    }
+  }
+
+  onItemSelect(index?: number) {
+    const href = this.links[index ? index : this._selectedIndex]?.href;
+    if (!href) throw new Error(`no href found at index ${index}`);
+    if (!this.editorView) return;
+
+    const { state, dispatch } = this.editorView;
+    const { from, to } = state.selection;
+
+    const texts = getTextBeforeAndAfterInBrackets(this.editorView);
+    if (!texts) throw new Error('da ist was nicht ganz richtig');
+
+    let fromOffset = from - texts.before.length;
+    let toOffset = to + texts.after.length;
+    let pipeText: string | undefined;
+
+    if (texts.before.includes('|')) {
+      const textBeforeParts = texts.before.split('|');
+      const pipeTextAfter = textBeforeParts[1];
+      if (!pipeTextAfter && typeof pipeTextAfter !== 'string') throw new Error('invalid pipe text was found');
+      pipeText = texts.after;
+      // -1 for the pipe itself
+      toOffset = to - pipeTextAfter.length - 1;
+    }
+
+    if (texts.after.includes('|')) {
+      const textAfterParts = texts.after.split('|');
+      const searchText = textAfterParts[0];
+      pipeText = textAfterParts[1];
+      if (!searchText && typeof searchText !== 'string') throw new Error('invalid pipe text was found');
+      toOffset = to + searchText.length;
+    }
+
+    const { tr } = state;
+    tr.insertText(href, fromOffset, toOffset);
+
+    // 2 for closing brackets ]]
+    // pipeText + 1, because the | itself was stripped out
+    const COUNT_CLOSING_BRACKETS = 2;
+    tr.setSelection(
+      TextSelection.create(tr.doc, tr.selection.from + COUNT_CLOSING_BRACKETS + (pipeText ? pipeText.length : 0)),
+    );
+    dispatch(tr);
+
+    this.dispatchEvent(new CustomEvent('close'));
+  }
+
+  override styles() {
+    return [
+      ...super.styles(),
+      {
+        ':host': {
+          background: '#fff',
+          borderRadius: '5px',
+          zIndex: '9999999',
+          position: 'absolute',
+          width: '400px',
+          overflow: 'hidden',
+          boxShadow: 'rgba(0, 0, 0, 0.24) 0px 3px 8px',
+        },
+        '.passive-item:hover': {
+          background: '#EFEEEF',
+          color: '#333',
+          cursor: 'pointer',
+        },
+      },
+    ] as Styles[];
+  }
+}
diff --git a/lab/html-based-buildify/client/prosemirror-editor.ts b/lab/html-based-buildify/client/prosemirror-editor.ts
index 03aed0235a..a965722eb6 100644
--- a/lab/html-based-buildify/client/prosemirror-editor.ts
+++ b/lab/html-based-buildify/client/prosemirror-editor.ts
@@ -379,6 +379,7 @@ export class ProsemirrorEditor extends FormField<string> {
         }}
       ></x-buildify-size-picker>
       <x-prosemirror-toolbar
+        ${css({ display: 'block', position: 'sticky', top: '0px', padding: '8px', background: '#fff', zIndex: '9999' })}
         .menuItemGroupOrder=${this.menuItemGroupOrder}
         .view=${this._editor}
         .menuItems=${this.getMenuItems()}
-- 
GitLab


From 65666078ffbb9fd85112a8287027bb519a54bace Mon Sep 17 00:00:00 2001
From: michi <michael@adornis.de>
Date: Fri, 20 Dec 2024 17:22:35 +0000
Subject: [PATCH 06/14] feat: port to newer prosemirror version

---
 lab/wiki/client/buildify/embedding-schema.ts | 115 +++++++++++++++----
 lab/wiki/client/buildify/embedding.ts        |  68 -----------
 lab/wiki/client/x-wiki-editor.ts             |  47 ++++----
 lab/wiki/client/x-wiki-view.ts               |   6 +-
 4 files changed, 120 insertions(+), 116 deletions(-)

diff --git a/lab/wiki/client/buildify/embedding-schema.ts b/lab/wiki/client/buildify/embedding-schema.ts
index 08b33dfe69..edd3a7d822 100644
--- a/lab/wiki/client/buildify/embedding-schema.ts
+++ b/lab/wiki/client/buildify/embedding-schema.ts
@@ -1,32 +1,101 @@
-import type { NodeSpec } from 'prosemirror-model';
+import type { Maybe, ValueEvent } from '@adornis/base/utilTypes.js';
+import { AdornisFilter } from '@adornis/filter/AdornisFilter.js';
+import { StringAspect } from '@adornis/filter/aspect-schemas/StringAspect.js';
+import { runInsertNode } from '@adornis/html-based-buildify/client/helper/runInsertNode.js';
+import type { EditorFunc, INodeConfig } from '@adornis/html-based-buildify/client/types.js';
+import { isNodeSelection } from '@adornis/html-based-buildify/client/util.js';
+import { html } from 'lit';
 import { HTMLEmbedding } from '../../db/buildify/HTMLEmbedding.js';
+import { WikiEntry } from '../../db/WikiEntry.js';
 
 const getNewDefault = () => JSON.stringify(new HTMLEmbedding({}).toObject());
 
-export const EMBEDDING_SCHEMA: NodeSpec = {
-  draggable: true,
-  atom: true,
-  isolating: true,
-  group: 'block',
-  attrs: {
-    data: { default: getNewDefault() },
+const EMBEDDING_KEY = 'embedding';
+
+const editor = (disallowedId: string): EditorFunc<HTMLEmbedding> => {
+  return ({ content, contentController, controllerBaseKeyPath, host }) => {
+    return html`
+      <x-flex space="sm">
+        <x-entity-picker
+          placeholder="Seite zum einbetten"
+          clearable
+          .options=${{
+            class: WikiEntry,
+            selectionSet: { title: 1 },
+            filter: new AdornisFilter({
+              searchFields: ['title'],
+              aspects: [
+                new StringAspect({
+                  fieldName: '_class',
+                  mode: 'equals',
+                  value: WikiEntry._class,
+                }),
+                ...(disallowedId
+                  ? [
+                      new StringAspect({
+                        fieldName: '_id',
+                        mode: 'regex',
+                        value: `^(?!${disallowedId}$)`,
+                      }),
+                    ]
+                  : []),
+              ],
+            }),
+          }}
+          .value=${contentController.document?.entryID}
+          @value-picked=${(e: ValueEvent<Maybe<string>>) => {
+            if (!contentController.document) {
+              return;
+            }
+            contentController.document.entryID = !e.detail.value ? null : e.detail.value;
+          }}
+        >
+        </x-entity-picker>
+      </x-flex>
+    `;
+  };
+};
+
+export const embedding = (disallowedId: string): INodeConfig => ({
+  node: {
+    name: EMBEDDING_KEY,
+    schema: {
+      draggable: true,
+      atom: true,
+      isolating: true,
+      group: 'block',
+      attrs: {
+        data: { default: getNewDefault() },
+      },
+      parseDOM: [
+        {
+          tag: 'node-embedding',
+          getAttrs(dom: HTMLElement) {
+            return {
+              data: dom.getAttribute('data') ?? getNewDefault(),
+            };
+          },
+        },
+      ],
+      toDOM: node => {
+        return ['node-embedding', { data: node.attrs.data, 'no-content': 'true' }];
+      },
+    },
   },
-  parseDOM: [
+  menuItems: [
     {
-      tag: 'node-embedding',
-      getAttrs(dom: HTMLElement) {
-        return {
-          data: dom.getAttribute('data') ?? getNewDefault(),
-        };
+      label: 'Seite einbetten',
+      icon: 'place_item',
+      run: view =>
+        runInsertNode(EMBEDDING_KEY, state => {
+          return {
+            content: [state.schema.nodes[EMBEDDING_KEY]!.create()],
+          };
+        })(view.state, view),
+      shouldVisualize: view => {
+        return !isNodeSelection(view.state.selection);
       },
     },
   ],
-  toDOM: node => {
-    return ['node-embedding', { data: node.attrs.data, 'no-content': 'true' }];
-  },
-};
-
-export const EMBEDDING_KEY = 'embedding';
-export const EMBEDDING = {
-  [EMBEDDING_KEY]: EMBEDDING_SCHEMA,
-};
+  editor: editor(disallowedId),
+});
diff --git a/lab/wiki/client/buildify/embedding.ts b/lab/wiki/client/buildify/embedding.ts
index 0a3afc3a9e..0d9bde0c9e 100644
--- a/lab/wiki/client/buildify/embedding.ts
+++ b/lab/wiki/client/buildify/embedding.ts
@@ -1,87 +1,19 @@
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
-import type { Maybe, ValueEvent } from '@adornis/base/utilTypes.js';
 import '@adornis/buildify/client/components/x-buildify-spacing-picker.js';
 import { RXController } from '@adornis/chemistry/controllers/RXController.js';
 import { css } from '@adornis/chemistry/directives/css.js';
 import '@adornis/chemistry/elements/components/x-flex';
 import '@adornis/chemistry/elements/components/x-icon';
 import '@adornis/entity-picker/client/x-entity-picker.js';
-import { AdornisFilter } from '@adornis/filter/AdornisFilter.js';
-import { StringAspect } from '@adornis/filter/aspect-schemas/StringAspect.js';
 import { BuildifyLitElement } from '@adornis/html-based-buildify/client/BuildifyLitElement.js';
-import { createMenuItemButton } from '@adornis/html-based-buildify/client/helper/createMenuItemIcon.js';
-import { runInsertNode } from '@adornis/html-based-buildify/client/helper/runInsertNode.js';
 import '@adornis/html-based-buildify/client/nodes/doc.js';
-import { type EditorFunc } from '@adornis/html-based-buildify/client/types.js';
-import { isNodeSelection } from '@adornis/html-based-buildify/client/util.js';
 import '@adornis/popover/x-dropdown-selection';
 import { html, nothing } from 'lit';
 import { customElement, state } from 'lit/decorators.js';
 import { unsafeHTML } from 'lit/directives/unsafe-html.js';
-import { MenuItem } from 'prosemirror-menu';
 import { switchMap } from 'rxjs';
 import type { HTMLEmbedding } from '../../db/buildify/HTMLEmbedding.js';
 import { WikiEntry } from '../../db/WikiEntry.js';
-import { EMBEDDING_KEY } from './embedding-schema.js';
-
-export const EMBEDDING_EDITOR: (disallowedId: string) => EditorFunc<HTMLEmbedding> = disallowedId => {
-  console.log('disallowedId', disallowedId);
-  return ({ content, contentController, controllerBaseKeyPath, host }) => {
-    return html`
-      <x-flex space="sm">
-        <x-entity-picker
-          placeholder="Seite zum einbetten"
-          clearable
-          .options=${{
-            class: WikiEntry,
-            selectionSet: { title: 1 },
-            filter: new AdornisFilter({
-              searchFields: ['title'],
-              aspects: [
-                new StringAspect({
-                  fieldName: '_class',
-                  mode: 'equals',
-                  value: WikiEntry._class,
-                }),
-                ...(disallowedId
-                  ? [
-                      new StringAspect({
-                        fieldName: '_id',
-                        mode: 'regex',
-                        value: `^(?!${disallowedId}$)`,
-                      }),
-                    ]
-                  : []),
-              ],
-            }),
-          }}
-          .value=${contentController.document?.entryID}
-          @value-picked=${(e: ValueEvent<Maybe<string>>) => {
-            if (!contentController.document) {
-              return;
-            }
-            contentController.document.entryID = !e.detail.value ? null : e.detail.value;
-          }}
-        >
-        </x-entity-picker>
-      </x-flex>
-    `;
-  };
-};
-
-export const EMBEDDING_MENU_ITEM = new MenuItem({
-  render: view => createMenuItemButton({ icon: 'place_item', tooltip: 'Seite einbetten' }),
-  run: runInsertNode(EMBEDDING_KEY, state => {
-    return {
-      content: [state.schema.nodes[EMBEDDING_KEY]!.create()],
-    };
-  }),
-  label: 'Seite einbetten',
-  title: 'Seite einbetten',
-  select(state) {
-    return !isNodeSelection(state.selection);
-  },
-});
 
 @customElement('node-embedding')
 export class NodeEmbedding extends BuildifyLitElement<HTMLEmbedding> {
diff --git a/lab/wiki/client/x-wiki-editor.ts b/lab/wiki/client/x-wiki-editor.ts
index 06ee9b8332..602a418424 100644
--- a/lab/wiki/client/x-wiki-editor.ts
+++ b/lab/wiki/client/x-wiki-editor.ts
@@ -7,19 +7,19 @@ import { css } from '@adornis/chemistry/directives/css.js';
 import { XSnackbar } from '@adornis/chemistry/elements/components/x-snackbar.js';
 import { xComponents } from '@adornis/chemistry/elements/x-components.js';
 import type { Renderable } from '@adornis/chemistry/renderable.js';
+import { baseConfig } from '@adornis/config/baseConfig.js';
 import { XDialog } from '@adornis/dialog/x-dialog.js';
 import '@adornis/entity-picker/client/x-entity-picker.js';
 import { AdornisFilter } from '@adornis/filter/AdornisFilter.js';
 import { StringAspect } from '@adornis/filter/aspect-schemas/StringAspect.js';
+import { obsidianLinkPlugin } from '@adornis/html-based-buildify/client/plugins/ObsidianLinkPlugin/ObsidianLinkPlugin.js';
 import '@adornis/html-based-buildify/client/prosemirror-editor';
-import { ALL_EDITORS } from '@adornis/html-based-buildify/client/variables/ALL_EDITORS.js';
-import { ALL_MENU_ITEMS } from '@adornis/html-based-buildify/client/variables/ALL_MENU_ITEMS.js';
-import { NODES } from '@adornis/html-based-buildify/schema/defaultSchema.js';
+import { startedPack } from '@adornis/html-based-buildify/client/starterPack.js';
+import type { IMarkConfig, INodeConfig } from '@adornis/html-based-buildify/client/types.js';
 import { TranslationController } from '@adornis/translation-core/client/translation-controller.js';
 import { html, nothing } from 'lit';
 import { customElement, property, state } from 'lit/decorators.js';
 import { guard } from 'lit/directives/guard.js';
-import type { NodeSpec } from 'prosemirror-model';
 import { from, of, switchMap, tap } from 'rxjs';
 import { WikiAPI } from '../api/WikiAPI.js';
 import { wikiApplyDraft } from '../api/WikiDraftAPI.js';
@@ -27,8 +27,7 @@ import { WikiPermissionAPI } from '../api/WikiPermissionAPI.js';
 import { WikiEntry, WikiEntryTemplate } from '../db/WikiEntry.js';
 import { WikiEntryDraft, WikiEntryDraftStatus } from '../db/WikiEntryDraft.js';
 import type { WikiPermission } from '../db/WikiPermission.js';
-import { EMBEDDING, EMBEDDING_KEY } from './buildify/embedding-schema.js';
-import { EMBEDDING_EDITOR, EMBEDDING_MENU_ITEM } from './buildify/embedding.js';
+import { embedding } from './buildify/embedding-schema.js';
 import type { WikiTranslationDict } from './translation.js';
 import './x-wiki-draft-list.js';
 import './x-wiki-splitter-view.js';
@@ -55,7 +54,7 @@ import './x-wiki-splitter-view.js';
  * */
 @customElement('x-wiki-editor')
 export class XWikiEditor extends ChemistryLitElement {
-  @property({ attribute: false }) additionalNodes: Record<string, NodeSpec> = {};
+  @property({ attribute: false }) additionalConfigs: Array<INodeConfig | IMarkConfig> = [];
 
   /**
    * Window size controller
@@ -675,26 +674,28 @@ export class XWikiEditor extends ChemistryLitElement {
               paddingTop: this.spacing.lg,
             })}
             .value=${this._entry.value?.content}
-            .nodes=${{
-              ...NODES,
-              ...EMBEDDING,
-              ...this.additionalNodes,
-            }}
-            .menuItems=${Object.values({
-              ...ALL_MENU_ITEMS,
-              [EMBEDDING_KEY]: {
-                element: EMBEDDING_MENU_ITEM,
-              },
-            })}
-            .editors=${{
-              ...ALL_EDITORS,
-              [EMBEDDING_KEY]: EMBEDDING_EDITOR(
+            .configs=${[
+              ...startedPack,
+              embedding(
                 (this._isDraft()
                   ? (this._entry.value as Maybe<WikiEntryDraft>)?.referenceID
                   : this._entry.value?._id) ?? '',
               ),
-            }}
-            }}
+            ]}
+            .additionalPlugIns=${[
+              obsidianLinkPlugin(async search => {
+                const wikiEntries = await WikiEntry.getAll<WikiEntry>()({ _id: 1, title: 1 });
+                const rootUrl = baseConfig.get('ROOT_URL');
+
+                return wikiEntries
+                  .filter(
+                    entry =>
+                      entry.title?.toLowerCase().includes(search.toLowerCase()) ||
+                      search.toLowerCase().includes(entry._id?.toLowerCase()),
+                  )
+                  .map(entry => ({ href: `${rootUrl}wiki/${entry._id}|${entry.title}`, name: entry.title }));
+              }),
+            ]}
             @value-changed=${async e => {
               if (!e.detail?.valueHTML || !this._entry.value) return;
               this._entry.value.content = e.detail.valueHTML;
diff --git a/lab/wiki/client/x-wiki-view.ts b/lab/wiki/client/x-wiki-view.ts
index e1b0e4637f..50fca3b1a3 100644
--- a/lab/wiki/client/x-wiki-view.ts
+++ b/lab/wiki/client/x-wiki-view.ts
@@ -3,6 +3,7 @@ import { ChemistryLitElement } from '@adornis/chemistry/chemistry-lit-element.js
 import { RXController } from '@adornis/chemistry/controllers/RXController.js';
 import { acss } from '@adornis/chemistry/directives/acss.js';
 import { css } from '@adornis/chemistry/directives/css.js';
+import { DesignSystem } from '@adornis/chemistry/elements/theming/design.js';
 import { xComponents } from '@adornis/chemistry/elements/x-components.js';
 import type { Renderable } from '@adornis/chemistry/renderable.js';
 import '@adornis/html-based-buildify/client/prosemirror-editor';
@@ -16,10 +17,9 @@ import { unsafeHTML } from 'lit/directives/unsafe-html.js';
 import { switchMap, tap } from 'rxjs';
 import { WikiEntry } from '../db/WikiEntry.js';
 import { WikiEntryDraft, WikiEntryDraftStatus } from '../db/WikiEntryDraft.js';
+import { queryAll } from './query-shadow-root.js';
 import type { WikiTranslationDict } from './translation.js';
 import './x-wiki-user-text.js';
-import { DesignSystem } from '@adornis/chemistry/elements/theming/design.js';
-import { queryAll } from './query-shadow-root.js';
 
 function isChildOfAccordionOrHeader(node: Element): boolean {
   let cur = node.parentElement;
@@ -405,6 +405,8 @@ export class XWikiView extends ChemistryLitElement {
   override render() {
     let content: Renderable = '';
 
+    console.log('render stuff: ', this._entry.value?.content);
+
     if (this._entry.isLoading || this._entry.value) {
       content = html`
         <x-flex padding="lg" crossaxis-center ${css({ display: !this._entry.isLoading ? 'none' : 'flex' })}>
-- 
GitLab


From 19dea36f5b471117d51edd58262de96d507aa652 Mon Sep 17 00:00:00 2001
From: michi <michael@adornis.de>
Date: Fri, 20 Dec 2024 17:23:32 +0000
Subject: [PATCH 07/14] feat: better upload in dms

---
 .../component/x-file-change-name-dialog.ts    | 98 +++++++++++++++++++
 .../client/component/x-file-item-upload.ts    | 21 +++-
 lab/dms/client/component/x-file-list.ts       | 16 +++
 3 files changed, 130 insertions(+), 5 deletions(-)
 create mode 100644 lab/dms/client/component/x-file-change-name-dialog.ts

diff --git a/lab/dms/client/component/x-file-change-name-dialog.ts b/lab/dms/client/component/x-file-change-name-dialog.ts
new file mode 100644
index 0000000000..f582778a84
--- /dev/null
+++ b/lab/dms/client/component/x-file-change-name-dialog.ts
@@ -0,0 +1,98 @@
+import type { Styles } from '@adornis/ass/style.js';
+import type { Maybe, ValueEvent } from '@adornis/base/utilTypes.js';
+import { css } from '@adornis/chemistry/directives/css.js';
+import '@adornis/chemistry/elements/components/x-button';
+import { XNativeDialog } from '@adornis/dialog/x-native-dialog.js';
+import { html } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import './x-file-list';
+import './x-file-upload.js';
+
+@customElement('x-file-change-name-dialog')
+export class XFileChangeNameDialog extends XNativeDialog<Maybe<string>> {
+  @property({ attribute: false, type: String }) fileName: Maybe<string>;
+
+  static changeName(currentName: string): Promise<Maybe<string>> {
+    return this.showPopup({
+      modal: true,
+      props: {
+        fileName: currentName,
+      },
+    });
+  }
+
+  static override get element_name(): string {
+    return 'x-file-change-name-dialog';
+  }
+
+  override cancel() {
+    this.close(undefined);
+  }
+
+  override content() {
+    return html`
+      <x-flex ${css({ gap: '24px', padding: '16px' })}>
+        <x-flex
+          horizontal
+          crossaxis-center
+          ${css({
+            gap: '8px',
+            position: 'sticky',
+            top: '0px',
+            padding: '16px 0',
+            background: '#fff',
+            zIndex: '999',
+          })}
+        >
+          <x-text flex ${css({ fontSize: '24px' })}> <b> Neuer Dateiname </b> </x-text>
+        </x-flex>
+
+        <x-input
+          placeholder="Dateiname"
+          .value=${this.fileName}
+          @value-changed=${(e: ValueEvent<string>) => {
+            this.fileName = e.detail.value;
+          }}
+        ></x-input>
+
+        <x-flex
+          horizontal
+          space-between
+          crossaxis-center
+          ${css({
+            gap: '8px',
+          })}
+        >
+          <x-button
+            mode="outline"
+            @click=${() => {
+              this.cancel();
+            }}
+          >
+            Abbrechen
+          </x-button>
+          <x-button
+            @click=${() => {
+              this.close(this.fileName);
+            }}
+          >
+            Speichern
+          </x-button>
+        </x-flex>
+      </x-flex>
+    `;
+  }
+
+  override styles() {
+    return [
+      ...super.styles(),
+      {
+        dialog: {
+          display: 'block',
+          width: 'min(700px, 90%)',
+          boxSizing: 'border-box',
+        },
+      },
+    ] as Styles[];
+  }
+}
diff --git a/lab/dms/client/component/x-file-item-upload.ts b/lab/dms/client/component/x-file-item-upload.ts
index 065aeded5b..3ec73dcc75 100644
--- a/lab/dms/client/component/x-file-item-upload.ts
+++ b/lab/dms/client/component/x-file-item-upload.ts
@@ -1,4 +1,5 @@
 import type { Styles } from '@adornis/ass/style.js';
+import type { Maybe } from '@adornis/base/utilTypes.js';
 import { ChemistryLitElement } from '@adornis/chemistry/chemistry-lit-element.js';
 import { css } from '@adornis/chemistry/directives/css.js';
 import '@adornis/chemistry/elements/components/x-button';
@@ -39,11 +40,21 @@ export class XFileItemUpload extends ChemistryLitElement {
 
     if (this.file && this._uploadClass && !this._isUploading && !this._uploaded) {
       this._isUploading = true;
-      this._uploadClass.upload(this.file).then(async file => {
-        this._isUploading = false;
-        this._uploaded = true;
-        firstValueFrom(timer(1500)).then(() => this.dispatchEvent(new Event('remove')));
-      });
+      this._uploadClass
+        .upload(this.file, { additionalInfo: { meta: JSON.stringify({ fileName: 'test-filename :D' }) } })
+        .then(async file => {
+          const resolvedFile = (await this._uploadClass?.getByID(file)(
+            this._uploadClass.allFields,
+          )) as Maybe<AdornisFile>;
+          if (resolvedFile && resolvedFile.meta && this.file?.name.split('.')[0]) {
+            const name = this.file.name.split('.')[0]!;
+            resolvedFile.meta.fileName = name;
+            await resolvedFile.save();
+          }
+          this._isUploading = false;
+          this._uploaded = true;
+          firstValueFrom(timer(1500)).then(() => this.dispatchEvent(new Event('remove')));
+        });
     }
   }
 
diff --git a/lab/dms/client/component/x-file-list.ts b/lab/dms/client/component/x-file-list.ts
index 82f39b5462..97abc5bef4 100644
--- a/lab/dms/client/component/x-file-list.ts
+++ b/lab/dms/client/component/x-file-list.ts
@@ -32,6 +32,7 @@ import { FileFilter } from '../../db/FileFilter.js';
 import { FileListItem } from '../../db/FileListItem.js';
 import { formatFileSize } from '../helper.js';
 import type { FileClassSpec } from '../x-dms.js';
+import { XFileChangeNameDialog } from './x-file-change-name-dialog.js';
 
 @customElement('x-file-list')
 export class XFileList extends ChemistryLitElement {
@@ -206,6 +207,21 @@ export class XFileList extends ChemistryLitElement {
 
                 return html`
                   <x-flex ${css({ gap: '4px' })} horizontal wrap>
+                    <x-icon
+                      ${css({ cursor: 'pointer', fontSize: '20px' })}
+                      @click=${async () => {
+                        if (!item.file.meta) return;
+                        const oldFileName = item.file.meta.fileName;
+                        await XFileChangeNameDialog.changeName(oldFileName).then(async newFileName => {
+                          if (!newFileName || oldFileName === newFileName) return;
+                          item.file.meta!.fileName = newFileName;
+                          await item.file.save();
+                          XSnackbar.show('Dateiname gespeichert!');
+                        });
+                      }}
+                    >
+                      edit
+                    </x-icon>
                     <x-icon
                       ${css({ cursor: 'pointer', fontSize: '20px' })}
                       @click=${async () => {
-- 
GitLab


From 29b5e0368eacc58930daf5b2599ef9b07984e4d2 Mon Sep 17 00:00:00 2001
From: michi <michael@adornis.de>
Date: Fri, 20 Dec 2024 17:24:02 +0000
Subject: [PATCH 08/14] feat: better links in prosemirror

---
 lab/html-based-buildify/client/marks/link.ts  | 373 +++++++++++-------
 .../client/marks/link/LinkMarkView.ts         |  82 ++++
 .../marks/link/link-mark-editor-dialog.ts     | 127 ++++++
 .../client/marks/link/linkConfig.ts           | 111 ++++++
 .../client/prosemirror-editor.ts              |  95 +----
 lab/html-based-buildify/client/starterPack.ts |  66 ++++
 lab/html-based-buildify/schema/marks/link.ts  |  67 +++-
 7 files changed, 696 insertions(+), 225 deletions(-)
 create mode 100644 lab/html-based-buildify/client/marks/link/LinkMarkView.ts
 create mode 100644 lab/html-based-buildify/client/marks/link/link-mark-editor-dialog.ts
 create mode 100644 lab/html-based-buildify/client/marks/link/linkConfig.ts
 create mode 100644 lab/html-based-buildify/client/starterPack.ts

diff --git a/lab/html-based-buildify/client/marks/link.ts b/lab/html-based-buildify/client/marks/link.ts
index 6d306e5cfe..43a69e3efa 100644
--- a/lab/html-based-buildify/client/marks/link.ts
+++ b/lab/html-based-buildify/client/marks/link.ts
@@ -1,140 +1,233 @@
-/* eslint-disable @typescript-eslint/no-non-null-assertion */
-import '@adornis/chemistry/elements/components/x-icon';
-import { XDialog } from '@adornis/dialog/x-dialog.js';
-import { NodeSelection } from 'prosemirror-state';
-import type { EditorView } from 'prosemirror-view';
-import { LINK_KEY, LINK_SCHEMA } from '../../schema/marks/link.js';
-import { MenuItemGroup, type IMarkConfig } from '../types.js';
-
-export const link: IMarkConfig = {
-  mark: {
-    name: LINK_KEY,
-    schema: LINK_SCHEMA,
-  },
-  menuItems: [
-    {
-      group: MenuItemGroup.TEXT_STYLE,
-      label: 'Link',
-      icon: 'add_link',
-      shouldVisualize: view => {
-        // Aktiviert das Menü, wenn eine Selektion existiert und kein Link vorhanden ist
-        const { state } = view;
-        const { from, to } = state.selection;
-        const linkMark = state.schema.marks[LINK_KEY];
-        if (!linkMark) return false;
-
-        return (
-          !state.selection.empty &&
-          !(state.selection instanceof NodeSelection) &&
-          !state.doc.rangeHasMark(from, to, linkMark)
-        );
-      },
-      run: view => {
-        const { state, dispatch } = view;
-        const linkMark = state.schema.marks[LINK_KEY];
-        if (!linkMark) return false;
-
-        const href = prompt('Enter the URL', 'http://');
-        if (href) {
-          toggleLinkMark(linkMark, { href })(state, dispatch);
-        }
-      },
-    },
-    {
-      group: MenuItemGroup.TEXT_STYLE,
-      label: 'Link bearbeiten',
-      icon: 'edit_square',
-      shouldVisualize: view => {
-        const { state } = view;
-        const linkMark = state.schema.marks[LINK_KEY];
-        if (!linkMark) return false;
-        // Aktiviert das Menü, wenn der Cursor in einem Link ist oder ein Bereich ausgewählt ist
-        const { from, empty } = state.selection;
-        if (empty) {
-          // Prüft, ob der Cursor in einer Mark ist
-          return !!linkMark.isInSet(state.storedMarks || state.doc.resolve(from).marks());
-        } else {
-          // Prüft, ob eine Selektion einen Link enthält
-          const { from, to } = state.selection;
-          return !!state.doc.rangeHasMark(from, to, linkMark);
-        }
-      },
-      run: view => {
-        const { state, dispatch } = view;
-        const linkMark = state.schema.marks[LINK_KEY];
-        if (!linkMark) return false;
-
-        const { from, to } = state.selection;
-        const mark = state.doc.rangeHasMark(from, to, linkMark)
-          ? linkMark.isInSet(state.doc.resolve(from).marks())
-          : null;
-
-        const currentHref = mark ? mark.attrs.href : '';
-        const newHref = prompt('Edit the URL', currentHref);
-
-        if (newHref) {
-          toggleLinkMark(linkMark, { href: newHref })(state, dispatch);
-        }
-      },
-    },
-    {
-      group: MenuItemGroup.TEXT_STYLE,
-      label: 'Link löschen',
-      icon: 'link_off',
-      shouldVisualize: (view: EditorView) => {
-        const { state } = view;
-        const linkMark = state.schema.marks[LINK_KEY];
-        if (!linkMark) return false;
-        // Aktiviert das Menü, wenn der Cursor in einem Link ist oder ein Bereich ausgewählt ist
-        const { from, empty } = state.selection;
-        if (empty) {
-          // Prüft, ob der Cursor in einer Mark ist
-          return !!linkMark.isInSet(state.storedMarks || state.doc.resolve(from).marks());
-        } else {
-          // Prüft, ob eine Selektion einen Link enthält
-          const { from, to } = state.selection;
-          return !!state.doc.rangeHasMark(from, to, linkMark);
-        }
-      },
-      run: async view => {
-        if (!(await XDialog.confirm('Wollen Sie diesen Link wirklich entfernen?'))) return;
-        const { state, dispatch } = view;
-        const { from, to, empty } = state.selection;
-        const linkMark = state.schema.marks[LINK_KEY];
-        if (!linkMark) return false;
-
-        if (empty) {
-          // Wenn der Cursor in einem Link ist, finde die gesamte Mark und entferne sie
-          const $pos = state.doc.resolve(from);
-          const isInSet = linkMark.isInSet($pos.marks());
-          if (isInSet) {
-            const linkStart = $pos.nodeBefore ? from - $pos.nodeBefore.nodeSize : from;
-            const linkEnd = $pos.nodeAfter ? from + $pos.nodeAfter.nodeSize : from;
-            dispatch(state.tr.removeMark(linkStart, linkEnd, linkMark));
-          }
-        } else {
-          // Entfernt die Markierung im ausgewählten Bereich
-          dispatch(state.tr.removeMark(from, to, linkMark));
-        }
-      },
-    },
-  ],
-};
-
-// Toggle-Logic Command
-function toggleLinkMark(markType, attrs) {
-  return (state, dispatch) => {
-    const { from, to } = state.selection;
-    const hasMark = state.doc.rangeHasMark(from, to, markType);
-
-    if (hasMark) {
-      // Wenn der Link existiert, entfernen
-      dispatch(state.tr.removeMark(from, to, markType));
-    } else {
-      // Wenn kein Link existiert, hinzufügen
-      dispatch(state.tr.addMark(from, to, markType.create(attrs)));
-    }
-
-    return true;
-  };
-}
+// /* eslint-disable @typescript-eslint/no-non-null-assertion */
+// import type { Styles } from '@adornis/ass/style.js';
+// import { css } from '@adornis/chemistry/directives/css.js';
+// import '@adornis/chemistry/elements/components/x-icon';
+// import { XDialog } from '@adornis/dialog/x-dialog.js';
+// import { XNativeDialog } from '@adornis/dialog/x-native-dialog.js';
+// import { html } from 'lit';
+// import { customElement } from 'lit/decorators.js';
+// import type { MarkType } from 'prosemirror-model';
+// import { Mark as ProseMirrorMark } from 'prosemirror-model';
+// import { EditorState, NodeSelection, Plugin } from 'prosemirror-state';
+// import type { Decoration, EditorView } from 'prosemirror-view';
+// import { LINK_KEY, LINK_SCHEMA } from '../../schema/marks/link.js';
+// import { MenuItemGroup, type IMarkConfig } from '../types.js';
+
+// export const link: IMarkConfig = {
+//   mark: {
+//     name: LINK_KEY,
+//     schema: LINK_SCHEMA(async view => LinkMarkEditorDialog.showPopup()),
+//   },
+//   menuItems: [
+//     {
+//       group: MenuItemGroup.TEXT_STYLE,
+//       label: 'Link',
+//       icon: 'add_link',
+//       shouldVisualize: view => {
+//         // Aktiviert das Menü, wenn eine Selektion existiert und kein Link vorhanden ist
+//         const { state } = view;
+//         const { from, to } = state.selection;
+//         const linkMark = state.schema.marks[LINK_KEY];
+//         if (!linkMark) return false;
+
+//         return (
+//           !state.selection.empty &&
+//           !(state.selection instanceof NodeSelection) &&
+//           !state.doc.rangeHasMark(from, to, linkMark)
+//         );
+//       },
+//       run: view => {
+//         const { state, dispatch } = view;
+//         const linkMark = state.schema.marks[LINK_KEY];
+//         if (!linkMark) return false;
+
+//         const href = prompt('Enter the URL', 'http://');
+//         if (href) {
+//           toggleLinkMark(linkMark, { href })(state, dispatch);
+//         }
+//       },
+//     },
+//     {
+//       group: MenuItemGroup.TEXT_STYLE,
+//       label: 'Link löschen',
+//       icon: 'link_off',
+//       shouldVisualize: (view: EditorView) => {
+//         const { state } = view;
+//         const linkMark = state.schema.marks[LINK_KEY];
+//         if (!linkMark) return false;
+//         // Aktiviert das Menü, wenn der Cursor in einem Link ist oder ein Bereich ausgewählt ist
+//         const { from, empty } = state.selection;
+//         if (empty) {
+//           // Prüft, ob der Cursor in einer Mark ist
+//           return !!linkMark.isInSet(state.storedMarks || state.doc.resolve(from).marks());
+//         } else {
+//           // Prüft, ob eine Selektion einen Link enthält
+//           const { from, to } = state.selection;
+//           return !!state.doc.rangeHasMark(from, to, linkMark);
+//         }
+//       },
+//       run: async view => {
+//         return removeLink(view);
+//       },
+//     },
+//   ],
+//   plugins: (schema) => [
+//     new Plugin<any>({
+//         props: {
+//           nodeViews: {
+//             link: ,
+//           },
+//         },
+//       })
+//   ]
+// };
+
+// type GetPos = (() => number) | boolean;
+
+// class LinkMarkView {
+//   mark: ProseMirrorMark;
+//   view: EditorView;
+//   getPos: GetPos;
+//   decorations: Decoration[];
+//   dom: HTMLElement;
+
+//   constructor(
+//     mark: ProseMirrorMark,
+//     view: EditorView,
+//     getPos: GetPos,
+//     decorations: Decoration[]
+//   ) {
+//     this.mark = mark;
+//     this.view = view;
+//     this.getPos = getPos;
+//     this.decorations = decorations;
+
+//     this.dom = document.createElement("a");
+//     this.dom.href = mark.attrs.href;
+//     this.dom.textContent = "Link"; // Placeholder; content will be rendered by ProseMirror.
+//     this.dom.classList.add("link-mark");
+
+//     this.dom.addEventListener("click", this.handleClick.bind(this));
+//   }
+
+//   handleClick(event: MouseEvent): void {
+//     event.preventDefault(); // Prevent default link navigation
+
+//     const linkHref = this.mark.attrs.href;
+
+//     // Example actions: show a modal to edit or delete the link
+//     const newHref = prompt("Edit Link URL", linkHref);
+
+//     if (newHref === null) {
+//       // User cancelled
+//       return;
+//     } else if (newHref === "") {
+//       // Delete link
+//       const pos = typeof this.getPos === "function" ? this.getPos() : 0;
+//       this.view.dispatch(
+//         this.view.state.tr.removeMark(
+//           pos,
+//           pos + this.mark.attrs.length,
+//           this.view.state.schema.marks.link
+//         )
+//       );
+//     } else {
+//       // Update link
+//       const pos = typeof this.getPos === "function" ? this.getPos() : 0;
+//       this.view.dispatch(
+//         this.view.state.tr.addMark(
+//           pos,
+//           pos + this.mark.attrs.length,
+//           this.view.state.schema.marks.link.create({ href: newHref })
+//         )
+//       );
+//     }
+//   }
+
+//   update(mark: ProseMirrorMark): boolean {
+//     if (mark.type !== this.mark.type) return false;
+//     this.mark = mark;
+//     this.dom.href = mark.attrs.href;
+//     return true;
+//   }
+
+//   destroy(): void {
+//     this.dom.removeEventListener("click", this.handleClick);
+//     this.dom = null;
+//   }
+// }
+
+// export function linkMarkView() {
+//   return (
+//     mark: ProseMirrorMark,
+//     view: EditorView,
+//     getPos: GetPos,
+//     decorations: Decoration[]
+//   ): LinkMarkView => {
+//     return new LinkMarkView(mark, view, getPos, decorations);
+//   };
+// }
+
+// async function removeLink(view: EditorView) {
+//   if (!(await XDialog.confirm('Wollen Sie diesen Link wirklich entfernen?'))) return;
+//   const { state, dispatch } = view;
+//   const { from, to, empty } = state.selection;
+//   const linkMark = state.schema.marks[LINK_KEY];
+//   if (!linkMark) return false;
+
+//   if (empty) {
+//     // Wenn der Cursor in einem Link ist, finde die gesamte Mark und entferne sie
+//     const $pos = state.doc.resolve(from);
+//     const isInSet = linkMark.isInSet($pos.marks());
+//     if (isInSet) {
+//       const linkStart = $pos.nodeBefore ? from - $pos.nodeBefore.nodeSize : from;
+//       const linkEnd = $pos.nodeAfter ? from + $pos.nodeAfter.nodeSize : from;
+//       dispatch(state.tr.removeMark(linkStart, linkEnd, linkMark));
+//     }
+//   } else {
+//     // Entfernt die Markierung im ausgewählten Bereich
+//     dispatch(state.tr.removeMark(from, to, linkMark));
+//   }
+// }
+
+// // Toggle-Logic Command
+// function toggleLinkMark(markType: MarkType, attrs: {}) {
+//   return (state: EditorState, dispatch) => {
+//     const { from, to } = state.selection;
+//     const hasMark = state.doc.rangeHasMark(from, to, markType);
+
+//     if (hasMark) {
+//       // Wenn der Link existiert, entfernen
+//       dispatch(state.tr.removeMark(from, to, markType));
+//     } else {
+//       // Wenn kein Link existiert, hinzufügen
+//       dispatch(state.tr.addMark(from, to, markType.create(attrs)));
+//     }
+
+//     return true;
+//   };
+// }
+
+// @customElement('link-mark-editor-dialog')
+// export class LinkMarkEditorDialog extends XNativeDialog<void> {
+//   static override get element_name(): string {
+//     return 'link-mark-editor-dialog';
+//   }
+
+//   override content() {
+//     return html` <x-flex ${css({ gap: '24px', padding: '16px' })}> better link edit prompt </x-flex> `;
+//   }
+
+//   override styles() {
+//     return [
+//       ...super.styles(),
+//       {
+//         dialog: {
+//           display: 'block',
+//           width: 'min(700px, 90%)',
+//           boxSizing: 'border-box',
+//         },
+//       },
+//     ] as Styles[];
+//   }
+// }
diff --git a/lab/html-based-buildify/client/marks/link/LinkMarkView.ts b/lab/html-based-buildify/client/marks/link/LinkMarkView.ts
new file mode 100644
index 0000000000..988c959b1b
--- /dev/null
+++ b/lab/html-based-buildify/client/marks/link/LinkMarkView.ts
@@ -0,0 +1,82 @@
+// MarkView for managing interactive link marks in ProseMirror (TypeScript)
+import { Mark as ProseMirrorMark } from 'prosemirror-model';
+import { Plugin, PluginKey } from 'prosemirror-state';
+import { EditorView, type MarkViewConstructor } from 'prosemirror-view';
+import { LinkMarkEditorDialog } from './link-mark-editor-dialog.js';
+
+class LinkMarkView {
+  mark: ProseMirrorMark;
+  view: EditorView;
+  dom: HTMLElement;
+
+  constructor(mark: ProseMirrorMark, view: EditorView, inline: boolean) {
+    this.mark = mark;
+    this.view = view;
+
+    const link = document.createElement(`a`);
+    const { href, target } = mark.attrs;
+    link.style.cursor = 'pointer';
+    link.setAttribute('href', href);
+    link.setAttribute('target', target);
+    link.addEventListener('click', this.handleClick.bind(this));
+    this.dom = link;
+  }
+
+  async handleClick(e: MouseEvent): Promise<void> {
+    console.log('handle click');
+    e.preventDefault(); // Prevent default link navigation
+    e.stopPropagation();
+
+    const href = this.mark.attrs.href as string;
+    const target = this.mark.attrs.target as string;
+    const range = this.getMarkRange();
+
+    await LinkMarkEditorDialog.editLink({ href, target, view: this.view, from: range.from, to: range.to });
+  }
+
+  getMarkRange(): { from: number; to: number } {
+    const pos = this.view.posAtDOM(this.dom, 0);
+    const node = this.view.state.doc.nodeAt(pos);
+
+    if (!node) {
+      throw new Error('Mark range could not be determined.');
+    }
+
+    const mark = node.marks.find(m => m.type === this.mark.type);
+
+    if (!mark) {
+      throw new Error('Mark not found in the node.');
+    }
+
+    const from = pos;
+    const to = pos + node.nodeSize;
+
+    return { from, to };
+  }
+
+  update(mark: ProseMirrorMark): boolean {
+    if (mark.type !== this.mark.type) return false;
+    this.mark = mark;
+    this.dom.href = mark.attrs.href;
+    return true;
+  }
+
+  destroy(): void {
+    this.dom.removeEventListener('click', this.handleClick);
+    this.dom = null;
+  }
+}
+
+const linkMarkView: MarkViewConstructor = (mark: ProseMirrorMark, view: EditorView, inline: boolean) => {
+  return new LinkMarkView(mark, view, inline);
+};
+
+// Example usage in your ProseMirror editor setup:
+export const linkPlugin = new Plugin({
+  key: new PluginKey('link-markview-plugin'),
+  props: {
+    markViews: {
+      link: linkMarkView,
+    },
+  },
+});
diff --git a/lab/html-based-buildify/client/marks/link/link-mark-editor-dialog.ts b/lab/html-based-buildify/client/marks/link/link-mark-editor-dialog.ts
new file mode 100644
index 0000000000..26d88c242b
--- /dev/null
+++ b/lab/html-based-buildify/client/marks/link/link-mark-editor-dialog.ts
@@ -0,0 +1,127 @@
+import type { Styles } from '@adornis/ass/style.js';
+import type { ValueEvent } from '@adornis/base/utilTypes.js';
+import { css } from '@adornis/chemistry/directives/css.js';
+import '@adornis/chemistry/elements/components/x-button.js';
+import { XNativeDialog } from '@adornis/dialog/x-native-dialog.js';
+import { goTo } from '@adornis/router/client/open-href.js';
+import { html } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import type { EditorView } from 'prosemirror-view';
+import { LINK_KEY } from '../../../schema/marks/link.js';
+
+@customElement('link-mark-editor-dialog')
+export class LinkMarkEditorDialog extends XNativeDialog<void> {
+  @property({ attribute: false }) href!: string;
+  @property({ attribute: false }) target!: '_blank' | '_self';
+  @property({ attribute: false }) view!: EditorView;
+  @property({ attribute: false }) from!: number;
+  @property({ attribute: false }) to!: number;
+
+  static override get element_name(): string {
+    return 'link-mark-editor-dialog';
+  }
+
+  static editLink({
+    href,
+    target,
+    view,
+    from,
+    to,
+  }: {
+    href: string;
+    target: string;
+    view: EditorView;
+    from: number;
+    to: number;
+  }) {
+    return this.showPopup({
+      props: {
+        href,
+        target,
+        view,
+        from,
+        to,
+      },
+    });
+  }
+
+  override content() {
+    return html`
+      <x-grid ${css({ gap: '24px', padding: '16px' })} columns="2fr 1fr">
+        <x-flex ${css({ gap: '24px', padding: '16px' })}>
+          <x-text ${css({ fontSize: '24px' })}> <b> Link bearbeiten </b></x-text>
+          <x-flex ${css({ gap: '8px' })}>
+            <x-input
+              placeholder="Link"
+              .value=${this.href}
+              @value-changed=${(e: ValueEvent<string>) => {
+                this.href = e.detail.value;
+              }}
+            ></x-input>
+            <x-checkbox
+              .label=${'In neuem Fenster öffnen?'}
+              .value=${this.target === '_blank'}
+              @value-changed=${(e: ValueEvent<boolean>) => {
+                this.target = e.detail.value ? '_blank' : '_self';
+              }}
+            ></x-checkbox>
+            <x-flex horizontal space="sm" space-between ${css({ gap: '8px' })}>
+              <x-button mode="outline" @click=${() => this.cancel()}> Abbrechen </x-button>
+              <x-button
+                @click=${() => {
+                  const tr = this.view.state.tr;
+                  this.view.dispatch(
+                    tr.addMark(
+                      this.from,
+                      this.to,
+                      this.view.state.schema.marks.link!.create({ href: this.href, target: this.target }),
+                    ),
+                  );
+                  this.close();
+                }}
+              >
+                Speichern
+              </x-button>
+            </x-flex>
+          </x-flex>
+        </x-flex>
+        <x-flex ${css({ gap: '24px', padding: '16px' })}>
+          <x-text ${css({ fontSize: '24px' })}> <b> Oder </b></x-text>
+          <x-flex ${css({ gap: '8px' })}>
+            <x-button
+              tone="error"
+              @click=${() => {
+                const linkMark = this.view.state.schema.mark[LINK_KEY];
+                this.view.dispatch(this.view.state.tr.removeMark(this.from, this.to, linkMark));
+                this.close();
+              }}
+            >
+              Löschen
+            </x-button>
+            <x-button
+              @click=${() => {
+                goTo(this.href, { target: this.target ?? '_blank' });
+                this.close();
+              }}
+            >
+              Öffnen
+            </x-button>
+          </x-flex>
+        </x-flex>
+      </x-grid>
+    `;
+  }
+
+  override styles() {
+    return [
+      ...super.styles(),
+      {
+        dialog: {
+          display: 'block',
+          width: 'min(700px, 90%)',
+          boxSizing: 'border-box',
+        },
+      },
+    ] as Styles[];
+  }
+}
diff --git a/lab/html-based-buildify/client/marks/link/linkConfig.ts b/lab/html-based-buildify/client/marks/link/linkConfig.ts
new file mode 100644
index 0000000000..52c3234a89
--- /dev/null
+++ b/lab/html-based-buildify/client/marks/link/linkConfig.ts
@@ -0,0 +1,111 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+import '@adornis/chemistry/elements/components/x-icon';
+import { XDialog } from '@adornis/dialog/x-dialog.js';
+import type { MarkType } from 'prosemirror-model';
+import { EditorState, NodeSelection } from 'prosemirror-state';
+import type { EditorView } from 'prosemirror-view';
+import { LINK_KEY, LINK_SCHEMA } from '../../../schema/marks/link.js';
+import { MenuItemGroup, type IMarkConfig } from '../../types.js';
+import { LinkMarkEditorDialog } from '../link.js';
+import { linkPlugin } from './LinkMarkView.js';
+
+export const link: IMarkConfig = {
+  mark: {
+    name: LINK_KEY,
+    schema: LINK_SCHEMA(async view => LinkMarkEditorDialog.showPopup()),
+  },
+  menuItems: [
+    {
+      group: MenuItemGroup.TEXT_STYLE,
+      label: 'Link',
+      icon: 'add_link',
+      shouldVisualize: view => {
+        // Aktiviert das Menü, wenn eine Selektion existiert und kein Link vorhanden ist
+        const { state } = view;
+        const { from, to } = state.selection;
+        const linkMark = state.schema.marks[LINK_KEY];
+        if (!linkMark) return false;
+
+        return (
+          !state.selection.empty &&
+          !(state.selection instanceof NodeSelection) &&
+          !state.doc.rangeHasMark(from, to, linkMark)
+        );
+      },
+      run: view => {
+        const { state, dispatch } = view;
+        const linkMark = state.schema.marks[LINK_KEY];
+        if (!linkMark) return false;
+
+        const href = prompt('Enter the URL', 'http://');
+        if (href) {
+          toggleLinkMark(linkMark, { href })(state, dispatch);
+        }
+      },
+    },
+    {
+      group: MenuItemGroup.TEXT_STYLE,
+      label: 'Link löschen',
+      icon: 'link_off',
+      shouldVisualize: (view: EditorView) => {
+        const { state } = view;
+        const linkMark = state.schema.marks[LINK_KEY];
+        if (!linkMark) return false;
+        // Aktiviert das Menü, wenn der Cursor in einem Link ist oder ein Bereich ausgewählt ist
+        const { from, empty } = state.selection;
+        if (empty) {
+          // Prüft, ob der Cursor in einer Mark ist
+          return !!linkMark.isInSet(state.storedMarks || state.doc.resolve(from).marks());
+        } else {
+          // Prüft, ob eine Selektion einen Link enthält
+          const { from, to } = state.selection;
+          return !!state.doc.rangeHasMark(from, to, linkMark);
+        }
+      },
+      run: async view => {
+        return removeLink(view);
+      },
+    },
+  ],
+  plugins: schema => [linkPlugin],
+};
+
+export async function removeLink(view: EditorView) {
+  if (!(await XDialog.confirm('Wollen Sie diesen Link wirklich entfernen?'))) return;
+  const { state, dispatch } = view;
+  const { from, to, empty } = state.selection;
+  const linkMark = state.schema.marks[LINK_KEY];
+  if (!linkMark) return false;
+
+  if (empty) {
+    // Wenn der Cursor in einem Link ist, finde die gesamte Mark und entferne sie
+    const $pos = state.doc.resolve(from);
+    const isInSet = linkMark.isInSet($pos.marks());
+    if (isInSet) {
+      const linkStart = $pos.nodeBefore ? from - $pos.nodeBefore.nodeSize : from;
+      const linkEnd = $pos.nodeAfter ? from + $pos.nodeAfter.nodeSize : from;
+      dispatch(state.tr.removeMark(linkStart, linkEnd, linkMark));
+    }
+  } else {
+    // Entfernt die Markierung im ausgewählten Bereich
+    dispatch(state.tr.removeMark(from, to, linkMark));
+  }
+}
+
+// Toggle-Logic Command
+function toggleLinkMark(markType: MarkType, attrs: {}) {
+  return (state: EditorState, dispatch) => {
+    const { from, to } = state.selection;
+    const hasMark = state.doc.rangeHasMark(from, to, markType);
+
+    if (hasMark) {
+      // Wenn der Link existiert, entfernen
+      dispatch(state.tr.removeMark(from, to, markType));
+    } else {
+      // Wenn kein Link existiert, hinzufügen
+      dispatch(state.tr.addMark(from, to, markType.create(attrs)));
+    }
+
+    return true;
+  };
+}
diff --git a/lab/html-based-buildify/client/prosemirror-editor.ts b/lab/html-based-buildify/client/prosemirror-editor.ts
index a965722eb6..65411b7cd8 100644
--- a/lab/html-based-buildify/client/prosemirror-editor.ts
+++ b/lab/html-based-buildify/client/prosemirror-editor.ts
@@ -26,40 +26,15 @@ import { EditorState, Plugin } from 'prosemirror-state';
 import { EditorView } from 'prosemirror-view';
 import { combineLatest, debounceTime, distinctUntilChanged, filter, switchMap, takeUntil } from 'rxjs';
 import { Contexts, Size } from '../db/enums.js';
-import { bold } from './marks/bold.js';
-import { color, colorMenuItem } from './marks/color.js';
-import { fontSize } from './marks/font-size.js';
-import { italic } from './marks/italic.js';
-import { link } from './marks/link.js';
-import { strikeThrough } from './marks/strike-through.js';
+import { colorMenuItem } from './marks/color.js';
 import { createTextAlignMenuItem } from './marks/text-align.js';
-import { underline } from './marks/underline.js';
 import { DeleteSelectedNodeMenuItem } from './menu-items/DeleteSelectedNode.js';
 import { EditGlobalSettingsMenuItem } from './menu-items/EditGlobalSettings.js';
 import { EditSelectedNodeMenuItem } from './menu-items/EditSelectedNode.js';
-import { accordeonConfigs } from './nodes/accordeon.js';
-import { button } from './nodes/button.js';
-import { doc } from './nodes/doc.js';
-import { excalidraw } from './nodes/excalidraw.js';
-import { file } from './nodes/file.js';
-import { flex } from './nodes/flex.js';
-import { grid, gridCell } from './nodes/grid.js';
-import { hardBreak } from './nodes/hard-break.js';
-import { heading } from './nodes/headings.js';
-import { icon } from './nodes/icon.js';
-import { iconText, iconWrapper, textWrapper } from './nodes/iconText.js';
-import { image } from './nodes/image.js';
-import { bulletList, listItem, orderedList } from './nodes/list.js';
-import { paragraph } from './nodes/paragraph.js';
-import { section } from './nodes/section.js';
-import { spacing } from './nodes/spacing.js';
-import { tag } from './nodes/tag.js';
-import { text } from './nodes/text.js';
-import { vimeo } from './nodes/vimeo.js';
-import { youtube } from './nodes/youtube.js';
 import { getCurrentPathPlugin } from './plugins/currentPathBarPlugin.js';
 import { handleTransactionMetaPlugin } from './plugins/handleTransactionMetaPlugin.js';
 import { germanSmartQuotesPlugin } from './plugins/quotePlugin.js';
+import { startedPack } from './starterPack.js';
 import { MenuItemGroup, type EditorFunc, type IMarkConfig, type IMenuItem, type INodeConfig } from './types.js';
 import './x-prosemirror-toolbar';
 
@@ -75,43 +50,7 @@ function getHtmlString(doc) {
 
 @customElement('prosemirror-editor')
 export class ProsemirrorEditor extends FormField<string> {
-  @property({ attribute: false }) configs = new RXController<Array<INodeConfig | IMarkConfig>>(this, [
-    // default nodes
-    doc,
-    paragraph,
-    text,
-    // marks
-    bold,
-    color,
-    fontSize,
-    italic,
-    link,
-    strikeThrough,
-    underline,
-    // nodes
-    ...accordeonConfigs,
-    button,
-    excalidraw,
-    file,
-    flex,
-    grid,
-    gridCell,
-    hardBreak,
-    heading,
-    icon,
-    iconText,
-    iconWrapper,
-    textWrapper,
-    image,
-    listItem,
-    bulletList,
-    orderedList,
-    section,
-    spacing,
-    tag,
-    vimeo,
-    youtube,
-  ]);
+  @property({ attribute: false }) configs = new RXController<Array<INodeConfig | IMarkConfig>>(this, startedPack);
 
   @property({ attribute: false }) menuItemGroupOrder: string[] = [
     MenuItemGroup.TEXT_STYLE,
@@ -122,12 +61,15 @@ export class ProsemirrorEditor extends FormField<string> {
     MenuItemGroup.COLOR,
   ];
 
+  @property({ attribute: false }) additionalPlugIns = new RXController<Plugin[]>(this, []);
+
   @property({ attribute: false }) additionalMenuItems = new RXController(this, [
     createTextAlignMenuItem('left'),
     createTextAlignMenuItem('center'),
     createTextAlignMenuItem('right'),
     createTextAlignMenuItem('justify'),
   ]);
+
   @property({ attribute: false }) globalSettingsClassName = new RXController(this, BuildifyGlobalSettings._class);
   @property({ attribute: false }) sizeBreakpoints = new RXController<Record<Size, number>>(this, {
     [Size.DESKTOP]: 1200,
@@ -151,7 +93,7 @@ export class ProsemirrorEditor extends FormField<string> {
   @state() private _selectedSize: Maybe<Size>;
 
   // * attributes
-  private _editor: Maybe<EditorView>;
+  public view: Maybe<EditorView>;
   private _schema: Maybe<Schema<any, any>>;
 
   protected _globalSettingsClassNameProvider = new ContextProvider<
@@ -194,6 +136,11 @@ export class ProsemirrorEditor extends FormField<string> {
       }),
     );
 
+    // additional ones
+    plugins.push(...this.additionalPlugIns.value);
+
+    console.log('plugins', ...plugins);
+
     return plugins;
   }
 
@@ -279,22 +226,22 @@ export class ProsemirrorEditor extends FormField<string> {
   protected override update(changedProperties: PropertyValues): void {
     super.update(changedProperties);
 
-    if (changedProperties.has('value') && this._editor && this._schema) {
+    if (changedProperties.has('value') && this.view && this._schema) {
       const html = this.value.value || '';
 
       const node = document.createElement('div');
       node.innerHTML = html;
 
-      if (getHtmlString(this._editor.state.doc) === html) {
+      if (getHtmlString(this.view.state.doc) === html) {
         return;
       }
 
       const newDoc = DOMParser.fromSchema(this._schema).parse(node);
-      this._editor.updateState(
+      this.view.updateState(
         EditorState.create({
           doc: newDoc,
-          plugins: this._editor.state.plugins,
-          schema: this._editor.state.schema,
+          plugins: this.view.state.plugins,
+          schema: this.view.state.schema,
         }),
       );
     }
@@ -316,11 +263,11 @@ export class ProsemirrorEditor extends FormField<string> {
       plugins: this.getPlugins(this._schema),
       doc: DOMParser.fromSchema(this._schema).parse(node),
     });
-    this._editor = new EditorView(editorElement, { state });
+    this.view = new EditorView(editorElement, { state });
   }
 
   protected throwValuePicked() {
-    const view = this._editor;
+    const view = this.view;
     if (!view) return;
     const { state } = view;
     const value = JSON.stringify(state.doc.toJSON());
@@ -333,7 +280,7 @@ export class ProsemirrorEditor extends FormField<string> {
   }
 
   protected updateEditor() {
-    const editor = this._editor;
+    const editor = this.view;
     if (!editor) return;
     const schema = editor.state.schema;
 
@@ -381,7 +328,7 @@ export class ProsemirrorEditor extends FormField<string> {
       <x-prosemirror-toolbar
         ${css({ display: 'block', position: 'sticky', top: '0px', padding: '8px', background: '#fff', zIndex: '9999' })}
         .menuItemGroupOrder=${this.menuItemGroupOrder}
-        .view=${this._editor}
+        .view=${this.view}
         .menuItems=${this.getMenuItems()}
       ></x-prosemirror-toolbar>
       <node-doc mode="edit" id=${EDITOR_ID} ${css({ width: maxWidth, maxWidth, margin: '0 auto' })} spellcheck="false">
diff --git a/lab/html-based-buildify/client/starterPack.ts b/lab/html-based-buildify/client/starterPack.ts
new file mode 100644
index 0000000000..8aae97fac4
--- /dev/null
+++ b/lab/html-based-buildify/client/starterPack.ts
@@ -0,0 +1,66 @@
+import { bold } from './marks/bold.js';
+import { color } from './marks/color.js';
+import { fontSize } from './marks/font-size.js';
+import { italic } from './marks/italic.js';
+import { link } from './marks/link/linkConfig.js';
+import { strikeThrough } from './marks/strike-through.js';
+import { underline } from './marks/underline.js';
+import { accordeonConfigs } from './nodes/accordeon.js';
+import { button } from './nodes/button.js';
+import { doc } from './nodes/doc.js';
+import { excalidraw } from './nodes/excalidraw.js';
+import { file } from './nodes/file.js';
+import { flex } from './nodes/flex.js';
+import { grid, gridCell } from './nodes/grid.js';
+import { hardBreak } from './nodes/hard-break.js';
+import { heading } from './nodes/headings.js';
+import { icon } from './nodes/icon.js';
+import { iconText, iconWrapper, textWrapper } from './nodes/iconText.js';
+import { image } from './nodes/image.js';
+import { bulletList, listItem, orderedList } from './nodes/list.js';
+import { paragraph } from './nodes/paragraph.js';
+import { section } from './nodes/section.js';
+import { spacing } from './nodes/spacing.js';
+import { tag } from './nodes/tag.js';
+import { text } from './nodes/text.js';
+import { vimeo } from './nodes/vimeo.js';
+import { youtube } from './nodes/youtube.js';
+import type { IMarkConfig, INodeConfig } from './types.js';
+
+export const startedPack: Array<INodeConfig | IMarkConfig> = [
+  // default nodes
+  doc,
+  paragraph,
+  text,
+  // marks
+  bold,
+  color,
+  fontSize,
+  italic,
+  link,
+  strikeThrough,
+  underline,
+  // nodes
+  ...accordeonConfigs,
+  button,
+  excalidraw,
+  file,
+  flex,
+  grid,
+  gridCell,
+  hardBreak,
+  heading,
+  icon,
+  iconText,
+  iconWrapper,
+  textWrapper,
+  image,
+  listItem,
+  bulletList,
+  orderedList,
+  section,
+  spacing,
+  tag,
+  vimeo,
+  youtube,
+];
diff --git a/lab/html-based-buildify/schema/marks/link.ts b/lab/html-based-buildify/schema/marks/link.ts
index 178ed61a40..e36e0a8939 100644
--- a/lab/html-based-buildify/schema/marks/link.ts
+++ b/lab/html-based-buildify/schema/marks/link.ts
@@ -1,28 +1,73 @@
 import { DesignSystem } from '@adornis/chemistry/elements/theming/design.js';
 import type { MarkSpec } from 'prosemirror-model';
+import { EditorView } from 'prosemirror-view';
 
-export const LINK_SCHEMA: MarkSpec = {
+/**
+ * Finds the closest parent element with the specified tag name.
+ *
+ * @param {HTMLElement} element - The starting HTML element.
+ * @param {string} tagName - The tag name of the desired parent (case-insensitive).
+ * @returns {HTMLElement|null} - The matching parent element or null if not found.
+ */
+function findParentByTag(element: HTMLElement, tagName: string) {
+  if (!element || !tagName) {
+    throw new Error('Both element and tagName are required.');
+  }
+
+  tagName = tagName.toUpperCase(); // HTML tag names are case-insensitive.
+
+  let currentElement: HTMLElement | null = element;
+
+  while (currentElement) {
+    if (currentElement.tagName === tagName) {
+      return currentElement;
+    }
+
+    // Traverse upwards, considering Shadow DOM boundaries
+    if (currentElement.parentElement) {
+      currentElement = currentElement.parentElement;
+    } else if (currentElement.getRootNode && currentElement.getRootNode() instanceof ShadowRoot) {
+      // @ts-expect-error host does exist
+      currentElement = currentElement.getRootNode().host; // Move to the host of the ShadowRoot
+    } else {
+      currentElement = null; // No more parents to check
+    }
+  }
+
+  return null; // No matching parent found
+}
+
+const getAttrs = (dom: HTMLElement) => {
+  return {
+    href: dom.getAttribute('href'),
+    target: dom.getAttribute('target') || '_blank',
+  };
+};
+
+export const LINK_SCHEMA = (askToNavigateCallback?: (view: EditorView) => Promise<boolean>): MarkSpec => ({
   attrs: {
-    href: {},
+    href: { default: null },
     target: { default: '_blank' }, // Standardmäßig neues Tab
   },
   inclusive: false, // Der Link hört auf, wenn Text ausgewählt wird und ein neuer Link gesetzt wird
   parseDOM: [
     {
       tag: 'a[href]',
-      getAttrs(dom: HTMLElement) {
-        return {
-          href: dom.getAttribute('href'),
-          target: dom.getAttribute('target') || '_blank',
-        };
-      },
+      getAttrs,
+    },
+    {
+      tag: 'x-link',
+      getAttrs,
+    },
+    {
+      tag: `${DesignSystem.prefix}-link`,
+      getAttrs,
     },
   ],
   toDOM(mark) {
-    const { href, target } = mark.attrs;
-    return [`${DesignSystem.prefix}-link`, { href, target }, 0];
+    return [`${DesignSystem.prefix}-link`, { href: mark.attrs.href, target: mark.attrs.target }, 0];
   },
-};
+});
 
 export const LINK_KEY = 'link';
 export const LINK = {
-- 
GitLab


From 35265b9d03039eb54f9bf22d8c82e2d42c9f8c32 Mon Sep 17 00:00:00 2001
From: michi <michael@adornis.de>
Date: Fri, 20 Dec 2024 17:27:20 +0000
Subject: [PATCH 09/14] fix: linting errors

---
 .../ObsidianLinkPlugin/helper/ensureLinkMark.ts        | 10 ----------
 .../ObsidianLinkPlugin/helper/getFilteredLinks.ts      |  2 +-
 .../ObsidianLinkPlugin/helper/getSearchInput.ts        |  2 +-
 .../ObsidianLinkPlugin/x-obsidian-link-selection.ts    |  6 +++---
 4 files changed, 5 insertions(+), 15 deletions(-)
 delete mode 100644 lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/ensureLinkMark.ts

diff --git a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/ensureLinkMark.ts b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/ensureLinkMark.ts
deleted file mode 100644
index 29ea1e6a9c..0000000000
--- a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/ensureLinkMark.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { EditorView } from 'prosemirror-view';
-
-export const ensureLinkMark = (view: EditorView) => {
-  const extensionManager = host.editor.extensionManager;
-  if (!extensionManager) throw new Error('extension manager not found');
-  const extensions = extensionManager.extensions;
-  if (!extensions || !Array.isArray(extensions)) throw new Error('invalid extensions');
-  const linkMark = extensions.find(e => e.type === 'mark' && e.name === 'link');
-  if (!linkMark) throw new Error('the link extension has to be added, when you want to use the obsidian links.');
-};
diff --git a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getFilteredLinks.ts b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getFilteredLinks.ts
index 6d73f2d28f..40ede2fdc0 100644
--- a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getFilteredLinks.ts
+++ b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getFilteredLinks.ts
@@ -1,4 +1,4 @@
-import type { LinkListItem } from '../ObsidianLink.js';
+import type { LinkListItem } from '../ObsidianLinkPlugin.js';
 
 export const getFilteredLinks = (linkList: LinkListItem[], search: string) => {
   const filteredLinks =
diff --git a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getSearchInput.ts b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getSearchInput.ts
index b53a7d4ce8..7612b53ea9 100644
--- a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getSearchInput.ts
+++ b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/getSearchInput.ts
@@ -1,4 +1,4 @@
-import type { EditorView } from '@tiptap/pm/view';
+import type { EditorView } from 'prosemirror-view';
 
 export const getSearchInput = (view: EditorView, text: string) => {
   const texts = getTextBeforeAndAfterInBrackets(view);
diff --git a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/x-obsidian-link-selection.ts b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/x-obsidian-link-selection.ts
index 3c70067531..e28740f6e9 100644
--- a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/x-obsidian-link-selection.ts
+++ b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/x-obsidian-link-selection.ts
@@ -2,13 +2,13 @@ import type { Styles } from '@adornis/ass/style.js';
 import type { Maybe } from '@adornis/base/utilTypes.js';
 import { ChemistryLitElement } from '@adornis/chemistry/chemistry-lit-element.js';
 import { css } from '@adornis/chemistry/directives/css.js';
-import { TextSelection } from '@tiptap/pm/state';
-import { EditorView } from '@tiptap/pm/view';
 import { html, type PropertyValueMap, type TemplateResult } from 'lit';
 import { customElement, property, state } from 'lit/decorators.js';
 import { repeat } from 'lit/directives/repeat.js';
+import { TextSelection } from 'prosemirror-state';
+import type { EditorView } from 'prosemirror-view';
 import { getTextBeforeAndAfterInBrackets } from './helper/getSearchInput.js';
-import type { LinkListItem } from './ObsidianLink.js';
+import type { LinkListItem } from './ObsidianLinkPlugin.js';
 
 @customElement('x-obsidian-link-selection')
 export class XObsidianLinkSelection extends ChemistryLitElement {
-- 
GitLab


From 827702e117136bf90e6d3316953a5e82c0ddd5bd Mon Sep 17 00:00:00 2001
From: michi <michael@adornis.de>
Date: Fri, 20 Dec 2024 17:27:31 +0000
Subject: [PATCH 10/14] fix: linting errors

---
 .../client/plugins/ObsidianLinkPlugin/helper/index.ts         | 4 ----
 .../plugins/ObsidianLinkPlugin/helper/parseLinkToText.ts      | 4 ++--
 .../plugins/ObsidianLinkPlugin/helper/parseTextToLink.ts      | 4 ++--
 3 files changed, 4 insertions(+), 8 deletions(-)

diff --git a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/index.ts b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/index.ts
index bb05bb9bc2..249f559383 100644
--- a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/index.ts
+++ b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/index.ts
@@ -1,4 +1,3 @@
-import { ensureLinkMark } from './ensureLinkMark.js';
 import { getFilteredLinks } from './getFilteredLinks.js';
 import { getSearchInput, getTextBeforeAndAfterInBrackets, hasInputAlreadyPipe } from './getSearchInput.js';
 import { parseLinkToTextNode } from './parseLinkToText.js';
@@ -13,9 +12,6 @@ export namespace GlobalLinkHelper {
     export const search = getSearchInput;
     export const isInputAlreadyPiped = hasInputAlreadyPipe;
   }
-  export namespace Ensure {
-    export const linkMark = ensureLinkMark;
-  }
   export const filterLinks = getFilteredLinks;
   export const textBeforeAndAfterInBrackets = getTextBeforeAndAfterInBrackets;
 }
diff --git a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseLinkToText.ts b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseLinkToText.ts
index 7f2f4c4589..e0ac2a8a8b 100644
--- a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseLinkToText.ts
+++ b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseLinkToText.ts
@@ -1,5 +1,5 @@
-import { TextSelection } from '@tiptap/pm/state';
-import type { EditorView } from '@tiptap/pm/view';
+import { TextSelection } from 'prosemirror-state';
+import type { EditorView } from 'prosemirror-view';
 import { findTextNodesWithPositions } from './parseTextToLink.js';
 
 export const parseLinkToTextNode = ({ view }: { view: EditorView }): boolean => {
diff --git a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseTextToLink.ts b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseTextToLink.ts
index ae967bec55..1999600f76 100644
--- a/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseTextToLink.ts
+++ b/lab/html-based-buildify/client/plugins/ObsidianLinkPlugin/helper/parseTextToLink.ts
@@ -1,5 +1,5 @@
-import { Node } from '@tiptap/pm/model';
-import type { EditorView } from '@tiptap/pm/view';
+import type { Node } from 'prosemirror-model';
+import type { EditorView } from 'prosemirror-view';
 
 export const findTextNodesWithPositions = (doc: Node) => {
   const node = doc;
-- 
GitLab


From 3ee916f27bb1faaa2229d61243237b53c8716405 Mon Sep 17 00:00:00 2001
From: michi <michael@adornis.de>
Date: Fri, 20 Dec 2024 17:31:28 +0000
Subject: [PATCH 11/14] fix: linting errors

---
 lab/html-based-buildify/client/types.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lab/html-based-buildify/client/types.ts b/lab/html-based-buildify/client/types.ts
index 9ecd6898a2..02e7ed1191 100644
--- a/lab/html-based-buildify/client/types.ts
+++ b/lab/html-based-buildify/client/types.ts
@@ -36,7 +36,7 @@ export interface IMenuItem {
 export interface IEditorConfigBase {
   menuItems?: IMenuItem[];
   plugins?: (schema: Schema) => Array<Plugin<any>>;
-  editor?: EditorFunc;
+  editor?: EditorFunc<any, any>;
 }
 
 export interface INodeConfig extends IEditorConfigBase {
-- 
GitLab


From 1279f60412fa1f017e3ac21a331c7a7baec10886 Mon Sep 17 00:00:00 2001
From: michi <michael@adornis.de>
Date: Fri, 20 Dec 2024 17:46:41 +0000
Subject: [PATCH 12/14] fix: linting errors

---
 .../helper/generateSchemaFromConfigs.ts       |  21 ++
 lab/html-based-buildify/client/marks/link.ts  | 233 ------------------
 .../client/marks/link/linkConfig.ts           |   3 +-
 lab/html-based-buildify/client/nodes/list.ts  |  67 ++---
 .../schema/defaultSchema.ts                   |  69 ------
 lab/html-based-buildify/schema/marks/link.ts  |   5 +-
 lab/html-based-buildify/schema/nodes/list.ts  |  40 ++-
 7 files changed, 69 insertions(+), 369 deletions(-)
 create mode 100644 lab/html-based-buildify/client/helper/generateSchemaFromConfigs.ts
 delete mode 100644 lab/html-based-buildify/client/marks/link.ts
 delete mode 100644 lab/html-based-buildify/schema/defaultSchema.ts

diff --git a/lab/html-based-buildify/client/helper/generateSchemaFromConfigs.ts b/lab/html-based-buildify/client/helper/generateSchemaFromConfigs.ts
new file mode 100644
index 0000000000..82fa5c8b13
--- /dev/null
+++ b/lab/html-based-buildify/client/helper/generateSchemaFromConfigs.ts
@@ -0,0 +1,21 @@
+import { Schema, type MarkSpec, type NodeSpec } from 'prosemirror-model';
+import type { IMarkConfig, INodeConfig } from '../types.js';
+
+export function generateSchema(configs: Array<IMarkConfig | INodeConfig>) {
+  const nodes: Record<string, NodeSpec> = {};
+  const marks: Record<string, MarkSpec> = {};
+
+  for (const config of configs) {
+    if ('mark' in config) {
+      marks[config.mark.name] = config.mark.schema;
+    }
+    if ('node' in config) {
+      nodes[config.node.name] = config.node.schema;
+    }
+  }
+
+  return new Schema({
+    nodes,
+    marks,
+  });
+}
diff --git a/lab/html-based-buildify/client/marks/link.ts b/lab/html-based-buildify/client/marks/link.ts
deleted file mode 100644
index 43a69e3efa..0000000000
--- a/lab/html-based-buildify/client/marks/link.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-// /* eslint-disable @typescript-eslint/no-non-null-assertion */
-// import type { Styles } from '@adornis/ass/style.js';
-// import { css } from '@adornis/chemistry/directives/css.js';
-// import '@adornis/chemistry/elements/components/x-icon';
-// import { XDialog } from '@adornis/dialog/x-dialog.js';
-// import { XNativeDialog } from '@adornis/dialog/x-native-dialog.js';
-// import { html } from 'lit';
-// import { customElement } from 'lit/decorators.js';
-// import type { MarkType } from 'prosemirror-model';
-// import { Mark as ProseMirrorMark } from 'prosemirror-model';
-// import { EditorState, NodeSelection, Plugin } from 'prosemirror-state';
-// import type { Decoration, EditorView } from 'prosemirror-view';
-// import { LINK_KEY, LINK_SCHEMA } from '../../schema/marks/link.js';
-// import { MenuItemGroup, type IMarkConfig } from '../types.js';
-
-// export const link: IMarkConfig = {
-//   mark: {
-//     name: LINK_KEY,
-//     schema: LINK_SCHEMA(async view => LinkMarkEditorDialog.showPopup()),
-//   },
-//   menuItems: [
-//     {
-//       group: MenuItemGroup.TEXT_STYLE,
-//       label: 'Link',
-//       icon: 'add_link',
-//       shouldVisualize: view => {
-//         // Aktiviert das Menü, wenn eine Selektion existiert und kein Link vorhanden ist
-//         const { state } = view;
-//         const { from, to } = state.selection;
-//         const linkMark = state.schema.marks[LINK_KEY];
-//         if (!linkMark) return false;
-
-//         return (
-//           !state.selection.empty &&
-//           !(state.selection instanceof NodeSelection) &&
-//           !state.doc.rangeHasMark(from, to, linkMark)
-//         );
-//       },
-//       run: view => {
-//         const { state, dispatch } = view;
-//         const linkMark = state.schema.marks[LINK_KEY];
-//         if (!linkMark) return false;
-
-//         const href = prompt('Enter the URL', 'http://');
-//         if (href) {
-//           toggleLinkMark(linkMark, { href })(state, dispatch);
-//         }
-//       },
-//     },
-//     {
-//       group: MenuItemGroup.TEXT_STYLE,
-//       label: 'Link löschen',
-//       icon: 'link_off',
-//       shouldVisualize: (view: EditorView) => {
-//         const { state } = view;
-//         const linkMark = state.schema.marks[LINK_KEY];
-//         if (!linkMark) return false;
-//         // Aktiviert das Menü, wenn der Cursor in einem Link ist oder ein Bereich ausgewählt ist
-//         const { from, empty } = state.selection;
-//         if (empty) {
-//           // Prüft, ob der Cursor in einer Mark ist
-//           return !!linkMark.isInSet(state.storedMarks || state.doc.resolve(from).marks());
-//         } else {
-//           // Prüft, ob eine Selektion einen Link enthält
-//           const { from, to } = state.selection;
-//           return !!state.doc.rangeHasMark(from, to, linkMark);
-//         }
-//       },
-//       run: async view => {
-//         return removeLink(view);
-//       },
-//     },
-//   ],
-//   plugins: (schema) => [
-//     new Plugin<any>({
-//         props: {
-//           nodeViews: {
-//             link: ,
-//           },
-//         },
-//       })
-//   ]
-// };
-
-// type GetPos = (() => number) | boolean;
-
-// class LinkMarkView {
-//   mark: ProseMirrorMark;
-//   view: EditorView;
-//   getPos: GetPos;
-//   decorations: Decoration[];
-//   dom: HTMLElement;
-
-//   constructor(
-//     mark: ProseMirrorMark,
-//     view: EditorView,
-//     getPos: GetPos,
-//     decorations: Decoration[]
-//   ) {
-//     this.mark = mark;
-//     this.view = view;
-//     this.getPos = getPos;
-//     this.decorations = decorations;
-
-//     this.dom = document.createElement("a");
-//     this.dom.href = mark.attrs.href;
-//     this.dom.textContent = "Link"; // Placeholder; content will be rendered by ProseMirror.
-//     this.dom.classList.add("link-mark");
-
-//     this.dom.addEventListener("click", this.handleClick.bind(this));
-//   }
-
-//   handleClick(event: MouseEvent): void {
-//     event.preventDefault(); // Prevent default link navigation
-
-//     const linkHref = this.mark.attrs.href;
-
-//     // Example actions: show a modal to edit or delete the link
-//     const newHref = prompt("Edit Link URL", linkHref);
-
-//     if (newHref === null) {
-//       // User cancelled
-//       return;
-//     } else if (newHref === "") {
-//       // Delete link
-//       const pos = typeof this.getPos === "function" ? this.getPos() : 0;
-//       this.view.dispatch(
-//         this.view.state.tr.removeMark(
-//           pos,
-//           pos + this.mark.attrs.length,
-//           this.view.state.schema.marks.link
-//         )
-//       );
-//     } else {
-//       // Update link
-//       const pos = typeof this.getPos === "function" ? this.getPos() : 0;
-//       this.view.dispatch(
-//         this.view.state.tr.addMark(
-//           pos,
-//           pos + this.mark.attrs.length,
-//           this.view.state.schema.marks.link.create({ href: newHref })
-//         )
-//       );
-//     }
-//   }
-
-//   update(mark: ProseMirrorMark): boolean {
-//     if (mark.type !== this.mark.type) return false;
-//     this.mark = mark;
-//     this.dom.href = mark.attrs.href;
-//     return true;
-//   }
-
-//   destroy(): void {
-//     this.dom.removeEventListener("click", this.handleClick);
-//     this.dom = null;
-//   }
-// }
-
-// export function linkMarkView() {
-//   return (
-//     mark: ProseMirrorMark,
-//     view: EditorView,
-//     getPos: GetPos,
-//     decorations: Decoration[]
-//   ): LinkMarkView => {
-//     return new LinkMarkView(mark, view, getPos, decorations);
-//   };
-// }
-
-// async function removeLink(view: EditorView) {
-//   if (!(await XDialog.confirm('Wollen Sie diesen Link wirklich entfernen?'))) return;
-//   const { state, dispatch } = view;
-//   const { from, to, empty } = state.selection;
-//   const linkMark = state.schema.marks[LINK_KEY];
-//   if (!linkMark) return false;
-
-//   if (empty) {
-//     // Wenn der Cursor in einem Link ist, finde die gesamte Mark und entferne sie
-//     const $pos = state.doc.resolve(from);
-//     const isInSet = linkMark.isInSet($pos.marks());
-//     if (isInSet) {
-//       const linkStart = $pos.nodeBefore ? from - $pos.nodeBefore.nodeSize : from;
-//       const linkEnd = $pos.nodeAfter ? from + $pos.nodeAfter.nodeSize : from;
-//       dispatch(state.tr.removeMark(linkStart, linkEnd, linkMark));
-//     }
-//   } else {
-//     // Entfernt die Markierung im ausgewählten Bereich
-//     dispatch(state.tr.removeMark(from, to, linkMark));
-//   }
-// }
-
-// // Toggle-Logic Command
-// function toggleLinkMark(markType: MarkType, attrs: {}) {
-//   return (state: EditorState, dispatch) => {
-//     const { from, to } = state.selection;
-//     const hasMark = state.doc.rangeHasMark(from, to, markType);
-
-//     if (hasMark) {
-//       // Wenn der Link existiert, entfernen
-//       dispatch(state.tr.removeMark(from, to, markType));
-//     } else {
-//       // Wenn kein Link existiert, hinzufügen
-//       dispatch(state.tr.addMark(from, to, markType.create(attrs)));
-//     }
-
-//     return true;
-//   };
-// }
-
-// @customElement('link-mark-editor-dialog')
-// export class LinkMarkEditorDialog extends XNativeDialog<void> {
-//   static override get element_name(): string {
-//     return 'link-mark-editor-dialog';
-//   }
-
-//   override content() {
-//     return html` <x-flex ${css({ gap: '24px', padding: '16px' })}> better link edit prompt </x-flex> `;
-//   }
-
-//   override styles() {
-//     return [
-//       ...super.styles(),
-//       {
-//         dialog: {
-//           display: 'block',
-//           width: 'min(700px, 90%)',
-//           boxSizing: 'border-box',
-//         },
-//       },
-//     ] as Styles[];
-//   }
-// }
diff --git a/lab/html-based-buildify/client/marks/link/linkConfig.ts b/lab/html-based-buildify/client/marks/link/linkConfig.ts
index 52c3234a89..9b9f4fa695 100644
--- a/lab/html-based-buildify/client/marks/link/linkConfig.ts
+++ b/lab/html-based-buildify/client/marks/link/linkConfig.ts
@@ -6,13 +6,12 @@ import { EditorState, NodeSelection } from 'prosemirror-state';
 import type { EditorView } from 'prosemirror-view';
 import { LINK_KEY, LINK_SCHEMA } from '../../../schema/marks/link.js';
 import { MenuItemGroup, type IMarkConfig } from '../../types.js';
-import { LinkMarkEditorDialog } from '../link.js';
 import { linkPlugin } from './LinkMarkView.js';
 
 export const link: IMarkConfig = {
   mark: {
     name: LINK_KEY,
-    schema: LINK_SCHEMA(async view => LinkMarkEditorDialog.showPopup()),
+    schema: LINK_SCHEMA,
   },
   menuItems: [
     {
diff --git a/lab/html-based-buildify/client/nodes/list.ts b/lab/html-based-buildify/client/nodes/list.ts
index 50151ad296..488b45b9de 100644
--- a/lab/html-based-buildify/client/nodes/list.ts
+++ b/lab/html-based-buildify/client/nodes/list.ts
@@ -3,33 +3,27 @@ import { Schema } from 'prosemirror-model';
 import { liftListItem, sinkListItem, splitListItem, wrapInList } from 'prosemirror-schema-list';
 import { Plugin } from 'prosemirror-state';
 import type { EditorView } from 'prosemirror-view';
+import {
+  LIST_ITEM_KEY,
+  LIST_ITEM_SCHEMA,
+  LIST_KEY,
+  LIST_SCHEMA,
+  ORDERED_LIST_KEY,
+  ORDERED_LIST_SCHEMA,
+} from '../../schema/nodes/list.js';
 import type { INodeConfig } from '../types.js';
 
 export const listItem: INodeConfig = {
   node: {
-    name: 'list_item',
-    schema: {
-      group: 'block',
-      content: 'paragraph block*',
-      toDOM() {
-        return ['li', 0];
-      },
-      parseDOM: [{ tag: 'li' }],
-    },
+    name: LIST_ITEM_KEY,
+    schema: LIST_ITEM_SCHEMA,
   },
 };
 
 export const bulletList: INodeConfig = {
   node: {
-    name: 'bullet_list',
-    schema: {
-      group: 'block',
-      content: `${listItem.node.name}+`,
-      toDOM() {
-        return ['ul', 0];
-      },
-      parseDOM: [{ tag: 'ul' }],
-    },
+    name: LIST_KEY,
+    schema: LIST_SCHEMA,
   },
   plugins: (schema: Schema) => [
     new Plugin({
@@ -51,7 +45,10 @@ export const bulletList: INodeConfig = {
 
           // Erstelle eine Bullet-Liste
           dispatch(tr);
-          wrapInList(state.schema.nodes.bullet_list)(view.state, dispatch);
+
+          const bulletList = state.schema.nodes[LIST_KEY];
+          if (!bulletList) throw new Error('node not found in schema');
+          wrapInList(bulletList)(view.state, dispatch);
           return true;
         },
       },
@@ -62,23 +59,8 @@ export const bulletList: INodeConfig = {
 
 export const orderedList: INodeConfig = {
   node: {
-    name: 'ordered_list',
-    schema: {
-      group: 'block',
-      content: `${listItem.node.name}+`,
-      attrs: { order: { default: 1 } },
-      toDOM(node) {
-        return ['ol', { start: node.attrs.order }, 0];
-      },
-      parseDOM: [
-        {
-          tag: 'ol',
-          getAttrs: dom => ({
-            order: dom.hasAttribute('start') ? +dom.getAttribute('start')! : 1,
-          }),
-        },
-      ],
-    },
+    name: ORDERED_LIST_KEY,
+    schema: ORDERED_LIST_SCHEMA,
   },
   plugins: (schema: Schema) => [
     new Plugin({
@@ -99,7 +81,10 @@ export const orderedList: INodeConfig = {
 
           // Erstelle eine Ordered-Liste
           dispatch(tr);
-          wrapInList(state.schema.nodes.ordered_list)(view.state, dispatch);
+
+          const orderedList = state.schema.nodes[ORDERED_LIST_KEY];
+          if (!orderedList) throw new Error('ordered list not found in schema');
+          wrapInList(orderedList)(view.state, dispatch);
           return true;
         },
       },
@@ -109,9 +94,11 @@ export const orderedList: INodeConfig = {
 };
 
 const listKeymapPlugin = (schema: Schema) => {
+  const listItem = schema.nodes[LIST_ITEM_KEY];
+  if (!listItem) throw new Error('list item not found in schema');
   return keymap({
-    Enter: splitListItem(schema.nodes.list_item),
-    Tab: sinkListItem(schema.nodes.list_item),
-    'Shift-Tab': liftListItem(schema.nodes.list_item),
+    Enter: splitListItem(listItem),
+    Tab: sinkListItem(listItem),
+    'Shift-Tab': liftListItem(listItem),
   });
 };
diff --git a/lab/html-based-buildify/schema/defaultSchema.ts b/lab/html-based-buildify/schema/defaultSchema.ts
deleted file mode 100644
index b9747f1dba..0000000000
--- a/lab/html-based-buildify/schema/defaultSchema.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { Schema, type NodeSpec } from 'prosemirror-model';
-import { BOLD } from './marks/bold.js';
-import { COLOR } from './marks/color.js';
-import { FONT_SIZE } from './marks/font-size.js';
-import { ITALIC } from './marks/italic.js';
-import { LINK } from './marks/link.js';
-import { STRIKE_THROUGH } from './marks/strike-through.js';
-import { UNDERLINE } from './marks/underline.js';
-import { ACCORDEON } from './nodes/accordeon.js';
-import { BUTTON } from './nodes/button.js';
-import { DOC } from './nodes/doc.js';
-import { EXCALIDRAW } from './nodes/excalidraw.js';
-import { FILE } from './nodes/file.js';
-import { FLEX } from './nodes/flex.js';
-import { GRID } from './nodes/grid.js';
-import { HARD_BREAK } from './nodes/hard-break.js';
-import { HEADING } from './nodes/heading.js';
-import { ICON_TEXT } from './nodes/icon-text.js';
-import { ICON } from './nodes/icon.js';
-import { IMAGE } from './nodes/image.js';
-import { LIST } from './nodes/list.js';
-import { PARAGRAPH } from './nodes/paragraph.js';
-import { SECTION } from './nodes/section.js';
-import { SPACING } from './nodes/spacing.js';
-import { TAG } from './nodes/tag.js';
-import { TEXT } from './nodes/text.js';
-import { VIMEO } from './nodes/vimeo.js';
-import { YOUTUBE } from './nodes/youtube.js';
-
-export const DEFAULT_NODES: Record<string, NodeSpec> = { ...DOC, ...PARAGRAPH, ...TEXT };
-export const NODES: Record<string, NodeSpec> = {
-  ...ACCORDEON,
-  ...BUTTON,
-  ...EXCALIDRAW,
-  ...FILE(),
-  ...FLEX,
-  ...GRID,
-  ...HARD_BREAK,
-  ...HEADING,
-  ...ICON_TEXT,
-  ...ICON,
-  ...IMAGE,
-  ...LIST,
-  ...SECTION,
-  ...SPACING,
-  ...TAG,
-  ...VIMEO,
-  ...YOUTUBE,
-};
-
-export const MARKS = {
-  ...BOLD,
-  ...COLOR,
-  ...FONT_SIZE,
-  ...ITALIC,
-  ...LINK,
-  ...STRIKE_THROUGH,
-  ...UNDERLINE,
-};
-
-export const schema = new Schema({
-  marks: {
-    ...MARKS,
-  },
-  nodes: {
-    ...DEFAULT_NODES,
-    ...NODES,
-  },
-});
diff --git a/lab/html-based-buildify/schema/marks/link.ts b/lab/html-based-buildify/schema/marks/link.ts
index e36e0a8939..9dc7595b42 100644
--- a/lab/html-based-buildify/schema/marks/link.ts
+++ b/lab/html-based-buildify/schema/marks/link.ts
@@ -1,6 +1,5 @@
 import { DesignSystem } from '@adornis/chemistry/elements/theming/design.js';
 import type { MarkSpec } from 'prosemirror-model';
-import { EditorView } from 'prosemirror-view';
 
 /**
  * Finds the closest parent element with the specified tag name.
@@ -44,7 +43,7 @@ const getAttrs = (dom: HTMLElement) => {
   };
 };
 
-export const LINK_SCHEMA = (askToNavigateCallback?: (view: EditorView) => Promise<boolean>): MarkSpec => ({
+export const LINK_SCHEMA: MarkSpec = {
   attrs: {
     href: { default: null },
     target: { default: '_blank' }, // Standardmäßig neues Tab
@@ -67,7 +66,7 @@ export const LINK_SCHEMA = (askToNavigateCallback?: (view: EditorView) => Promis
   toDOM(mark) {
     return [`${DesignSystem.prefix}-link`, { href: mark.attrs.href, target: mark.attrs.target }, 0];
   },
-});
+};
 
 export const LINK_KEY = 'link';
 export const LINK = {
diff --git a/lab/html-based-buildify/schema/nodes/list.ts b/lab/html-based-buildify/schema/nodes/list.ts
index 6bc8099488..fa5b2bf027 100644
--- a/lab/html-based-buildify/schema/nodes/list.ts
+++ b/lab/html-based-buildify/schema/nodes/list.ts
@@ -1,44 +1,40 @@
 import type { NodeSpec } from 'prosemirror-model';
 
-const LIST_ITEM_SCHEMA: NodeSpec = {
-  content: 'block',
-  atom: true,
-  parseDOM: [{ tag: 'li' }],
-  group: 'list_item',
+export const LIST_ITEM_KEY = 'list_item';
+export const LIST_KEY = 'list';
+export const ORDERED_LIST_KEY = 'ordered_list';
+
+export const LIST_ITEM_SCHEMA: NodeSpec = {
+  group: 'block',
+  content: 'paragraph block*',
   toDOM() {
     return ['li', 0];
   },
+  parseDOM: [{ tag: 'li' }],
 };
 
-const LIST_SCHEMA: NodeSpec = {
+export const LIST_SCHEMA: NodeSpec = {
   group: 'block',
-  content: 'list_item+',
-  parseDOM: [{ tag: 'ul' }],
+  content: `${LIST_ITEM_KEY}+`,
   toDOM() {
     return ['ul', 0];
   },
+  parseDOM: [{ tag: 'ul' }],
 };
 
-const ORDERED_LIST_SCHEMA: NodeSpec = {
-  content: 'list_item+',
+export const ORDERED_LIST_SCHEMA: NodeSpec = {
   group: 'block',
+  content: `${LIST_ITEM_KEY}+`,
   attrs: { order: { default: 1 } },
+  toDOM(node) {
+    return ['ol', { start: node.attrs.order }, 0];
+  },
   parseDOM: [
     {
       tag: 'ol',
-      getAttrs: (dom: HTMLElement) => ({
-        order: dom.hasAttribute('start') ? Number(dom.getAttribute('start')) : 1,
+      getAttrs: dom => ({
+        order: dom.hasAttribute('start') ? +dom.getAttribute('start')! : 1,
       }),
     },
   ],
-  toDOM: node => ['ol', { start: node.attrs.order }, 0],
-};
-
-export const LIST_ITEM_KEY = 'list-item';
-export const LIST_KEY = 'list';
-export const ORDERED_LIST_KEY = 'ordered-list';
-export const LIST: Record<string, NodeSpec> = {
-  [LIST_ITEM_KEY]: LIST_ITEM_SCHEMA,
-  [LIST_KEY]: LIST_SCHEMA,
-  [ORDERED_LIST_KEY]: ORDERED_LIST_SCHEMA,
 };
-- 
GitLab


From 7293e477d6a5126827b02671a2277a609236b4d5 Mon Sep 17 00:00:00 2001
From: michi <michael@adornis.de>
Date: Fri, 20 Dec 2024 17:50:08 +0000
Subject: [PATCH 13/14] fix: linting errors

---
 .../client/marks/link/LinkMarkView.ts         |  3 +-
 lab/html-based-buildify/client/util.ts        | 48 -------------------
 lab/html-based-buildify/schema/marks/link.ts  | 35 --------------
 3 files changed, 2 insertions(+), 84 deletions(-)

diff --git a/lab/html-based-buildify/client/marks/link/LinkMarkView.ts b/lab/html-based-buildify/client/marks/link/LinkMarkView.ts
index 988c959b1b..41f0aadc9c 100644
--- a/lab/html-based-buildify/client/marks/link/LinkMarkView.ts
+++ b/lab/html-based-buildify/client/marks/link/LinkMarkView.ts
@@ -57,12 +57,13 @@ class LinkMarkView {
   update(mark: ProseMirrorMark): boolean {
     if (mark.type !== this.mark.type) return false;
     this.mark = mark;
-    this.dom.href = mark.attrs.href;
+    this.dom.setAttribute('href', mark.attrs.href);
     return true;
   }
 
   destroy(): void {
     this.dom.removeEventListener('click', this.handleClick);
+    // @ts-expect-error still works
     this.dom = null;
   }
 }
diff --git a/lab/html-based-buildify/client/util.ts b/lab/html-based-buildify/client/util.ts
index 5290df08d6..1498f32875 100644
--- a/lab/html-based-buildify/client/util.ts
+++ b/lab/html-based-buildify/client/util.ts
@@ -2,8 +2,6 @@ import { xComponents } from '@adornis/chemistry/elements/x-components.js';
 import { render, type TemplateResult } from 'lit';
 import type { DOMOutputSpec, MarkType, Node as ProsemirrorNode } from 'prosemirror-model';
 import { NodeSelection, type EditorState, type Selection, type Transaction } from 'prosemirror-state';
-import type { Decoration, DecorationSource, EditorView, NodeView } from 'prosemirror-view';
-import type { SchemaNodeDefinition } from './types.js';
 
 //
 // public helpers
@@ -100,49 +98,3 @@ export function isInHeading(state: EditorState): boolean {
 export function isInText(state: EditorState): boolean {
   return isInParagraph(state) || isInHeading(state);
 }
-
-export function createNodeViewForNodeSchema(
-  nodeSchema: SchemaNodeDefinition<any>,
-  editConfig?: (
-    nodeView: NodeView,
-    { node, view, getPos }: { node: ProsemirrorNode; view: EditorView; getPos: () => number | undefined },
-  ) => void,
-) {
-  return (
-    node: ProsemirrorNode,
-    view: EditorView,
-    getPos: () => number | undefined,
-    decorations: readonly Decoration[],
-    innerDecorations: DecorationSource,
-  ): NodeView => {
-    const config = nodeSchema.extension.toDOM?.(node) as Record<string, any> | undefined;
-    if (!config) throw new Error('node config not found');
-
-    const dom = config.dom ?? config;
-
-    dom.addEventListener('click', () => {
-      if (!(view.state.selection instanceof NodeSelection)) return;
-      view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, getPos() ?? 0)));
-    });
-
-    const nodeView: NodeView = {
-      dom,
-      contentDOM: config.contentDOM,
-      selectNode: () => {
-        dom.classList.add('focus-node');
-        console.log('node selected');
-      },
-      deselectNode: () => {
-        dom.classList.remove('focus-node');
-        console.log('node deselected');
-      },
-    };
-
-    if (editConfig) {
-      editConfig(nodeView, { node, view, getPos });
-      console.log('new node view: ', nodeView);
-    }
-
-    return nodeView;
-  };
-}
diff --git a/lab/html-based-buildify/schema/marks/link.ts b/lab/html-based-buildify/schema/marks/link.ts
index 9dc7595b42..e50b29b675 100644
--- a/lab/html-based-buildify/schema/marks/link.ts
+++ b/lab/html-based-buildify/schema/marks/link.ts
@@ -1,41 +1,6 @@
 import { DesignSystem } from '@adornis/chemistry/elements/theming/design.js';
 import type { MarkSpec } from 'prosemirror-model';
 
-/**
- * Finds the closest parent element with the specified tag name.
- *
- * @param {HTMLElement} element - The starting HTML element.
- * @param {string} tagName - The tag name of the desired parent (case-insensitive).
- * @returns {HTMLElement|null} - The matching parent element or null if not found.
- */
-function findParentByTag(element: HTMLElement, tagName: string) {
-  if (!element || !tagName) {
-    throw new Error('Both element and tagName are required.');
-  }
-
-  tagName = tagName.toUpperCase(); // HTML tag names are case-insensitive.
-
-  let currentElement: HTMLElement | null = element;
-
-  while (currentElement) {
-    if (currentElement.tagName === tagName) {
-      return currentElement;
-    }
-
-    // Traverse upwards, considering Shadow DOM boundaries
-    if (currentElement.parentElement) {
-      currentElement = currentElement.parentElement;
-    } else if (currentElement.getRootNode && currentElement.getRootNode() instanceof ShadowRoot) {
-      // @ts-expect-error host does exist
-      currentElement = currentElement.getRootNode().host; // Move to the host of the ShadowRoot
-    } else {
-      currentElement = null; // No more parents to check
-    }
-  }
-
-  return null; // No matching parent found
-}
-
 const getAttrs = (dom: HTMLElement) => {
   return {
     href: dom.getAttribute('href'),
-- 
GitLab


From 5c98e5d2037f176e8f215c564e4aa4679bd5b404 Mon Sep 17 00:00:00 2001
From: michi <michael@adornis.de>
Date: Fri, 20 Dec 2024 17:53:30 +0000
Subject: [PATCH 14/14] fix: linting errors

---
 lab/wiki/client/x-wiki-editor.ts | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/lab/wiki/client/x-wiki-editor.ts b/lab/wiki/client/x-wiki-editor.ts
index 602a418424..52888cb2ec 100644
--- a/lab/wiki/client/x-wiki-editor.ts
+++ b/lab/wiki/client/x-wiki-editor.ts
@@ -12,7 +12,10 @@ import { XDialog } from '@adornis/dialog/x-dialog.js';
 import '@adornis/entity-picker/client/x-entity-picker.js';
 import { AdornisFilter } from '@adornis/filter/AdornisFilter.js';
 import { StringAspect } from '@adornis/filter/aspect-schemas/StringAspect.js';
-import { obsidianLinkPlugin } from '@adornis/html-based-buildify/client/plugins/ObsidianLinkPlugin/ObsidianLinkPlugin.js';
+import {
+  obsidianLinkPlugin,
+  type LinkListItem,
+} from '@adornis/html-based-buildify/client/plugins/ObsidianLinkPlugin/ObsidianLinkPlugin.js';
 import '@adornis/html-based-buildify/client/prosemirror-editor';
 import { startedPack } from '@adornis/html-based-buildify/client/starterPack.js';
 import type { IMarkConfig, INodeConfig } from '@adornis/html-based-buildify/client/types.js';
@@ -693,7 +696,10 @@ export class XWikiEditor extends ChemistryLitElement {
                       entry.title?.toLowerCase().includes(search.toLowerCase()) ||
                       search.toLowerCase().includes(entry._id?.toLowerCase()),
                   )
-                  .map(entry => ({ href: `${rootUrl}wiki/${entry._id}|${entry.title}`, name: entry.title }));
+                  .map(
+                    entry =>
+                      ({ href: `${rootUrl}wiki/${entry._id}|${entry.title}`, name: entry.title } as LinkListItem),
+                  );
               }),
             ]}
             @value-changed=${async e => {
-- 
GitLab