mirror of
https://github.com/hcengineering/platform.git
synced 2025-06-11 12:57:59 +00:00
Text edtor embed plugin improvements (#9078)
Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
parent
14c7541bd3
commit
bd6c8d6758
@ -37,6 +37,7 @@
|
|||||||
export let props: Record<string, any> = {}
|
export let props: Record<string, any> = {}
|
||||||
export let fit: boolean = false
|
export let fit: boolean = false
|
||||||
export let embedded: boolean = false
|
export let embedded: boolean = false
|
||||||
|
export let setLoading: ((loading: boolean) => void) | undefined = undefined
|
||||||
|
|
||||||
let download: HTMLAnchorElement
|
let download: HTMLAnchorElement
|
||||||
let parentWidth: number
|
let parentWidth: number
|
||||||
@ -102,7 +103,10 @@
|
|||||||
<Label label={presentation.string.FailedToPreview} />
|
<Label label={presentation.string.FailedToPreview} />
|
||||||
</div>
|
</div>
|
||||||
{:else if previewType !== undefined}
|
{: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}
|
{:else if loading}
|
||||||
<Loading />
|
<Loading />
|
||||||
{:else}
|
{:else}
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
import { type Editor } from '@tiptap/core'
|
import { type Editor } from '@tiptap/core'
|
||||||
import { type TextEditorAction, type ActionContext } from '@hcengineering/text-editor'
|
import { type TextEditorAction, type ActionContext } from '@hcengineering/text-editor'
|
||||||
import { getResource } from '@hcengineering/platform'
|
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 tr from 'date-fns/locale/tr'
|
||||||
import { Transaction } from '@tiptap/pm/state'
|
import { Transaction } from '@tiptap/pm/state'
|
||||||
|
|
||||||
@ -27,6 +27,7 @@
|
|||||||
export let actionCtx: ActionContext
|
export let actionCtx: ActionContext
|
||||||
export let blockMouseEvents = true
|
export let blockMouseEvents = true
|
||||||
export let listenCursorUpdate = false
|
export let listenCursorUpdate = false
|
||||||
|
export let tooltipOptions: LabelAndProps | undefined = undefined
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
let selected: boolean = false
|
let selected: boolean = false
|
||||||
@ -85,7 +86,7 @@
|
|||||||
<button
|
<button
|
||||||
class="button {size}"
|
class="button {size}"
|
||||||
class:selected
|
class:selected
|
||||||
use:tooltip={{ label: action.label }}
|
use:tooltip={{ label: action.label, ...tooltipOptions }}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
data-id={'btn' + action.label.split(':').pop()}
|
data-id={'btn' + action.label.split(':').pop()}
|
||||||
on:click={handleClick}
|
on:click={handleClick}
|
||||||
|
@ -91,7 +91,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if cursor && actions.length > 0}
|
{#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">
|
<div class="text-editor-toolbar buttons-group xsmall-gap">
|
||||||
{#if showSrc}
|
{#if showSrc}
|
||||||
{#if !reference}
|
{#if !reference}
|
||||||
@ -110,7 +116,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each category as [_, action]}
|
{#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}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@ -127,25 +141,19 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--theme-link-color);
|
color: var(--theme-link-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--theme-link-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.embed-toolbar {
|
.embed-toolbar {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
background-color: var(--theme-comp-header-color);
|
background-color: var(--primary-button-default);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: var(--button-shadow);
|
box-shadow: var(--button-shadow);
|
||||||
|
|
||||||
&.reference::before {
|
--theme-link-color: var(--theme-content-color);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -20,12 +20,13 @@ import textEditor from '@hcengineering/text-editor'
|
|||||||
import { DebouncedCaller } from '@hcengineering/ui'
|
import { DebouncedCaller } from '@hcengineering/ui'
|
||||||
import { type Editor, type Range } from '@tiptap/core'
|
import { type Editor, type Range } from '@tiptap/core'
|
||||||
import { Fragment, type Node, type ResolvedPos, Slice } from '@tiptap/pm/model'
|
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 { type EditorView } from '@tiptap/pm/view'
|
||||||
import tippy from 'tippy.js'
|
import tippy from 'tippy.js'
|
||||||
import { SvelteRenderer } from '../../node-view'
|
import { SvelteRenderer } from '../../node-view'
|
||||||
import { buildReferenceUrl, parseReferenceUrl } from '../reference'
|
import { buildReferenceUrl, parseReferenceUrl } from '../reference'
|
||||||
import EmbedToolbar from './EmbedToolbar.svelte'
|
import EmbedToolbar from './EmbedToolbar.svelte'
|
||||||
|
import { AddMarkStep } from '@tiptap/pm/transform'
|
||||||
|
|
||||||
export interface EmbedNodeOptions {
|
export interface EmbedNodeOptions {
|
||||||
providers: EmbedNodeProvider[]
|
providers: EmbedNodeProvider[]
|
||||||
@ -38,8 +39,12 @@ export interface EmbedNodeViewHandle {
|
|||||||
destroy?: () => void
|
destroy?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EmbedNodeView = (root: HTMLDivElement) => EmbedNodeViewHandle | undefined
|
export interface EmbedNodeProvider {
|
||||||
export type EmbedNodeProvider = (src: string) => Promise<EmbedNodeView | undefined>
|
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 type EmbedNodeProviderConstructor<T> = (options: T) => EmbedNodeProvider
|
||||||
|
|
||||||
export const EmbedNode = BaseEmbedNode.extend<EmbedNodeOptions>({
|
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.setAttribute('data-embed-src', node.attrs.src)
|
||||||
root.classList.add('embed-node')
|
root.classList.add('embed-node')
|
||||||
|
|
||||||
|
setLoadingState(editor.view, root, true)
|
||||||
|
root.setAttribute('block-editor-blur', 'true')
|
||||||
|
|
||||||
let handle: EmbedNodeViewHandle | undefined
|
let handle: EmbedNodeViewHandle | undefined
|
||||||
|
|
||||||
void providerPromise.then((view) => {
|
void providerPromise
|
||||||
view = view ?? StubEmbedNodeView
|
.then((view) => {
|
||||||
handle = view(root)
|
view = view ?? StubEmbedNodeView
|
||||||
if (handle !== undefined) {
|
handle = view(editor, root)
|
||||||
root.classList.add(`embed-${handle.name}`)
|
if (handle !== undefined) {
|
||||||
}
|
root.classList.add(`embed-${handle.name}`)
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoadingState(editor.view, root, false)
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dom: root,
|
dom: root,
|
||||||
@ -147,6 +159,7 @@ const embedControlPluginKey = new PluginKey('embedControlPlugin')
|
|||||||
export function EmbedControlPlugin (editor: Editor, options: EmbedNodeOptions): Plugin {
|
export function EmbedControlPlugin (editor: Editor, options: EmbedNodeOptions): Plugin {
|
||||||
return new Plugin<EmbedControlState>({
|
return new Plugin<EmbedControlState>({
|
||||||
key: embedControlPluginKey,
|
key: embedControlPluginKey,
|
||||||
|
|
||||||
state: {
|
state: {
|
||||||
init () {
|
init () {
|
||||||
return {
|
return {
|
||||||
@ -163,24 +176,32 @@ export function EmbedControlPlugin (editor: Editor, options: EmbedNodeOptions):
|
|||||||
return { ...prev, cursor: meta.cursor }
|
return { ...prev, cursor: meta.cursor }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tr.docChanged && prev.cursor !== null) {
|
if (tr.docChanged) {
|
||||||
const from = tr.mapping.map(prev.cursor.from, -1)
|
const cursor = prev.cursor
|
||||||
const cursor = resolveCursor(prev, newState.doc.resolve(from))
|
const fromSelection = cursor === null || cursor.selected === true
|
||||||
|
|
||||||
updateCursor(tr, cursor)
|
const from = fromSelection ? newState.selection.from : tr.mapping.map(cursor.from, -1)
|
||||||
return { ...prev, cursor }
|
|
||||||
|
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)) {
|
if (!oldState.selection.eq(newState.selection)) {
|
||||||
const $pos = newState.doc.resolve(newState.selection.from)
|
const $pos = newState.doc.resolve(newState.selection.from)
|
||||||
const cursor = resolveCursor(prev, $pos)
|
const cursor = resolveCursor($pos)
|
||||||
|
|
||||||
if (cursor !== null) {
|
if (cursor !== null) {
|
||||||
cursor.selected = true
|
cursor.selected = true
|
||||||
updateCursor(tr, cursor)
|
updateCursor(tr, cursor, prev)
|
||||||
return { ...prev, cursor }
|
return { ...prev, cursor }
|
||||||
} else if (prev.cursor !== null && prev.cursor.selected === true) {
|
} else if (prev.cursor !== null && prev.cursor.selected === true) {
|
||||||
updateCursor(tr, null)
|
updateCursor(tr, null, prev)
|
||||||
return { ...prev, cursor: null }
|
return { ...prev, cursor: null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -188,16 +209,34 @@ export function EmbedControlPlugin (editor: Editor, options: EmbedNodeOptions):
|
|||||||
return prev
|
return prev
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
view (view) {
|
view (view) {
|
||||||
interface State {
|
let cursor: EmbedControlCursor | null = null
|
||||||
cursor: EmbedControlCursor | null
|
let blockToolbarUpdate = false
|
||||||
}
|
let rect: DOMRect = getReferenceRect(view, 0, 0)
|
||||||
let state: State = {
|
|
||||||
cursor: null
|
|
||||||
}
|
|
||||||
|
|
||||||
const getReferenceClientRect = (): DOMRect => {
|
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 => {
|
const listener = (event: MouseEvent): void => {
|
||||||
@ -210,28 +249,32 @@ export function EmbedControlPlugin (editor: Editor, options: EmbedNodeOptions):
|
|||||||
|
|
||||||
const renderer = new SvelteRenderer(EmbedToolbar, {
|
const renderer = new SvelteRenderer(EmbedToolbar, {
|
||||||
element: container,
|
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 => {
|
const updateToolbar = (): void => {
|
||||||
if (newState.cursor?.selected === true) {
|
getReferenceClientRect()
|
||||||
const pluginState = getEmbedControlState(editor)
|
killPendingMouseEvents()
|
||||||
pluginState?.debounce.updateCursor.call(() => {
|
|
||||||
/* reset pending mouse move event handling */
|
if (blockToolbarUpdate) return
|
||||||
})
|
|
||||||
}
|
if (!tippynode.state.isShown && cursor !== null) {
|
||||||
if (!tippynode.state.isShown && newState.cursor !== null) {
|
|
||||||
tippynode.show()
|
tippynode.show()
|
||||||
tippynode.setProps({})
|
|
||||||
}
|
}
|
||||||
if (tippynode.state.isShown && newState.cursor === null) {
|
if (tippynode.state.isShown && cursor === null) {
|
||||||
tippynode.hide()
|
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, {
|
const tippynode = (this.tippynode = tippy(view.dom, {
|
||||||
@ -243,17 +286,44 @@ export function EmbedControlPlugin (editor: Editor, options: EmbedNodeOptions):
|
|||||||
maxWidth: 640,
|
maxWidth: 640,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
trigger: 'manual',
|
trigger: 'manual',
|
||||||
placement: 'top-start',
|
placement: 'top-end',
|
||||||
hideOnClick: 'toggle',
|
hideOnClick: 'toggle',
|
||||||
onDestroy: () => {},
|
onDestroy: () => {},
|
||||||
appendTo: () => options.popupContainer ?? document.body,
|
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
|
const meta = transaction.getMeta(embedControlPluginKey) as EmbedControlTxMeta
|
||||||
if (meta?.cursor !== undefined) {
|
const loadingState = transaction.getMeta('loadingState') as boolean | undefined
|
||||||
updateState({ cursor: meta.cursor })
|
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)
|
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 {
|
function updateCursorFromMouseEvent (view: EditorView, event: MouseEvent): void {
|
||||||
const state = embedControlPluginKey.getState(view.state) as EmbedControlState
|
const state = embedControlPluginKey.getState(view.state) as EmbedControlState
|
||||||
const prevCursor = state?.cursor ?? null
|
const prevCursor = state?.cursor ?? null
|
||||||
|
|
||||||
let target = event?.target as HTMLElement | null
|
const target = event?.target as HTMLElement | null
|
||||||
let blockCursorUpdate = false
|
const blockCursorUpdate = scanForDataMarker(target, 'blockCursorUpdate')
|
||||||
let disableCursor = false
|
const disableCursor = scanForDataMarker(target, 'disableCursor')
|
||||||
|
|
||||||
while (target != null) {
|
|
||||||
if (target.dataset.blockCursorUpdate === 'true') {
|
|
||||||
blockCursorUpdate = true
|
|
||||||
}
|
|
||||||
if (target.dataset.disableCursor === 'true') {
|
|
||||||
disableCursor = true
|
|
||||||
}
|
|
||||||
target = target.parentElement
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blockCursorUpdate) return
|
if (blockCursorUpdate) return
|
||||||
|
|
||||||
const coords = { left: event.clientX, top: event.clientY }
|
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)) {
|
if (eqCursors(newCursor, prevCursor)) {
|
||||||
return
|
return
|
||||||
@ -333,15 +478,12 @@ async function matchUrl (providers: EmbedControlState['providers'], url?: string
|
|||||||
if (url === undefined) return
|
if (url === undefined) return
|
||||||
|
|
||||||
for (const provider of providers) {
|
for (const provider of providers) {
|
||||||
const view = await provider(url)
|
const view = await provider.buildView(url)
|
||||||
if (view !== undefined) return view
|
if (view !== undefined) return view
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCursorChildNode (
|
function resolveCursorChildNode ($pos?: ResolvedPos): { node: Node | null, index: number, offset: number } | null {
|
||||||
state: EmbedControlState,
|
|
||||||
$pos?: ResolvedPos
|
|
||||||
): { node: Node | null, index: number, offset: number } | null {
|
|
||||||
if ($pos === undefined) return null
|
if ($pos === undefined) return null
|
||||||
|
|
||||||
const parent = $pos.parent
|
const parent = $pos.parent
|
||||||
@ -364,10 +506,10 @@ function resolveCursorChildNode (
|
|||||||
return nodeAfter ?? nodeBefore
|
return nodeAfter ?? nodeBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCursor (state: EmbedControlState, $pos?: ResolvedPos): EmbedControlCursor | null {
|
function resolveCursor ($pos?: ResolvedPos): EmbedControlCursor | null {
|
||||||
if ($pos === undefined) return null
|
if ($pos === undefined) return null
|
||||||
|
|
||||||
const child = resolveCursorChildNode(state, $pos)
|
const child = resolveCursorChildNode($pos)
|
||||||
const node = child?.node ?? null
|
const node = child?.node ?? null
|
||||||
|
|
||||||
if (child === null || node === null) return null
|
if (child === null || node === null) return null
|
||||||
@ -416,8 +558,15 @@ function isLink (node: Node, strict: boolean = false): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCursor (tr: Transaction, cursor: EmbedControlCursor | null): Transaction {
|
function setMeta (tr: Transaction, meta: EmbedControlTxMeta): Transaction {
|
||||||
return tr.setMeta(embedControlPluginKey, { cursor }).setMeta('contextCursorUpdate', true)
|
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 {
|
function getEmbedControlState (editor: Editor): EmbedControlState | undefined {
|
||||||
@ -493,7 +642,7 @@ export async function convertToLinkPreviewAction (editor: Editor, event: MouseEv
|
|||||||
const from = cursor.from
|
const from = cursor.from
|
||||||
const to = cursor.to
|
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)
|
editor.view.dispatch(tr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -515,7 +664,7 @@ export async function convertToEmbedPreviewAction (editor: Editor, event: MouseE
|
|||||||
const to = cursor.to
|
const to = cursor.to
|
||||||
|
|
||||||
const tr = editor.state.tr
|
const tr = editor.state.tr
|
||||||
replacePreviewContent({ from, to }, fragment, tr, editor)
|
replacePreviewContent({ from, to }, fragment, tr, true)
|
||||||
|
|
||||||
editor.view.focus()
|
editor.view.focus()
|
||||||
editor.view.dispatch(tr)
|
editor.view.dispatch(tr)
|
||||||
@ -573,11 +722,8 @@ export function replacePreviewContent (
|
|||||||
{ from, to }: Range,
|
{ from, to }: Range,
|
||||||
fragment: Fragment,
|
fragment: Fragment,
|
||||||
tr: Transaction,
|
tr: Transaction,
|
||||||
editor: Editor
|
selected: boolean = false
|
||||||
): Transaction {
|
): Transaction {
|
||||||
const state = getEmbedControlState(editor)
|
|
||||||
if (state === undefined) return tr
|
|
||||||
|
|
||||||
const slice = new Slice(fragment, 0, 0)
|
const slice = new Slice(fragment, 0, 0)
|
||||||
tr.replaceRange(from, to, slice)
|
tr.replaceRange(from, to, slice)
|
||||||
|
|
||||||
@ -597,13 +743,17 @@ export function replacePreviewContent (
|
|||||||
|
|
||||||
tr.setSelection(selection)
|
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)
|
updateCursor(tr, cursor)
|
||||||
|
|
||||||
return tr
|
return tr
|
||||||
}
|
}
|
||||||
|
|
||||||
const StubEmbedNodeView: EmbedNodeView = (root: HTMLElement) => {
|
const StubEmbedNodeView: EmbedNodeView = (editor: Editor, root: HTMLElement) => {
|
||||||
const hint = document.createElement('p')
|
const hint = document.createElement('p')
|
||||||
const hintIcon = hint.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'svg'))
|
const hintIcon = hint.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'svg'))
|
||||||
const hintSpan = hint.appendChild(document.createElement('span'))
|
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 {
|
function minmax (value = 0, min = 0, max = 0): number {
|
||||||
return Math.min(Math.max(value, min), max)
|
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))
|
||||||
|
}
|
||||||
|
@ -22,9 +22,10 @@ import {
|
|||||||
getPreviewType,
|
getPreviewType,
|
||||||
type FilePreviewExtension
|
type FilePreviewExtension
|
||||||
} from '@hcengineering/presentation'
|
} from '@hcengineering/presentation'
|
||||||
|
import { type Editor } from '@tiptap/core'
|
||||||
import { SvelteRenderer } from '../../../node-view'
|
import { SvelteRenderer } from '../../../node-view'
|
||||||
import { parseReferenceUrl } from '../../reference'
|
import { parseReferenceUrl } from '../../reference'
|
||||||
import { type EmbedNodeProviderConstructor } from '../embed'
|
import { setLoadingState, type EmbedNodeProviderConstructor } from '../embed'
|
||||||
|
|
||||||
export interface DriveEmbedOptions {
|
export interface DriveEmbedOptions {
|
||||||
_x?: number
|
_x?: number
|
||||||
@ -32,46 +33,52 @@ export interface DriveEmbedOptions {
|
|||||||
|
|
||||||
export const defaultDriveEmbedOptions: DriveEmbedOptions = {}
|
export const defaultDriveEmbedOptions: DriveEmbedOptions = {}
|
||||||
|
|
||||||
export const DriveEmbedProvider: EmbedNodeProviderConstructor<DriveEmbedOptions> = (options) => async (src: string) => {
|
export const DriveEmbedProvider: EmbedNodeProviderConstructor<DriveEmbedOptions> = (options) => ({
|
||||||
const ref = parseReferenceUrl(src)
|
buildView: async (src: string) => {
|
||||||
if (ref?.objectclass !== drive.class.File || ref.id === undefined) {
|
const ref = parseReferenceUrl(src)
|
||||||
return
|
if (ref?.objectclass !== drive.class.File || ref.id === undefined) {
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const client = getClient()
|
const client = getClient()
|
||||||
const file = await client.findOne(drive.class.File, { _id: ref.id as Ref<File> })
|
const file = await client.findOne(drive.class.File, { _id: ref.id as Ref<File> })
|
||||||
if (file === undefined) return
|
if (file === undefined) return
|
||||||
|
|
||||||
const version = await client.findOne(drive.class.FileVersion, { attachedTo: file._id, version: file.version })
|
const version = await client.findOne(drive.class.FileVersion, { attachedTo: file._id, version: file.version })
|
||||||
if (version === undefined) return
|
if (version === undefined) return
|
||||||
|
|
||||||
const allPreviewTypesPromise = new Promise<FilePreviewExtension[]>((resolve) => {
|
const allPreviewTypesPromise = new Promise<FilePreviewExtension[]>((resolve) => {
|
||||||
$previewTypes.subscribe((types) => {
|
$previewTypes.subscribe((types) => {
|
||||||
if (types.length > 0) resolve(types)
|
if (types.length > 0) resolve(types)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
const allPreviewTypes = await allPreviewTypesPromise
|
const allPreviewTypes = await allPreviewTypesPromise
|
||||||
const previewType = await getPreviewType(version.type, allPreviewTypes)
|
const previewType = await getPreviewType(version.type, allPreviewTypes)
|
||||||
|
|
||||||
if (previewType === undefined) return
|
if (previewType === undefined) return
|
||||||
|
|
||||||
return (root: HTMLDivElement) => {
|
return (editor: Editor, root: HTMLDivElement) => {
|
||||||
const renderer = new SvelteRenderer(FilePreview as any, {
|
const setLoading = (loading: boolean): void => {
|
||||||
element: root,
|
setLoadingState(editor.view, root, loading)
|
||||||
props: {
|
|
||||||
file: version.file,
|
|
||||||
contentType: version.type,
|
|
||||||
name: version.title,
|
|
||||||
metadata: version.metadata,
|
|
||||||
embedded: true
|
|
||||||
}
|
}
|
||||||
})
|
const renderer = new SvelteRenderer(FilePreview as any, {
|
||||||
return {
|
element: root,
|
||||||
name: 'drive',
|
props: {
|
||||||
destroy: () => {
|
file: version.file,
|
||||||
renderer.destroy()
|
contentType: version.type,
|
||||||
|
name: version.title,
|
||||||
|
metadata: version.metadata,
|
||||||
|
embedded: true,
|
||||||
|
setLoading
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
name: 'drive',
|
||||||
|
destroy: () => {
|
||||||
|
renderer.destroy()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
@ -13,27 +13,33 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import { type Editor } from '@tiptap/core'
|
||||||
import { type EmbedNodeProviderConstructor } from '../embed'
|
import { type EmbedNodeProviderConstructor } from '../embed'
|
||||||
|
|
||||||
export const YoutubeEmbedProvider: EmbedNodeProviderConstructor<YoutubeEmbedUrlOptions> = (options) => async (src) => {
|
export const YoutubeEmbedProvider: EmbedNodeProviderConstructor<YoutubeEmbedUrlOptions> = (options) => ({
|
||||||
const url = getEmbedUrlFromYoutubeUrl(src, options)
|
buildView: async (src) => {
|
||||||
if (url === undefined) return
|
const url = getEmbedUrlFromYoutubeUrl(src, options)
|
||||||
|
if (url === undefined) return
|
||||||
|
|
||||||
return (root: HTMLDivElement) => {
|
return (editor: Editor, root: HTMLDivElement) => {
|
||||||
const iframe = document.createElement('iframe')
|
const iframe = document.createElement('iframe')
|
||||||
iframe.src = url
|
iframe.src = url
|
||||||
for (const key in options.iframe) {
|
for (const key in options.iframe) {
|
||||||
const value = (options as any)[key]
|
const value = (options as any)[key]
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
iframe.setAttribute(key, `${value}`)
|
iframe.setAttribute(key, `${value}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root.appendChild(iframe)
|
||||||
|
return {
|
||||||
|
name: 'youtube'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
root.appendChild(iframe)
|
},
|
||||||
return {
|
autoEmbedUrl: (src: string) => {
|
||||||
name: 'youtube'
|
return getEmbedUrlFromYoutubeUrl(src, options) !== undefined
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
export const isValidYoutubeUrl = (url: string): boolean => {
|
export const isValidYoutubeUrl = (url: string): boolean => {
|
||||||
return url.match(YOUTUBE_REGEX) !== null
|
return url.match(YOUTUBE_REGEX) !== null
|
||||||
|
@ -27,6 +27,8 @@
|
|||||||
export let drawings: any
|
export let drawings: any
|
||||||
export let createDrawing: (data: any) => Promise<any>
|
export let createDrawing: (data: any) => Promise<any>
|
||||||
|
|
||||||
|
export let setLoading: ((loading: boolean) => void) | undefined = undefined
|
||||||
|
|
||||||
$: originalWidth = metadata?.originalWidth
|
$: originalWidth = metadata?.originalWidth
|
||||||
$: originalHeight = metadata?.originalHeight
|
$: originalHeight = metadata?.originalHeight
|
||||||
$: pixelRatio = metadata?.pixelRatio ?? 1
|
$: pixelRatio = metadata?.pixelRatio ?? 1
|
||||||
@ -38,6 +40,12 @@
|
|||||||
$: height = imageHeight != null ? `min(${imageHeight}px, ${fit ? '100%' : '80vh'})` : '100%'
|
$: height = imageHeight != null ? `min(${imageHeight}px, ${fit ? '100%' : '80vh'})` : '100%'
|
||||||
|
|
||||||
let loading = true
|
let loading = true
|
||||||
|
function _setLoading (newState: boolean): void {
|
||||||
|
loading = newState
|
||||||
|
setLoading?.(loading)
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (value !== undefined) _setLoading(true)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#await getBlobRef(value, name) then blobRef}
|
{#await getBlobRef(value, name) then blobRef}
|
||||||
@ -58,7 +66,7 @@
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
on:load={() => {
|
on:load={() => {
|
||||||
loading = false
|
_setLoading(false)
|
||||||
}}
|
}}
|
||||||
class="object-contain mx-auto"
|
class="object-contain mx-auto"
|
||||||
style:max-width={width}
|
style:max-width={width}
|
||||||
|
Loading…
Reference in New Issue
Block a user