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