mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Lexical: Started on form UI
This commit is contained in:
parent
57259aee00
commit
ae98745439
@ -3,7 +3,6 @@ import {
|
||||
$createParagraphNode,
|
||||
$isParagraphNode,
|
||||
BaseSelection, FORMAT_TEXT_COMMAND,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
REDO_COMMAND, TextFormatType,
|
||||
UNDO_COMMAND
|
||||
@ -19,11 +18,12 @@ import {
|
||||
HeadingTagType
|
||||
} from "@lexical/rich-text";
|
||||
import {$isLinkNode, $toggleLink} from "@lexical/link";
|
||||
import {EditorUiContext} from "../framework/core";
|
||||
|
||||
export const undo: EditorButtonDefinition = {
|
||||
label: 'Undo',
|
||||
action(editor: LexicalEditor) {
|
||||
editor.dispatchCommand(UNDO_COMMAND, undefined);
|
||||
action(context: EditorUiContext) {
|
||||
context.editor.dispatchCommand(UNDO_COMMAND, undefined);
|
||||
},
|
||||
isActive(selection: BaseSelection|null): boolean {
|
||||
return false;
|
||||
@ -32,8 +32,8 @@ export const undo: EditorButtonDefinition = {
|
||||
|
||||
export const redo: EditorButtonDefinition = {
|
||||
label: 'Redo',
|
||||
action(editor: LexicalEditor) {
|
||||
editor.dispatchCommand(REDO_COMMAND, undefined);
|
||||
action(context: EditorUiContext) {
|
||||
context.editor.dispatchCommand(REDO_COMMAND, undefined);
|
||||
},
|
||||
isActive(selection: BaseSelection|null): boolean {
|
||||
return false;
|
||||
@ -43,9 +43,9 @@ export const redo: EditorButtonDefinition = {
|
||||
function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition {
|
||||
return {
|
||||
label: `${name} Callout`,
|
||||
action(editor: LexicalEditor) {
|
||||
action(context: EditorUiContext) {
|
||||
toggleSelectionBlockNodeType(
|
||||
editor,
|
||||
context.editor,
|
||||
(node) => $isCalloutNodeOfCategory(node, category),
|
||||
() => $createCalloutNode(category),
|
||||
)
|
||||
@ -68,9 +68,9 @@ const isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTag
|
||||
function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefinition {
|
||||
return {
|
||||
label: name,
|
||||
action(editor: LexicalEditor) {
|
||||
action(context: EditorUiContext) {
|
||||
toggleSelectionBlockNodeType(
|
||||
editor,
|
||||
context.editor,
|
||||
(node) => isHeaderNodeOfTag(node, tag),
|
||||
() => $createHeadingNode(tag),
|
||||
)
|
||||
@ -88,8 +88,8 @@ export const h5: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header')
|
||||
|
||||
export const blockquote: EditorButtonDefinition = {
|
||||
label: 'Blockquote',
|
||||
action(editor: LexicalEditor) {
|
||||
toggleSelectionBlockNodeType(editor, $isQuoteNode, $createQuoteNode);
|
||||
action(context: EditorUiContext) {
|
||||
toggleSelectionBlockNodeType(context.editor, $isQuoteNode, $createQuoteNode);
|
||||
},
|
||||
isActive(selection: BaseSelection|null): boolean {
|
||||
return selectionContainsNodeType(selection, $isQuoteNode);
|
||||
@ -98,8 +98,8 @@ export const blockquote: EditorButtonDefinition = {
|
||||
|
||||
export const paragraph: EditorButtonDefinition = {
|
||||
label: 'Paragraph',
|
||||
action(editor: LexicalEditor) {
|
||||
toggleSelectionBlockNodeType(editor, $isParagraphNode, $createParagraphNode);
|
||||
action(context: EditorUiContext) {
|
||||
toggleSelectionBlockNodeType(context.editor, $isParagraphNode, $createParagraphNode);
|
||||
},
|
||||
isActive(selection: BaseSelection|null): boolean {
|
||||
return selectionContainsNodeType(selection, $isParagraphNode);
|
||||
@ -109,8 +109,8 @@ export const paragraph: EditorButtonDefinition = {
|
||||
function buildFormatButton(label: string, format: TextFormatType): EditorButtonDefinition {
|
||||
return {
|
||||
label: label,
|
||||
action(editor: LexicalEditor) {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
|
||||
action(context: EditorUiContext) {
|
||||
context.editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
|
||||
},
|
||||
isActive(selection: BaseSelection|null): boolean {
|
||||
return selectionContainsTextFormat(selection, format);
|
||||
@ -132,8 +132,8 @@ export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'co
|
||||
|
||||
export const link: EditorButtonDefinition = {
|
||||
label: 'Insert/edit link',
|
||||
action(editor: LexicalEditor) {
|
||||
editor.update(() => {
|
||||
action(context: EditorUiContext) {
|
||||
context.editor.update(() => {
|
||||
$toggleLink('http://example.com');
|
||||
})
|
||||
},
|
||||
|
43
resources/js/wysiwyg/ui/defaults/form-definitions.ts
Normal file
43
resources/js/wysiwyg/ui/defaults/form-definitions.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import {EditorFormDefinition, EditorFormFieldDefinition, EditorSelectFormFieldDefinition} from "../framework/forms";
|
||||
import {EditorUiContext} from "../framework/core";
|
||||
|
||||
|
||||
export const link: EditorFormDefinition = {
|
||||
submitText: 'Apply',
|
||||
cancelText: 'Cancel',
|
||||
action(formData, context: EditorUiContext) {
|
||||
// Todo
|
||||
console.log('link-form-action', formData);
|
||||
return true;
|
||||
},
|
||||
cancel() {
|
||||
// Todo
|
||||
console.log('link-form-cancel');
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
label: 'URL',
|
||||
name: 'url',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
label: 'Text to display',
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
label: 'Title',
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
label: 'Open link in...',
|
||||
name: 'target',
|
||||
type: 'select',
|
||||
valuesByLabel: {
|
||||
'Current window': '',
|
||||
'New window': '_blank',
|
||||
}
|
||||
} as EditorSelectFormFieldDefinition,
|
||||
],
|
||||
};
|
@ -1,15 +1,16 @@
|
||||
import {BaseSelection, LexicalEditor} from "lexical";
|
||||
import {EditorUiElement, EditorUiStateUpdate} from "./base-elements";
|
||||
import {BaseSelection} from "lexical";
|
||||
import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
|
||||
import {el} from "../../helpers";
|
||||
|
||||
export interface EditorButtonDefinition {
|
||||
label: string;
|
||||
action: (editor: LexicalEditor) => void;
|
||||
action: (context: EditorUiContext) => void;
|
||||
isActive: (selection: BaseSelection|null) => boolean;
|
||||
}
|
||||
|
||||
export class EditorButton extends EditorUiElement {
|
||||
protected definition: EditorButtonDefinition;
|
||||
protected active: boolean = false;
|
||||
|
||||
constructor(definition: EditorButtonDefinition) {
|
||||
super();
|
||||
@ -20,7 +21,7 @@ export class EditorButton extends EditorUiElement {
|
||||
const button = el('button', {
|
||||
type: 'button',
|
||||
class: 'editor-button',
|
||||
}, [this.definition.label]) as HTMLButtonElement;
|
||||
}, [this.getLabel()]) as HTMLButtonElement;
|
||||
|
||||
button.addEventListener('click', this.onClick.bind(this));
|
||||
|
||||
@ -28,17 +29,25 @@ export class EditorButton extends EditorUiElement {
|
||||
}
|
||||
|
||||
protected onClick() {
|
||||
this.definition.action(this.getContext().editor);
|
||||
this.definition.action(this.getContext());
|
||||
}
|
||||
|
||||
updateActiveState(selection: BaseSelection|null) {
|
||||
const isActive = this.definition.isActive(selection);
|
||||
this.dom?.classList.toggle('editor-button-active', isActive);
|
||||
this.active = this.definition.isActive(selection);
|
||||
this.dom?.classList.toggle('editor-button-active', this.active);
|
||||
}
|
||||
|
||||
updateState(state: EditorUiStateUpdate): void {
|
||||
this.updateActiveState(state.selection);
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
getLabel(): string {
|
||||
return this.trans(this.definition.label);
|
||||
}
|
||||
}
|
||||
|
||||
export class FormatPreviewButton extends EditorButton {
|
||||
@ -55,7 +64,7 @@ export class FormatPreviewButton extends EditorButton {
|
||||
|
||||
const preview = el('span', {
|
||||
class: 'editor-button-format-preview'
|
||||
}, [this.definition.label]);
|
||||
}, [this.getLabel()]);
|
||||
|
||||
const stylesToApply = this.getStylesFromPreview();
|
||||
console.log(stylesToApply);
|
||||
@ -70,7 +79,7 @@ export class FormatPreviewButton extends EditorButton {
|
||||
protected getStylesFromPreview(): Record<string, string> {
|
||||
const wrap = el('div', {style: 'display: none', hidden: 'true', class: 'page-content'});
|
||||
const sampleClone = this.previewSampleElement.cloneNode() as HTMLElement;
|
||||
sampleClone.textContent = this.definition.label;
|
||||
sampleClone.textContent = this.getLabel();
|
||||
wrap.append(sampleClone);
|
||||
document.body.append(wrap);
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./base-elements";
|
||||
import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
|
||||
import {el} from "../../helpers";
|
||||
import {EditorButton} from "./buttons";
|
||||
|
||||
export class EditorContainerUiElement extends EditorUiElement {
|
||||
protected children : EditorUiElement[];
|
||||
@ -24,6 +25,7 @@ export class EditorContainerUiElement extends EditorUiElement {
|
||||
}
|
||||
|
||||
setContext(context: EditorUiContext) {
|
||||
super.setContext(context);
|
||||
for (const child of this.getChildren()) {
|
||||
child.setContext(context);
|
||||
}
|
||||
@ -54,9 +56,9 @@ export class EditorFormatMenu extends EditorContainerUiElement {
|
||||
}, childElements);
|
||||
|
||||
const toggle = el('button', {
|
||||
class: 'editor-format-menu-toggle',
|
||||
class: 'editor-format-menu-toggle editor-button',
|
||||
type: 'button',
|
||||
}, ['Formats']);
|
||||
}, [this.trans('Formats')]);
|
||||
|
||||
const wrapper = el('div', {
|
||||
class: 'editor-format-menu editor-dropdown-menu-container',
|
||||
@ -88,4 +90,24 @@ export class EditorFormatMenu extends EditorContainerUiElement {
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
updateState(state: EditorUiStateUpdate) {
|
||||
super.updateState(state);
|
||||
|
||||
for (const child of this.children) {
|
||||
if (child instanceof EditorButton && child.isActive()) {
|
||||
this.updateToggleLabel(child.getLabel());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateToggleLabel(this.trans('Formats'));
|
||||
}
|
||||
|
||||
protected updateToggleLabel(text: string): void {
|
||||
const button = this.getDOMElement().querySelector('button');
|
||||
if (button) {
|
||||
button.innerText = text;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import {BaseSelection, LexicalEditor} from "lexical";
|
||||
import {EditorUIManager} from "./manager";
|
||||
|
||||
export type EditorUiStateUpdate = {
|
||||
editor: LexicalEditor,
|
||||
@ -7,6 +8,8 @@ export type EditorUiStateUpdate = {
|
||||
|
||||
export type EditorUiContext = {
|
||||
editor: LexicalEditor,
|
||||
translate: (text: string) => string,
|
||||
manager: EditorUIManager,
|
||||
};
|
||||
|
||||
export abstract class EditorUiElement {
|
||||
@ -35,5 +38,11 @@ export abstract class EditorUiElement {
|
||||
return this.dom;
|
||||
}
|
||||
|
||||
abstract updateState(state: EditorUiStateUpdate): void;
|
||||
trans(text: string) {
|
||||
return this.getContext().translate(text);
|
||||
}
|
||||
|
||||
updateState(state: EditorUiStateUpdate): void {
|
||||
return;
|
||||
}
|
||||
}
|
82
resources/js/wysiwyg/ui/framework/forms.ts
Normal file
82
resources/js/wysiwyg/ui/framework/forms.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import {EditorUiContext, EditorUiElement} from "./core";
|
||||
import {EditorContainerUiElement} from "./containers";
|
||||
import {el} from "../../helpers";
|
||||
|
||||
export interface EditorFormFieldDefinition {
|
||||
label: string;
|
||||
name: string;
|
||||
type: 'text' | 'select';
|
||||
}
|
||||
|
||||
export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefinition {
|
||||
type: 'select',
|
||||
valuesByLabel: Record<string, string>
|
||||
}
|
||||
|
||||
export interface EditorFormDefinition {
|
||||
submitText: string;
|
||||
cancelText: string;
|
||||
action: (formData: FormData, context: EditorUiContext) => boolean;
|
||||
cancel: () => void;
|
||||
fields: EditorFormFieldDefinition[];
|
||||
}
|
||||
|
||||
export class EditorFormField extends EditorUiElement {
|
||||
protected definition: EditorFormFieldDefinition;
|
||||
|
||||
constructor(definition: EditorFormFieldDefinition) {
|
||||
super();
|
||||
this.definition = definition;
|
||||
}
|
||||
|
||||
protected buildDOM(): HTMLElement {
|
||||
const id = `editor-form-field-${this.definition.name}-${Date.now()}`;
|
||||
let input: HTMLElement;
|
||||
|
||||
if (this.definition.type === 'select') {
|
||||
const options = (this.definition as EditorSelectFormFieldDefinition).valuesByLabel
|
||||
const labels = Object.keys(options);
|
||||
const optionElems = labels.map(label => el('option', {value: options[label]}, [label]));
|
||||
input = el('select', {id, name: this.definition.name, class: 'editor-form-field-input'}, optionElems);
|
||||
} else {
|
||||
input = el('input', {id, name: this.definition.name, class: 'editor-form-field-input'});
|
||||
}
|
||||
|
||||
return el('div', {class: 'editor-form-field-wrapper'}, [
|
||||
el('label', {class: 'editor-form-field-label', for: id}, [this.trans(this.definition.label)]),
|
||||
input,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorForm extends EditorContainerUiElement {
|
||||
protected definition: EditorFormDefinition;
|
||||
|
||||
constructor(definition: EditorFormDefinition) {
|
||||
super(definition.fields.map(fieldDefinition => new EditorFormField(fieldDefinition)));
|
||||
this.definition = definition;
|
||||
}
|
||||
|
||||
protected buildDOM(): HTMLElement {
|
||||
const cancelButton = el('button', {type: 'button', class: 'editor-form-action-secondary'}, [this.trans(this.definition.cancelText)]);
|
||||
const form = el('form', {}, [
|
||||
...this.children.map(child => child.getDOMElement()),
|
||||
el('div', {class: 'editor-form-actions'}, [
|
||||
cancelButton,
|
||||
el('button', {type: 'submit', class: 'editor-form-action-primary'}, [this.trans(this.definition.submitText)]),
|
||||
])
|
||||
]);
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(form as HTMLFormElement);
|
||||
this.definition.action(formData, this.getContext());
|
||||
});
|
||||
|
||||
cancelButton.addEventListener('click', (event) => {
|
||||
this.definition.cancel();
|
||||
});
|
||||
|
||||
return form;
|
||||
}
|
||||
}
|
11
resources/js/wysiwyg/ui/framework/manager.ts
Normal file
11
resources/js/wysiwyg/ui/framework/manager.ts
Normal file
@ -0,0 +1,11 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export class EditorUIManager {
|
||||
|
||||
// Todo - Register and show modal via this
|
||||
// (Part of UI context)
|
||||
|
||||
}
|
@ -5,12 +5,28 @@ import {
|
||||
SELECTION_CHANGE_COMMAND
|
||||
} from "lexical";
|
||||
import {getMainEditorFullToolbar} from "./toolbars";
|
||||
import {EditorUIManager} from "./framework/manager";
|
||||
import {EditorForm} from "./framework/forms";
|
||||
import {link} from "./defaults/form-definitions";
|
||||
|
||||
export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) {
|
||||
const manager = new EditorUIManager();
|
||||
const context = {
|
||||
editor,
|
||||
manager,
|
||||
translate: (text: string): string => text,
|
||||
};
|
||||
|
||||
// Create primary toolbar
|
||||
const toolbar = getMainEditorFullToolbar();
|
||||
toolbar.setContext({editor});
|
||||
toolbar.setContext(context);
|
||||
element.before(toolbar.getDOMElement());
|
||||
|
||||
// Form test
|
||||
const linkForm = new EditorForm(link);
|
||||
linkForm.setContext(context);
|
||||
element.before(linkForm.getDOMElement());
|
||||
|
||||
// Update button states on editor selection change
|
||||
editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
|
||||
const selection = $getSelection();
|
||||
|
Loading…
Reference in New Issue
Block a user