mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-05-01 06:59:52 +00:00
Lexical: Created core modal functionality
This commit is contained in:
parent
ae98745439
commit
7c504a10a8
8 changed files with 222 additions and 35 deletions
resources
|
@ -3,7 +3,7 @@ import {
|
||||||
$getSelection,
|
$getSelection,
|
||||||
$isTextNode,
|
$isTextNode,
|
||||||
BaseSelection,
|
BaseSelection,
|
||||||
LexicalEditor, TextFormatType
|
LexicalEditor, LexicalNode, TextFormatType
|
||||||
} from "lexical";
|
} from "lexical";
|
||||||
import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes";
|
import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes";
|
||||||
import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils";
|
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 {
|
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) {
|
if (!selection) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const node of selection.getNodes()) {
|
for (const node of selection.getNodes()) {
|
||||||
if (matcher(node)) {
|
if (matcher(node)) {
|
||||||
return true;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const parent of node.getParents()) {
|
for (const parent of node.getParents()) {
|
||||||
if (matcher(parent)) {
|
if (matcher(parent)) {
|
||||||
return true;
|
return parent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectionContainsTextFormat(selection: BaseSelection|null, format: TextFormatType): boolean {
|
export function selectionContainsTextFormat(selection: BaseSelection|null, format: TextFormatType): boolean {
|
||||||
|
|
|
@ -1,13 +1,19 @@
|
||||||
import {EditorButtonDefinition} from "../framework/buttons";
|
import {EditorButtonDefinition} from "../framework/buttons";
|
||||||
import {
|
import {
|
||||||
$createParagraphNode,
|
$createNodeSelection,
|
||||||
$isParagraphNode,
|
$createParagraphNode, $getSelection,
|
||||||
|
$isParagraphNode, $setSelection,
|
||||||
BaseSelection, FORMAT_TEXT_COMMAND,
|
BaseSelection, FORMAT_TEXT_COMMAND,
|
||||||
LexicalNode,
|
LexicalNode,
|
||||||
REDO_COMMAND, TextFormatType,
|
REDO_COMMAND, TextFormatType,
|
||||||
UNDO_COMMAND
|
UNDO_COMMAND
|
||||||
} from "lexical";
|
} from "lexical";
|
||||||
import {selectionContainsNodeType, selectionContainsTextFormat, toggleSelectionBlockNodeType} from "../../helpers";
|
import {
|
||||||
|
getNodeFromSelection,
|
||||||
|
selectionContainsNodeType,
|
||||||
|
selectionContainsTextFormat,
|
||||||
|
toggleSelectionBlockNodeType
|
||||||
|
} from "../../helpers";
|
||||||
import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../nodes/callout";
|
import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../nodes/callout";
|
||||||
import {
|
import {
|
||||||
$createHeadingNode,
|
$createHeadingNode,
|
||||||
|
@ -17,7 +23,7 @@ import {
|
||||||
HeadingNode,
|
HeadingNode,
|
||||||
HeadingTagType
|
HeadingTagType
|
||||||
} from "@lexical/rich-text";
|
} from "@lexical/rich-text";
|
||||||
import {$isLinkNode, $toggleLink} from "@lexical/link";
|
import {$isLinkNode, $toggleLink, LinkNode} from "@lexical/link";
|
||||||
import {EditorUiContext} from "../framework/core";
|
import {EditorUiContext} from "../framework/core";
|
||||||
|
|
||||||
export const undo: EditorButtonDefinition = {
|
export const undo: EditorButtonDefinition = {
|
||||||
|
@ -133,9 +139,29 @@ export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'co
|
||||||
export const link: EditorButtonDefinition = {
|
export const link: EditorButtonDefinition = {
|
||||||
label: 'Insert/edit link',
|
label: 'Insert/edit link',
|
||||||
action(context: EditorUiContext) {
|
action(context: EditorUiContext) {
|
||||||
context.editor.update(() => {
|
const linkModal = context.manager.createModal('link');
|
||||||
$toggleLink('http://example.com');
|
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 {
|
isActive(selection: BaseSelection|null): boolean {
|
||||||
return selectionContainsNodeType(selection, $isLinkNode);
|
return selectionContainsNodeType(selection, $isLinkNode);
|
||||||
|
|
|
@ -1,19 +1,26 @@
|
||||||
import {EditorFormDefinition, EditorFormFieldDefinition, EditorSelectFormFieldDefinition} from "../framework/forms";
|
import {EditorFormDefinition, EditorSelectFormFieldDefinition} from "../framework/forms";
|
||||||
import {EditorUiContext} from "../framework/core";
|
import {EditorUiContext} from "../framework/core";
|
||||||
|
import {$createLinkNode} from "@lexical/link";
|
||||||
|
import {$createTextNode, $getSelection} from "lexical";
|
||||||
|
|
||||||
|
|
||||||
export const link: EditorFormDefinition = {
|
export const link: EditorFormDefinition = {
|
||||||
submitText: 'Apply',
|
submitText: 'Apply',
|
||||||
cancelText: 'Cancel',
|
|
||||||
action(formData, context: EditorUiContext) {
|
action(formData, context: EditorUiContext) {
|
||||||
// Todo
|
context.editor.update(() => {
|
||||||
console.log('link-form-action', formData);
|
|
||||||
|
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;
|
return true;
|
||||||
},
|
},
|
||||||
cancel() {
|
|
||||||
// Todo
|
|
||||||
console.log('link-form-cancel');
|
|
||||||
},
|
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
label: 'URL',
|
label: 'URL',
|
||||||
|
|
|
@ -15,9 +15,7 @@ export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefiniti
|
||||||
|
|
||||||
export interface EditorFormDefinition {
|
export interface EditorFormDefinition {
|
||||||
submitText: string;
|
submitText: string;
|
||||||
cancelText: string;
|
|
||||||
action: (formData: FormData, context: EditorUiContext) => boolean;
|
action: (formData: FormData, context: EditorUiContext) => boolean;
|
||||||
cancel: () => void;
|
|
||||||
fields: EditorFormFieldDefinition[];
|
fields: EditorFormFieldDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,6 +27,15 @@ export class EditorFormField extends EditorUiElement {
|
||||||
this.definition = definition;
|
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 {
|
protected buildDOM(): HTMLElement {
|
||||||
const id = `editor-form-field-${this.definition.name}-${Date.now()}`;
|
const id = `editor-form-field-${this.definition.name}-${Date.now()}`;
|
||||||
let input: HTMLElement;
|
let input: HTMLElement;
|
||||||
|
@ -51,14 +58,38 @@ export class EditorFormField extends EditorUiElement {
|
||||||
|
|
||||||
export class EditorForm extends EditorContainerUiElement {
|
export class EditorForm extends EditorContainerUiElement {
|
||||||
protected definition: EditorFormDefinition;
|
protected definition: EditorFormDefinition;
|
||||||
|
protected onCancel: null|(() => void) = null;
|
||||||
|
|
||||||
constructor(definition: EditorFormDefinition) {
|
constructor(definition: EditorFormDefinition) {
|
||||||
super(definition.fields.map(fieldDefinition => new EditorFormField(fieldDefinition)));
|
super(definition.fields.map(fieldDefinition => new EditorFormField(fieldDefinition)));
|
||||||
this.definition = definition;
|
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 {
|
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', {}, [
|
const form = el('form', {}, [
|
||||||
...this.children.map(child => child.getDOMElement()),
|
...this.children.map(child => child.getDOMElement()),
|
||||||
el('div', {class: 'editor-form-actions'}, [
|
el('div', {class: 'editor-form-actions'}, [
|
||||||
|
@ -74,7 +105,9 @@ export class EditorForm extends EditorContainerUiElement {
|
||||||
});
|
});
|
||||||
|
|
||||||
cancelButton.addEventListener('click', (event) => {
|
cancelButton.addEventListener('click', (event) => {
|
||||||
this.definition.cancel();
|
if (this.onCancel) {
|
||||||
|
this.onCancel();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return form;
|
return form;
|
||||||
|
|
|
@ -1,11 +1,38 @@
|
||||||
|
import {EditorFormModal, EditorFormModalDefinition} from "./modals";
|
||||||
|
import {EditorUiContext} from "./core";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export class EditorUIManager {
|
export class EditorUIManager {
|
||||||
|
|
||||||
// Todo - Register and show modal via this
|
protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
|
||||||
// (Part of UI context)
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
63
resources/js/wysiwyg/ui/framework/modals.ts
Normal file
63
resources/js/wysiwyg/ui/framework/modals.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,8 +6,7 @@ import {
|
||||||
} from "lexical";
|
} from "lexical";
|
||||||
import {getMainEditorFullToolbar} from "./toolbars";
|
import {getMainEditorFullToolbar} from "./toolbars";
|
||||||
import {EditorUIManager} from "./framework/manager";
|
import {EditorUIManager} from "./framework/manager";
|
||||||
import {EditorForm} from "./framework/forms";
|
import {link as linkFormDefinition} from "./defaults/form-definitions";
|
||||||
import {link} from "./defaults/form-definitions";
|
|
||||||
|
|
||||||
export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) {
|
export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) {
|
||||||
const manager = new EditorUIManager();
|
const manager = new EditorUIManager();
|
||||||
|
@ -16,16 +15,18 @@ export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) {
|
||||||
manager,
|
manager,
|
||||||
translate: (text: string): string => text,
|
translate: (text: string): string => text,
|
||||||
};
|
};
|
||||||
|
manager.setContext(context);
|
||||||
|
|
||||||
// Create primary toolbar
|
// Create primary toolbar
|
||||||
const toolbar = getMainEditorFullToolbar();
|
const toolbar = getMainEditorFullToolbar();
|
||||||
toolbar.setContext(context);
|
toolbar.setContext(context);
|
||||||
element.before(toolbar.getDOMElement());
|
element.before(toolbar.getDOMElement());
|
||||||
|
|
||||||
// Form test
|
// Register modals
|
||||||
const linkForm = new EditorForm(link);
|
manager.registerModal('link', {
|
||||||
linkForm.setContext(context);
|
title: 'Insert/Edit link',
|
||||||
element.before(linkForm.getDOMElement());
|
form: linkFormDefinition,
|
||||||
|
});
|
||||||
|
|
||||||
// Update button states on editor selection change
|
// Update button states on editor selection change
|
||||||
editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
|
editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
|
||||||
|
|
|
@ -46,4 +46,30 @@
|
||||||
|
|
||||||
.editor-format-menu .editor-dropdown-menu {
|
.editor-format-menu .editor-dropdown-menu {
|
||||||
min-width: 320px;
|
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;
|
||||||
}
|
}
|
Loading…
Add table
Add a link
Reference in a new issue