UBER-187: Inline attachments (#3264)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-05-27 22:49:14 +07:00 committed by GitHub
parent 79486e1b8c
commit 01a1a3fe6c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 276 additions and 29 deletions

View File

@ -37,6 +37,9 @@
"CategoryColumn": "Columns",
"Table": "Table",
"InsertTable": "Insert table",
"TableOptions": "Customize table"
"TableOptions": "Customize table",
"Width": "Width",
"Height": "Height",
"Unset": "Unset"
}
}

View File

@ -37,6 +37,9 @@
"CategoryColumn": "Колонки",
"Table": "Таблица",
"InsertTable": "Добавить таблицу",
"TableOptions": "Настроить таблицу"
"TableOptions": "Настроить таблицу",
"Width": "Щирина",
"Height": "Высота",
"Unset": "Убрать"
}
}

View File

@ -16,7 +16,7 @@
import { Completion } from '../Completion'
import textEditorPlugin from '../plugin'
import StyledTextEditor from './StyledTextEditor.svelte'
import { completionConfig } from './extensions'
import { completionConfig, imagePlugin } from './extensions'
export let label: IntlString | undefined = undefined
export let content: string
@ -161,7 +161,7 @@
{enableFormatting}
{autofocus}
{isScrollable}
extensions={enableBackReferences ? [completionPlugin] : []}
extensions={enableBackReferences ? [completionPlugin, imagePlugin] : [imagePlugin]}
bind:content={rawValue}
bind:this={textEditor}
on:attach

View File

@ -17,6 +17,7 @@ import Link from '@tiptap/extension-link'
import { CompletionOptions } from '../Completion'
import MentionList from './MentionList.svelte'
import { SvelteRenderer } from './SvelteRenderer'
import { ImageRef } from './imageExt'
export const tableExtensions = [
Table.configure({
@ -166,3 +167,8 @@ export const completionConfig: Partial<CompletionOptions> = {
}
}
}
/**
* @public
*/
export const imagePlugin = ImageRef.configure({ inline: false, HTMLAttributes: {} })

View File

@ -0,0 +1,225 @@
import { getEmbeddedLabel } from '@hcengineering/platform'
import { getFileUrl } from '@hcengineering/presentation'
import { Action, Menu, getEventPositionElement, showPopup } from '@hcengineering/ui'
import { Node, createNodeFromContent, mergeAttributes, nodeInputRule } from '@tiptap/core'
import { Plugin, PluginKey } from 'prosemirror-state'
import plugin from '../plugin'
export interface ImageOptions {
inline: boolean
HTMLAttributes: Record<string, any>
showPreview?: (event: MouseEvent, fileId: string) => void
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
image: {
/**
* Add an image
*/
setImage: (options: { src: string, alt?: string, title?: string }) => ReturnType
}
}
}
export const inputRegex = /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/
export const ImageRef = Node.create<ImageOptions>({
name: 'image',
addOptions () {
return {
inline: false,
HTMLAttributes: {}
}
},
inline () {
return this.options.inline
},
group () {
return this.options.inline ? 'inline' : 'block'
},
draggable: true,
selectable: true,
addAttributes () {
return {
fileid: {
default: null,
parseHTML: (element) => element.getAttribute('file-id'),
renderHTML: (attributes) => {
// eslint-disable-next-line
if (!attributes.fileid) {
return {}
}
return {
'file-id': attributes.fileid
}
}
},
width: {
default: null
},
height: {
default: null
}
}
},
parseHTML () {
return [
{
tag: `img[data-type="${this.name}"]`
}
]
},
renderHTML ({ HTMLAttributes }) {
const merged = mergeAttributes(
{
'data-type': this.name
},
this.options.HTMLAttributes,
HTMLAttributes
)
merged.src = getFileUrl(merged['file-id'], 'full')
merged.class = 'textEditorImage'
return ['img', merged]
},
addCommands () {
return {
setImage:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options
})
}
}
},
addInputRules () {
return [
nodeInputRule({
find: inputRegex,
type: this.type,
getAttributes: (match) => {
const [, , alt, src, title] = match
return { src, alt, title }
}
})
]
},
addProseMirrorPlugins () {
return [
new Plugin({
key: new PluginKey('handle-image-paste'),
props: {
handleDrop (view, event, slice) {
const uris = (event.dataTransfer?.getData('text/uri-list') ?? '')
.split('\r\n')
.filter((it) => !it.startsWith('#'))
let result = false
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
}
const _file = (url.searchParams.get('file') ?? '').split('/').join('')
if (_file.trim().length === 0) {
return
}
const content = createNodeFromContent(
`<img data-type='image' width='25%' file-id='${_file}'></img>`,
view.state.schema,
{
parseOptions: {
preserveWhitespace: 'full'
}
}
)
event.preventDefault()
event.stopPropagation()
view.dispatch(view.state.tr.insert(pos?.pos ?? 0, content))
result = true
}
}
return result
},
handleClick: (view, pos, event) => {
if (event.button !== 0) {
return false
}
const node = event.target as unknown as HTMLElement
if (node != null) {
const fileId = (node as any).attributes['file-id']?.value
if (fileId === undefined) {
return false
}
const pos = view.posAtDOM(node, 0)
const actions: Action[] = [
{
label: plugin.string.Width,
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)
)
}
}
}
)
}
},
{
label: plugin.string.Height,
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)
)
}
}
}
)
}
}
]
event.preventDefault()
event.stopPropagation()
showPopup(Menu, { actions }, getEventPositionElement(event))
}
return false
}
}
})
]
}
})

