Text edtor embed plugin improvements (#9078)

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Victor Ilyushchenko 2025-05-23 18:06:37 +03:00 committed by GitHub
parent 14c7541bd3
commit bd6c8d6758
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 336 additions and 143 deletions

View File

@ -37,6 +37,7 @@
export let props: Record<string, any> = {}
export let fit: boolean = false
export let embedded: boolean = false
export let setLoading: ((loading: boolean) => void) | undefined = undefined
let download: HTMLAnchorElement
let parentWidth: number
@ -102,7 +103,10 @@
<Label label={presentation.string.FailedToPreview} />
</div>
{:else if previewType !== undefined}
<Component is={previewType.component} props={{ value: file, name, contentType, metadata, ...props, fit }} />
<Component
is={previewType.component}
props={{ value: file, name, contentType, metadata, ...props, fit, setLoading }}
/>
{:else if loading}
<Loading />
{:else}

View File

@ -17,7 +17,7 @@
import { type Editor } from '@tiptap/core'
import { type TextEditorAction, type ActionContext } from '@hcengineering/text-editor'
import { getResource } from '@hcengineering/platform'
import { Icon, IconSize, tooltip } from '@hcengineering/ui'
import { Icon, IconSize, tooltip, type LabelAndProps } from '@hcengineering/ui'
import tr from 'date-fns/locale/tr'
import { Transaction } from '@tiptap/pm/state'
@ -27,6 +27,7 @@
export let actionCtx: ActionContext
export let blockMouseEvents = true
export let listenCursorUpdate = false
export let tooltipOptions: LabelAndProps | undefined = undefined
const dispatch = createEventDispatcher()
let selected: boolean = false
@ -85,7 +86,7 @@
<button
class="button {size}"
class:selected
use:tooltip={{ label: action.label }}
use:tooltip={{ label: action.label, ...tooltipOptions }}
tabindex="0"
data-id={'btn' + action.label.split(':').pop()}
on:click={handleClick}

View File

@ -91,7 +91,13 @@
</script>
{#if cursor && actions.length > 0}
<div class="embed-toolbar flex" class:reference={showSrc && !!reference} contenteditable="false">
<div
class="embed-toolbar flex theme-dark"
class:reference={showSrc && !!reference}
contenteditable="false"
tabindex="-1"
data-block-editor-blur="true"
>
<div class="text-editor-toolbar buttons-group xsmall-gap">
{#if showSrc}
{#if !reference}
@ -110,7 +116,15 @@
{/if}
{#each category as [_, action]}
<TextActionButton {action} {editor} size="small" {actionCtx} listenCursorUpdate blockMouseEvents={false} />
<TextActionButton
{action}
{editor}
size="small"
{actionCtx}
listenCursorUpdate
blockMouseEvents={false}
tooltipOptions={{ direction: 'top' }}
/>
{/each}
{/each}
</div>
@ -127,25 +141,19 @@
text-overflow: ellipsis;
white-space: nowrap;
color: var(--theme-link-color);
&:hover {
color: var(--theme-link-color);
}
}
.embed-toolbar {
position: relative;
padding: 0.25rem;
background-color: var(--theme-comp-header-color);
background-color: var(--primary-button-default);
border-radius: 0.5rem;
box-shadow: var(--button-shadow);
&.reference::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--theme-mention-bg-color);
pointer-events: none;
border-radius: 0.5rem;
}
--theme-link-color: var(--theme-content-color);
}
</style>

View File

@ -20,12 +20,13 @@ import textEditor from '@hcengineering/text-editor'
import { DebouncedCaller } from '@hcengineering/ui'
import { type Editor, type Range } from '@tiptap/core'
import { Fragment, type Node, type ResolvedPos, Slice } from '@tiptap/pm/model'
import { Plugin, PluginKey, Selection, type Transaction } from '@tiptap/pm/state'
import { type EditorState, Plugin, PluginKey, Selection, type Transaction } from '@tiptap/pm/state'
import { type EditorView } from '@tiptap/pm/view'
import tippy from 'tippy.js'
import { SvelteRenderer } from '../../node-view'
import { buildReferenceUrl, parseReferenceUrl } from '../reference'
import EmbedToolbar from './EmbedToolbar.svelte'
import { AddMarkStep } from '@tiptap/pm/transform'
export interface EmbedNodeOptions {
providers: EmbedNodeProvider[]
@ -38,8 +39,12 @@ export interface EmbedNodeViewHandle {
destroy?: () => void
}
export type EmbedNodeView = (root: HTMLDivElement) => EmbedNodeViewHandle | undefined
export type EmbedNodeProvider = (src: string) => Promise<EmbedNodeView | undefined>
export interface EmbedNodeProvider {
buildView: (src: string) => Promise<EmbedNodeView | undefined>
autoEmbedUrl?: (src: string) => boolean
}
export type EmbedNodeView = (editor: Editor, root: HTMLDivElement) => EmbedNodeViewHandle | undefined
export type EmbedNodeProviderConstructor<T> = (options: T) => EmbedNodeProvider
export const EmbedNode = BaseEmbedNode.extend<EmbedNodeOptions>({
@ -98,15 +103,22 @@ export const EmbedNode = BaseEmbedNode.extend<EmbedNodeOptions>({
root.setAttribute('data-embed-src', node.attrs.src)
root.classList.add('embed-node')
setLoadingState(editor.view, root, true)
root.setAttribute('block-editor-blur', 'true')
let handle: EmbedNodeViewHandle | undefined
void providerPromise.then((view) => {
view = view ?? StubEmbedNodeView
handle = view(root)
if (handle !== undefined) {
root.classList.add(`embed-${handle.name}`)
}
})
void providerPromise
.then((view) => {
view = view ?? StubEmbedNodeView
handle = view(editor, root)
if (handle !== undefined) {
root.classList.add(`embed-${handle.name}`)
}
})
.finally(() => {
setLoadingState(editor.view, root, false)
})
return {
dom: root,
@ -147,6 +159,7 @@ const embedControlPluginKey = new PluginKey('embedControlPlugin')
export function EmbedControlPlugin (editor: Editor, options: EmbedNodeOptions): Plugin {
return new Plugin<EmbedControlState>({
key: embedControlPluginKey,
state: {
init () {
return {
@ -163,24 +176,32 @@ export function EmbedControlPlugin (editor: Editor, options: EmbedNodeOptions):
return { ...prev, cursor: meta.cursor }
}
if (tr.docChanged && prev.cursor !== null) {
const from = tr.mapping.map(prev.cursor.from, -1)
const cursor = resolveCursor(prev, newState.doc.resolve(from))
if (tr.docChanged) {
const cursor = prev.cursor
const fromSelection = cursor === null || cursor.selected === true
updateCursor(tr, cursor)
return { ...prev, cursor }
const from = fromSelection ? newState.selection.from : tr.mapping.map(cursor.from, -1)
const newCursor = resolveCursor(newState.doc.resolve(from))
if (newCursor !== null && (cursor?.selected === true || fromSelection)) {
newCursor.selected = true
}
updateCursor(tr, newCursor, prev)
return { ...prev, cursor: newCursor }
}
if (!oldState.selection.eq(newState.selection)) {
const $pos = newState.doc.resolve(newState.selection.from)
const cursor = resolveCursor(prev, $pos)
const cursor = resolveCursor($pos)
if (cursor !== null) {
cursor.selected = true
updateCursor(tr, cursor)
updateCursor(tr, cursor, prev)
return { ...prev, cursor }
} else if (prev.cursor !== null && prev.cursor.selected === true) {
updateCursor(tr, null)
updateCursor(tr, null, prev)
return { ...prev, cursor: null }
}
}
@ -188,16 +209,34 @@ export function EmbedControlPlugin (editor: Editor, options: EmbedNodeOptions):
return prev
}
},
view (view) {
interface State {
cursor: EmbedControlCursor | null
}
let state: State = {
cursor: null
}
let cursor: EmbedControlCursor | null = null
let blockToolbarUpdate = false
let rect: DOMRect = getReferenceRect(view, 0, 0)
const getReferenceClientRect = (): DOMRect => {
return getReferenceRect(view, state.cursor?.from ?? 0, state.cursor?.to ?? 0)
const from = cursor?.from ?? 0
const to = cursor?.to ?? 0
blockToolbarUpdate = false
view.state.doc.nodesBetween(from, to, (node, pos) => {
const element = view.nodeDOM(pos)
if (!(element instanceof HTMLElement)) return
if (element.dataset.loading === 'true') {
blockToolbarUpdate = true
return false
}
})
if (blockToolbarUpdate) {
return rect
}
const newRect = getReferenceRect(view, from, to)
rect = newRect
return newRect
}
const listener = (event: MouseEvent): void => {
@ -210,28 +249,32 @@ export function EmbedControlPlugin (editor: Editor, options: EmbedNodeOptions):
const renderer = new SvelteRenderer(EmbedToolbar, {
element: container,
props: { editor, cursor: state.cursor }
props: { editor, cursor }
})
renderer.updateProps({ editor, cursor: state.cursor })
renderer.updateProps({ editor, cursor })
const updateState = (newState: State): void => {
if (newState.cursor?.selected === true) {
const pluginState = getEmbedControlState(editor)
pluginState?.debounce.updateCursor.call(() => {
/* reset pending mouse move event handling */
})
}
if (!tippynode.state.isShown && newState.cursor !== null) {
const updateToolbar = (): void => {
getReferenceClientRect()
killPendingMouseEvents()
if (blockToolbarUpdate) return
if (!tippynode.state.isShown && cursor !== null) {
tippynode.show()
tippynode.setProps({})
}
if (tippynode.state.isShown && newState.cursor === null) {
if (tippynode.state.isShown && cursor === null) {
tippynode.hide()
} else {
tippynode.setProps({})
}
state = newState
renderer.updateProps({ editor, cursor: state.cursor })
renderer.updateProps({ editor, cursor })
tippynode.setProps({})
}
const killPendingMouseEvents = (): void => {
const pluginState = getEmbedControlState(editor)
pluginState?.debounce.updateCursor.call(() => {
/* reset pending mouse move event handling */
})
}
const tippynode = (this.tippynode = tippy(view.dom, {
@ -243,17 +286,44 @@ export function EmbedControlPlugin (editor: Editor, options: EmbedNodeOptions):
maxWidth: 640,
interactive: true,
trigger: 'manual',
placement: 'top-start',
placement: 'top-end',
hideOnClick: 'toggle',
onDestroy: () => {},
appendTo: () => options.popupContainer ?? document.body,
zIndex: 10000
zIndex: 10000,
popperOptions: {
modifiers: [
{
name: 'flip',
options: {
fallbackPlacements: ['top-start', 'bottom-end', 'bottom-start']
}
}
]
}
}))
editor.on('transaction', ({ transaction }) => {
editor.on('transaction', ({ editor, transaction }) => {
cursor = getEmbedControlCursor(editor)
const meta = transaction.getMeta(embedControlPluginKey) as EmbedControlTxMeta
if (meta?.cursor !== undefined) {
updateState({ cursor: meta.cursor })
const loadingState = transaction.getMeta('loadingState') as boolean | undefined
if (meta?.cursor !== undefined || loadingState !== undefined) {
if (loadingState === true) {
updateToolbar()
} else {
requestAnimationFrame(() => {
updateToolbar()
})
}
}
})
editor.on('blur', (e) => {
const target = e.event.relatedTarget
const ignore = scanForDataMarker(target as HTMLElement | null, 'blockEditorBlur')
if (!ignore) {
view.dispatch(updateCursor(view.state.tr, null))
}
})
@ -263,32 +333,107 @@ export function EmbedControlPlugin (editor: Editor, options: EmbedNodeOptions):
window.removeEventListener('mousemove', listener)
}
}
},
appendTransaction: (transactions, oldState, newState) => {
// editor.on('transaction', ...) fires for root transactions
// but not for internal transactions appended via plugins,
// so we need to propagate metadata to the rest of transactions.
let meta: EmbedControlTxMeta | undefined
for (const tx of transactions) {
meta = (tx.getMeta(embedControlPluginKey) as EmbedControlTxMeta) ?? meta
}
if (meta !== undefined) {
for (const tx of transactions) {
setMeta(tx, meta)
}
}
if (transactions.length > 1) {
const rest = transactions.slice(1)
for (const tx of rest) {
if (tx.steps.length !== 1) continue
const step = tx.steps[0]
if (!(step instanceof AddMarkStep)) continue
if (step.mark.type.name !== 'link') continue
const src = step.mark.attrs.href as string | undefined
if (src === undefined) continue
const $pos = newState.doc.resolve(step.from)
const index = $pos.index()
const parent = $pos.parent
if ($pos.depth !== 1 || parent.type.name !== 'paragraph') continue
let canConvert = true
for (let i = 0; i < parent.childCount; i++) {
const child = parent.child(i)
if (i === index) continue
if (child.type.name !== 'text') {
canConvert = false
break
}
if (child.textContent.trim() !== '') {
canConvert = false
break
}
}
if (canConvert) {
const embedTx = tryAutoEmbedUrl(newState, options.providers, step, src)
if (embedTx !== undefined) {
return embedTx
}
}
}
}
return null
}
})
}
function tryAutoEmbedUrl (
state: EditorState,
providers: EmbedNodeProvider[],
{ from, to }: Range,
src: string
): Transaction | undefined {
const provider = providers.find((p) => p.autoEmbedUrl !== undefined && p.autoEmbedUrl(src))
if (provider === undefined) return
const embedNode = state.schema.nodes.embed.create({ src })
const fragment = Fragment.from(embedNode)
const tr = state.tr
return replacePreviewContent({ from, to }, fragment, tr, true)
}
function scanForDataMarker (target: HTMLElement | null, field: string): boolean {
while (target != null) {
if (target.dataset[field] === 'true') {
return true
}
target = target.parentElement
}
return false
}
function updateCursorFromMouseEvent (view: EditorView, event: MouseEvent): void {
const state = embedControlPluginKey.getState(view.state) as EmbedControlState
const prevCursor = state?.cursor ?? null
let target = event?.target as HTMLElement | null
let blockCursorUpdate = false
let disableCursor = false
while (target != null) {
if (target.dataset.blockCursorUpdate === 'true') {
blockCursorUpdate = true
}
if (target.dataset.disableCursor === 'true') {
disableCursor = true
}
target = target.parentElement
}
const target = event?.target as HTMLElement | null
const blockCursorUpdate = scanForDataMarker(target, 'blockCursorUpdate')
const disableCursor = scanForDataMarker(target, 'disableCursor')
if (blockCursorUpdate) return
const coords = { left: event.clientX, top: event.clientY }
const newCursor = disableCursor ? null : resolveCursor(state, resolveCursorPositionFromCoords(view, coords))
const newCursor = disableCursor ? null : resolveCursor(resolveCursorPositionFromCoords(view, coords))
if (prevCursor?.selected === true && newCursor === null) {
return
}
if (eqCursors(newCursor, prevCursor)) {
return
@ -333,15 +478,12 @@ async function matchUrl (providers: EmbedControlState['providers'], url?: string
if (url === undefined) return
for (const provider of providers) {
const view = await provider(url)
const view = await provider.buildView(url)
if (view !== undefined) return view
}
}
function resolveCursorChildNode (
state: EmbedControlState,
$pos?: ResolvedPos
): { node: Node | null, index: number, offset: number } | null {
function resolveCursorChildNode ($pos?: ResolvedPos): { node: Node | null, index: number, offset: number } | null {
if ($pos === undefined) return null
const parent = $pos.parent
@ -364,10 +506,10 @@ function resolveCursorChildNode (
return nodeAfter ?? nodeBefore
}
function resolveCursor (state: EmbedControlState, $pos?: ResolvedPos): EmbedControlCursor | null {
function resolveCursor ($pos?: ResolvedPos): EmbedControlCursor | null {
if ($pos === undefined) return null
const child = resolveCursorChildNode(state, $pos)
const child = resolveCursorChildNode($pos)
const node = child?.node ?? null
if (child === null || node === null) return null
@ -416,8 +558,15 @@ function isLink (node: Node, strict: boolean = false): boolean {
return false
}
function updateCursor (tr: Transaction, cursor: EmbedControlCursor | null): Transaction {
return tr.setMeta(embedControlPluginKey, { cursor }).setMeta('contextCursorUpdate', true)
function setMeta (tr: Transaction, meta: EmbedControlTxMeta): Transaction {
return tr.setMeta(embedControlPluginKey, meta).setMeta('contextCursorUpdate', true)
}
function updateCursor (tr: Transaction, cursor: EmbedControlCursor | null, state?: EmbedControlState): Transaction {
state?.debounce?.updateCursor.call(() => {
/* reset pending mouse move event handling */
})
return setMeta(tr, { cursor })
}
function getEmbedControlState (editor: Editor): EmbedControlState | undefined {
@ -493,7 +642,7 @@ export async function convertToLinkPreviewAction (editor: Editor, event: MouseEv
const from = cursor.from
const to = cursor.to
const tr = replacePreviewContent({ from, to }, fragment, editor.state.tr, editor)
const tr = replacePreviewContent({ from, to }, fragment, editor.state.tr)
editor.view.dispatch(tr)
}
@ -515,7 +664,7 @@ export async function convertToEmbedPreviewAction (editor: Editor, event: MouseE
const to = cursor.to
const tr = editor.state.tr
replacePreviewContent({ from, to }, fragment, tr, editor)
replacePreviewContent({ from, to }, fragment, tr, true)
editor.view.focus()
editor.view.dispatch(tr)
@ -573,11 +722,8 @@ export function replacePreviewContent (
{ from, to }: Range,
fragment: Fragment,
tr: Transaction,
editor: Editor
selected: boolean = false
): Transaction {
const state = getEmbedControlState(editor)
if (state === undefined) return tr
const slice = new Slice(fragment, 0, 0)
tr.replaceRange(from, to, slice)
@ -597,13 +743,17 @@ export function replacePreviewContent (
tr.setSelection(selection)
const cursor = resolveCursor(state, tr.doc.resolve(isOnlyBlockContent ? start : end))
const cursor = resolveCursor(tr.doc.resolve(isOnlyBlockContent ? start : end))
if (selected && cursor !== null) {
cursor.selected = true
}
updateCursor(tr, cursor)
return tr
}
const StubEmbedNodeView: EmbedNodeView = (root: HTMLElement) => {
const StubEmbedNodeView: EmbedNodeView = (editor: Editor, root: HTMLElement) => {
const hint = document.createElement('p')
const hintIcon = hint.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'svg'))
const hintSpan = hint.appendChild(document.createElement('span'))
@ -665,3 +815,12 @@ function getReferenceRect (view: EditorView, from: number, to: number): DOMRect
function minmax (value = 0, min = 0, max = 0): number {
return Math.min(Math.max(value, min), max)
}
export function setLoadingState (view: EditorView, element: HTMLElement, loading: boolean): void {
if (loading) {
element.setAttribute('data-loading', 'true')
} else {
element.removeAttribute('data-loading')
}
view.dispatch(view.state.tr.setMeta('loadingState', loading))
}

View File

@ -22,9 +22,10 @@ import {
getPreviewType,
type FilePreviewExtension
} from '@hcengineering/presentation'
import { type Editor } from '@tiptap/core'
import { SvelteRenderer } from '../../../node-view'
import { parseReferenceUrl } from '../../reference'
import { type EmbedNodeProviderConstructor } from '../embed'
import { setLoadingState, type EmbedNodeProviderConstructor } from '../embed'
export interface DriveEmbedOptions {
_x?: number
@ -32,46 +33,52 @@ export interface DriveEmbedOptions {
export const defaultDriveEmbedOptions: DriveEmbedOptions = {}
export const DriveEmbedProvider: EmbedNodeProviderConstructor<DriveEmbedOptions> = (options) => async (src: string) => {
const ref = parseReferenceUrl(src)
if (ref?.objectclass !== drive.class.File || ref.id === undefined) {
return
}
export const DriveEmbedProvider: EmbedNodeProviderConstructor<DriveEmbedOptions> = (options) => ({
buildView: async (src: string) => {
const ref = parseReferenceUrl(src)
if (ref?.objectclass !== drive.class.File || ref.id === undefined) {
return
}
const client = getClient()
const file = await client.findOne(drive.class.File, { _id: ref.id as Ref<File> })
if (file === undefined) return
const client = getClient()
const file = await client.findOne(drive.class.File, { _id: ref.id as Ref<File> })
if (file === undefined) return
const version = await client.findOne(drive.class.FileVersion, { attachedTo: file._id, version: file.version })
if (version === undefined) return
const version = await client.findOne(drive.class.FileVersion, { attachedTo: file._id, version: file.version })
if (version === undefined) return
const allPreviewTypesPromise = new Promise<FilePreviewExtension[]>((resolve) => {
$previewTypes.subscribe((types) => {
if (types.length > 0) resolve(types)
const allPreviewTypesPromise = new Promise<FilePreviewExtension[]>((resolve) => {
$previewTypes.subscribe((types) => {
if (types.length > 0) resolve(types)
})
})
})
const allPreviewTypes = await allPreviewTypesPromise
const previewType = await getPreviewType(version.type, allPreviewTypes)
const allPreviewTypes = await allPreviewTypesPromise
const previewType = await getPreviewType(version.type, allPreviewTypes)
if (previewType === undefined) return
if (previewType === undefined) return
return (root: HTMLDivElement) => {
const renderer = new SvelteRenderer(FilePreview as any, {
element: root,
props: {
file: version.file,
contentType: version.type,
name: version.title,
metadata: version.metadata,
embedded: true
return (editor: Editor, root: HTMLDivElement) => {
const setLoading = (loading: boolean): void => {
setLoadingState(editor.view, root, loading)
}
})
return {
name: 'drive',
destroy: () => {
renderer.destroy()
const renderer = new SvelteRenderer(FilePreview as any, {
element: root,
props: {
file: version.file,
contentType: version.type,
name: version.title,
metadata: version.metadata,
embedded: true,
setLoading
}
})
return {
name: 'drive',
destroy: () => {
renderer.destroy()
}
}
}
}
}
})

View File

@ -13,27 +13,33 @@
// limitations under the License.
//
import { type Editor } from '@tiptap/core'
import { type EmbedNodeProviderConstructor } from '../embed'
export const YoutubeEmbedProvider: EmbedNodeProviderConstructor<YoutubeEmbedUrlOptions> = (options) => async (src) => {
const url = getEmbedUrlFromYoutubeUrl(src, options)
if (url === undefined) return
export const YoutubeEmbedProvider: EmbedNodeProviderConstructor<YoutubeEmbedUrlOptions> = (options) => ({
buildView: async (src) => {
const url = getEmbedUrlFromYoutubeUrl(src, options)
if (url === undefined) return
return (root: HTMLDivElement) => {
const iframe = document.createElement('iframe')
iframe.src = url
for (const key in options.iframe) {
const value = (options as any)[key]
if (value !== undefined) {
iframe.setAttribute(key, `${value}`)
return (editor: Editor, root: HTMLDivElement) => {
const iframe = document.createElement('iframe')
iframe.src = url
for (const key in options.iframe) {
const value = (options as any)[key]
if (value !== undefined) {
iframe.setAttribute(key, `${value}`)
}
}
root.appendChild(iframe)
return {
name: 'youtube'
}
}
root.appendChild(iframe)
return {
name: 'youtube'
}
},
autoEmbedUrl: (src: string) => {
return getEmbedUrlFromYoutubeUrl(src, options) !== undefined
}
}
})
export const isValidYoutubeUrl = (url: string): boolean => {
return url.match(YOUTUBE_REGEX) !== null

View File

@ -27,6 +27,8 @@
export let drawings: any
export let createDrawing: (data: any) => Promise<any>
export let setLoading: ((loading: boolean) => void) | undefined = undefined
$: originalWidth = metadata?.originalWidth
$: originalHeight = metadata?.originalHeight
$: pixelRatio = metadata?.pixelRatio ?? 1
@ -38,6 +40,12 @@
$: height = imageHeight != null ? `min(${imageHeight}px, ${fit ? '100%' : '80vh'})` : '100%'
let loading = true
function _setLoading (newState: boolean): void {
loading = newState
setLoading?.(loading)
}
$: if (value !== undefined) _setLoading(true)
</script>
{#await getBlobRef(value, name) then blobRef}
@ -58,7 +66,7 @@
>
<img
on:load={() => {
loading = false
_setLoading(false)
}}
class="object-contain mx-auto"
style:max-width={width}