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 accentHeader: boolean = false
export let gap: string | undefined = undefined export let gap: string | undefined = undefined
export let width: 'large' | 'medium' | 'small' | 'x-small' | 'menu' = 'large' export let width: 'large' | 'medium' | 'small' | 'x-small' | 'menu' = 'large'
export let noFade = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -87,7 +88,7 @@
</div> </div>
{/if} {/if}
<div class="antiCard-content"> <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 /> <slot />
</Scroller> </Scroller>
</div> </div>

View File

@ -16,7 +16,9 @@
import { Completion } from '../Completion' import { Completion } from '../Completion'
import textEditorPlugin from '../plugin' import textEditorPlugin from '../plugin'
import StyledTextEditor from './StyledTextEditor.svelte' 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 label: IntlString | undefined = undefined
export let content: string export let content: string
@ -38,6 +40,8 @@
export let enableBackReferences: boolean = false export let enableBackReferences: boolean = false
export let isScrollable: boolean = true export let isScrollable: boolean = true
export let attachFile: FileAttachFunction | undefined = undefined
const Mode = { const Mode = {
View: 1, View: 1,
Edit: 2 Edit: 2
@ -129,6 +133,27 @@
dispatch('open-document', { event, _id, _class }) 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> </script>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->

View File

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

View File

@ -16,7 +16,7 @@
<script lang="ts"> <script lang="ts">
import { IntlString, translate } from '@hcengineering/platform' 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 { AnyExtension, Editor, Extension, HTMLContent } from '@tiptap/core'
import { Level } from '@tiptap/extension-heading' import { Level } from '@tiptap/extension-heading'
import Placeholder from '@tiptap/extension-placeholder' import Placeholder from '@tiptap/extension-placeholder'
@ -24,6 +24,7 @@
import textEditorPlugin from '../plugin' import textEditorPlugin from '../plugin'
import { FormatMode } from '../types' import { FormatMode } from '../types'
import { defaultExtensions } from './extensions' import { defaultExtensions } from './extensions'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
export let content: string = '' export let content: string = ''
export let placeholder: IntlString = textEditorPlugin.string.EditorPlaceholder export let placeholder: IntlString = textEditorPlugin.string.EditorPlaceholder
@ -236,6 +237,22 @@
editor.destroy() 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> </script>
<div class="select-text" style="width: 100%;" bind:this={element} /> <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 Highlight from '@tiptap/extension-highlight'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import TipTapCodeBlock from '@tiptap/extension-code-block'
import Code from '@tiptap/extension-code' import Code from '@tiptap/extension-code'
import TipTapCodeBlock from '@tiptap/extension-code-block'
import Gapcursor from '@tiptap/extension-gapcursor' import Gapcursor from '@tiptap/extension-gapcursor'
import { AnyExtension } from '@tiptap/core'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
import Typography from '@tiptap/extension-typography'
import { CompletionOptions } from '../Completion' import { CompletionOptions } from '../Completion'
import MentionList from './MentionList.svelte' import MentionList from './MentionList.svelte'
import { SvelteRenderer } from './SvelteRenderer' import { SvelteRenderer } from './SvelteRenderer'
import { ImageRef } from './imageExt'
import Typography from '@tiptap/extension-typography'
import { AnyExtension } from '@tiptap/core'
export const tableExtensions = [ export const tableExtensions = [
Table.configure({ 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, PluginKey } from 'prosemirror-state'
import plugin from '../plugin' 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 { export interface ImageOptions {
inline: boolean inline: boolean
HTMLAttributes: Record<string, any> HTMLAttributes: Record<string, any>
showPreview?: (event: MouseEvent, fileId: string) => void attachFile?: FileAttachFunction
reportNode?: (id: string, node: ProseMirrorNode) => void
} }
declare module '@tiptap/core' { declare module '@tiptap/core' {
@ -23,8 +35,14 @@ declare module '@tiptap/core' {
} }
} }
/**
* @public
*/
export const inputRegex = /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/ export const inputRegex = /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/
/**
* @public
*/
export const ImageRef = Node.create<ImageOptions>({ export const ImageRef = Node.create<ImageOptions>({
name: 'image', name: 'image',
@ -79,7 +97,7 @@ export const ImageRef = Node.create<ImageOptions>({
] ]
}, },
renderHTML ({ HTMLAttributes }) { renderHTML ({ node, HTMLAttributes }) {
const merged = mergeAttributes( const merged = mergeAttributes(
{ {
'data-type': this.name 'data-type': this.name
@ -87,7 +105,8 @@ export const ImageRef = Node.create<ImageOptions>({
this.options.HTMLAttributes, this.options.HTMLAttributes,
HTMLAttributes HTMLAttributes
) )
merged.src = getFileUrl(merged['file-id'], 'full') const id = merged['file-id']
merged.src = getFileUrl(id, 'full')
let width: IconSize | undefined let width: IconSize | undefined
switch (merged.width) { switch (merged.width) {
case '32px': case '32px':
@ -105,11 +124,11 @@ export const ImageRef = Node.create<ImageOptions>({
break break
} }
if (width !== undefined) { if (width !== undefined) {
merged.src = getFileUrl(merged['file-id'], width) merged.src = getFileUrl(id, width)
merged.srcset = merged.srcset = getFileUrl(id, width) + ' 1x,' + getFileUrl(id, getIconSize2x(width)) + ' 2x'
getFileUrl(merged['file-id'], width) + ' 1x,' + getFileUrl(merged['file-id'], getIconSize2x(width)) + ' 2x'
} }
merged.class = 'textEditorImage' merged.class = 'textEditorImage'
this.options.reportNode?.(id, node)
return ['img', merged] return ['img', merged]
}, },
@ -140,6 +159,7 @@ export const ImageRef = Node.create<ImageOptions>({
] ]
}, },
addProseMirrorPlugins () { addProseMirrorPlugins () {
const opt = this.options
return [ return [
new Plugin({ new Plugin({
key: new PluginKey('handle-image-paste'), key: new PluginKey('handle-image-paste'),
@ -149,10 +169,9 @@ export const ImageRef = Node.create<ImageOptions>({
.split('\r\n') .split('\r\n')
.filter((it) => !it.startsWith('#')) .filter((it) => !it.startsWith('#'))
let result = false let result = false
const pos = view.posAtCoords({ left: event.x, top: event.y })
for (const uri of uris) { for (const uri of uris) {
if (uri !== '') { if (uri !== '') {
const pos = view.posAtCoords({ left: event.x, top: event.y })
const url = new URL(uri) const url = new URL(uri)
if (url.hostname !== location.hostname) { if (url.hostname !== location.hostname) {
return return
@ -164,7 +183,7 @@ export const ImageRef = Node.create<ImageOptions>({
return return
} }
const content = createNodeFromContent( 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, view.state.schema,
{ {
parseOptions: { parseOptions: {
@ -178,6 +197,36 @@ export const ImageRef = Node.create<ImageOptions>({
result = true 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 return result
}, },
handleClick: (view, pos, event) => { handleClick: (view, pos, event) => {
@ -199,18 +248,27 @@ export const ImageRef = Node.create<ImageOptions>({
action: async (props, event) => {}, action: async (props, event) => {},
component: Menu, component: Menu,
props: { props: {
actions: ['32px', '64px', '128px', '256px', '512px', '25%', '50%', '100%', plugin.string.Unset].map( actions: [
(it) => { '32px',
return { '64px',
label: it === plugin.string.Unset ? it : getEmbeddedLabel(it), '128px',
action: async () => { '256px',
view.dispatch( '512px',
view.state.tr.setNodeAttribute(pos, 'width', it === plugin.string.Unset ? null : it) '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) => {}, action: async (props, event) => {},
component: Menu, component: Menu,
props: { props: {
actions: ['32px', '64px', '128px', '256px', '512px', '25%', '50%', '100%', plugin.string.Unset].map( actions: [
(it) => { '32px',
return { '64px',
label: it === plugin.string.Unset ? it : getEmbeddedLabel(it), '128px',
action: async () => { '256px',
view.dispatch( '512px',
view.state.tr.setNodeAttribute(pos, 'height', it === plugin.string.Unset ? null : it) '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 if (space === undefined || objectId === undefined || _class === undefined) return
try { try {
const uuid = await uploadFile(file) const uuid = await uploadFile(file)
@ -164,6 +164,7 @@
saveDraft() saveDraft()
dispatch('attach', { action: 'saved', value: attachments.size }) dispatch('attach', { action: 'saved', value: attachments.size })
dispatch('attached', _id) dispatch('attached', _id)
return { file: uuid, type: file.type }
} catch (err: any) { } catch (err: any) {
setPlatformStatus(unknownError(err)) setPlatformStatus(unknownError(err))
} }
@ -199,6 +200,7 @@
removedAttachments.add(attachment) removedAttachments.add(attachment)
attachments.delete(attachment._id) attachments.delete(attachment._id)
attachments = attachments attachments = attachments
refInput.removeAttachment(attachment.file)
saveDraft() saveDraft()
dispatch('detached', attachment._id) dispatch('detached', attachment._id)
} }
@ -367,6 +369,9 @@
on:attach={() => { on:attach={() => {
attach() attach()
}} }}
attachFile={async (file) => {
return createAttachment(file)
}}
/> />
</div> </div>
{#if attachments.size && enableAttachments} {#if attachments.size && enableAttachments}

View File

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