mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-12 04:37:44 +02:00
Support for embedding links and references in text editor (#9003)
Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
@@ -488,6 +488,10 @@
|
||||
drawingBoard: {
|
||||
getSavedBoard
|
||||
},
|
||||
embed: {
|
||||
boundary: boundary ?? element,
|
||||
popupContainer: editorPopupContainer
|
||||
},
|
||||
...kitOptions
|
||||
}),
|
||||
...optionalExtensions,
|
||||
|
||||
@@ -13,22 +13,37 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { createEventDispatcher, onDestroy } from 'svelte'
|
||||
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 tr from 'date-fns/locale/tr'
|
||||
import { Transaction } from '@tiptap/pm/state'
|
||||
|
||||
export let action: TextEditorAction
|
||||
export let size: IconSize
|
||||
export let editor: Editor
|
||||
export let actionCtx: ActionContext
|
||||
export let blockMouseEvents = true
|
||||
export let listenCursorUpdate = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let selected: boolean = false
|
||||
$: void updateSelected(editor, action)
|
||||
|
||||
if (listenCursorUpdate) {
|
||||
const listener = ({ transaction }: { transaction: Transaction }) => {
|
||||
if (transaction.getMeta('contextCursorUpdate') === true) {
|
||||
void updateSelected(editor, action)
|
||||
}
|
||||
}
|
||||
editor.on('transaction', listener)
|
||||
onDestroy(() => {
|
||||
editor.off('transaction', listener)
|
||||
})
|
||||
}
|
||||
|
||||
async function updateSelected (e: Editor, { isActive }: TextEditorAction): Promise<void> {
|
||||
if (isActive === undefined) {
|
||||
selected = false
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<!--
|
||||
//
|
||||
// Copyright © 2023, 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { NodeViewProps } from '../../node-view'
|
||||
import textEditor, { ActionContext, TextEditorAction } from '@hcengineering/text-editor'
|
||||
import { ObjectNode, createQuery } from '@hcengineering/presentation'
|
||||
import TextActionButton from '../../TextActionButton.svelte'
|
||||
import { getResource } from '@hcengineering/platform'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { Transaction } from '@tiptap/pm/state'
|
||||
import { EmbedControlCursor, shouldShowLink } from './embed'
|
||||
import { parseReferenceUrl } from '../reference'
|
||||
|
||||
export let editor: NodeViewProps['editor']
|
||||
export let cursor: EmbedControlCursor | null = null
|
||||
|
||||
const actionsQuery = createQuery()
|
||||
const actionCtx: ActionContext = {
|
||||
mode: 'full',
|
||||
tag: 'embed-toolbar'
|
||||
}
|
||||
|
||||
let allActions: TextEditorAction[] = []
|
||||
let actions: TextEditorAction[] = []
|
||||
|
||||
async function updateActions (newActions: TextEditorAction[], ctx: ActionContext): Promise<void> {
|
||||
allActions = newActions
|
||||
const out: TextEditorAction[] = []
|
||||
for (const action of newActions) {
|
||||
const tester = action.visibilityTester
|
||||
|
||||
if (tester === undefined) {
|
||||
out.push(action)
|
||||
continue
|
||||
}
|
||||
|
||||
const testerFunc = await getResource(tester)
|
||||
if (await testerFunc(editor, ctx)) {
|
||||
out.push(action)
|
||||
}
|
||||
}
|
||||
|
||||
actions = out
|
||||
}
|
||||
|
||||
const listener = ({ transaction }: { transaction: Transaction }) => {
|
||||
if (transaction.getMeta('contextCursorUpdate') === true) {
|
||||
actions = []
|
||||
void updateActions(allActions, actionCtx)
|
||||
}
|
||||
}
|
||||
|
||||
if (editor !== undefined) {
|
||||
editor.on('transaction', listener)
|
||||
onDestroy(() => {
|
||||
editor.off('transaction', listener)
|
||||
})
|
||||
}
|
||||
|
||||
actionsQuery.query(textEditor.class.TextEditorAction, { kind: 'preview' }, (result) => {
|
||||
void updateActions([...result], actionCtx)
|
||||
})
|
||||
|
||||
$: categories = actions.reduce<[number, TextEditorAction][][]>((acc, action) => {
|
||||
const { category, index } = action
|
||||
if (acc[category] === undefined) acc[category] = []
|
||||
acc[category].push([index, action])
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
$: categories.forEach((category) => {
|
||||
category.sort((a, b) => a[0] - b[0])
|
||||
})
|
||||
|
||||
$: showSrc = shouldShowLink(cursor)
|
||||
$: reference = cursor?.src !== undefined ? parseReferenceUrl(cursor.src) : undefined
|
||||
</script>
|
||||
|
||||
{#if cursor && actions.length > 0}
|
||||
<div class="embed-toolbar flex" class:reference={showSrc && !!reference} contenteditable="false">
|
||||
<div class="text-editor-toolbar buttons-group xsmall-gap">
|
||||
{#if showSrc}
|
||||
{#if !reference}
|
||||
<a class="link" href={cursor.src} target="_blank">{cursor.src}</a>
|
||||
{/if}
|
||||
{#if reference}
|
||||
<ObjectNode _id={reference.id} _class={reference.objectclass} title={reference.label} transparent />
|
||||
{/if}
|
||||
{#if reference}
|
||||
<div class="buttons-divider" />
|
||||
{/if}
|
||||
{/if}
|
||||
{#each Object.values(categories) as category, index}
|
||||
{#if index > 0}
|
||||
<div class="buttons-divider" />
|
||||
{/if}
|
||||
|
||||
{#each category as [_, action]}
|
||||
<TextActionButton {action} {editor} size="small" {actionCtx} listenCursorUpdate blockMouseEvents={false} />
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.link {
|
||||
padding: 0 0.5rem;
|
||||
padding-right: 0;
|
||||
max-width: 20rem;
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--theme-link-color);
|
||||
}
|
||||
|
||||
.embed-toolbar {
|
||||
position: relative;
|
||||
padding: 0.25rem;
|
||||
background-color: var(--theme-comp-header-color);
|
||||
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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,667 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { getMetadata, translate } from '@hcengineering/platform'
|
||||
import { type ActionContext, copyTextToClipboard } from '@hcengineering/presentation'
|
||||
import { EmbedNode as BaseEmbedNode, type ReferenceNodeProps } from '@hcengineering/text'
|
||||
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 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'
|
||||
|
||||
export interface EmbedNodeOptions {
|
||||
providers: EmbedNodeProvider[]
|
||||
boundary?: HTMLElement
|
||||
popupContainer?: HTMLElement
|
||||
}
|
||||
|
||||
export interface EmbedNodeViewHandle {
|
||||
name: string
|
||||
destroy?: () => void
|
||||
}
|
||||
|
||||
export type EmbedNodeView = (root: HTMLDivElement) => EmbedNodeViewHandle | undefined
|
||||
export type EmbedNodeProvider = (src: string) => Promise<EmbedNodeView | undefined>
|
||||
export type EmbedNodeProviderConstructor<T> = (options: T) => EmbedNodeProvider
|
||||
|
||||
export const EmbedNode = BaseEmbedNode.extend<EmbedNodeOptions>({
|
||||
addOptions () {
|
||||
return {
|
||||
providers: []
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes () {
|
||||
return {
|
||||
src: {
|
||||
default: null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML () {
|
||||
return [
|
||||
{
|
||||
priority: 60,
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
getAttrs (node) {
|
||||
const src = node.dataset.embedSrc?.trim()
|
||||
if (src === undefined) return false
|
||||
return { src }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML ({ HTMLAttributes, node }) {
|
||||
return [
|
||||
'div',
|
||||
{
|
||||
'data-type': this.name,
|
||||
'data-embed-src': node.attrs.src,
|
||||
class: 'embed-node'
|
||||
},
|
||||
[
|
||||
'a',
|
||||
{
|
||||
href: node.attrs.src
|
||||
},
|
||||
node.attrs.src
|
||||
]
|
||||
]
|
||||
},
|
||||
|
||||
addNodeView () {
|
||||
return ({ node, HTMLAttributes, editor }) => {
|
||||
const providerPromise = matchUrl(this.options.providers, node.attrs.src)
|
||||
|
||||
const root = document.createElement('div')
|
||||
root.setAttribute('data-type', this.name)
|
||||
root.setAttribute('data-embed-src', node.attrs.src)
|
||||
root.classList.add('embed-node')
|
||||
|
||||
let handle: EmbedNodeViewHandle | undefined
|
||||
|
||||
void providerPromise.then((view) => {
|
||||
view = view ?? StubEmbedNodeView
|
||||
handle = view(root)
|
||||
if (handle !== undefined) {
|
||||
root.classList.add(`embed-${handle.name}`)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
dom: root,
|
||||
destroy: () => {
|
||||
handle?.destroy?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins () {
|
||||
return [EmbedControlPlugin(this.editor, this.options)]
|
||||
}
|
||||
})
|
||||
|
||||
export interface EmbedControlState {
|
||||
cursor: EmbedControlCursor | null
|
||||
providers: EmbedNodeProvider[]
|
||||
debounce: {
|
||||
updateCursor: DebouncedCaller
|
||||
}
|
||||
}
|
||||
|
||||
export interface EmbedControlCursor {
|
||||
from: number
|
||||
to: number
|
||||
node: Node
|
||||
src: string
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
export interface EmbedControlTxMeta {
|
||||
cursor?: EmbedControlCursor | null
|
||||
}
|
||||
|
||||
const embedControlPluginKey = new PluginKey('embedControlPlugin')
|
||||
|
||||
export function EmbedControlPlugin (editor: Editor, options: EmbedNodeOptions): Plugin {
|
||||
return new Plugin<EmbedControlState>({
|
||||
key: embedControlPluginKey,
|
||||
state: {
|
||||
init () {
|
||||
return {
|
||||
cursor: null,
|
||||
providers: options.providers,
|
||||
debounce: {
|
||||
updateCursor: new DebouncedCaller(250)
|
||||
}
|
||||
}
|
||||
},
|
||||
apply (tr, prev, oldState, newState) {
|
||||
const meta = tr.getMeta(embedControlPluginKey) as EmbedControlTxMeta
|
||||
if (meta?.cursor !== undefined) {
|
||||
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))
|
||||
|
||||
updateCursor(tr, cursor)
|
||||
return { ...prev, cursor }
|
||||
}
|
||||
|
||||
if (!oldState.selection.eq(newState.selection)) {
|
||||
const $pos = newState.doc.resolve(newState.selection.from)
|
||||
const cursor = resolveCursor(prev, $pos)
|
||||
|
||||
if (cursor !== null) {
|
||||
cursor.selected = true
|
||||
updateCursor(tr, cursor)
|
||||
return { ...prev, cursor }
|
||||
} else if (prev.cursor !== null && prev.cursor.selected === true) {
|
||||
updateCursor(tr, null)
|
||||
return { ...prev, cursor: null }
|
||||
}
|
||||
}
|
||||
|
||||
return prev
|
||||
}
|
||||
},
|
||||
view (view) {
|
||||
interface State {
|
||||
cursor: EmbedControlCursor | null
|
||||
}
|
||||
let state: State = {
|
||||
cursor: null
|
||||
}
|
||||
|
||||
const getReferenceClientRect = (): DOMRect => {
|
||||
return getReferenceRect(view, state.cursor?.from ?? 0, state.cursor?.to ?? 0)
|
||||
}
|
||||
|
||||
const listener = (event: MouseEvent): void => {
|
||||
handleMouseMove(view, event)
|
||||
}
|
||||
window.addEventListener('mousemove', listener)
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.dataset.blockCursorUpdate = 'true'
|
||||
|
||||
const renderer = new SvelteRenderer(EmbedToolbar, {
|
||||
element: container,
|
||||
props: { editor, cursor: state.cursor }
|
||||
})
|
||||
renderer.updateProps({ editor, cursor: state.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) {
|
||||
tippynode.show()
|
||||
tippynode.setProps({})
|
||||
}
|
||||
if (tippynode.state.isShown && newState.cursor === null) {
|
||||
tippynode.hide()
|
||||
} else {
|
||||
tippynode.setProps({})
|
||||
}
|
||||
state = newState
|
||||
renderer.updateProps({ editor, cursor: state.cursor })
|
||||
}
|
||||
|
||||
const tippynode = (this.tippynode = tippy(view.dom, {
|
||||
delay: [0, 0],
|
||||
duration: [0, 0],
|
||||
getReferenceClientRect,
|
||||
inertia: true,
|
||||
content: container,
|
||||
maxWidth: 640,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'top-start',
|
||||
hideOnClick: 'toggle',
|
||||
onDestroy: () => {},
|
||||
appendTo: () => options.popupContainer ?? document.body,
|
||||
zIndex: 10000
|
||||
}))
|
||||
|
||||
editor.on('transaction', ({ transaction }) => {
|
||||
const meta = transaction.getMeta(embedControlPluginKey) as EmbedControlTxMeta
|
||||
if (meta?.cursor !== undefined) {
|
||||
updateState({ cursor: meta.cursor })
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
destroy () {
|
||||
tippynode.destroy()
|
||||
window.removeEventListener('mousemove', listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if (blockCursorUpdate) return
|
||||
|
||||
const coords = { left: event.clientX, top: event.clientY }
|
||||
const newCursor = disableCursor ? null : resolveCursor(state, resolveCursorPositionFromCoords(view, coords))
|
||||
|
||||
if (eqCursors(newCursor, prevCursor)) {
|
||||
return
|
||||
}
|
||||
|
||||
view.dispatch(updateCursor(view.state.tr, newCursor))
|
||||
}
|
||||
|
||||
function eqCursors (c1: EmbedControlCursor | null, c2: EmbedControlCursor | null): boolean {
|
||||
const eqRange = c2?.from === c1?.from && c2?.to === c1?.to
|
||||
const eqNode = c2?.node === c1?.node || (c2?.node !== undefined && c1?.node !== undefined && c2.node.eq(c1.node))
|
||||
return eqRange && eqNode
|
||||
}
|
||||
|
||||
function handleMouseMove (view: EditorView, event: MouseEvent): void {
|
||||
const state = embedControlPluginKey.getState(view.state) as EmbedControlState | undefined
|
||||
if (state === undefined) return
|
||||
|
||||
state.debounce.updateCursor.call(() => {
|
||||
updateCursorFromMouseEvent(view, event)
|
||||
})
|
||||
}
|
||||
|
||||
function getNodeUrl (node?: Node | null): string | undefined {
|
||||
if (node == null || node === undefined) return
|
||||
|
||||
switch (node.type.name) {
|
||||
case 'text': {
|
||||
const link = node.marks.find((m) => m.type.name === 'link')
|
||||
return link?.attrs.href ?? undefined
|
||||
}
|
||||
case 'reference': {
|
||||
return buildReferenceUrl(node.attrs as ReferenceNodeProps)
|
||||
}
|
||||
case 'embed': {
|
||||
return node.attrs.src
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function matchUrl (providers: EmbedControlState['providers'], url?: string): Promise<EmbedNodeView | undefined> {
|
||||
if (url === undefined) return
|
||||
|
||||
for (const provider of providers) {
|
||||
const view = await provider(url)
|
||||
if (view !== undefined) return view
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCursorChildNode (
|
||||
state: EmbedControlState,
|
||||
$pos?: ResolvedPos
|
||||
): { node: Node | null, index: number, offset: number } | null {
|
||||
if ($pos === undefined) return null
|
||||
|
||||
const parent = $pos.parent
|
||||
const offset = $pos.pos - $pos.start()
|
||||
|
||||
const childAfter = parent.childAfter(offset)
|
||||
let childBefore = parent.childBefore(offset)
|
||||
|
||||
// Special case for reference nodes, since autocomplete adds a space after the node
|
||||
if (childBefore.node?.type.name === 'text' && childBefore.node.textContent === ' ' && childBefore.offset > 0) {
|
||||
const lookupChild = parent.childBefore(childBefore.offset)
|
||||
if (lookupChild.node?.type.name === 'reference') {
|
||||
childBefore = lookupChild
|
||||
}
|
||||
}
|
||||
|
||||
const nodeAfter = getNodeUrl(childAfter.node) !== undefined ? childAfter : null
|
||||
const nodeBefore = getNodeUrl(childBefore.node) !== undefined ? childBefore : null
|
||||
|
||||
return nodeAfter ?? nodeBefore
|
||||
}
|
||||
|
||||
function resolveCursor (state: EmbedControlState, $pos?: ResolvedPos): EmbedControlCursor | null {
|
||||
if ($pos === undefined) return null
|
||||
|
||||
const child = resolveCursorChildNode(state, $pos)
|
||||
const node = child?.node ?? null
|
||||
|
||||
if (child === null || node === null) return null
|
||||
|
||||
const from = $pos.start() + child.offset
|
||||
const to = from + node.nodeSize
|
||||
|
||||
const src = getNodeUrl(node)
|
||||
if (src === undefined) return null
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
node,
|
||||
src
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCursorPositionFromCoords (
|
||||
view: EditorView,
|
||||
coords: { left: number, top: number }
|
||||
): ResolvedPos | undefined {
|
||||
const posInfo = view.posAtCoords(coords)
|
||||
if (posInfo === null) return
|
||||
|
||||
const posInside = posInfo.inside
|
||||
const posBase = posInfo.pos
|
||||
|
||||
const $posInside = posInfo.inside >= 0 ? view.state.doc.resolve(posInside) : null
|
||||
const $posBase = view.state.doc.resolve(posBase)
|
||||
|
||||
const $pos = $posInside === null ? $posBase : $posInside.nodeAfter?.type.name === 'paragraph' ? $posBase : $posInside
|
||||
|
||||
return $pos
|
||||
}
|
||||
|
||||
function isLink (node: Node, strict: boolean = false): boolean {
|
||||
if (node.type.name === 'text') {
|
||||
const mark = node.marks.find((m) => m.type.name === 'link')
|
||||
if (mark === undefined) return false
|
||||
return strict ? mark.attrs.href === node.textContent : true
|
||||
}
|
||||
if (node.type.name === 'reference') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function updateCursor (tr: Transaction, cursor: EmbedControlCursor | null): Transaction {
|
||||
return tr.setMeta(embedControlPluginKey, { cursor }).setMeta('contextCursorUpdate', true)
|
||||
}
|
||||
|
||||
function getEmbedControlState (editor: Editor): EmbedControlState | undefined {
|
||||
return embedControlPluginKey.getState(editor.view.state) as EmbedControlState | undefined
|
||||
}
|
||||
|
||||
function getEmbedControlCursor (editor: Editor): EmbedControlCursor | null {
|
||||
const state = getEmbedControlState(editor)
|
||||
return state?.cursor ?? null
|
||||
}
|
||||
|
||||
export async function shouldShowConvertToLinkPreviewAction (editor: Editor, context: ActionContext): Promise<boolean> {
|
||||
if (!editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (context.tag !== 'embed-toolbar') {
|
||||
return false
|
||||
}
|
||||
|
||||
const cursor = getEmbedControlCursor(editor)
|
||||
if (cursor?.node === undefined) return false
|
||||
|
||||
const canEmbed = await shouldShowConvertToEmbedPreviewAction(editor, context)
|
||||
|
||||
if (!canEmbed && isLink(cursor.node, true)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export async function shouldShowConvertToEmbedPreviewAction (editor: Editor, context: ActionContext): Promise<boolean> {
|
||||
if (!editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (context.tag !== 'embed-toolbar') {
|
||||
return false
|
||||
}
|
||||
|
||||
const cursor = getEmbedControlCursor(editor)
|
||||
if (cursor?.node === undefined) return false
|
||||
|
||||
const url = getNodeUrl(cursor.node)
|
||||
const view = await matchUrl(getEmbedControlState(editor)?.providers ?? [], url)
|
||||
return view !== undefined
|
||||
}
|
||||
|
||||
export async function convertToLinkPreviewAction (editor: Editor, event: MouseEvent): Promise<void> {
|
||||
const cursor = getEmbedControlCursor(editor)
|
||||
if (cursor?.node === undefined) return
|
||||
|
||||
const node = cursor.node
|
||||
|
||||
if (node.type.name !== 'embed') return
|
||||
|
||||
const ref = parseReferenceUrl(cursor.src)
|
||||
const schema = editor.schema
|
||||
|
||||
let fragment: Fragment
|
||||
|
||||
if (ref !== undefined) {
|
||||
const refNode = schema.nodes.reference.create(ref)
|
||||
fragment = Fragment.from(refNode)
|
||||
} else {
|
||||
const textNode = schema.text(cursor.src)
|
||||
const linkMark = schema.marks.link.create({ href: cursor.src })
|
||||
const textWithLink = textNode.mark([linkMark])
|
||||
fragment = Fragment.from(textWithLink)
|
||||
}
|
||||
|
||||
const from = cursor.from
|
||||
const to = cursor.to
|
||||
|
||||
const tr = replacePreviewContent({ from, to }, fragment, editor.state.tr, editor)
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
|
||||
export async function convertToEmbedPreviewAction (editor: Editor, event: MouseEvent): Promise<void> {
|
||||
const cursor = getEmbedControlCursor(editor)
|
||||
if (cursor?.node === undefined) return
|
||||
|
||||
const node = cursor.node
|
||||
|
||||
if (!isLink(node)) return
|
||||
|
||||
const src = getNodeUrl(node)
|
||||
if (src === undefined) return
|
||||
|
||||
const embedNode = editor.schema.nodes.embed.create({ src })
|
||||
const fragment = Fragment.from(embedNode)
|
||||
|
||||
const from = cursor.from
|
||||
const to = cursor.to
|
||||
|
||||
const tr = editor.state.tr
|
||||
replacePreviewContent({ from, to }, fragment, tr, editor)
|
||||
|
||||
editor.view.focus()
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
|
||||
export function shouldShowLink (cursor: EmbedControlCursor | null): boolean {
|
||||
if (cursor === null) return false
|
||||
|
||||
if (cursor.node.type.name === 'text' && cursor.src !== cursor.node.textContent) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (cursor.node.type.name === 'embed') {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function shouldShowCopyPreviewLinkAction (editor: Editor, context: ActionContext): Promise<boolean> {
|
||||
const cursor = getEmbedControlCursor(editor)
|
||||
|
||||
if (!shouldShowLink(cursor)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (parseReferenceUrl(cursor?.src ?? '') !== undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export async function copyPreviewLinkAction (editor: Editor, event: MouseEvent): Promise<void> {
|
||||
const cursor = getEmbedControlCursor(editor)
|
||||
|
||||
const src = cursor?.src
|
||||
if (typeof src !== 'string') return
|
||||
|
||||
await copyTextToClipboard(src)
|
||||
}
|
||||
|
||||
export async function convertToLinkPreviewActionIsActive (editor: Editor): Promise<boolean> {
|
||||
const cursor = getEmbedControlCursor(editor)
|
||||
return cursor?.node !== undefined && isLink(cursor.node)
|
||||
}
|
||||
|
||||
export async function convertToEmbedPreviewActionIsActive (editor: Editor): Promise<boolean> {
|
||||
const cursor = getEmbedControlCursor(editor)
|
||||
if (cursor?.node === undefined) return false
|
||||
return cursor.node.type.name === 'embed'
|
||||
}
|
||||
|
||||
export function replacePreviewContent (
|
||||
{ from, to }: Range,
|
||||
fragment: Fragment,
|
||||
tr: Transaction,
|
||||
editor: Editor
|
||||
): Transaction {
|
||||
const state = getEmbedControlState(editor)
|
||||
if (state === undefined) return tr
|
||||
|
||||
const slice = new Slice(fragment, 0, 0)
|
||||
tr.replaceRange(from, to, slice)
|
||||
|
||||
const start = tr.mapping.map(from, -1)
|
||||
const end = start + slice.size
|
||||
|
||||
let isOnlyBlockContent = true
|
||||
|
||||
fragment.forEach((node) => {
|
||||
node.check()
|
||||
isOnlyBlockContent = isOnlyBlockContent ? node.isBlock : false
|
||||
})
|
||||
|
||||
const selection = isOnlyBlockContent
|
||||
? Selection.near(tr.doc.resolve(start), 1)
|
||||
: Selection.near(tr.doc.resolve(end + 1), 1)
|
||||
|
||||
tr.setSelection(selection)
|
||||
|
||||
const cursor = resolveCursor(state, tr.doc.resolve(isOnlyBlockContent ? start : end))
|
||||
updateCursor(tr, cursor)
|
||||
|
||||
return tr
|
||||
}
|
||||
|
||||
const StubEmbedNodeView: EmbedNodeView = (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'))
|
||||
|
||||
const embed = async (): Promise<void> => {
|
||||
const hintText = await translate(textEditor.string.UnableToLoadEmbeddedContent, {})
|
||||
hintSpan.textContent = hintText
|
||||
|
||||
const iconUrl = getMetadata(textEditor.icon.EmbedPreview) ?? ''
|
||||
if (iconUrl !== '') {
|
||||
root.appendChild(document.createTextNode(' '))
|
||||
hintIcon.setAttribute('class', 'svg-small')
|
||||
hintIcon.setAttribute('fill', 'currentColor')
|
||||
const use = hintIcon.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'use'))
|
||||
use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', iconUrl)
|
||||
}
|
||||
}
|
||||
|
||||
void embed()
|
||||
root.appendChild(hint)
|
||||
|
||||
return {
|
||||
name: 'stub'
|
||||
}
|
||||
}
|
||||
|
||||
function getReferenceRect (view: EditorView, from: number, to: number): DOMRect {
|
||||
const minPos = 0
|
||||
const maxPos = view.state.doc.content.size
|
||||
const resolvedFrom = minmax(from, minPos, maxPos)
|
||||
const resolvedEnd = minmax(to, minPos, maxPos)
|
||||
const start = view.coordsAtPos(resolvedFrom)
|
||||
const end = view.coordsAtPos(resolvedEnd, -1)
|
||||
const top = Math.min(start.top, end.top)
|
||||
const bottom = Math.max(start.bottom, end.bottom)
|
||||
const left = Math.min(start.left, end.left)
|
||||
const right = Math.max(start.right, end.right)
|
||||
const width = right - left
|
||||
const height = bottom - top
|
||||
const x = left
|
||||
const y = top
|
||||
const data = {
|
||||
top,
|
||||
bottom,
|
||||
left,
|
||||
right,
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
toJSON: () => data
|
||||
}
|
||||
}
|
||||
|
||||
function minmax (value = 0, min = 0, max = 0): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { type Ref } from '@hcengineering/core'
|
||||
import drive, { type File } from '@hcengineering/drive'
|
||||
import {
|
||||
previewTypes as $previewTypes,
|
||||
FilePreview,
|
||||
getClient,
|
||||
getPreviewType,
|
||||
type FilePreviewExtension
|
||||
} from '@hcengineering/presentation'
|
||||
import { SvelteRenderer } from '../../../node-view'
|
||||
import { parseReferenceUrl } from '../../reference'
|
||||
import { type EmbedNodeProviderConstructor } from '../embed'
|
||||
|
||||
export interface DriveEmbedOptions {
|
||||
_x?: number
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 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)
|
||||
|
||||
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 {
|
||||
name: 'drive',
|
||||
destroy: () => {
|
||||
renderer.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { type EmbedNodeProviderConstructor } from '../embed'
|
||||
|
||||
export const YoutubeEmbedProvider: EmbedNodeProviderConstructor<YoutubeEmbedUrlOptions> = (options) => 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}`)
|
||||
}
|
||||
}
|
||||
root.appendChild(iframe)
|
||||
return {
|
||||
name: 'youtube'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const isValidYoutubeUrl = (url: string): boolean => {
|
||||
return url.match(YOUTUBE_REGEX) !== null
|
||||
}
|
||||
|
||||
export interface YoutubeEmbedUrlOptions {
|
||||
iframe: {
|
||||
allowFullscreen?: boolean
|
||||
autoplay?: boolean
|
||||
ccLanguage?: string
|
||||
ccLoadPolicy?: boolean
|
||||
controls?: boolean
|
||||
disableKBcontrols?: boolean
|
||||
enableIFrameApi?: boolean
|
||||
endTime?: number
|
||||
interfaceLanguage?: string
|
||||
ivLoadPolicy?: number
|
||||
loop?: boolean
|
||||
modestBranding?: boolean
|
||||
nocookie?: boolean
|
||||
origin?: string
|
||||
playlist?: string
|
||||
progressBarColor?: string
|
||||
startAt?: number
|
||||
rel?: number
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultYoutubeEmbedUrlOptions: YoutubeEmbedUrlOptions = {
|
||||
iframe: {
|
||||
allowFullscreen: true,
|
||||
autoplay: false,
|
||||
ccLanguage: undefined,
|
||||
ccLoadPolicy: undefined,
|
||||
controls: true,
|
||||
disableKBcontrols: false,
|
||||
enableIFrameApi: false,
|
||||
endTime: undefined,
|
||||
interfaceLanguage: undefined,
|
||||
ivLoadPolicy: 0,
|
||||
loop: false,
|
||||
modestBranding: false,
|
||||
nocookie: false,
|
||||
origin: undefined,
|
||||
playlist: undefined,
|
||||
progressBarColor: undefined,
|
||||
rel: 1
|
||||
}
|
||||
}
|
||||
|
||||
export const getYoutubeEmbedUrl = (nocookie?: boolean, isPlaylist?: boolean): string => {
|
||||
if (isPlaylist ?? false) {
|
||||
return 'https://www.youtube-nocookie.com/embed/videoseries?list='
|
||||
}
|
||||
return nocookie ?? false ? 'https://www.youtube-nocookie.com/embed/' : 'https://www.youtube.com/embed/'
|
||||
}
|
||||
|
||||
export const getEmbedUrlFromYoutubeUrl = (url: string, options: YoutubeEmbedUrlOptions): string | undefined => {
|
||||
const {
|
||||
allowFullscreen,
|
||||
autoplay,
|
||||
ccLanguage,
|
||||
ccLoadPolicy,
|
||||
controls,
|
||||
disableKBcontrols,
|
||||
enableIFrameApi,
|
||||
endTime,
|
||||
interfaceLanguage,
|
||||
ivLoadPolicy,
|
||||
loop,
|
||||
modestBranding,
|
||||
nocookie,
|
||||
origin,
|
||||
playlist,
|
||||
progressBarColor,
|
||||
startAt,
|
||||
rel
|
||||
} = options.iframe
|
||||
|
||||
if (!isValidYoutubeUrl(url)) {
|
||||
return
|
||||
}
|
||||
|
||||
// if is already an embed url, return it
|
||||
if (url.includes('/embed/')) {
|
||||
return url
|
||||
}
|
||||
|
||||
// if is a youtu.be url, get the id after the /
|
||||
if (url.includes('youtu.be')) {
|
||||
const id = url.split('/').pop()
|
||||
|
||||
if (id !== undefined) {
|
||||
return
|
||||
}
|
||||
return `${getYoutubeEmbedUrl(nocookie)}${id}`
|
||||
}
|
||||
|
||||
const videoIdRegex = /(?:(v|list)=|shorts\/)([-\w]+)/gm
|
||||
const matches = videoIdRegex.exec(url)
|
||||
|
||||
if (matches === null || (matches?.[2] ?? null) === null) {
|
||||
return
|
||||
}
|
||||
|
||||
let outputUrl = `${getYoutubeEmbedUrl(nocookie, matches[1] === 'list')}${matches[2]}`
|
||||
|
||||
const params = []
|
||||
|
||||
if (allowFullscreen === false) {
|
||||
params.push('fs=0')
|
||||
}
|
||||
|
||||
if (autoplay ?? false) {
|
||||
params.push('autoplay=1')
|
||||
}
|
||||
|
||||
if (typeof ccLanguage === 'string') {
|
||||
params.push(`cc_lang_pref=${ccLanguage}`)
|
||||
}
|
||||
|
||||
if (ccLoadPolicy ?? false) {
|
||||
params.push('cc_load_policy=1')
|
||||
}
|
||||
|
||||
if (controls !== true) {
|
||||
params.push('controls=0')
|
||||
}
|
||||
|
||||
if (disableKBcontrols ?? false) {
|
||||
params.push('disablekb=1')
|
||||
}
|
||||
|
||||
if (enableIFrameApi ?? false) {
|
||||
params.push('enablejsapi=1')
|
||||
}
|
||||
|
||||
if (typeof endTime === 'number') {
|
||||
params.push(`end=${endTime}`)
|
||||
}
|
||||
|
||||
if (typeof interfaceLanguage === 'string') {
|
||||
params.push(`hl=${interfaceLanguage}`)
|
||||
}
|
||||
|
||||
if (typeof ivLoadPolicy === 'number') {
|
||||
params.push(`iv_load_policy=${ivLoadPolicy}`)
|
||||
}
|
||||
|
||||
if (loop ?? false) {
|
||||
params.push('loop=1')
|
||||
}
|
||||
|
||||
if (modestBranding ?? false) {
|
||||
params.push('modestbranding=1')
|
||||
}
|
||||
|
||||
if (typeof origin === 'string') {
|
||||
params.push(`origin=${origin}`)
|
||||
}
|
||||
|
||||
if (typeof playlist === 'string') {
|
||||
params.push(`playlist=${playlist}`)
|
||||
}
|
||||
|
||||
if (typeof startAt === 'number') {
|
||||
params.push(`start=${startAt}`)
|
||||
}
|
||||
|
||||
if (typeof progressBarColor === 'string') {
|
||||
params.push(`color=${progressBarColor}`)
|
||||
}
|
||||
|
||||
if (rel !== undefined) {
|
||||
params.push(`rel=${rel}`)
|
||||
}
|
||||
|
||||
if (params.length > 0) {
|
||||
outputUrl += `${matches[1] === 'v' ? '?' : '&'}${params.join('&')}`
|
||||
}
|
||||
|
||||
return outputUrl
|
||||
}
|
||||
|
||||
export const YOUTUBE_REGEX =
|
||||
/^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be|youtube-nocookie\.com))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/
|
||||
export const YOUTUBE_REGEX_GLOBAL =
|
||||
/^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be|youtube-nocookie\.com))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/g
|
||||
@@ -179,7 +179,10 @@ export const ReferenceExtension = ReferenceNode.extend<ReferenceExtensionOptions
|
||||
const label = await getReferenceLabel(objectclass, id, obj)
|
||||
if (label === '') return
|
||||
|
||||
const tooltipOptions = await getReferenceTooltip(objectclass, id, obj)
|
||||
let tooltipOptions: LabelAndProps | undefined = await getReferenceTooltip(objectclass, id, obj)
|
||||
if (tooltipOptions.component === undefined) {
|
||||
tooltipOptions = undefined
|
||||
}
|
||||
resetTooltipHandle(tooltip(root, tooltipOptions))
|
||||
renderLabel({ id, objectclass, label })
|
||||
}
|
||||
@@ -524,3 +527,34 @@ async function getObjectFromFragment (
|
||||
_class: objectclass
|
||||
}
|
||||
}
|
||||
|
||||
export function buildReferenceUrl (props: Partial<ReferenceNodeProps>, refUrl: string = 'ref://'): string | undefined {
|
||||
if (props.id === undefined || props.objectclass === undefined) return
|
||||
let url = refUrl + (refUrl.includes('?') ? '&' : '?')
|
||||
const query = makeQuery({ _class: props.objectclass, _id: props.id, label: props.label })
|
||||
url = `${url}${query}`
|
||||
return url
|
||||
}
|
||||
|
||||
export function parseReferenceUrl (urlString: string, refUrl: string = 'ref://'): ReferenceNodeProps | undefined {
|
||||
if (!urlString.startsWith(refUrl)) return
|
||||
if (!URL.canParse(urlString)) return
|
||||
|
||||
const url = new URL(urlString)
|
||||
const label = url.searchParams?.get('label') ?? ''
|
||||
const id = (url.searchParams?.get('_id') as Ref<Doc>) ?? undefined
|
||||
const objectclass = (url.searchParams?.get('_class') as Ref<Class<Doc>>) ?? undefined
|
||||
|
||||
if (id === undefined || objectclass === undefined) return
|
||||
|
||||
return { label, id, objectclass }
|
||||
}
|
||||
|
||||
function makeQuery (obj: Record<string, string | number | boolean | null | undefined>): string {
|
||||
return Object.keys(obj)
|
||||
.filter((it) => it[1] != null)
|
||||
.map(function (k) {
|
||||
return encodeURIComponent(k) + '=' + encodeURIComponent(obj[k] as string | number | boolean)
|
||||
})
|
||||
.join('&')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user