mirror of
https://github.com/hcengineering/platform.git
synced 2025-04-14 12:25:17 +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 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>
|
||||||
|
@ -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 -->
|
||||||
|
@ -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) => {
|
||||||
|
@ -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} />
|
||||||
|
@ -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: {} })
|
|
||||||
|
@ -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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -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}
|
||||||
|
@ -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}
|
||||||
|
Loading…
Reference in New Issue
Block a user