Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion app/Users/Controllers/UserSearchController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;

class UserSearchController extends Controller
Expand Down Expand Up @@ -34,8 +35,43 @@ public function forSelect(Request $request)
$query->where('name', 'like', '%' . $search . '%');
}

/** @var Collection<User> $users */
$users = $query->get();

return view('form.user-select-list', [
'users' => $query->get(),
'users' => $users,
]);
}

/**
* Search users in the system, with the response formatted
* for use in a list of mentions.
*/
public function forMentions(Request $request)
{
$hasPermission = !user()->isGuest() && (
userCan(Permission::CommentCreateAll)
|| userCan(Permission::CommentUpdate)
);

if (!$hasPermission) {
$this->showPermissionError();
}

$search = $request->get('search', '');
$query = User::query()
->orderBy('name', 'asc')
->take(20);

if (!empty($search)) {
$query->where('name', 'like', '%' . $search . '%');
}

/** @var Collection<User> $users */
$users = $query->get();

return view('form.user-mention-list', [
'users' => $users,
]);
}
}
4 changes: 2 additions & 2 deletions resources/js/components/page-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {Component} from './component';
import {getLoading, htmlToDom} from '../services/dom';
import {PageCommentReference} from "./page-comment-reference";
import {HttpError} from "../services/http";
import {SimpleWysiwygEditorInterface} from "../wysiwyg";
import {createCommentEditorInstance, SimpleWysiwygEditorInterface} from "../wysiwyg";
import {el} from "../wysiwyg/utils/dom";