View File

@ -64,6 +64,9 @@ export default plugin(textEditorId, {
CategoryRow: '' as IntlString,
CategoryColumn: '' as IntlString,
Table: '' as IntlString,
TableOptions: '' as IntlString
TableOptions: '' as IntlString,
Width: '' as IntlString,
Height: '' as IntlString,
Unset: '' as IntlString
}
})

View File

@ -0,0 +1,4 @@
.textEditorImage {
cursor: pointer;
object-fit: contain;
}

View File

@ -23,6 +23,7 @@
@import "./panel.scss";
@import "./prose.scss";
@import "./button.scss";
@import "./_text-editor.scss";
@font-face {
font-family: 'IBM Plex Sans';

View File

@ -29,6 +29,7 @@
export let items: DropdownTextItem[]
export let selected: DropdownTextItem['id'] | DropdownTextItem['id'][] | undefined = undefined
export let multiselect: boolean = false
export let enableSearch = true
let search: string = ''
const dispatch = createEventDispatcher()
@ -91,18 +92,20 @@
dispatch('changeContent')
}}
>
<div class="header">
<EditWithIcon
icon={IconSearch}
size={'large'}
width={'100%'}
focus={!$deviceOptionsStore.isMobile}
bind:value={search}
{placeholder}
{placeholderParam}
on:change
/>
</div>
{#if enableSearch}
<div class="header">
<EditWithIcon
icon={IconSearch}
size={'large'}
width={'100%'}
focus={!$deviceOptionsStore.isMobile}
bind:value={search}
{placeholder}
{placeholderParam}
on:change
/>
</div>
{/if}
<div class="scroll">
<div class="box">
<ListView bind:this={list} count={objects.length} bind:selection>

View File

@ -115,7 +115,7 @@
bind:this={attachments[i]}
alwaysEdit
showButtons
fakeAttach={withoutAttach ? 'hidden' : i < items.length - 1 ? 'fake' : 'normal'}
enableAttachments={!withoutAttach}
bind:content={item.content}
placeholder={textEditorPlugin.string.EditorPlaceholder}
{objectId}

View File

@ -83,7 +83,7 @@
class="flex-center icon"
class:svg={value.type === 'image/svg+xml'}
class:image={isImage(value.type)}
style:background-image={isImage(value.type) ? `url(${getFileUrl(value.file)})` : 'none'}
style:background-image={isImage(value.type) ? `url(${getFileUrl(value.file, 'large')})` : 'none'}
>
{#if !isImage(value.type)}{iconLabel(value.name)}{/if}
</div>

View File

@ -47,7 +47,7 @@
dispatch('open', popupInfo.id)
}}
>
<img src={getFileUrl(value.file)} alt={value.name} />
<img src={getFileUrl(value.file, 'large')} alt={value.name} />
<div class="actions conner">
<AttachmentActions attachment={value} {isSaved} />
</div>

View File

@ -83,7 +83,7 @@
{focusIndex}
enableBackReferences={true}
bind:this={descriptionBox}
useAttachmentPreview={true}
useAttachmentPreview={false}
objectId={object._id}
_class={object._class}
space={object.space}

View File

@ -38,11 +38,11 @@
export let formatButtonSize: IconSize = 'small'
export let maxHeight: 'max' | 'card' | 'limited' | string = 'max'
export let focusable: boolean = false
export let fakeAttach: 'fake' | 'hidden' | 'normal' = 'normal'
export let refContainer: HTMLElement | undefined = undefined
export let shouldSaveDraft: boolean = false
export let useAttachmentPreview = false
export let focusIndex: number | undefined = -1
export let enableAttachments: boolean = true
export let enableBackReferences: boolean = false
let draftKey = objectId ? `${objectId}_attachments` : undefined
@ -334,12 +334,11 @@
<div
class="flex-col clear-mins"
on:paste={(ev) => (fakeAttach === 'normal' ? pasteAction(ev) : undefined)}
on:paste={(ev) => pasteAction(ev)}
on:dragover|preventDefault={() => {}}
on:dragleave={() => {}}
on:drop|preventDefault|stopPropagation={(ev) => {
if (fakeAttach === 'fake') dispatch('attach', { action: 'drop', event: ev })
else fileDrop(ev)
fileDrop(ev)
}}
>
<div class="expand-collapse">
@ -350,7 +349,7 @@
{placeholder}
{alwaysEdit}
{showButtons}
hideAttachments={fakeAttach === 'hidden'}
hideAttachments={!enableAttachments}
{buttonSize}
{formatButtonSize}
{maxHeight}
@ -364,12 +363,11 @@
on:open-document
on:open-document
on:attach={() => {
if (fakeAttach === 'fake') dispatch('attach', { action: 'add' })
else if (fakeAttach === 'normal') attach()
attach()
}}
/>
</div>
{#if attachments.size && fakeAttach === 'normal'}
{#if attachments.size && enableAttachments}
<div class="flex-row-center list scroll-divider-color">
{#each Array.from(attachments.values()) as attachment, index}
<div class="item flex-center flex-no-shrink clear-mins">

View File

@ -597,6 +597,7 @@
showButtons={false}
kind={'indented'}
enableBackReferences={true}
enableAttachments={false}
bind:content={object.description}
placeholder={tracker.string.IssueDescriptionPlaceholder}
on:changeSize={() => dispatch('changeContent')}