229 lines
5.7 KiB
TypeScript
229 lines
5.7 KiB
TypeScript
|
import { Plugin } from 'prosemirror-state'
|
||
|
import type { Node, Schema } from 'prosemirror-model'
|
||
|
import type { EditorView } from 'prosemirror-view'
|
||
|
// import { convertFileSrc } from '@tauri-apps/api/tauri'
|
||
|
// import { resolvePath, dirname } from '../../remote'
|
||
|
// import { isTauri } from '../../env'
|
||
|
import type { ProseMirrorExtension } from '../state'
|
||
|
|
||
|
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
|
||
|
onResizeFn: any
|
||
|
onResizeEndFn: any
|
||
|
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)
|
||
|
}
|
||
|
}
|
||
|
})
|