mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-25 02:47:28 +00:00
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
270 lines
7.3 KiB
TypeScript
270 lines
7.3 KiB
TypeScript
import { Plugin } from 'prosemirror-state';
|
|
import { AttributeSpec, Node, NodeSpec } from 'prosemirror-model';
|
|
import { EditorView } from 'prosemirror-view';
|
|
import SelectableNodeView, { GetPosition } from '../utils/SelectableNodeView';
|
|
import { getEditorApi } from './joplinEditorApiPlugin';
|
|
import showModal from '../utils/dom/showModal';
|
|
import createTextArea from '../utils/dom/createTextArea';
|
|
import createExternalEditorPlugin, { OnHide } from './utils/createExternalEditorPlugin';
|
|
import createFloatingButtonPlugin, { ToolbarType } from './utils/createFloatingButtonPlugin';
|
|
|
|
// See the fold example for more information about
|
|
// writing similar ProseMirror plugins:
|
|
// https://prosemirror.net/examples/fold/
|
|
|
|
type NodeAttrs = Readonly<{
|
|
// Placeholder attributes (e.g. if the src is not
|
|
// yet valid).
|
|
isPlaceholder: boolean;
|
|
placeholderSrc: string;
|
|
placeholderAlt: string;
|
|
notDownloaded: boolean;
|
|
isImage: boolean;
|
|
|
|
resourceId: string;
|
|
src: string;
|
|
fromMd: boolean;
|
|
alt: string;
|
|
title: string;
|
|
width: string;
|
|
height: string;
|
|
}>;
|
|
|
|
const attrsSpec = {
|
|
isPlaceholder: { default: '', validate: 'boolean' },
|
|
placeholderSrc: { default: '', validate: 'string' },
|
|
placeholderAlt: { default: '', validate: 'string' },
|
|
notDownloaded: { validate: 'boolean' },
|
|
isImage: { validate: 'boolean' },
|
|
width: { validate: 'string', default: '' },
|
|
height: { validate: 'string', default: '' },
|
|
|
|
resourceId: { default: null as string|null, validate: 'string|null' },
|
|
src: { default: '', validate: 'string' },
|
|
fromMd: { default: false, validate: 'boolean' },
|
|
alt: { default: '', validate: 'string' },
|
|
title: { default: '', validate: 'string' },
|
|
} satisfies Record<keyof NodeAttrs, AttributeSpec>;
|
|
|
|
const imageSpec: NodeSpec = {
|
|
group: 'inline',
|
|
inline: true,
|
|
attrs: attrsSpec,
|
|
draggable: true,
|
|
parseDOM: [
|
|
{
|
|
// Images
|
|
tag: 'img[src]',
|
|
getAttrs: (node): NodeAttrs => ({
|
|
src: node.getAttribute('src'),
|
|
alt: node.getAttribute('alt'),
|
|
title: node.getAttribute('title'),
|
|
width: node.getAttribute('width') ?? '',
|
|
height: node.getAttribute('height') ?? '',
|
|
fromMd: node.hasAttribute('data-from-md'),
|
|
resourceId: node.getAttribute('data-resource-id') || null,
|
|
|
|
isPlaceholder: false,
|
|
placeholderSrc: '',
|
|
placeholderAlt: '',
|
|
notDownloaded: false,
|
|
isImage: true,
|
|
}),
|
|
},
|
|
{
|
|
// Placeholders
|
|
tag: 'span[data-resource-id].not-loaded-resource',
|
|
getAttrs: (node): NodeAttrs => {
|
|
return {
|
|
isPlaceholder: true,
|
|
resourceId: node.getAttribute('data-resource-id'),
|
|
placeholderSrc: node.querySelector('img')?.src,
|
|
src: '',
|
|
width: null,
|
|
height: null,
|
|
placeholderAlt: node.querySelector('img')?.alt,
|
|
fromMd: false,
|
|
alt: node.getAttribute('data-original-alt'),
|
|
title: node.getAttribute('data-original-title'),
|
|
isImage: node.classList.contains('not-loaded-image-resource'),
|
|
notDownloaded: node.classList.contains('resource-status-notDownloaded'),
|
|
};
|
|
},
|
|
},
|
|
],
|
|
toDOM: (node) => {
|
|
const attrs = node.attrs as NodeAttrs;
|
|
// Continue to render non-images as placeholders for now, even after downloading:
|
|
return (attrs.isPlaceholder || !attrs.isImage) ? [
|
|
'span',
|
|
{
|
|
'data-resource-id': attrs.resourceId,
|
|
'data-original-alt': attrs.alt,
|
|
'data-original-title': attrs.title,
|
|
class: [
|
|
'not-loaded-resource',
|
|
attrs.isImage ? 'not-loaded-image-resource' : null,
|
|
attrs.notDownloaded ? 'resource-status-notDownloaded' : null,
|
|
].filter(item => !!item).join(' '),
|
|
},
|
|
['img', { src: attrs.placeholderSrc, alt: attrs.placeholderAlt }],
|
|
] : [
|
|
'img',
|
|
{
|
|
...(attrs.fromMd ? {
|
|
'data-from-md': true,
|
|
} : {}),
|
|
...(attrs.resourceId ? {
|
|
'data-resource-id': attrs.resourceId,
|
|
} : {}),
|
|
...(attrs.width ? {
|
|
width: attrs.width,
|
|
} : {}),
|
|
...(attrs.height ? {
|
|
height: attrs.height,
|
|
} : {}),
|
|
src: attrs.src,
|
|
alt: attrs.alt,
|
|
title: attrs.title,
|
|
},
|
|
];
|
|
},
|
|
};
|
|
|
|
export const nodeSpecs = {
|
|
image: imageSpec,
|
|
};
|
|
|
|
|
|
const createAltTextDialog = (nodePosition: number, view: EditorView, onHide: ()=> void) => {
|
|
const node = view.state.doc.nodeAt(nodePosition);
|
|
const attrs = node.attrs as NodeAttrs;
|
|
|
|
const { localize: _ } = getEditorApi(view.state);
|
|
|
|
const content = document.createElement('div');
|
|
content.classList.add('alt-text-editor');
|
|
const input = createTextArea({
|
|
label: _('A brief description of the image:'),
|
|
initialContent: attrs.alt,
|
|
onChange: (newContent) => {
|
|
view.dispatch(
|
|
view.state.tr.setNodeAttribute(nodePosition, 'alt', newContent.replace(/[\n]+/g, '\n')),
|
|
);
|
|
},
|
|
});
|
|
input.textArea.setAttribute('autofocus', 'true');
|
|
content.appendChild(input.label);
|
|
content.appendChild(input.textArea);
|
|
|
|
const modal = showModal({
|
|
content,
|
|
doneLabel: _('Done'),
|
|
onDismiss: ()=>{
|
|
onHide();
|
|
},
|
|
});
|
|
|
|
return {
|
|
onPositionChange: (newPosition: number) => {
|
|
nodePosition = newPosition;
|
|
},
|
|
dismiss: () => modal.dismiss(),
|
|
};
|
|
};
|
|
|
|
const { plugin: altTextEditorPlugin, editAt: editAltTextAt } = createExternalEditorPlugin({
|
|
canEdit: (node: Node) => {
|
|
return node.type.name === 'image';
|
|
},
|
|
showEditor: (pos: number, view: EditorView, onHide: OnHide) => {
|
|
return createAltTextDialog(pos, view, onHide);
|
|
},
|
|
});
|
|
|
|
class ImageView extends SelectableNodeView {
|
|
public constructor(node: Node, view: EditorView, getPosition: GetPosition) {
|
|
super(true);
|
|
this.dom.classList.add('joplin-image-view');
|
|
|
|
this.dom.appendChild(this.createDom_(node));
|
|
this.dom.ondblclick = () => {
|
|
editAltTextAt(getPosition())(view.state, view.dispatch, view);
|
|
};
|
|
}
|
|
|
|
private createDom_(node: Node) {
|
|
const attrs = node.attrs as NodeAttrs;
|
|
|
|
const createDom = (imageSrc: string, imageAlt: string, loaded: boolean) => {
|
|
const image = document.createElement('img');
|
|
image.src = imageSrc;
|
|
image.alt = imageAlt;
|
|
|
|
image.setAttribute('width', attrs.width);
|
|
image.setAttribute('height', attrs.height);
|
|
|
|
let dom;
|
|
if (!loaded) {
|
|
dom = document.createElement('span');
|
|
dom.classList.add('not-loaded-resource');
|
|
dom.appendChild(image);
|
|
} else {
|
|
dom = image;
|
|
dom.classList.add('late-loaded-resource');
|
|
}
|
|
// For testing
|
|
dom.setAttribute('data-resource-id', attrs.resourceId);
|
|
return dom;
|
|
};
|
|
|
|
let imageSrc = attrs.placeholderSrc;
|
|
let imageAlt = attrs.placeholderAlt;
|
|
let loaded = false;
|
|
|
|
if (!attrs.isPlaceholder) {
|
|
loaded = true;
|
|
imageSrc = attrs.src;
|
|
imageAlt = attrs.alt;
|
|
}
|
|
|
|
return createDom(imageSrc, imageAlt, loaded);
|
|
}
|
|
}
|
|
|
|
export const onResourceDownloaded = (view: EditorView, resourceId: string, newSrc: string) => {
|
|
let tr = view.state.tr;
|
|
view.state.doc.descendants((node, pos) => {
|
|
if (node.type.name === 'image') {
|
|
const attrs = node.attrs as NodeAttrs;
|
|
const itemId = attrs.resourceId;
|
|
|
|
if (itemId === resourceId && attrs.isPlaceholder) {
|
|
tr = tr.setNodeAttribute(pos, 'isPlaceholder', false)
|
|
.setNodeAttribute(pos, 'notDownloaded', false)
|
|
.setNodeAttribute(pos, 'src', newSrc)
|
|
.setMeta('addToHistory', false);
|
|
}
|
|
}
|
|
});
|
|
view.dispatch(tr);
|
|
};
|
|
|
|
const imagePlugin = [
|
|
altTextEditorPlugin,
|
|
new Plugin({
|
|
props: {
|
|
nodeViews: {
|
|
image: (node, view, getPosition) => {
|
|
return new ImageView(node, view, getPosition);
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
createFloatingButtonPlugin('image', [
|
|
{ label: _ => _('Label'), command: (_node, offset) => editAltTextAt(offset) },
|
|
], ToolbarType.AnchorTopRight),
|
|
];
|
|
|
|
export default imagePlugin;
|