mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Lexical: Integrated image manager to image button/form
This commit is contained in:
parent
ec965f28c0
commit
accf2565a0
1
resources/icons/editor/image-search.svg
Normal file
1
resources/icons/editor/image-search.svg
Normal 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 |
@ -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);
|
||||
});
|
||||
}
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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',
|
||||
|
26
resources/js/wysiwyg/ui/framework/blocks/action-field.ts
Normal file
26
resources/js/wysiwyg/ui/framework/blocks/action-field.ts
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
70
resources/js/wysiwyg/utils/diagrams.ts
Normal file
70
resources/js/wysiwyg/utils/diagrams.ts
Normal 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);
|
||||
});
|
||||
}
|
26
resources/js/wysiwyg/utils/images.ts
Normal file
26
resources/js/wysiwyg/utils/images.ts
Normal 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;
|
||||
}
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user