EQMS-885 Refactor image and file extensions (#5584)

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
This commit is contained in:
Alexander Onnikov
2024-05-13 21:11:40 +07:00
committed by GitHub
parent adca777b33
commit cb82eb7a9f
11 changed files with 318 additions and 229 deletions
@@ -64,9 +64,9 @@
import { noSelectionRender, renderCursor } from './editor/collaboration'
import { defaultEditorAttributes } from './editor/editorProps'
import { EmojiExtension } from './extension/emoji'
import { ImageExtension } from './extension/imageExt'
import { ImageUploadExtension } from './extension/imageUploadExt'
import { type FileAttachFunction } from './extension/types'
import { FileExtension } from './extension/fileExt'
import { FileUploadExtension } from './extension/fileUploadExt'
import { LeftMenuExtension } from './extension/leftMenu'
import { InlineCommandsExtension } from './extension/inlineCommands'
import { InlinePopupExtension } from './extension/inlinePopup'
@@ -279,16 +279,14 @@
if (attachFile !== undefined) {
if (canEmbedFiles) {
optionalExtensions.push(
FileExtension.configure({
inline: true,
FileUploadExtension.configure({
attachFile
})
)
}
if (canEmbedImages) {
optionalExtensions.push(
ImageExtension.configure({
inline: true,
ImageUploadExtension.configure({
attachFile,
uploadUrl: getMetadata(presentation.metadata.UploadURL)
})
@@ -19,13 +19,10 @@
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { DecorationSet } from '@tiptap/pm/view'
import { onDestroy, onMount } from 'svelte'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { MarkupNode, ReferenceNode, jsonToPmNode } from '@hcengineering/text'
import { calculateDecorations } from './diff/decorations'
import { defaultEditorAttributes } from './editor/editorProps'
import { ImageExtension } from './extension/imageExt'
import { EditorKit } from '../kits/editor-kit'
export let content: MarkupNode
@@ -82,14 +79,7 @@
element,
content,
editable: false,
extensions: [
EditorKit,
ReferenceNode,
ImageExtension.configure({
uploadUrl: getMetadata(presentation.metadata.UploadURL)
}),
DecorationExtension
],
extensions: [EditorKit, ReferenceNode, DecorationExtension],
onTransaction: () => {
// force re-render so `editor.isActive` works as expected
editor = editor
@@ -27,7 +27,7 @@
import { completionConfig, inlineCommandsConfig } from './extensions'
import { EmojiExtension } from './extension/emoji'
import { FocusExtension } from './extension/focus'
import { ImageExtension } from './extension/imageExt'
import { ImageUploadExtension } from './extension/imageUploadExt'
import { InlineCommandsExtension } from './extension/inlineCommands'
import { type FileAttachFunction } from './extension/types'
import { RefAction } from '../types'
@@ -175,9 +175,7 @@
}
function configureExtensions (): AnyExtension[] {
const imagePlugin = ImageExtension.configure({
inline: true,
HTMLAttributes: {},
const imageUploadPlugin = ImageUploadExtension.configure({
attachFile,
uploadUrl: getMetadata(presentation.metadata.UploadURL)
})
@@ -194,7 +192,7 @@
extensions.push(completionPlugin)
}
extensions.push(
imagePlugin,
imageUploadPlugin,
FocusExtension.configure({ onCanBlur: (value: boolean) => (canBlur = value), onFocus: handleFocus })
)
if (enableEmojiReplace) {
@@ -21,6 +21,7 @@
import { AnyExtension, Content, Editor, FocusPosition, mergeAttributes } from '@tiptap/core'
import Placeholder from '@tiptap/extension-placeholder'
import { ParseOptions } from '@tiptap/pm/model'
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import { deleteAttachment } from '../command/deleteAttachment'
@@ -34,8 +35,6 @@
import { InlineStyleToolbarExtension } from './extension/inlineStyleToolbar'
import { SubmitExtension } from './extension/submit'
import { EditorKit } from '../kits/editor-kit'
import { FileAttachFunction } from './extension/types'
import { ParseOptions } from '@tiptap/pm/model'
export let content: Markup = EmptyMarkup
export let placeholder: IntlString = textEditorPlugin.string.EditorPlaceholder
@@ -45,7 +44,6 @@
export let editorAttributes: Record<string, string> = {}
export let boundary: HTMLElement | undefined = undefined
export let autofocus: FocusPosition = false
export let attachFile: FileAttachFunction | undefined = undefined
let element: HTMLElement
let editor: Editor
@@ -16,11 +16,8 @@ import { getFileUrl, PDFViewer } from '@hcengineering/presentation'
import { FileNode, type FileOptions as FileNodeOptions } from '@hcengineering/text'
import { showPopup } from '@hcengineering/ui'
import { nodeInputRule } from '@tiptap/core'
import { type Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { type EditorView } from '@tiptap/pm/view'
import filesize from 'filesize'
import { type FileAttachFunction } from './types'
const attachIcon =
@@ -33,7 +30,6 @@ const imageIcon =
*/
export interface FileOptions extends FileNodeOptions {
attachFile?: FileAttachFunction
reportNode?: (id: string, node: ProseMirrorNode) => void
}
/**
@@ -84,7 +80,6 @@ export const FileExtension = FileNode.extend<FileOptions>({
let href: string = ''
if (id != null) {
href = getFileUrl(id, 'full', fileName)
this.options.reportNode?.(id, node)
}
const linkAttributes = {
class: 'file-name',
@@ -115,74 +110,15 @@ export const FileExtension = FileNode.extend<FileOptions>({
},
addProseMirrorPlugins () {
const opt = this.options
function handleDrop (
view: EditorView,
pos: { pos: number, inside: number } | null,
dataTransfer: DataTransfer
): any {
let result = false
const files = dataTransfer?.files
if (files !== undefined && opt.attachFile !== undefined) {
let hasNotImageFile: boolean = false
for (let i = 0; i < files.length; i++) {
const file = files.item(i)
if (file != null) {
if (!file.type.startsWith('image')) {
hasNotImageFile = true
}
}
}
if (!hasNotImageFile) {
return false
}
for (let i = 0; i < files.length; i++) {
const file = files.item(i)
if (file != null) {
result = true
void opt.attachFile(file).then((id) => {
if (id !== undefined) {
const node = view.state.schema.nodes.file.create({
'file-id': id.file,
'data-file-name': file.name,
'data-file-type': file.type,
'data-file-size': file.size
})
const transaction = view.state.tr.insert(pos?.pos ?? 0, node)
view.dispatch(transaction)
}
})
}
}
}
return result
}
return [
new Plugin({
key: new PluginKey('handle-file-paste'),
key: new PluginKey('handle-file-open'),
props: {
handlePaste (view, event) {
const dataTransfer = event.clipboardData
if (dataTransfer !== null) {
const res = handleDrop(view, { pos: view.state.selection.$from.pos, inside: 0 }, dataTransfer)
if (res === true) {
event.preventDefault()
event.stopPropagation()
}
return res
}
},
handleDrop (view, event) {
event.preventDefault()
event.stopPropagation()
const dataTransfer = event.dataTransfer
if (dataTransfer !== null) {
return handleDrop(view, view.posAtCoords({ left: event.x, top: event.y }), dataTransfer)
}
},
handleDoubleClickOn (view, pos, node, nodePos, event) {
if (node.type.name !== FileExtension.name) {
return
}
const fileId = node.attrs['file-id'] ?? ''
if (fileId === '') return
const fileName = node.attrs['data-file-name'] ?? ''
@@ -0,0 +1,106 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { type EditorView } from '@tiptap/pm/view'
import { type FileAttachFunction } from './types'
/**
* @public
*/
export interface FileUploadOptions {
attachFile?: FileAttachFunction
}
/**
* @public
*/
export const FileUploadExtension = Extension.create<FileUploadOptions>({
name: 'file-upload-ext',
addProseMirrorPlugins () {
const opt = this.options
function handleDrop (
view: EditorView,
pos: { pos: number, inside: number } | null,
dataTransfer: DataTransfer
): any {
let result = false
const files = dataTransfer?.files
if (files !== undefined && opt.attachFile !== undefined) {
let hasNotImageFile: boolean = false
for (let i = 0; i < files.length; i++) {
const file = files.item(i)
if (file != null) {
if (!file.type.startsWith('image/')) {
hasNotImageFile = true
}
}
}
if (!hasNotImageFile) {
return false
}
for (let i = 0; i < files.length; i++) {
const file = files.item(i)
if (file != null && !file.type.startsWith('image/')) {
result = true
void opt.attachFile(file).then((id) => {
if (id !== undefined) {
const node = view.state.schema.nodes.file.create({
'file-id': id.file,
'data-file-name': file.name,
'data-file-type': file.type,
'data-file-size': file.size
})
const transaction = view.state.tr.insert(pos?.pos ?? 0, node)
view.dispatch(transaction)
}
})
}
}
}
return result
}
return [
new Plugin({
key: new PluginKey('handle-file-paste'),
props: {
handlePaste (view, event) {
const dataTransfer = event.clipboardData
if (dataTransfer !== null) {
const res = handleDrop(view, { pos: view.state.selection.$from.pos, inside: 0 }, dataTransfer)
if (res === true) {
event.preventDefault()
event.stopPropagation()
}
return res
}
},
handleDrop (view, event) {
event.preventDefault()
event.stopPropagation()
const dataTransfer = event.dataTransfer
if (dataTransfer !== null) {
return handleDrop(view, view.posAtCoords({ left: event.x, top: event.y }), dataTransfer)
}
}
}
})
]
}
})
@@ -12,16 +12,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { PDFViewer, getImageSize } from '@hcengineering/presentation'
import { PDFViewer } from '@hcengineering/presentation'
import { showPopup } from '@hcengineering/ui'
import { ImageNode, type ImageOptions as ImageNodeOptions } from '@hcengineering/text'
import { type IconSize, getIconSize2x, showPopup } from '@hcengineering/ui'
import { type IconSize, getIconSize2x } from '@hcengineering/ui'
import { mergeAttributes, nodeInputRule } from '@tiptap/core'
import { type Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { type EditorView } from '@tiptap/pm/view'
import { setPlatformStatus, unknownError } from '@hcengineering/platform'
import { type FileAttachFunction } from './types'
/**
* @public
@@ -32,8 +28,6 @@ export type ImageAlignment = 'center' | 'left' | 'right'
* @public
*/
export interface ImageOptions extends ImageNodeOptions {
attachFile?: FileAttachFunction
reportNode?: (id: string, node: ProseMirrorNode) => void
uploadUrl: string
}
@@ -70,16 +64,8 @@ declare module '@tiptap/core' {
*/
export const inputRegex = /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/
function getType (type: string): 'image' | 'other' {
if (type.startsWith('image/')) {
return 'image'
}
return 'other'
}
// This is a simplified version of getFileUrl from presentation plugin, which we cannot use
function getFileUrl (fileId: string, size: IconSize = 'full', uploadUrl: string): string {
export function getFileUrl (fileId: string, size: IconSize = 'full', uploadUrl: string): string {
return `${uploadUrl}?file=${fileId}&size=${size as string}`
}
@@ -149,7 +135,6 @@ export const ImageExtension = ImageNode.extend<ImageOptions>({
}
imgAttributes.class = 'text-editor-image'
imgAttributes.contentEditable = false
this.options.reportNode?.(id, node)
}
return ['div', divAttributes, ['img', imgAttributes]]
@@ -203,90 +188,12 @@ export const ImageExtension = ImageNode.extend<ImageOptions>({
},
addProseMirrorPlugins () {
const attachFile = this.options.attachFile
const uploadUrl = this.options.uploadUrl
function handleDrop (
view: EditorView,
pos: { pos: number, inside: number } | null,
dataTransfer: DataTransfer
): any {
const uris = (dataTransfer.getData('text/uri-list') ?? '').split('\r\n').filter((it) => !it.startsWith('#'))
let result = false
for (const uri of uris) {
if (uri !== '') {
const url = new URL(uri)
if (uploadUrl === undefined || !url.href.includes(uploadUrl)) {
continue
}
const _file = (url.searchParams.get('file') ?? '').split('/').join('')
if (_file.trim().length === 0) {
continue
}
const ctype = dataTransfer.getData('application/contentType')
const type = getType(ctype ?? 'other')
if (type === 'image') {
const node = view.state.schema.nodes.image.create({
'file-id': _file,
src: getFileUrl(_file, 'full', uploadUrl)
})
const transaction = view.state.tr.insert(pos?.pos ?? 0, node)
view.dispatch(transaction)
result = true
}
}
}
if (result) {
return result
}
const files = dataTransfer?.files
if (files !== undefined && attachFile !== undefined) {
for (let i = 0; i < files.length; i++) {
const file = files.item(i)
if (file != null) {
result = true
void handleImageUpload(file, view, pos, attachFile, uploadUrl)
}
}
}
return result
}
return [
new Plugin({
key: new PluginKey('handle-image-paste'),
props: {
handlePaste (view, event) {
const dataTransfer = event.clipboardData
if (dataTransfer !== null) {
const res = handleDrop(view, { pos: view.state.selection.$from.pos, inside: 0 }, dataTransfer)
if (res === true) {
event.preventDefault()
event.stopPropagation()
}
return res
}
},
handleDrop (view, event) {
event.preventDefault()
event.stopPropagation()
const dataTransfer = event.dataTransfer
if (dataTransfer !== null) {
return handleDrop(view, view.posAtCoords({ left: event.x, top: event.y }), dataTransfer)
}
}
}
}),
new Plugin({
key: new PluginKey('handle-image-open'),
props: {
handleDoubleClickOn (view, pos, node, nodePos, event) {
if (node.type.name !== 'image') {
if (node.type.name !== ImageExtension.name) {
return
}
@@ -310,37 +217,3 @@ export const ImageExtension = ImageNode.extend<ImageOptions>({
]
}
})
async function handleImageUpload (
file: File,
view: EditorView,
pos: { pos: number, inside: number } | null,
attachFile: FileAttachFunction,
uploadUrl: string
): Promise<void> {
const attached = await attachFile(file)
if (attached === undefined) {
return
}
if (!attached.type.includes('image')) {
return
}
try {
const url = getFileUrl(attached.file, 'full', uploadUrl)
const size = await getImageSize(file, url)
const node = view.state.schema.nodes.image.create({
'file-id': attached.file,
src: url,
width: Math.round(size.width / size.pixelRatio)
})
const transaction = view.state.tr.insert(pos?.pos ?? 0, node)
view.dispatch(transaction)
} catch (e) {
void setPlatformStatus(unknownError(e))
}
}
@@ -0,0 +1,166 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { getImageSize } from '@hcengineering/presentation'
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { type EditorView } from '@tiptap/pm/view'
import { setPlatformStatus, unknownError } from '@hcengineering/platform'
import { getFileUrl } from './imageExt'
import { type FileAttachFunction } from './types'
/**
* @public
*/
export interface ImageUploadOptions {
attachFile?: FileAttachFunction
uploadUrl: string
}
function getType (type: string): 'image' | 'other' {
if (type.startsWith('image/')) {
return 'image'
}
return 'other'
}
/**
* @public
*/
export const ImageUploadExtension = Extension.create<ImageUploadOptions>({
addOptions () {
return {
uploadUrl: ''
}
},
addProseMirrorPlugins () {
const attachFile = this.options.attachFile
const uploadUrl = this.options.uploadUrl
function handleDrop (
view: EditorView,
pos: { pos: number, inside: number } | null,
dataTransfer: DataTransfer
): any {
const uris = (dataTransfer.getData('text/uri-list') ?? '').split('\r\n').filter((it) => !it.startsWith('#'))
let result = false
for (const uri of uris) {
if (uri !== '') {
const url = new URL(uri)
if (uploadUrl === undefined || !url.href.includes(uploadUrl)) {
continue
}
const _file = (url.searchParams.get('file') ?? '').split('/').join('')
if (_file.trim().length === 0) {
continue
}
const ctype = dataTransfer.getData('application/contentType')
const type = getType(ctype ?? 'other')
if (type === 'image') {
const node = view.state.schema.nodes.image.create({
'file-id': _file,
src: getFileUrl(_file, 'full', uploadUrl)
})
const transaction = view.state.tr.insert(pos?.pos ?? 0, node)
view.dispatch(transaction)
result = true
}
}
}
if (result) {
return result
}
const files = dataTransfer?.files
if (files !== undefined && attachFile !== undefined) {
for (let i = 0; i < files.length; i++) {
const file = files.item(i)
if (file != null && file.type.startsWith('image/')) {
result = true
void handleImageUpload(file, view, pos, attachFile, uploadUrl)
}
}
}
return result
}
return [
new Plugin({
key: new PluginKey('handle-image-paste'),
props: {
handlePaste (view, event) {
const dataTransfer = event.clipboardData
if (dataTransfer !== null) {
const res = handleDrop(view, { pos: view.state.selection.$from.pos, inside: 0 }, dataTransfer)
if (res === true) {
event.preventDefault()
event.stopPropagation()
}
return res
}
},
handleDrop (view, event) {
event.preventDefault()
event.stopPropagation()
const dataTransfer = event.dataTransfer
if (dataTransfer !== null) {
return handleDrop(view, view.posAtCoords({ left: event.x, top: event.y }), dataTransfer)
}
}
}
})
]
}
})
async function handleImageUpload (
file: File,
view: EditorView,
pos: { pos: number, inside: number } | null,
attachFile: FileAttachFunction,
uploadUrl: string
): Promise<void> {
const attached = await attachFile(file)
if (attached === undefined) {
return
}
if (!attached.type.includes('image')) {
return
}
try {
const url = getFileUrl(attached.file, 'full', uploadUrl)
const size = await getImageSize(file, url)
const node = view.state.schema.nodes.image.create({
'file-id': attached.file,
src: url,
width: Math.round(size.width / size.pixelRatio)
})
const transaction = view.state.tr.insert(pos?.pos ?? 0, node)
view.dispatch(transaction)
} catch (e) {
void setPlatformStatus(unknownError(e))
}
}
+1
View File
@@ -73,6 +73,7 @@ export {
export { InlinePopupExtension } from './components/extension/inlinePopup'
export { InlineStyleToolbarExtension, type InlineStyleToolbarOptions } from './components/extension/inlineStyleToolbar'
export { ImageExtension, type ImageOptions } from './components/extension/imageExt'
export { ImageUploadExtension, type ImageUploadOptions } from './components/extension/imageUploadExt'
export { TodoItemExtension, TodoListExtension } from './components/extension/todo'
export * from './command/deleteAttachment'
+24 -1
View File
@@ -22,10 +22,14 @@ import TaskList from '@tiptap/extension-task-list'
import { DefaultKit, type DefaultKitOptions } from './default-kit'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { CodeBlockExtension, codeBlockOptions } from '@hcengineering/text'
import { CodemarkExtension } from '../components/extension/codemark'
import { NodeUuidExtension } from '../components/extension/nodeUuid'
import { Table, TableCell, TableRow } from '../components/extension/table'
import { type ImageOptions, ImageExtension } from '../components/extension/imageExt'
import { type FileOptions, FileExtension } from '../components/extension/fileExt'
const headingLevels: Level[] = [1, 2, 3]
@@ -53,6 +57,8 @@ export const taskListExtensions = [
export interface EditorKitOptions extends DefaultKitOptions {
history?: false
file?: Partial<FileOptions> | false
image?: Partial<ImageOptions> | false
}
export const EditorKit = Extension.create<EditorKitOptions>({
@@ -86,7 +92,24 @@ export const EditorKit = Extension.create<EditorKitOptions>({
]
}),
NodeUuidExtension,
...tableExtensions
...tableExtensions,
...(this.options.file !== false
? [
FileExtension.configure({
inline: true,
...this.options.file
})
]
: []),
...(this.options.image !== false
? [
ImageExtension.configure({
inline: true,
uploadUrl: getMetadata(presentation.metadata.UploadURL) ?? '',
...this.options.image
})
]
: [])
// ...taskListExtensions // Disable since tasks are not working properly now.
]
}
@@ -21,7 +21,7 @@
import {
CollaboratorEditor,
HeadingsExtension,
ImageOptions,
ImageUploadOptions,
SvelteNodeViewRenderer,
TodoItemExtension,
TodoListExtension
@@ -37,7 +37,7 @@
export let object: Document
export let readonly = false
export let boundary: HTMLElement | undefined = undefined
export let attachFile: ImageOptions['attachFile'] | undefined = undefined
export let attachFile: ImageUploadOptions['attachFile'] | undefined = undefined
export let focusIndex = -1
export let overflow: 'auto' | 'none' = 'none'
export let editorAttributes: Record<string, string> = {}