diff --git a/resources/icons/editor/image-search.svg b/resources/icons/editor/image-search.svg new file mode 100644 index 000000000..b8cb2cfc8 --- /dev/null +++ b/resources/icons/editor/image-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/diagram.ts b/resources/js/wysiwyg/nodes/diagram.ts index e2ffeaadd..bd37b200c 100644 --- a/resources/js/wysiwyg/nodes/diagram.ts +++ b/resources/js/wysiwyg/nodes/diagram.ts @@ -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 { 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 { - const drawingId = await new Promise((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 { - 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); - }); -} \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index c8a0293d5..1b10ef91b 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -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) diff --git a/resources/js/wysiwyg/ui/decorators/diagram.ts b/resources/js/wysiwyg/ui/decorators/diagram.ts index 7c79f9f41..44d332939 100644 --- a/resources/js/wysiwyg/ui/decorators/diagram.ts +++ b/resources/js/wysiwyg/ui/decorators/diagram.ts @@ -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 { diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 0eac497fc..f4075a740 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -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 { diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index dbb89b18f..c37696695 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -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', diff --git a/resources/js/wysiwyg/ui/framework/blocks/action-field.ts b/resources/js/wysiwyg/ui/framework/blocks/action-field.ts new file mode 100644 index 000000000..1f40c2864 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/action-field.ts @@ -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(), + ]); + } +} diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 29d959910..92891b540 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -11,6 +11,7 @@ export type SelectionChangeHandler = (selection: BaseSelection|null) => void; export class EditorUIManager { protected modalDefinitionsByKey: Record = {}; + protected activeModalsByKey: Record = {}; protected decoratorConstructorsByType: Record = {}; protected decoratorInstancesByNodeKey: Record = {}; 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; } diff --git a/resources/js/wysiwyg/ui/framework/modals.ts b/resources/js/wysiwyg/ui/framework/modals.ts index 1768f6f54..ae69302f6 100644 --- a/resources/js/wysiwyg/ui/framework/modals.ts +++ b/resources/js/wysiwyg/ui/framework/modals.ts @@ -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) { @@ -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; } diff --git a/resources/js/wysiwyg/utils/diagrams.ts b/resources/js/wysiwyg/utils/diagrams.ts new file mode 100644 index 000000000..50d7d5b3f --- /dev/null +++ b/resources/js/wysiwyg/utils/diagrams.ts @@ -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 { + const drawingId = await new Promise((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 { + 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); + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/images.ts b/resources/js/wysiwyg/utils/images.ts new file mode 100644 index 000000000..89a4a60f0 --- /dev/null +++ b/resources/js/wysiwyg/utils/images.ts @@ -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; +} \ No newline at end of file diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 0cf145559..379c436f4 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -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 {