diff --git a/app/Util/HtmlDescriptionFilter.php b/app/Util/HtmlDescriptionFilter.php
index d4f7d2c8f..1baa11ffc 100644
--- a/app/Util/HtmlDescriptionFilter.php
+++ b/app/Util/HtmlDescriptionFilter.php
@@ -19,7 +19,7 @@ class HtmlDescriptionFilter
*/
protected static array $allowedAttrsByElements = [
'p' => [],
- 'a' => ['href', 'title', 'target'],
+ 'a' => ['href', 'title', 'target', 'data-mention-user-id'],
'ol' => [],
'ul' => [],
'li' => [],
diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts
index 13cc350fa..273657c47 100644
--- a/resources/js/wysiwyg/index.ts
+++ b/resources/js/wysiwyg/index.ts
@@ -22,12 +22,13 @@ import {registerKeyboardHandling} from "./services/keyboard-handling";
import {registerAutoLinks} from "./services/auto-links";
import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "./ui/defaults/toolbars";
import {modals} from "./ui/defaults/modals";
-import {CodeBlockDecorator} from "./ui/decorators/code-block";
-import {DiagramDecorator} from "./ui/decorators/diagram";
+import {CodeBlockDecorator} from "./ui/decorators/CodeBlockDecorator";
+import {DiagramDecorator} from "./ui/decorators/DiagramDecorator";
import {registerMouseHandling} from "./services/mouse-handling";
import {registerSelectionHandling} from "./services/selection-handling";
import {EditorApi} from "./api/api";
import {registerMentions} from "./services/mentions";
+import {MentionDecorator} from "./ui/decorators/MentionDecorator";
const theme = {
text: {
@@ -151,7 +152,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent:
});
// TODO - Dedupe this with the basic editor instance
- // Changed elements: namespace, registerMentions, toolbar, public event usage
+ // Changed elements: namespace, registerMentions, toolbar, public event usage, mentioned decorator
const context: EditorUiContext = buildEditorUI(container, editor, options);
editor.setRootElement(context.editorDOM);
@@ -168,6 +169,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent:
context.manager.registerContextToolbar('link', contextToolbars.link);
context.manager.registerModal('link', modals.link);
context.manager.onTeardown(editorTeardown);
+ context.manager.registerDecoratorType('mention', MentionDecorator);
setEditorContentFromHtml(editor, htmlContent);
diff --git a/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts b/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts
index a57173208..62213a42c 100644
--- a/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts
+++ b/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts
@@ -1,20 +1,21 @@
import {
+ DecoratorNode,
DOMConversion,
- DOMConversionMap, DOMConversionOutput,
+ DOMConversionMap, DOMConversionOutput, DOMExportOutput,
type EditorConfig,
- ElementNode,
LexicalEditor, LexicalNode,
- SerializedElementNode,
+ SerializedLexicalNode,
Spread
} from "lexical";
+import {EditorDecoratorAdapter} from "../../ui/framework/decorator";
export type SerializedMentionNode = Spread<{
user_id: number;
user_name: string;
user_slug: string;
-}, SerializedElementNode>
+}, SerializedLexicalNode>
-export class MentionNode extends ElementNode {
+export class MentionNode extends DecoratorNode {
__user_id: number = 0;
__user_name: string = '';
__user_slug: string = '';
@@ -22,7 +23,6 @@ export class MentionNode extends ElementNode {
static getType(): string {
return 'mention';
}
-
static clone(node: MentionNode): MentionNode {
const newNode = new MentionNode(node.__key);
newNode.__user_id = node.__user_id;
@@ -42,12 +42,24 @@ export class MentionNode extends ElementNode {
return true;
}
+ isParentRequired(): boolean {
+ return true;
+ }
+
+ decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter {
+ return {
+ type: 'mention',
+ getNode: () => this,
+ };
+ }
+
createDOM(_config: EditorConfig, _editor: LexicalEditor) {
const element = document.createElement('a');
element.setAttribute('target', '_blank');
- element.setAttribute('href', window.baseUrl('/users/' + this.__user_slug));
- element.setAttribute('data-user-mention-id', String(this.__user_id));
+ element.setAttribute('href', window.baseUrl('/user/' + this.__user_slug));
+ element.setAttribute('data-mention-user-id', String(this.__user_id));
element.textContent = '@' + this.__user_name;
+ // element.setAttribute('contenteditable', 'false');
return element;
}
@@ -55,21 +67,30 @@ export class MentionNode extends ElementNode {
return prevNode.__user_id !== this.__user_id;
}
+ exportDOM(editor: LexicalEditor): DOMExportOutput {
+ const element = this.createDOM(editor._config, editor);
+ // element.removeAttribute('contenteditable');
+ return {element};
+ }
+
static importDOM(): DOMConversionMap|null {
return {
a(node: HTMLElement): DOMConversion|null {
- if (node.hasAttribute('data-user-mention-id')) {
+ if (node.hasAttribute('data-mention-user-id')) {
return {
conversion: (element: HTMLElement): DOMConversionOutput|null => {
const node = new MentionNode();
node.setUserDetails(
- Number(element.getAttribute('data-user-mention-id') || '0'),
+ Number(element.getAttribute('data-mention-user-id') || '0'),
element.innerText.replace(/^@/, ''),
element.getAttribute('href')?.split('/user/')[1] || ''
);
return {
node,
+ after(childNodes): LexicalNode[] {
+ return [];
+ }
};
},
priority: 4,
@@ -82,7 +103,6 @@ export class MentionNode extends ElementNode {
exportJSON(): SerializedMentionNode {
return {
- ...super.exportJSON(),
type: 'mention',
version: 1,
user_id: this.__user_id,
diff --git a/resources/js/wysiwyg/services/mentions.ts b/resources/js/wysiwyg/services/mentions.ts
index e41457b8a..87a1f4b8b 100644
--- a/resources/js/wysiwyg/services/mentions.ts
+++ b/resources/js/wysiwyg/services/mentions.ts
@@ -1,14 +1,11 @@
import {
- $createTextNode,
$getSelection, $isRangeSelection,
COMMAND_PRIORITY_NORMAL, RangeSelection, TextNode
} from "lexical";
import {KEY_AT_COMMAND} from "lexical/LexicalCommands";
import {$createMentionNode} from "@lexical/link/LexicalMentionNode";
-import {el, htmlToDom} from "../utils/dom";
import {EditorUiContext} from "../ui/framework/core";
-import {debounce} from "../../services/util";
-import {removeLoading, showLoading} from "../../services/dom";
+import {MentionDecorator} from "../ui/decorators/MentionDecorator";
function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection) {
@@ -32,126 +29,13 @@ function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection
const mention = $createMentionNode(0, '', '');
newNode.replace(mention);
- mention.select();
-
- const revertEditorMention = () => {
- context.editor.update(() => {
- const text = $createTextNode('@');
- mention.replace(text);
- text.selectEnd();
- });
- };
requestAnimationFrame(() => {
- const mentionDOM = context.editor.getElementByKey(mention.getKey());
- if (!mentionDOM) {
- revertEditorMention();
- return;
+ const mentionDecorator = context.manager.getDecoratorByNodeKey(mention.getKey());
+ if (mentionDecorator instanceof MentionDecorator) {
+ mentionDecorator.showSelection()
}
-
- const selectList = buildAndShowUserSelectorAtElement(context, mentionDOM);
- handleUserListLoading(selectList);
- handleUserSelectCancel(context, selectList, revertEditorMention);
});
-
-
- // TODO - On enter, replace with name mention element.
-}
-
-function handleUserSelectCancel(context: EditorUiContext, selectList: HTMLElement, revertEditorMention: () => void) {
- const controller = new AbortController();
-
- const onCancel = () => {
- revertEditorMention();
- selectList.remove();
- controller.abort();
- }
-
- selectList.addEventListener('keydown', (event) => {
- if (event.key === 'Escape') {
- onCancel();
- }
- }, {signal: controller.signal});
-
- const input = selectList.querySelector('input') as HTMLInputElement;
- input.addEventListener('keydown', (event) => {
- if (event.key === 'Backspace' && input.value === '') {
- onCancel();
- event.preventDefault();
- event.stopPropagation();
- }
- }, {signal: controller.signal});
-
- context.editorDOM.addEventListener('click', (event) => {
- onCancel()
- }, {signal: controller.signal});
- context.editorDOM.addEventListener('keydown', (event) => {
- onCancel();
- }, {signal: controller.signal});
-}
-
-function handleUserListLoading(selectList: HTMLElement) {
- const cache = new Map();
-
- const updateUserList = async (searchTerm: string) => {
- // Empty list
- for (const child of [...selectList.children].slice(1)) {
- child.remove();
- }
-
- // Fetch new content
- let responseHtml = '';
- if (cache.has(searchTerm)) {
- responseHtml = cache.get(searchTerm) || '';
- } else {
- const loadingWrap = el('li');
- showLoading(loadingWrap);
- selectList.appendChild(loadingWrap);
-
- const resp = await window.$http.get(`/search/users/mention?search=${searchTerm}`);
- responseHtml = resp.data as string;
- cache.set(searchTerm, responseHtml);
- loadingWrap.remove();
- }
-
- const doc = htmlToDom(responseHtml);
- const toInsert = doc.querySelectorAll('li');
- for (const listEl of toInsert) {
- const adopted = window.document.adoptNode(listEl) as HTMLElement;
- selectList.appendChild(adopted);
- }
-
- };
-
- // Initial load
- updateUserList('');
-
- const input = selectList.querySelector('input') as HTMLInputElement;
- const updateUserListDebounced = debounce(updateUserList, 200, false);
- input.addEventListener('input', () => {
- const searchTerm = input.value;
- updateUserListDebounced(searchTerm);
- });
-}
-
-function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM: HTMLElement): HTMLElement {
- const searchInput = el('input', {type: 'text'});
- const searchItem = el('li', {}, [searchInput]);
- const userSelect = el('ul', {class: 'suggestion-box dropdown-menu'}, [searchItem]);
-
- context.containerDOM.appendChild(userSelect);
-
- userSelect.style.display = 'block';
- userSelect.style.top = '0';
- userSelect.style.left = '0';
- const mentionPos = mentionDOM.getBoundingClientRect();
- const userSelectPos = userSelect.getBoundingClientRect();
- userSelect.style.top = `${mentionPos.bottom - userSelectPos.top + 3}px`;
- userSelect.style.left = `${mentionPos.left - userSelectPos.left}px`;
-
- searchInput.focus();
-
- return userSelect;
}
export function registerMentions(context: EditorUiContext): () => void {
diff --git a/resources/js/wysiwyg/ui/decorators/code-block.ts b/resources/js/wysiwyg/ui/decorators/CodeBlockDecorator.ts
similarity index 84%
rename from resources/js/wysiwyg/ui/decorators/code-block.ts
rename to resources/js/wysiwyg/ui/decorators/CodeBlockDecorator.ts
index daae32e19..d95185e0b 100644
--- a/resources/js/wysiwyg/ui/decorators/code-block.ts
+++ b/resources/js/wysiwyg/ui/decorators/CodeBlockDecorator.ts
@@ -14,7 +14,7 @@ export class CodeBlockDecorator extends EditorDecorator {
// @ts-ignore
protected editor: any = null;
- setup(context: EditorUiContext, element: HTMLElement) {
+ setup(element: HTMLElement) {
const codeNode = this.getNode() as CodeBlockNode;
const preEl = element.querySelector('pre');
if (!preEl) {
@@ -35,24 +35,24 @@ export class CodeBlockDecorator extends EditorDecorator {
element.addEventListener('click', event => {
requestAnimationFrame(() => {
- context.editor.update(() => {
+ this.context.editor.update(() => {
$selectSingleNode(this.getNode());
});
});
});
element.addEventListener('dblclick', event => {
- context.editor.getEditorState().read(() => {
- $openCodeEditorForNode(context.editor, (this.getNode() as CodeBlockNode));
+ this.context.editor.getEditorState().read(() => {
+ $openCodeEditorForNode(this.context.editor, (this.getNode() as CodeBlockNode));
});
});
const selectionChange = (selection: BaseSelection|null): void => {
element.classList.toggle('selected', $selectionContainsNode(selection, codeNode));
};
- context.manager.onSelectionChange(selectionChange);
+ this.context.manager.onSelectionChange(selectionChange);
this.onDestroy(() => {
- context.manager.offSelectionChange(selectionChange);
+ this.context.manager.offSelectionChange(selectionChange);
});
// @ts-ignore
@@ -89,11 +89,11 @@ export class CodeBlockDecorator extends EditorDecorator {
}
}
- render(context: EditorUiContext, element: HTMLElement): void {
+ render(element: HTMLElement): void {
if (this.completedSetup) {
this.update();
} else {
- this.setup(context, element);
+ this.setup(element);
}
}
}
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/decorators/diagram.ts b/resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts
similarity index 70%
rename from resources/js/wysiwyg/ui/decorators/diagram.ts
rename to resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts
index 52a73ad72..e46dcc312 100644
--- a/resources/js/wysiwyg/ui/decorators/diagram.ts
+++ b/resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts
@@ -9,33 +9,33 @@ import {$openDrawingEditorForNode} from "../../utils/diagrams";
export class DiagramDecorator extends EditorDecorator {
protected completedSetup: boolean = false;
- setup(context: EditorUiContext, element: HTMLElement) {
+ setup(element: HTMLElement) {
const diagramNode = this.getNode();
element.classList.add('editor-diagram');
- context.editor.registerCommand(CLICK_COMMAND, (event: MouseEvent): boolean => {
+ this.context.editor.registerCommand(CLICK_COMMAND, (event: MouseEvent): boolean => {
if (!element.contains(event.target as HTMLElement)) {
return false;
}
- context.editor.update(() => {
+ this.context.editor.update(() => {
$selectSingleNode(this.getNode());
});
return true;
}, COMMAND_PRIORITY_NORMAL);
element.addEventListener('dblclick', event => {
- context.editor.getEditorState().read(() => {
- $openDrawingEditorForNode(context, (this.getNode() as DiagramNode));
+ this.context.editor.getEditorState().read(() => {
+ $openDrawingEditorForNode(this.context, (this.getNode() as DiagramNode));
});
});
const selectionChange = (selection: BaseSelection|null): void => {
element.classList.toggle('selected', $selectionContainsNode(selection, diagramNode));
};
- context.manager.onSelectionChange(selectionChange);
+ this.context.manager.onSelectionChange(selectionChange);
this.onDestroy(() => {
- context.manager.offSelectionChange(selectionChange);
+ this.context.manager.offSelectionChange(selectionChange);
});
this.completedSetup = true;
@@ -45,11 +45,11 @@ export class DiagramDecorator extends EditorDecorator {
//
}
- render(context: EditorUiContext, element: HTMLElement): void {
+ render(element: HTMLElement): void {
if (this.completedSetup) {
this.update();
} else {
- this.setup(context, element);
+ this.setup(element);
}
}
}
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts b/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts
new file mode 100644
index 000000000..df2d0a227
--- /dev/null
+++ b/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts
@@ -0,0 +1,172 @@
+import {EditorDecorator} from "../framework/decorator";
+import {EditorUiContext} from "../framework/core";
+import {el, htmlToDom} from "../../utils/dom";
+import {showLoading} from "../../../services/dom";
+import {MentionNode} from "@lexical/link/LexicalMentionNode";
+import {debounce} from "../../../services/util";
+import {$createTextNode} from "lexical";
+
+function userClickHandler(onSelect: (id: number, name: string, slug: string)=>void): (event: PointerEvent) => void {
+ return (event: PointerEvent) => {
+ const userItem = (event.target as HTMLElement).closest('a[data-id]') as HTMLAnchorElement | null;
+ if (!userItem) {
+ return;
+ }
+
+ const id = Number(userItem.dataset.id || '0');
+ const name = userItem.dataset.name || '';
+ const slug = userItem.dataset.slug || '';
+
+ onSelect(id, name, slug);
+ event.preventDefault();
+ };
+}
+
+function handleUserSelectCancel(context: EditorUiContext, selectList: HTMLElement, controller: AbortController, onCancel: () => void): void {
+ selectList.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ onCancel();
+ }
+ }, {signal: controller.signal});
+
+ const input = selectList.querySelector('input') as HTMLInputElement;
+ input.addEventListener('keydown', (event) => {
+ if (event.key === 'Backspace' && input.value === '') {
+ onCancel();
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }, {signal: controller.signal});
+
+ context.editorDOM.addEventListener('click', (event) => {
+ onCancel()
+ }, {signal: controller.signal});
+ context.editorDOM.addEventListener('keydown', (event) => {
+ onCancel();
+ }, {signal: controller.signal});
+}
+
+function handleUserListLoading(selectList: HTMLElement) {
+ const cache = new Map();
+
+ const updateUserList = async (searchTerm: string) => {
+ // Empty list
+ for (const child of [...selectList.children].slice(1)) {
+ child.remove();
+ }
+
+ // Fetch new content
+ let responseHtml = '';
+ if (cache.has(searchTerm)) {
+ responseHtml = cache.get(searchTerm) || '';
+ } else {
+ const loadingWrap = el('li');
+ showLoading(loadingWrap);
+ selectList.appendChild(loadingWrap);
+
+ const resp = await window.$http.get(`/search/users/mention?search=${searchTerm}`);
+ responseHtml = resp.data as string;
+ cache.set(searchTerm, responseHtml);
+ loadingWrap.remove();
+ }
+
+ const doc = htmlToDom(responseHtml);
+ const toInsert = doc.querySelectorAll('li');
+ for (const listEl of toInsert) {
+ const adopted = window.document.adoptNode(listEl) as HTMLElement;
+ selectList.appendChild(adopted);
+ }
+
+ };
+
+ // Initial load
+ updateUserList('');
+
+ const input = selectList.querySelector('input') as HTMLInputElement;
+ const updateUserListDebounced = debounce(updateUserList, 200, false);
+ input.addEventListener('input', () => {
+ const searchTerm = input.value;
+ updateUserListDebounced(searchTerm);
+ });
+}
+
+function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM: HTMLElement): HTMLElement {
+ const searchInput = el('input', {type: 'text'});
+ const searchItem = el('li', {}, [searchInput]);
+ const userSelect = el('ul', {class: 'suggestion-box dropdown-menu'}, [searchItem]);
+
+ context.containerDOM.appendChild(userSelect);
+
+ userSelect.style.display = 'block';
+ userSelect.style.top = '0';
+ userSelect.style.left = '0';
+ const mentionPos = mentionDOM.getBoundingClientRect();
+ const userSelectPos = userSelect.getBoundingClientRect();
+ userSelect.style.top = `${mentionPos.bottom - userSelectPos.top + 3}px`;
+ userSelect.style.left = `${mentionPos.left - userSelectPos.left}px`;
+
+ searchInput.focus();
+
+ return userSelect;
+}
+
+export class MentionDecorator extends EditorDecorator {
+ protected completedSetup: boolean = false;
+ protected abortController: AbortController | null = null;
+ protected selectList: HTMLElement | null = null;
+ protected mentionElement: HTMLElement | null = null;
+
+ setup(element: HTMLElement) {
+ this.mentionElement = element;
+ this.completedSetup = true;
+ }
+
+ showSelection() {
+ if (!this.mentionElement) {
+ return;
+ }
+
+ this.hideSelection();
+ this.abortController = new AbortController();
+
+ this.selectList = buildAndShowUserSelectorAtElement(this.context, this.mentionElement);
+ handleUserListLoading(this.selectList);
+
+ this.selectList.addEventListener('click', userClickHandler((id, name, slug) => {
+ this.context.editor.update(() => {
+ const mentionNode = this.getNode() as MentionNode;
+ this.hideSelection();
+ mentionNode.setUserDetails(id, name, slug);
+ mentionNode.selectNext();
+ });
+ }), {signal: this.abortController.signal});
+
+ handleUserSelectCancel(this.context, this.selectList, this.abortController, this.revertMention.bind(this));
+ }
+
+ hideSelection() {
+ this.abortController?.abort();
+ this.selectList?.remove();
+ }
+
+ revertMention() {
+ this.hideSelection();
+ this.context.editor.update(() => {
+ const text = $createTextNode('@');
+ this.getNode().replace(text);
+ text.selectEnd();
+ });
+ }
+
+ update() {
+ //
+ }
+
+ render(element: HTMLElement): void {
+ if (this.completedSetup) {
+ this.update();
+ } else {
+ this.setup(element);
+ }
+ }
+}
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/framework/decorator.ts b/resources/js/wysiwyg/ui/framework/decorator.ts
index 6ea0b8b39..2f46a19ef 100644
--- a/resources/js/wysiwyg/ui/framework/decorator.ts
+++ b/resources/js/wysiwyg/ui/framework/decorator.ts
@@ -42,7 +42,7 @@ export abstract class EditorDecorator {
* If an element is returned, this will be appended to the element
* that is being decorated.
*/
- abstract render(context: EditorUiContext, decorated: HTMLElement): HTMLElement|void;
+ abstract render(decorated: HTMLElement): HTMLElement|void;
/**
* Destroy this decorator. Used for tear-down operations upon destruction
diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts
index 1adc0b619..cbe3cca19 100644
--- a/resources/js/wysiwyg/ui/framework/manager.ts
+++ b/resources/js/wysiwyg/ui/framework/manager.ts
@@ -90,7 +90,7 @@ export class EditorUIManager {
}
// @ts-ignore
- const decorator = new decoratorClass(nodeKey);
+ const decorator = new decoratorClass(this.getContext());
this.decoratorInstancesByNodeKey[nodeKey] = decorator;
return decorator;
}
@@ -262,7 +262,7 @@ export class EditorUIManager {
const adapter = decorators[key];
const decorator = this.getDecorator(adapter.type, key);
decorator.setNode(adapter.getNode());
- const decoratorEl = decorator.render(this.getContext(), decoratedEl);
+ const decoratorEl = decorator.render(decoratedEl);
if (decoratorEl) {
decoratedEl.append(decoratorEl);
}
diff --git a/resources/sass/_content.scss b/resources/sass/_content.scss
index aba1556a9..98548609e 100644
--- a/resources/sass/_content.scss
+++ b/resources/sass/_content.scss
@@ -198,4 +198,23 @@ body .page-content img,
color: inherit;
text-decoration: underline;
}
+}
+
+a[data-mention-user-id] {
+ display: inline-block;
+ position: relative;
+ color: var(--color-link);
+ padding: 0.1em 0.2em;
+ &:after {
+ content: '';
+ background-color: currentColor;
+ opacity: 0.2;
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 100%;
+ height: 100%;
+ display: block;
+ border-radius: 0.2em;
+ }
}
\ No newline at end of file