export interface PageCommentReplyEventData {
Expand Down Expand Up @@ -104,7 +104,7 @@ export class PageComment extends Component {
this.input.parentElement?.appendChild(container);
this.input.hidden = true;

this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, editorContent, {
this.wysiwygEditor = wysiwygModule.createCommentEditorInstance(container as HTMLElement, editorContent, {
darkMode: document.documentElement.classList.contains('dark-mode'),
textDirection: this.$opts.textDirection,
translations: (window as unknown as Record<string, Object>).editor_translations,
Expand Down
4 changes: 2 additions & 2 deletions resources/js/components/page-comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {PageCommentReference} from "./page-comment-reference";
import {scrollAndHighlightElement} from "../services/util";
import {PageCommentArchiveEventData, PageCommentReplyEventData} from "./page-comment";
import {el} from "../wysiwyg/utils/dom";
import {SimpleWysiwygEditorInterface} from "../wysiwyg";
import {createCommentEditorInstance, SimpleWysiwygEditorInterface} from "../wysiwyg";

export class PageComments extends Component {

Expand Down Expand Up @@ -200,7 +200,7 @@ export class PageComments extends Component {
this.formInput.parentElement?.appendChild(container);
this.formInput.hidden = true;

this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, '<p></p>', {
this.wysiwygEditor = wysiwygModule.createCommentEditorInstance(container as HTMLElement, '<p></p>', {
darkMode: document.documentElement.classList.contains('dark-mode'),
textDirection: this.wysiwygTextDirection,
translations: (window as unknown as Record<string, Object>).editor_translations,
Expand Down
45 changes: 44 additions & 1 deletion resources/js/wysiwyg/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import {createEditor} from 'lexical';
import {createEmptyHistoryState, registerHistory} from '@lexical/history';
import {registerRichText} from '@lexical/rich-text';
import {mergeRegister} from '@lexical/utils';
import {getNodesForBasicEditor, getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
import {
getNodesForBasicEditor,
getNodesForCommentEditor,
getNodesForPageEditor,
registerCommonNodeMutationListeners
} from './nodes';
import {buildEditorUI} from "./ui";
import {focusEditor, getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions";
import {registerTableResizer} from "./ui/framework/helpers/table-resizer";
Expand All @@ -22,6 +27,7 @@ import {DiagramDecorator} from "./ui/decorators/diagram";
import {registerMouseHandling} from "./services/mouse-handling";
import {registerSelectionHandling} from "./services/selection-handling";
import {EditorApi} from "./api/api";
import {registerMentions} from "./services/mentions";

const theme = {
text: {
Expand Down Expand Up @@ -136,6 +142,43 @@ export function createBasicEditorInstance(container: HTMLElement, htmlContent: s
return new SimpleWysiwygEditorInterface(context);
}

export function createCommentEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
const editor = createEditor({
namespace: 'BookStackCommentEditor',
nodes: getNodesForCommentEditor(),
onError: console.error,
theme: theme,
});

// TODO - Dedupe this with the basic editor instance
// Changed elements: namespace, registerMentions, toolbar, public event usage
const context: EditorUiContext = buildEditorUI(container, editor, options);
editor.setRootElement(context.editorDOM);

const editorTeardown = mergeRegister(
registerRichText(editor),
registerHistory(editor, createEmptyHistoryState(), 300),
registerShortcuts(context),
registerAutoLinks(editor),
registerMentions(context),
);

// Register toolbars, modals & decorators
context.manager.setToolbar(getBasicEditorToolbar(context));
context.manager.registerContextToolbar('link', contextToolbars.link);
context.manager.registerModal('link', modals.link);
context.manager.onTeardown(editorTeardown);

setEditorContentFromHtml(editor, htmlContent);

window.$events.emitPublic(container, 'editor-wysiwyg::post-init', {
usage: 'comment-editor',
api: new EditorApi(context),
});

return new SimpleWysiwygEditorInterface(context);
}

export class SimpleWysiwygEditorInterface {
protected context: EditorUiContext;
protected onChangeListeners: (() => void)[] = [];
Expand Down
2 changes: 2 additions & 0 deletions resources/js/wysiwyg/lexical/core/LexicalCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export const KEY_ESCAPE_COMMAND: LexicalCommand<KeyboardEvent> =
createCommand('KEY_ESCAPE_COMMAND');
export const KEY_DELETE_COMMAND: LexicalCommand<KeyboardEvent> =
createCommand('KEY_DELETE_COMMAND');
export const KEY_AT_COMMAND: LexicalCommand<KeyboardEvent> =
createCommand('KEY_AT_COMMAND');
export const KEY_TAB_COMMAND: LexicalCommand<KeyboardEvent> =
createCommand('KEY_TAB_COMMAND');
export const INSERT_TAB_COMMAND: LexicalCommand<void> =
Expand Down
6 changes: 4 additions & 2 deletions resources/js/wysiwyg/lexical/core/LexicalEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import {
SELECTION_CHANGE_COMMAND,
UNDO_COMMAND,
} from '.';
import {KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands';
import {KEY_AT_COMMAND, KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands';
import {
COMPOSITION_START_CHAR,
DOM_ELEMENT_TYPE,
Expand Down Expand Up @@ -97,7 +97,7 @@ import {
getEditorPropertyFromDOMNode,
getEditorsToPropagate,
getNearestEditorFromDOMNode,
getWindow,
getWindow, isAt,
isBackspace,
isBold,
isCopy,
Expand Down Expand Up @@ -1062,6 +1062,8 @@ function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void {
} else if (isDeleteLineForward(key, metaKey)) {
event.preventDefault();
dispatchCommand(editor, DELETE_LINE_COMMAND, false);
} else if (isAt(key)) {
dispatchCommand(editor, KEY_AT_COMMAND, event);
} else if (isBold(key, altKey, metaKey, ctrlKey)) {
event.preventDefault();
dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
Expand Down
4 changes: 4 additions & 0 deletions resources/js/wysiwyg/lexical/core/LexicalUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,10 @@ export function isDelete(key: string): boolean {
return key === 'Delete';
}

export function isAt(key: string): boolean {
return key === '@';
}

export function isSelectAll(
key: string,
metaKey: boolean,
Expand Down
107 changes: 107 additions & 0 deletions resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
DOMConversion,
DOMConversionMap, DOMConversionOutput,
type EditorConfig,
ElementNode,
LexicalEditor, LexicalNode,
SerializedElementNode,
Spread
} from "lexical";

export type SerializedMentionNode = Spread<{
user_id: number;
user_name: string;
user_slug: string;
}, SerializedElementNode>

export class MentionNode extends ElementNode {
__user_id: number = 0;
__user_name: string = '';
__user_slug: string = '';

static getType(): string {
return 'mention';
}

static clone(node: MentionNode): MentionNode {
const newNode = new MentionNode(node.__key);
newNode.__user_id = node.__user_id;
newNode.__user_name = node.__user_name;
newNode.__user_slug = node.__user_slug;
return newNode;
}

setUserDetails(userId: number, userName: string, userSlug: string): void {
const self = this.getWritable();
self.__user_id = userId;
self.__user_name = userName;
self.__user_slug = userSlug;
}

isInline(): boolean {
return true;
}

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.textContent = '@' + this.__user_name;
return element;
}

updateDOM(prevNode: MentionNode): boolean {
return prevNode.__user_id !== this.__user_id;
}

static importDOM(): DOMConversionMap|null {
return {
a(node: HTMLElement): DOMConversion|null {
if (node.hasAttribute('data-user-mention-id')) {
return {
conversion: (element: HTMLElement): DOMConversionOutput|null => {
const node = new MentionNode();
node.setUserDetails(
Number(element.getAttribute('data-user-mention-id') || '0'),
element.innerText.replace(/^@/, ''),
element.getAttribute('href')?.split('/user/')[1] || ''
);

return {
node,
};
},
priority: 4,
};
}
return null;
},
};
}

exportJSON(): SerializedMentionNode {
return {
...super.exportJSON(),
type: 'mention',
version: 1,
user_id: this.__user_id,
user_name: this.__user_name,
user_slug: this.__user_slug,
};
}

static importJSON(serializedNode: SerializedMentionNode): MentionNode {
return $createMentionNode(serializedNode.user_id, serializedNode.user_name, serializedNode.user_slug);
}
}

export function $createMentionNode(userId: number, userName: string, userSlug: string) {
const node = new MentionNode();
node.setUserDetails(userId, userName, userSlug);
return node;
}

export function $isMentionNode(node: LexicalNode | null | undefined): node is MentionNode {
return node instanceof MentionNode;
}
8 changes: 8 additions & 0 deletions resources/js/wysiwyg/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {MediaNode} from "@lexical/rich-text/LexicalMediaNode";
import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
import {CaptionNode} from "@lexical/table/LexicalCaptionNode";
import {MentionNode} from "@lexical/link/LexicalMentionNode";

export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
return [
Expand Down Expand Up @@ -51,6 +52,13 @@ export function getNodesForBasicEditor(): (KlassConstructor<typeof LexicalNode>
];
}

export function getNodesForCommentEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
return [
...getNodesForBasicEditor(),
MentionNode,
];
}

export function registerCommonNodeMutationListeners(context: EditorUiContext): void {
const decorated = [ImageNode, CodeBlockNode, DiagramNode];

Expand Down
Loading