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:
parent
ae98745439
commit
7c504a10a8
@ -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 {
|
||||
|
@ -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);
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
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";
|
||||
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, () => {
|
||||
|
@ -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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user