Lexical: Integrated image manager to image button/form

This commit is contained in:
Dan Brown 2024-08-13 19:36:18 +01:00
parent ec965f28c0
commit accf2565a0
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
12 changed files with 231 additions and 109 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h200v80H200v560h560v-214l80 80v134q0 33-23.5 56.5T760-120H200Zm40-160 120-160 90 120 120-160 150 200H240Zm622-144L738-548q-21 14-45 21t-51 7q-74 0-126-52.5T464-700q0-75 52.5-127.5T644-880q75 0 127.5 52.5T824-700q0 27-8 52t-20 46l122 122-56 56ZM644-600q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29Z"/></svg>

After

Width:  |  Height:  |  Size: 466 B

View File

@ -3,15 +3,12 @@ import {
DOMConversion,
DOMConversionMap,
DOMConversionOutput,
LexicalEditor, LexicalNode,
LexicalEditor,
SerializedLexicalNode,
Spread
} from "lexical";
import type {EditorConfig} from "lexical/LexicalEditor";
import {EditorDecoratorAdapter} from "../ui/framework/decorator";
import * as DrawIO from '../../services/drawio';
import {EditorUiContext} from "../ui/framework/core";
import {HttpError} from "../../services/http";
import {el} from "../utils/dom";
export type SerializedDiagramNode = Spread<{
@ -156,69 +153,3 @@ export class DiagramNode extends DecoratorNode<EditorDecoratorAdapter> {
export function $createDiagramNode(drawingId: string = '', drawingUrl: string = ''): DiagramNode {
return new DiagramNode(drawingId, drawingUrl);
}
export function $isDiagramNode(node: LexicalNode | null | undefined): node is DiagramNode {
return node instanceof DiagramNode;
}
function handleUploadError(error: HttpError, context: EditorUiContext): void {
if (error.status === 413) {
window.$events.emit('error', context.options.translations.serverUploadLimitText || '');
} else {
window.$events.emit('error', context.options.translations.imageUploadErrorText || '');
}
console.error(error);
}
async function loadDiagramIdFromNode(editor: LexicalEditor, node: DiagramNode): Promise<string> {
const drawingId = await new Promise<string>((res, rej) => {
editor.getEditorState().read(() => {
const {id: drawingId} = node.getDrawingIdAndUrl();
res(drawingId);
});
});
return drawingId || '';
}
async function updateDrawingNodeFromData(context: EditorUiContext, node: DiagramNode, pngData: string, isNew: boolean): Promise<void> {
DrawIO.close();
if (isNew) {
const loadingImage: string = window.baseUrl('/loading.gif');
context.editor.update(() => {
node.setDrawingIdAndUrl('', loadingImage);
});
}
try {
const img = await DrawIO.upload(pngData, context.options.pageId);
context.editor.update(() => {
node.setDrawingIdAndUrl(String(img.id), img.url);
});
} catch (err) {
if (err instanceof HttpError) {
handleUploadError(err, context);
}
if (isNew) {
context.editor.update(() => {
node.remove();
});
}
throw new Error(`Failed to save image with error: ${err}`);
}
}
export function $openDrawingEditorForNode(context: EditorUiContext, node: DiagramNode): void {
let isNew = false;
DrawIO.show(context.options.drawioUrl, async () => {
const drawingId = await loadDiagramIdFromNode(context.editor, node);
isNew = !drawingId;
return isNew ? '' : DrawIO.load(drawingId);
}, async (pngData: string) => {
return updateDrawingNodeFromData(context, node, pngData, isNew);
});
}

View File

@ -2,7 +2,7 @@
## In progress
//
//
## Main Todo
@ -12,8 +12,6 @@
- Keyboard shortcuts support
- Link popup menu for cross-content reference
- Link heading-based ID reference menu
- Image gallery integration for insert
- Image gallery integration for form
- Drawing gallery integration
- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts)
- Media resize support (like images)

View File

@ -1,8 +1,9 @@
import {EditorDecorator} from "../framework/decorator";
import {EditorUiContext} from "../framework/core";
import {BaseSelection} from "lexical";
import {$openDrawingEditorForNode, DiagramNode} from "../../nodes/diagram";
import {DiagramNode} from "../../nodes/diagram";
import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection";
import {$openDrawingEditorForNode} from "../../utils/diagrams";
export class DiagramDecorator extends EditorDecorator {

View File

@ -5,7 +5,7 @@ import {
$createNodeSelection,
$createTextNode,
$getRoot,
$getSelection,
$getSelection, $insertNodes,
$setSelection,
BaseSelection,
ElementNode
@ -20,7 +20,7 @@ import codeBlockIcon from "@icons/editor/code-block.svg";
import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../../../nodes/code-block";
import editIcon from "@icons/edit.svg";
import diagramIcon from "@icons/editor/diagram.svg";
import {$createDiagramNode, $isDiagramNode, $openDrawingEditorForNode, DiagramNode} from "../../../nodes/diagram";
import {$createDiagramNode, DiagramNode} from "../../../nodes/diagram";
import detailsIcon from "@icons/editor/details.svg";
import mediaIcon from "@icons/editor/media.svg";
import {$createDetailsNode, $isDetailsNode} from "../../../nodes/details";
@ -30,6 +30,9 @@ import {
$insertNewBlockNodeAtSelection,
$selectionContainsNodeType
} from "../../../utils/selection";
import {$isDiagramNode, $openDrawingEditorForNode} from "../../../utils/diagrams";
import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images";
import {$showImageForm} from "../forms/objects";
export const link: EditorButtonDefinition = {
label: 'Insert/edit link',
@ -94,28 +97,19 @@ export const image: EditorButtonDefinition = {
label: 'Insert/Edit Image',
icon: imageIcon,
action(context: EditorUiContext) {
const imageModal = context.manager.createModal('image');
const selection = context.lastSelection;
const selectedImage = $getNodeFromSelection(selection, $isImageNode) as ImageNode | null;
context.editor.getEditorState().read(() => {
let formDefaults = {};
const selectedImage = $getNodeFromSelection(context.lastSelection, $isImageNode) as ImageNode | null;
if (selectedImage) {
formDefaults = {
src: selectedImage.getSrc(),
alt: selectedImage.getAltText(),
height: selectedImage.getHeight(),
width: selectedImage.getWidth(),
}
context.editor.update(() => {
const selection = $createNodeSelection();
selection.add(selectedImage.getKey());
$setSelection(selection);
});
$showImageForm(selectedImage, context);
return;
}
imageModal.show(formDefaults);
showImageManager((image) => {
context.editor.update(() => {
const link = $createLinkedImageNodeFromImageData(image);
$insertNodes([link]);
});
})
});
},
isActive(selection: BaseSelection | null): boolean {

View File

@ -1,31 +1,78 @@
import {EditorFormDefinition, EditorFormTabs, EditorSelectFormFieldDefinition} from "../../framework/forms";
import {
EditorFormDefinition,
EditorFormField,
EditorFormTabs,
EditorSelectFormFieldDefinition
} from "../../framework/forms";
import {EditorUiContext} from "../../framework/core";
import {$createTextNode, $getSelection} from "lexical";
import {$createImageNode} from "../../../nodes/image";
import {$isImageNode, ImageNode} from "../../../nodes/image";
import {$createLinkNode} from "@lexical/link";
import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media";
import {$insertNodeToNearestRoot} from "@lexical/utils";
import {$getNodeFromSelection} from "../../../utils/selection";
import {EditorFormModal} from "../../framework/modals";
import {EditorActionField} from "../../framework/blocks/action-field";
import {EditorButton} from "../../framework/buttons";
import {showImageManager} from "../../../utils/images";
import searchImageIcon from "@icons/editor/image-search.svg";
export function $showImageForm(image: ImageNode, context: EditorUiContext) {
const imageModal: EditorFormModal = context.manager.createModal('image');
const height = image.getHeight();
const width = image.getWidth();
const formData = {
src: image.getSrc(),
alt: image.getAltText(),
height: height === 0 ? '' : String(height),
width: width === 0 ? '' : String(width),
};
imageModal.show(formData);
}
export const image: EditorFormDefinition = {
submitText: 'Apply',
async action(formData, context: EditorUiContext) {
context.editor.update(() => {
const selection = $getSelection();
const imageNode = $createImageNode(formData.get('src')?.toString() || '', {
alt: formData.get('alt')?.toString() || '',
height: Number(formData.get('height')?.toString() || '0'),
width: Number(formData.get('width')?.toString() || '0'),
});
selection?.insertNodes([imageNode]);
const selectedImage = $getNodeFromSelection(context.lastSelection, $isImageNode);
if ($isImageNode(selectedImage)) {
selectedImage.setSrc(formData.get('src')?.toString() || '');
selectedImage.setAltText(formData.get('alt')?.toString() || '');
selectedImage.setWidth(Number(formData.get('width')?.toString() || '0'));
selectedImage.setHeight(Number(formData.get('height')?.toString() || '0'));
}
});
return true;
},
fields: [
{
label: 'Source',
name: 'src',
type: 'text',
build() {
return new EditorActionField(
new EditorFormField({
label: 'Source',
name: 'src',
type: 'text',
}),
new EditorButton({
label: 'Browse files',
icon: searchImageIcon,
action(context: EditorUiContext) {
showImageManager((image) => {
const modal = context.manager.getActiveModal('image');
if (modal) {
modal.getForm().setValues({
src: image.thumbs?.display || image.url,
alt: image.name,
});
}
});
}
}),
);
},
},
{
label: 'Alternative description',

View File

@ -0,0 +1,26 @@
import {EditorContainerUiElement, EditorUiElement} from "../core";
import {el} from "../../../utils/dom";
import {EditorFormField} from "../forms";
import {EditorButton} from "../buttons";
export class EditorActionField extends EditorContainerUiElement {
protected input: EditorFormField;
protected action: EditorButton;
constructor(input: EditorFormField, action: EditorButton) {
super([input, action]);
this.input = input;
this.action = action;
}
buildDOM(): HTMLElement {
return el('div', {
class: 'editor-action-input-container',
}, [
this.input.getDOMElement(),
this.action.getDOMElement(),
]);
}
}

View File

@ -11,6 +11,7 @@ export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
export class EditorUIManager {
protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
protected activeModalsByKey: Record<string, EditorFormModal> = {};
protected decoratorConstructorsByType: Record<string, typeof EditorDecorator> = {};
protected decoratorInstancesByNodeKey: Record<string, EditorDecorator> = {};
protected context: EditorUiContext|null = null;
@ -50,12 +51,24 @@ export class EditorUIManager {
throw new Error(`Attempted to show modal of key [${key}] but no modal registered for that key`);
}
const modal = new EditorFormModal(modalDefinition);
const modal = new EditorFormModal(modalDefinition, key);
modal.setContext(this.getContext());
return modal;
}
setModalActive(key: string, modal: EditorFormModal): void {
this.activeModalsByKey[key] = modal;
}
setModalInactive(key: string): void {
delete this.activeModalsByKey[key];
}
getActiveModal(key: string): EditorFormModal|null {
return this.activeModalsByKey[key];
}
registerDecoratorType(type: string, decorator: typeof EditorDecorator) {
this.decoratorConstructorsByType[type] = decorator;
}

View File

@ -13,10 +13,12 @@ export interface EditorFormModalDefinition extends EditorModalDefinition {
export class EditorFormModal extends EditorContainerUiElement {
protected definition: EditorFormModalDefinition;
protected key: string;
constructor(definition: EditorFormModalDefinition) {
constructor(definition: EditorFormModalDefinition, key: string) {
super([new EditorForm(definition.form)]);
this.definition = definition;
this.key = key;
}
show(defaultValues: Record<string, string>) {
@ -26,13 +28,16 @@ export class EditorFormModal extends EditorContainerUiElement {
const form = this.getForm();
form.setValues(defaultValues);
form.setOnCancel(this.hide.bind(this));
this.getContext().manager.setModalActive(this.key, this);
}
hide() {
this.getDOMElement().remove();
this.getContext().manager.setModalInactive(this.key);
}
protected getForm(): EditorForm {
getForm(): EditorForm {
return this.children[0] as EditorForm;
}

View File

@ -0,0 +1,70 @@
import {LexicalEditor, LexicalNode} from "lexical";
import {HttpError} from "../../services/http";
import {EditorUiContext} from "../ui/framework/core";
import * as DrawIO from "../../services/drawio";
import {DiagramNode} from "../nodes/diagram";
export function $isDiagramNode(node: LexicalNode | null | undefined): node is DiagramNode {
return node instanceof DiagramNode;
}
function handleUploadError(error: HttpError, context: EditorUiContext): void {
if (error.status === 413) {
window.$events.emit('error', context.options.translations.serverUploadLimitText || '');
} else {
window.$events.emit('error', context.options.translations.imageUploadErrorText || '');
}
console.error(error);
}
async function loadDiagramIdFromNode(editor: LexicalEditor, node: DiagramNode): Promise<string> {
const drawingId = await new Promise<string>((res, rej) => {
editor.getEditorState().read(() => {
const {id: drawingId} = node.getDrawingIdAndUrl();
res(drawingId);
});
});
return drawingId || '';
}
async function updateDrawingNodeFromData(context: EditorUiContext, node: DiagramNode, pngData: string, isNew: boolean): Promise<void> {
DrawIO.close();
if (isNew) {
const loadingImage: string = window.baseUrl('/loading.gif');
context.editor.update(() => {
node.setDrawingIdAndUrl('', loadingImage);
});
}
try {
const img = await DrawIO.upload(pngData, context.options.pageId);
context.editor.update(() => {
node.setDrawingIdAndUrl(String(img.id), img.url);
});
} catch (err) {
if (err instanceof HttpError) {
handleUploadError(err, context);
}
if (isNew) {
context.editor.update(() => {
node.remove();
});
}
throw new Error(`Failed to save image with error: ${err}`);
}
}
export function $openDrawingEditorForNode(context: EditorUiContext, node: DiagramNode): void {
let isNew = false;
DrawIO.show(context.options.drawioUrl, async () => {
const drawingId = await loadDiagramIdFromNode(context.editor, node);
isNew = !drawingId;
return isNew ? '' : DrawIO.load(drawingId);
}, async (pngData: string) => {
return updateDrawingNodeFromData(context, node, pngData, isNew);
});
}

View File

@ -0,0 +1,26 @@
import {ImageManager} from "../../components";
import {$createImageNode} from "../nodes/image";
import {$createLinkNode, LinkNode} from "@lexical/link";
type EditorImageData = {
url: string;
thumbs?: {display: string};
name: string;
};
export function showImageManager(callback: (image: EditorImageData) => any) {
const imageManager: ImageManager = window.$components.first('image-manager') as ImageManager;
imageManager.show((image: EditorImageData) => {
callback(image);
}, 'gallery');
}
export function $createLinkedImageNodeFromImageData(image: EditorImageData): LinkNode {
const url = image.thumbs?.display || image.url;
const linkNode = $createLinkNode(url, {target: '_blank'});
const imageNode = $createImageNode(url, {
alt: image.name
});
linkNode.append(imageNode);
return linkNode;
}

View File

@ -479,6 +479,16 @@ textarea.editor-form-field-input {
.editor-form-tab-contents {
width: 360px;
}
.editor-action-input-container {
display: flex;
flex-direction: row;
align-items: end;
justify-content: space-between;
gap: .1rem;
.editor-button {
margin-bottom: 12px;
}
}
// Editor theme styles
.editor-theme-bold {