mirror of
https://github.com/hcengineering/platform.git
synced 2024-12-22 11:01:54 +03:00
UBER-187: Inline attachments (#3264)
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
parent
79486e1b8c
commit
01a1a3fe6c
@ -37,6 +37,9 @@
|
||||
"CategoryColumn": "Columns",
|
||||
"Table": "Table",
|
||||
"InsertTable": "Insert table",
|
||||
"TableOptions": "Customize table"
|
||||
"TableOptions": "Customize table",
|
||||
"Width": "Width",
|
||||
"Height": "Height",
|
||||
"Unset": "Unset"
|
||||
}
|
||||
}
|
||||
|
@ -37,6 +37,9 @@
|
||||
"CategoryColumn": "Колонки",
|
||||
"Table": "Таблица",
|
||||
"InsertTable": "Добавить таблицу",
|
||||
"TableOptions": "Настроить таблицу"
|
||||
"TableOptions": "Настроить таблицу",
|
||||
"Width": "Щирина",
|
||||
"Height": "Высота",
|
||||
"Unset": "Убрать"
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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: {} })
|
||||
|
225
packages/text-editor/src/components/imageExt.ts
Normal file
225
packages/text-editor/src/components/imageExt.ts
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
@ -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
|
||||
}
|
||||
})
|
||||
|
4
packages/theme/styles/_text-editor.scss
Normal file
4
packages/theme/styles/_text-editor.scss
Normal file
@ -0,0 +1,4 @@
|
||||
.textEditorImage {
|
||||
cursor: pointer;
|
||||
object-fit: contain;
|
||||
}
|
@ -23,6 +23,7 @@
|
||||
@import "./panel.scss";
|
||||
@import "./prose.scss";
|
||||
@import "./button.scss";
|
||||
@import "./_text-editor.scss";
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -83,7 +83,7 @@
|
||||
{focusIndex}
|
||||
enableBackReferences={true}
|
||||
bind:this={descriptionBox}
|
||||
useAttachmentPreview={true}
|
||||
useAttachmentPreview={false}
|
||||
objectId={object._id}
|
||||
_class={object._class}
|
||||
space={object.space}
|
||||
|
@ -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">
|
||||
|
@ -597,6 +597,7 @@
|
||||
showButtons={false}
|
||||
kind={'indented'}
|
||||
enableBackReferences={true}
|
||||
enableAttachments={false}
|
||||
bind:content={object.description}
|
||||
placeholder={tracker.string.IssueDescriptionPlaceholder}
|
||||
on:changeSize={() => dispatch('changeContent')}
|
||||
|
Loading…
Reference in New Issue
Block a user