Support for embedding links and references in text editor (#9003)

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Victor Ilyushchenko
2025-05-21 11:45:08 +07:00
committed by GitHub
parent e201abcc91
commit 007b11c9df
39 changed files with 1554 additions and 77 deletions
@@ -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('&')
}