webapp/src/components/Editor/prosemirror/extension/image.ts

226 lines
5.6 KiB
TypeScript
Raw Normal View History

2022-09-09 11:53:35 +00:00
import { Plugin } from 'prosemirror-state'
import type { Node, Schema } from 'prosemirror-model'
import type { EditorView } from 'prosemirror-view'
2022-10-08 16:40:58 +00:00
import type { ProseMirrorExtension } from '../../store/state'
2022-09-09 11:53:35 +00:00
const REGEX = /^!\[([^[\]]*)]\((.+?)\)\s+/
const MAX_MATCH = 500
const isUrl = (str: string) => {
try {
const url = new URL(str)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
const isBlank = (text: string) => text === ' ' || text === '\u00A0'
/*
export const getImagePath = async (src: string, path?: string) => {
let paths = [src]
if (path) paths = [await dirname(path), src]
const absolutePath = await resolvePath(paths)
return convertFileSrc(absolutePath)
}
*/
const imageInput = (schema: Schema, _path?: string) =>
new Plugin({
props: {
handleTextInput(view, from, to, text) {
if (view.composing || !isBlank(text)) return false
const $from = view.state.doc.resolve(from)
if ($from.parent.type.spec.code) return false
const textBefore =
$from.parent.textBetween(
Math.max(0, $from.parentOffset - MAX_MATCH),
$from.parentOffset,
undefined,
'\uFFFC'
) + text
const match = REGEX.exec(textBefore)
if (match) {
const [, title, src] = match
if (isUrl(src)) {
const node = schema.node('image', { src, title })
const start = from - (match[0].length - text.length)
const tr = view.state.tr
tr.delete(start, to)
tr.insert(start, node)
view.dispatch(tr)
return true
}
// if (!isTauri) return false
/*
getImagePath(src, path).then((p) => {
const node = schema.node('image', { src: p, title, path: src })
const start = from - (match[0].length - text.length)
const tr = view.state.tr
tr.delete(start, to)
tr.insert(start, node)
view.dispatch(tr)
})
*/
return false
}
}
}
})
const imageSchema = {
inline: true,
attrs: {
src: {},
alt: { default: null },
title: { default: null },
path: { default: null },
width: { default: null }
},
group: 'inline',
draggable: true,
parseDOM: [
{
tag: 'img[src]',
getAttrs: (dom: HTMLElement) => ({
src: dom.getAttribute('src'),
title: dom.getAttribute('title'),
alt: dom.getAttribute('alt'),
path: dom.dataset.path
})
}
],
toDOM: (node: Node) => [
'img',
{
src: node.attrs.src,
title: node.attrs.title,
alt: node.attrs.alt,
'data-path': node.attrs.path
}
]
}
export const insertImage = (view: EditorView, src: string, left: number, top: number) => {
const state = view.state
const tr = state.tr
const node = state.schema.nodes.image.create({ src })
if (view) {
const pos = view.posAtCoords({ left, top }).pos
tr.insert(pos, node)
view.dispatch(tr)
}
}
class ImageView {
node: Node
view: EditorView
getPos: () => number
schema: Schema
dom: Element
contentDOM: Element
container: HTMLElement
handle: HTMLElement
2022-10-08 16:40:58 +00:00
onResizeFn: (e: Event) => void
onResizeEndFn: (e: Event) => void
2022-09-09 11:53:35 +00:00
width: number
updating: number
constructor(node: Node, view: EditorView, getPos: () => number, schema: Schema, _path: string) {
this.node = node
this.view = view
this.getPos = getPos
this.schema = schema
this.onResizeFn = this.onResize.bind(this)
this.onResizeEndFn = this.onResizeEnd.bind(this)
this.container = document.createElement('span')
this.container.className = 'image-container'
if (node.attrs.width) this.setWidth(node.attrs.width)
const image = document.createElement('img')
image.setAttribute('title', node.attrs.title ?? '')
if (
// isTauri &&
!node.attrs.src.startsWith('asset:') &&
!node.attrs.src.startsWith('data:') &&
!isUrl(node.attrs.src)
) {
// getImagePath(node.attrs.src, path).then((p) => image.setAttribute('src', p))
} else {
image.setAttribute('src', node.attrs.src)
}
this.handle = document.createElement('span')
this.handle.className = 'resize-handle'
this.handle.addEventListener('mousedown', (e) => {
e.preventDefault()
window.addEventListener('mousemove', this.onResizeFn)
window.addEventListener('mouseup', this.onResizeEndFn)
})
this.container.append(image)
this.container.append(this.handle)
this.dom = this.container
}
onResize(e: MouseEvent) {
this.width = e.pageX - this.container.getBoundingClientRect().left
this.setWidth(this.width)
}
onResizeEnd() {
window.removeEventListener('mousemove', this.onResizeFn)
if (this.updating === this.width) return
this.updating = this.width
const tr = this.view.state.tr
tr.setNodeMarkup(this.getPos(), undefined, {
...this.node.attrs,
width: this.width
})
this.view.dispatch(tr)
}
setWidth(width: number) {
this.container.style.width = `${width}px`
}
}
export default (path?: string): ProseMirrorExtension => ({
schema: (prev) => ({
...prev,
nodes: (prev.nodes as any).update('image', imageSchema)
}),
plugins: (prev, schema) => [...prev, imageInput(schema, path)],
nodeViews: {
// FIXME something is not right
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
image: (node, view, getPos) => {
return new ImageView(node, view, getPos, view.state.schema, path)
}
}
})