1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2024-11-26 04:42:37 +01:00

Lexical: Created core modal functionality

This commit is contained in:
Dan Brown 2024-06-01 16:49:47 +01:00
parent ae98745439
commit 7c504a10a8
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
8 changed files with 222 additions and 35 deletions

View File

@ -3,7 +3,7 @@ import {
$getSelection,
$isTextNode,
BaseSelection,
LexicalEditor, TextFormatType
LexicalEditor, LexicalNode, TextFormatType
} from "lexical";
import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes";
import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils";
@ -28,23 +28,27 @@ export function el(tag: string, attrs: Record<string, string> = {}, children: (s
}
export function selectionContainsNodeType(selection: BaseSelection|null, matcher: LexicalNodeMatcher): boolean {
return getNodeFromSelection(selection, matcher) !== null;
}
export function getNodeFromSelection(selection: BaseSelection|null, matcher: LexicalNodeMatcher): LexicalNode|null {
if (!selection) {
return false;
return null;
}
for (const node of selection.getNodes()) {
if (matcher(node)) {
return true;
return node;
}
for (const parent of node.getParents()) {
if (matcher(parent)) {
return true;
return parent;
}
}
}
return false;
return null;
}
export function selectionContainsTextFormat(selection: BaseSelection|null, format: TextFormatType): boolean {

View File

@ -1,13 +1,19 @@
import {EditorButtonDefinition} from "../framework/buttons";
import {
$createParagraphNode,
$isParagraphNode,
$createNodeSelection,
$createParagraphNode, $getSelection,
$isParagraphNode, $setSelection,
BaseSelection, FORMAT_TEXT_COMMAND,
LexicalNode,
REDO_COMMAND, TextFormatType,
UNDO_COMMAND
} from "lexical";
import {selectionContainsNodeType, selectionContainsTextFormat, toggleSelectionBlockNodeType} from "../../helpers";
import {
getNodeFromSelection,
selectionContainsNodeType,
selectionContainsTextFormat,
toggleSelectionBlockNodeType
} from "../../helpers";
import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../nodes/callout";
import {
$createHeadingNode,
@ -17,7 +23,7 @@ import {
HeadingNode,
HeadingTagType
} from "@lexical/rich-text";
import {$isLinkNode, $toggleLink} from "@lexical/link";
import {$isLinkNode, $toggleLink, LinkNode} from "@lexical/link";
import {EditorUiContext} from "../framework/core";
export const undo: EditorButtonDefinition = {
@ -133,9 +139,29 @@ export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'co
export const link: EditorButtonDefinition = {
label: 'Insert/edit link',
action(context: EditorUiContext) {
context.editor.update(() => {
$toggleLink('http://example.com');
})
const linkModal = context.manager.createModal('link');
context.editor.getEditorState().read(() => {
const selection = $getSelection();
const selectedLink = getNodeFromSelection(selection, $isLinkNode) as LinkNode|null;
let formDefaults = {};
if (selectedLink) {
formDefaults = {
url: selectedLink.getURL(),
text: selectedLink.getTextContent(),
title: selectedLink.getTitle(),
target: selectedLink.getTarget(),
}
context.editor.update(() => {
const selection = $createNodeSelection();
selection.add(selectedLink.getKey());
$setSelection(selection);
});
}
linkModal.show(formDefaults);
});
},
isActive(selection: BaseSelection|null): boolean {
return selectionContainsNodeType(selection, $isLinkNode);

View File

@ -1,19 +1,26 @@
import {EditorFormDefinition, EditorFormFieldDefinition, EditorSelectFormFieldDefinition} from "../framework/forms";
import {EditorFormDefinition, EditorSelectFormFieldDefinition} from "../framework/forms";
import {EditorUiContext} from "../framework/core";
import {$createLinkNode} from "@lexical/link";
import {$createTextNode, $getSelection} from "lexical";
export const link: EditorFormDefinition = {
submitText: 'Apply',
cancelText: 'Cancel',
action(formData, context: EditorUiContext) {
// Todo
console.log('link-form-action', formData);
context.editor.update(() => {
const selection = $getSelection();
const linkNode = $createLinkNode(formData.get('url')?.toString() || '', {
title: formData.get('title')?.toString() || '',
target: formData.get('target')?.toString() || '',
});
linkNode.append($createTextNode(formData.get('text')?.toString() || ''));
selection?.insertNodes([linkNode]);
});
return true;
},
cancel() {
// Todo
console.log('link-form-cancel');
},
fields: [
{
label: 'URL',

View File

@ -15,9 +15,7 @@ export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefiniti
export interface EditorFormDefinition {
submitText: string;
cancelText: string;
action: (formData: FormData, context: EditorUiContext) => boolean;
cancel: () => void;
fields: EditorFormFieldDefinition[];
}
@ -29,6 +27,15 @@ export class EditorFormField extends EditorUiElement {
this.definition = definition;
}
setValue(value: string) {
const input = this.getDOMElement().querySelector('input,select') as HTMLInputElement;
input.value = value;
}
getName(): string {
return this.definition.name;
}
protected buildDOM(): HTMLElement {
const id = `editor-form-field-${this.definition.name}-${Date.now()}`;
let input: HTMLElement;
@ -51,14 +58,38 @@ export class EditorFormField extends EditorUiElement {
export class EditorForm extends EditorContainerUiElement {
protected definition: EditorFormDefinition;
protected onCancel: null|(() => void) = null;
constructor(definition: EditorFormDefinition) {
super(definition.fields.map(fieldDefinition => new EditorFormField(fieldDefinition)));
this.definition = definition;
}
setValues(values: Record<string, string>) {
for (const name of Object.keys(values)) {
const field = this.getFieldByName(name);
if (field) {
field.setValue(values[name]);
}
}
}
setOnCancel(callback: () => void) {
this.onCancel = callback;
}
protected getFieldByName(name: string): EditorFormField|null {
for (const child of this.children as EditorFormField[]) {
if (child.getName() === name) {
return child;
}
}
return null;
}
protected buildDOM(): HTMLElement {
const cancelButton = el('button', {type: 'button', class: 'editor-form-action-secondary'}, [this.trans(this.definition.cancelText)]);
const cancelButton = el('button', {type: 'button', class: 'editor-form-action-secondary'}, [this.trans('Cancel')]);
const form = el('form', {}, [
...this.children.map(child => child.getDOMElement()),
el('div', {class: 'editor-form-actions'}, [
@ -74,7 +105,9 @@ export class EditorForm extends EditorContainerUiElement {
});
cancelButton.addEventListener('click', (event) => {
this.definition.cancel();
if (this.onCancel) {
this.onCancel();
}
});
return form;

View File

@ -1,11 +1,38 @@
import {EditorFormModal, EditorFormModalDefinition} from "./modals";
import {EditorUiContext} from "./core";
export class EditorUIManager {
// Todo - Register and show modal via this
// (Part of UI context)
protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
protected context: EditorUiContext|null = null;
setContext(context: EditorUiContext) {
this.context = context;
}
getContext(): EditorUiContext {
if (this.context === null) {
throw new Error(`Context attempted to be used without being set`);
}
return this.context;
}
registerModal(key: string, modalDefinition: EditorFormModalDefinition) {
this.modalDefinitionsByKey[key] = modalDefinition;
}
createModal(key: string): EditorFormModal {
const modalDefinition = this.modalDefinitionsByKey[key];
if (!modalDefinition) {
console.error(`Attempted to show modal of key [${key}] but no modal registered for that key`);
}
const modal = new EditorFormModal(modalDefinition);
modal.setContext(this.getContext());
return modal;
}
}

View File

@ -0,0 +1,63 @@
import {EditorForm, EditorFormDefinition} from "./forms";
import {el} from "../../helpers";
import {EditorContainerUiElement} from "./containers";
export interface EditorModalDefinition {
title: string;
}
export interface EditorFormModalDefinition extends EditorModalDefinition {
form: EditorFormDefinition;
}
export class EditorFormModal extends EditorContainerUiElement {
protected definition: EditorFormModalDefinition;
constructor(definition: EditorFormModalDefinition) {
super([new EditorForm(definition.form)]);
this.definition = definition;
}
show(defaultValues: Record<string, string>) {
const dom = this.getDOMElement();
document.body.append(dom);
const form = this.getForm();
form.setValues(defaultValues);
form.setOnCancel(this.hide.bind(this));
}
hide() {
this.getDOMElement().remove();
}
protected getForm(): EditorForm {
return this.children[0] as EditorForm;
}
protected buildDOM(): HTMLElement {
const closeButton = el('button', {class: 'editor-modal-close', type: 'button', title: this.trans('Close')}, ['x']);
closeButton.addEventListener('click', this.hide.bind(this));
const modal = el('div', {class: 'editor-modal editor-form-modal'}, [
el('div', {class: 'editor-modal-header'}, [
el('div', {class: 'editor-modal-title'}, [this.trans(this.definition.title)]),
closeButton,
]),
el('div', {class: 'editor-modal-body'}, [
this.getForm().getDOMElement(),
]),
]);
const wrapper = el('div', {class: 'editor-modal-wrapper'}, [modal]);
wrapper.addEventListener('click', event => {
if (event.target && !modal.contains(event.target as HTMLElement)) {
this.hide();
}
});
return wrapper;
}
}

View File

@ -6,8 +6,7 @@ import {
} from "lexical";
import {getMainEditorFullToolbar} from "./toolbars";
import {EditorUIManager} from "./framework/manager";
import {EditorForm} from "./framework/forms";
import {link} from "./defaults/form-definitions";
import {link as linkFormDefinition} from "./defaults/form-definitions";
export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) {
const manager = new EditorUIManager();
@ -16,16 +15,18 @@ export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) {
manager,
translate: (text: string): string => text,
};
manager.setContext(context);
// Create primary toolbar
const toolbar = getMainEditorFullToolbar();
toolbar.setContext(context);
element.before(toolbar.getDOMElement());
// Form test
const linkForm = new EditorForm(link);
linkForm.setContext(context);
element.before(linkForm.getDOMElement());
// Register modals
manager.registerModal('link', {
title: 'Insert/Edit link',
form: linkFormDefinition,
});
// Update button states on editor selection change
editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {

View File

@ -46,4 +46,30 @@
.editor-format-menu .editor-dropdown-menu {
min-width: 320px;
}
// Modals
.editor-modal-wrapper {
position: fixed;
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
background-color: rgba(0, 0, 0, 0.5);
width: 100%;
height: 100%;
}
.editor-modal {
background-color: #FFF;
border: 1px solid #DDD;
padding: 1rem;
border-radius: 4px;
}
.editor-modal-header {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
}
.editor-modal-title {
font-weight: 700;
}