UBER-395: Allow to drop images into description (#3382)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-06-07 16:38:20 +07:00 committed by GitHub
parent 3c5648c87f
commit aa09fa62c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 162 additions and 42 deletions

View File

@ -33,6 +33,7 @@
export let accentHeader: boolean = false
export let gap: string | undefined = undefined
export let width: 'large' | 'medium' | 'small' | 'x-small' | 'menu' = 'large'
export let noFade = false
const dispatch = createEventDispatcher()
@ -87,7 +88,7 @@
</div>
{/if}
<div class="antiCard-content">
<Scroller padding={$$slots.pool ? '.5rem 1.5rem' : '.5rem 1.5rem 1.5rem'} {gap}>
<Scroller padding={$$slots.pool ? '.5rem 1.5rem' : '.5rem 1.5rem 1.5rem'} {gap} {noFade}>
<slot />
</Scroller>
</div>

View File

@ -16,7 +16,9 @@
import { Completion } from '../Completion'
import textEditorPlugin from '../plugin'
import StyledTextEditor from './StyledTextEditor.svelte'
import { completionConfig, imagePlugin } from './extensions'
import { completionConfig } from './extensions'
import { ImageRef, FileAttachFunction } from './imageExt'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
export let label: IntlString | undefined = undefined
export let content: string
@ -38,6 +40,8 @@
export let enableBackReferences: boolean = false
export let isScrollable: boolean = true
export let attachFile: FileAttachFunction | undefined = undefined
const Mode = {
View: 1,
Edit: 2
@ -129,6 +133,27 @@
dispatch('open-document', { event, _id, _class })
}
})
const attachments = new Map<string, ProseMirrorNode>()
const imagePlugin = ImageRef.configure({
inline: false,
HTMLAttributes: {},
attachFile,
reportNode: (id, node) => {
attachments.set(id, node)
}
})
/**
* @public
*/
export function removeAttachment (id: string): void {
const nde = attachments.get(id)
if (nde !== undefined) {
textEditor.removeNode(nde)
}
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->

View File

@ -60,6 +60,7 @@
import LinkPopup from './LinkPopup.svelte'
import StyleButton from './StyleButton.svelte'
import TextEditor from './TextEditor.svelte'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
const dispatch = createEventDispatcher()
@ -450,6 +451,13 @@
: buttonSize === 'medium'
? 'h-5 max-h-5'
: 'h-4 max-h-4'
/**
* @public
*/
export function removeNode (nde: ProseMirrorNode): void {
textEditor.removeNode(nde)
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
@ -587,6 +595,7 @@
bind:content
{placeholder}
{extensions}
bind:this={textEditor}
bind:isEmpty
on:value
on:content={(ev) => {

View File

@ -16,7 +16,7 @@
<script lang="ts">
import { IntlString, translate } from '@hcengineering/platform'
import type { FocusPosition } from '@tiptap/core'
import { FocusPosition } from '@tiptap/core'
import { AnyExtension, Editor, Extension, HTMLContent } from '@tiptap/core'
import { Level } from '@tiptap/extension-heading'
import Placeholder from '@tiptap/extension-placeholder'
@ -24,6 +24,7 @@
import textEditorPlugin from '../plugin'
import { FormatMode } from '../types'
import { defaultExtensions } from './extensions'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
export let content: string = ''
export let placeholder: IntlString = textEditorPlugin.string.EditorPlaceholder
@ -236,6 +237,22 @@
editor.destroy()
}
})
/**
* @public
*/
export function removeNode (nde: ProseMirrorNode): void {
const deleteOp = (n: ProseMirrorNode, pos: number) => {
if (nde === n) {
// const pos = editor.view.posAtDOM(nde, 0)
editor.view.dispatch(editor.view.state.tr.delete(pos, pos + 1))
}
n.descendants(deleteOp)
}
editor.view.state.doc.descendants((n, pos) => {
deleteOp(n, pos)
})
}
</script>
<div class="select-text" style="width: 100%;" bind:this={element} />

View File

@ -10,17 +10,16 @@ import Heading, { Level } from '@tiptap/extension-heading'
import Highlight from '@tiptap/extension-highlight'
import StarterKit from '@tiptap/starter-kit'
import TipTapCodeBlock from '@tiptap/extension-code-block'
import Code from '@tiptap/extension-code'
import TipTapCodeBlock from '@tiptap/extension-code-block'
import Gapcursor from '@tiptap/extension-gapcursor'
import { AnyExtension } from '@tiptap/core'
import Link from '@tiptap/extension-link'
import Typography from '@tiptap/extension-typography'
import { CompletionOptions } from '../Completion'
import MentionList from './MentionList.svelte'
import { SvelteRenderer } from './SvelteRenderer'
import { ImageRef } from './imageExt'
import Typography from '@tiptap/extension-typography'
import { AnyExtension } from '@tiptap/core'
export const tableExtensions = [
Table.configure({
@ -176,8 +175,3 @@ export const completionConfig: Partial<CompletionOptions> = {
}
}
}
/**
* @public
*/
export const imagePlugin = ImageRef.configure({ inline: false, HTMLAttributes: {} })

View File

@ -5,11 +5,23 @@ import { Node, createNodeFromContent, mergeAttributes, nodeInputRule } from '@ti
import { Plugin, PluginKey } from 'prosemirror-state'
import plugin from '../plugin'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
/**
* @public
*/
export type FileAttachFunction = (file: File) => Promise<{ file: string, type: string } | undefined>
/**
* @public
*/
export interface ImageOptions {
inline: boolean
HTMLAttributes: Record<string, any>
showPreview?: (event: MouseEvent, fileId: string) => void
attachFile?: FileAttachFunction
reportNode?: (id: string, node: ProseMirrorNode) => void
}
declare module '@tiptap/core' {
@ -23,8 +35,14 @@ declare module '@tiptap/core' {
}
}
/**
* @public
*/
export const inputRegex = /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/
/**
* @public
*/
export const ImageRef = Node.create<ImageOptions>({
name: 'image',
@ -79,7 +97,7 @@ export const ImageRef = Node.create<ImageOptions>({
]
},
renderHTML ({ HTMLAttributes }) {
renderHTML ({ node, HTMLAttributes }) {
const merged = mergeAttributes(
{
'data-type': this.name
@ -87,7 +105,8 @@ export const ImageRef = Node.create<ImageOptions>({
this.options.HTMLAttributes,
HTMLAttributes
)
merged.src = getFileUrl(merged['file-id'], 'full')
const id = merged['file-id']
merged.src = getFileUrl(id, 'full')
let width: IconSize | undefined
switch (merged.width) {
case '32px':
@ -105,11 +124,11 @@ export const ImageRef = Node.create<ImageOptions>({
break
}
if (width !== undefined) {
merged.src = getFileUrl(merged['file-id'], width)
merged.srcset =
getFileUrl(merged['file-id'], width) + ' 1x,' + getFileUrl(merged['file-id'], getIconSize2x(width)) + ' 2x'
merged.src = getFileUrl(id, width)
merged.srcset = getFileUrl(id, width) + ' 1x,' + getFileUrl(id, getIconSize2x(width)) + ' 2x'
}
merged.class = 'textEditorImage'
this.options.reportNode?.(id, node)
return ['img', merged]
},
@ -140,6 +159,7 @@ export const ImageRef = Node.create<ImageOptions>({
]
},
addProseMirrorPlugins () {
const opt = this.options
return [
new Plugin({
key: new PluginKey('handle-image-paste'),
@ -149,10 +169,9 @@ export const ImageRef = Node.create<ImageOptions>({
.split('\r\n')
.filter((it) => !it.startsWith('#'))
let result = false
const pos = view.posAtCoords({ left: event.x, top: event.y })
for (const uri of uris) {
if (uri !== '') {
const pos = view.posAtCoords({ left: event.x, top: event.y })
const url = new URL(uri)
if (url.hostname !== location.hostname) {
return
@ -164,7 +183,7 @@ export const ImageRef = Node.create<ImageOptions>({
return
}
const content = createNodeFromContent(
`<img data-type='image' width='25%' file-id='${_file}'></img>`,
`<img data-type='image' width='75%' file-id='${_file}'></img>`,
view.state.schema,
{
parseOptions: {
@ -178,6 +197,36 @@ export const ImageRef = Node.create<ImageOptions>({
result = true
}
}
if (result) {
return result
}
const files = event.dataTransfer?.files
if (files !== undefined && opt.attachFile !== undefined) {
event.preventDefault()
event.stopPropagation()
for (let i = 0; i < files.length; i++) {
const file = files.item(i)
if (file != null) {
void opt.attachFile(file).then((id) => {
if (id !== undefined) {
if (id.type.includes('image')) {
const content = createNodeFromContent(
`<img data-type='image' width='75%' file-id='${id.file}'></img>`,
view.state.schema,
{
parseOptions: {
preserveWhitespace: 'full'
}
}
)
view.dispatch(view.state.tr.insert(pos?.pos ?? 0, content))
}
}
})
}
}
}
return result
},
handleClick: (view, pos, event) => {
@ -199,18 +248,27 @@ export const ImageRef = Node.create<ImageOptions>({
action: async (props, event) => {},
component: Menu,
props: {
actions: ['32px', '64px', '128px', '256px', '512px', '25%', '50%', '100%', plugin.string.Unset].map(
(it) => {
return {
label: it === plugin.string.Unset ? it : getEmbeddedLabel(it),
action: async () => {
view.dispatch(
view.state.tr.setNodeAttribute(pos, 'width', it === plugin.string.Unset ? null : it)
)
}
actions: [
'32px',
'64px',
'128px',
'256px',
'512px',
'25%',
'50%',
'75%',
'100%',
plugin.string.Unset
].map((it) => {
return {
label: it === plugin.string.Unset ? it : getEmbeddedLabel(it),
action: async () => {
view.dispatch(
view.state.tr.setNodeAttribute(pos, 'width', it === plugin.string.Unset ? null : it)
)
}
}
)
})
}
},
{
@ -218,18 +276,27 @@ export const ImageRef = Node.create<ImageOptions>({
action: async (props, event) => {},
component: Menu,
props: {
actions: ['32px', '64px', '128px', '256px', '512px', '25%', '50%', '100%', plugin.string.Unset].map(
(it) => {
return {
label: it === plugin.string.Unset ? it : getEmbeddedLabel(it),
action: async () => {
view.dispatch(
view.state.tr.setNodeAttribute(pos, 'height', it === plugin.string.Unset ? null : it)
)
}
actions: [
'32px',
'64px',
'128px',
'256px',
'512px',
'25%',
'50%',
'75%',
'100%',
plugin.string.Unset
].map((it) => {
return {
label: it === plugin.string.Unset ? it : getEmbeddedLabel(it),
action: async () => {
view.dispatch(
view.state.tr.setNodeAttribute(pos, 'height', it === plugin.string.Unset ? null : it)
)
}
}
)
})
}
}
]

View File

@ -138,7 +138,7 @@
}
}
async function createAttachment (file: File) {
async function createAttachment (file: File): Promise<{ file: string; type: string } | undefined> {
if (space === undefined || objectId === undefined || _class === undefined) return
try {
const uuid = await uploadFile(file)
@ -164,6 +164,7 @@
saveDraft()
dispatch('attach', { action: 'saved', value: attachments.size })
dispatch('attached', _id)
return { file: uuid, type: file.type }
} catch (err: any) {
setPlatformStatus(unknownError(err))
}
@ -199,6 +200,7 @@
removedAttachments.add(attachment)
attachments.delete(attachment._id)
attachments = attachments
refInput.removeAttachment(attachment.file)
saveDraft()
dispatch('detached', attachment._id)
}
@ -367,6 +369,9 @@
on:attach={() => {
attach()
}}
attachFile={async (file) => {
return createAttachment(file)
}}
/>
</div>
{#if attachments.size && enableAttachments}

View File

@ -533,6 +533,7 @@
onCancel={showConfirmationDialog}
hideAttachments={attachments.size === 0}
hideSubheader={!parentIssue}
noFade={true}
on:changeContent
>
<svelte:fragment slot="header">
@ -611,6 +612,7 @@
alwaysEdit
showButtons={false}
kind={'indented'}
isScrollable={false}
enableBackReferences={true}
enableAttachments={false}
bind:content={object.description}