mirror of
https://github.com/hcengineering/platform.git
synced 2025-06-09 09:20:54 +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 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}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
Loading…
Reference in New Issue
Block a user