mirror of
https://github.com/hcengineering/platform.git
synced 2025-04-13 03:40:48 +00:00
UBER-395: Allow to drop images into description (#3382)
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
parent
3c5648c87f
commit
aa09fa62c9
@ -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>
|
||||
|
@ -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 -->
|
||||
|
@ -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) => {
|
||||
|
@ -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} />
|
||||
|
@ -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: {} })
|
||||
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
Loading…
Reference in New Issue
Block a user