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
+2 -1
View File
@@ -35,6 +35,7 @@
"@hcengineering/ui": "^0.6.15",
"@hcengineering/text": "^0.6.5",
"@hcengineering/text-editor": "^0.6.0",
"@hcengineering/model-core": "^0.6.0"
"@hcengineering/model-core": "^0.6.0",
"@hcengineering/view": "^0.6.13"
}
}
+33
View File
@@ -33,6 +33,7 @@ import {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { EditorKitOptions } from '@hcengineering/text-editor-resources/src/kits/editor-kit'
import textEditor from './plugin'
import view from '@hcengineering/view'
export { textEditorOperation } from './migration'
export { default } from './plugin'
@@ -462,4 +463,36 @@ export function createModel (builder: Builder): void {
category: 110,
index: 10
})
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'preview',
action: textEditor.function.CopyPreviewLinkAction,
icon: view.icon.Copy,
visibilityTester: textEditor.function.ShouldShowCopyPreviewLinkAction,
label: view.string.CopyToClipboard,
category: 115,
index: 10
})
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'preview',
action: textEditor.function.ConvertToLinkPreviewAction,
icon: textEditor.icon.LinkPreview,
visibilityTester: textEditor.function.ShouldShowConvertToLinkPreviewAction,
isActive: textEditor.function.ConvertToLinkPreviewActionIsActive,
label: textEditor.string.ConvertToLinkPreview,
category: 120,
index: 10
})
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'preview',
action: textEditor.function.ConvertToEmbedPreviewAction,
icon: textEditor.icon.EmbedPreview,
visibilityTester: textEditor.function.ShouldShowConvertToEmbedPreviewAction,
isActive: textEditor.function.ConvertToEmbedPreviewActionIsActive,
label: textEditor.string.ConvertToEmbedPreview,
category: 120,
index: 20
})
}
+13
View File
@@ -16,6 +16,7 @@
import { mergeIds, type Resource } from '@hcengineering/platform'
import textEditor, {
type TextActionActiveFunction,
type TextActionFunction,
type TextActionVisibleFunction,
textEditorId
@@ -40,8 +41,20 @@ export default mergeIds(textEditorId, textEditor, {
IsHeadingVisible: '' as Resource<TextActionVisibleFunction>,
CreateInlineComment: '' as Resource<TextActionFunction>,
ShouldShowCreateInlineCommentAction: '' as Resource<TextActionVisibleFunction>,
ShouldShowConvertToLinkPreviewAction: '' as Resource<TextActionVisibleFunction>,
ConvertToLinkPreviewActionIsActive: '' as Resource<TextActionActiveFunction>,
ConvertToLinkPreviewAction: '' as Resource<TextActionFunction>,
ShouldShowConvertToEmbedPreviewAction: '' as Resource<TextActionVisibleFunction>,
ConvertToEmbedPreviewActionIsActive: '' as Resource<TextActionActiveFunction>,
ConvertToEmbedPreviewAction: '' as Resource<TextActionFunction>,
ShouldShowCopyPreviewLinkAction: '' as Resource<TextActionVisibleFunction>,
CopyPreviewLinkAction: '' as Resource<TextActionFunction>,
SetBackgroundColor: '' as Resource<TextActionFunction>,
SetTextColor: '' as Resource<TextActionFunction>
}
@@ -36,6 +36,7 @@
export let metadata: BlobMetadata | undefined
export let props: Record<string, any> = {}
export let fit: boolean = false
export let embedded: boolean = false
let download: HTMLAnchorElement
let parentWidth: number
@@ -90,7 +91,8 @@
<div
use:resizeObserver={(element) => (parentWidth = element.clientWidth)}
class="content w-full h-full"
class:content-default={!embedded}
class:content-embedded={embedded}
class:flex-center={fit && !audio}
style:min-height={fit ? '0' : `${minHeight ?? 0}px`}
>
@@ -122,9 +124,16 @@
</div>
<style lang="scss">
.content {
.content-default {
flex-grow: 1;
overflow: auto;
border: none;
width: 100%;
height: 100%;
}
.content-embedded {
width: 100%;
border: none;
}
</style>
@@ -27,6 +27,7 @@
export let accent: boolean = false
export let noOverflow: boolean = false
export let inlineReference: boolean = false
export let transparent: boolean = false
function clickHandler (e: MouseEvent): void {
if (disabled) return
@@ -74,6 +75,7 @@
class:inline
class:colorInherit
class:antiMention={inlineReference}
class:transparent
class:fs-bold={accent}
style:flex-shrink={shrink}
on:click={clickHandler}
@@ -88,6 +90,7 @@
class:inline
class:colorInherit
class:antiMention={inlineReference}
class:transparent
class:fs-bold={accent}
style:flex-shrink={shrink}
on:click={clickHandler}
@@ -25,6 +25,7 @@
export let _id: Ref<Doc> | undefined = undefined
export let _class: Ref<Class<Doc>> | undefined = undefined
export let title: string = ''
export let transparent: boolean = false
const client = getClient()
const hierarchy = client.getHierarchy()
@@ -56,7 +57,7 @@
</script>
{#if !doc && title}
<span class="antiMention" class:broken on:click={onBrokenLinkClick}>
<span class="antiMention" class:transparent class:broken on:click={onBrokenLinkClick}>
{#if icon}<Icon {icon} size="small" />{' '}{:else}@{/if}{title}
</span>
{:else if doc}
@@ -65,7 +66,8 @@
showLoading={false}
props={{
object: doc,
title
title,
transparent
}}
/>
{/if}
+1
View File
@@ -52,6 +52,7 @@ export { default as DrawingBoard } from './components/DrawingBoard.svelte'
export { default as DrawingBoardToolbar } from './components/DrawingBoardToolbar.svelte'
export { default as Image } from './components/Image.svelte'
export { default as IconWithEmoji } from './components/IconWithEmoji.svelte'
export { default as ObjectNode } from './components/markup/ObjectNode.svelte'
export { default } from './plugin'
export * from './types'
export * from './utils'
+3 -1
View File
@@ -41,6 +41,7 @@ import { EmojiNode } from '../nodes/emoji'
import { TodoItemNode, TodoListNode } from '../nodes/todo'
import { DefaultKit, DefaultKitOptions } from './default-kit'
import { EmbedNode } from '../nodes/embed'
const headingLevels: Level[] = [1, 2, 3, 4, 5, 6]
@@ -111,7 +112,8 @@ export const ServerKit = Extension.create<ServerKitOptions>({
NoteBaseExtension,
TextStyle.configure({}),
TextColor.configure({}),
BackgroundColor.configure({ types: ['tableCell'] })
BackgroundColor.configure({ types: ['tableCell'] }),
EmbedNode.configure({})
]
}
})
+50
View File
@@ -0,0 +1,50 @@
//
// 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 { mergeAttributes, Node } from '@tiptap/core'
export const EmbedNode = Node.create<any>({
name: 'embed',
addOptions () {
return {}
},
inline: false,
group: 'block',
atom: false,
draggable: false,
addAttributes () {
return {
src: {
default: null
}
}
},
parseHTML () {
return [
{
priority: 60,
tag: `figure[data-type="${this.name}"] iframe[src]`
}
]
},
renderHTML ({ HTMLAttributes }) {
return ['figure', { 'data-type': this.name }, ['iframe', mergeAttributes(HTMLAttributes)]]
}
})
+1
View File
@@ -21,4 +21,5 @@ export * from './file'
export * from './codeblock'
export * from './comment'
export * from './markdown'
export * from './embed'
export { getDataAttribute } from './utils'
+111 -22
View File
@@ -98,7 +98,7 @@
padding-left: 0.375rem;
}
ul > li:not(.todo-item)::before {
ul>li:not(.todo-item)::before {
content: "";
font-size: 1.5rem;
display: inline-block;
@@ -112,6 +112,7 @@
li:not(.todo-item)::before {
content: '';
}
ul {
li:not(.todo-item)::before {
content: '';
@@ -119,8 +120,9 @@
}
}
}
ul > li.todo-item, ol > li.todo-item {
ul>li.todo-item,
ol>li.todo-item {
list-style: none;
margin-left: -2.25rem;
padding-left: 0;
@@ -131,18 +133,35 @@
margin-left: 0;
}
ul.todo-list > li.todo-item {
ul.todo-list>li.todo-item {
margin: 0;
padding: 0;
margin-left: -1.5rem;
}
ol ol { list-style: lower-alpha; }
ol ol ol { list-style: lower-roman; }
ol ol ol ol { list-style: decimal; }
ol ol ol ol ol { list-style: lower-alpha; }
ol ol ol ol ol ol { list-style: lower-roman; }
ol ol ol ol ol ol ol { list-style: decimal; }
ol ol {
list-style: lower-alpha;
}
ol ol ol {
list-style: lower-roman;
}
ol ol ol ol {
list-style: decimal;
}
ol ol ol ol ol {
list-style: lower-alpha;
}
ol ol ol ol ol ol {
list-style: lower-roman;
}
ol ol ol ol ol ol ol {
list-style: decimal;
}
/* Placeholder (at the top) */
p.is-editor-empty:first-child::before {
@@ -239,8 +258,13 @@
.text-markup-view {
margin: 0;
p:first-child { margin-block-start: 0; }
p:last-child { margin-block-end: 0; }
p:first-child {
margin-block-start: 0;
}
p:last-child {
margin-block-end: 0;
}
}
@supports (selector(:has(.text-editor-image-container))) {
@@ -314,7 +338,8 @@
text-decoration: none;
max-width: 16rem;
}
a:hover {
a:hover {
text-decoration: underline;
}
}
@@ -351,6 +376,7 @@
}
.reference {
&:hover,
&.ProseMirror-selectednode {
background-color: var(--theme-mention-focused-bg-color);
@@ -363,7 +389,8 @@
padding-bottom: 2px;
transition: background 0.2s ease, border 0.2s ease;
&.text-editor-highlighted-node-selected, &:hover {
&.text-editor-highlighted-node-selected,
&:hover {
background-color: var(--text-editor-highlighted-node-warning-active-background-color);
}
@@ -421,27 +448,27 @@
&.dangerous-light {
background-color: var(--theme-text-editor-note-anchor-bg-dangerous-light);
}
&.warning {
background-color: var(--theme-text-editor-note-anchor-bg-warning);
}
&.warning-light {
background-color: var(--theme-text-editor-note-anchor-bg-warning-light);
}
&.positive {
background-color: var(--theme-text-editor-note-anchor-bg-positive);
}
&.positive-light {
background-color: var(--theme-text-editor-note-anchor-bg-positive-light);
}
&.primary {
background-color: var(--theme-text-editor-note-anchor-bg-primary);
}
&.primary-light {
background-color: var(--theme-text-editor-note-anchor-bg-primary-light);
}
@@ -484,10 +511,72 @@
}
}
.embed-node {
margin: 1rem 0;
padding: 0;
position: relative;
&::after {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
// background-color: rgba(0, 0, 0, 0.2);
&.ProseMirror-selectednode {
outline: 2px solid var(--primary-button-outline);
outline-offset: 2px;
&::after {
display: none;
}
}
&.embed-youtube {
iframe {
margin: 0;
padding: 0;
aspect-ratio: 16 / 9;
width: 100%;
height: 100%;
border: none;
position: relative;
}
}
&.embed-stub {
background-color: var(--theme-broken-mention-bg-color);
border-radius: 1rem;
padding: 0.25rem 1rem;
p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
svg {
display: inline-block;
vertical-align: sub;
margin-bottom: 1px;
width: .875rem;
margin-right: 0.25rem;
}
}
}
// Hiding the selection of an empty line
.select-text p > br.ProseMirror-trailingBreak::selection {
.select-text p>br.ProseMirror-trailingBreak::selection {
background: transparent;
}
.select-text .text-editor-image-container {
user-select: all;
}
}
+8
View File
@@ -530,6 +530,14 @@
user-select: text;
font-size: var(--body-font-size);
&.transparent {
background-color: transparent;
&:hover {
background-color: transparent;
}
}
&:hover {
text-decoration: none !important;
background-color: var(--theme-mention-focused-bg-color);
@@ -229,4 +229,11 @@
<path d="M13 12H3V14H13V12Z" fill="currentColor"/>
<path d="M11 10.5H12L8.69037 2.46234C8.57518 2.18258 8.30254 2 8 2C7.69746 2 7.42482 2.18258 7.30963 2.46234L4 10.5H5L5.805 8.5H10.175L11 10.5ZM6.215 7.5L7.935 3.315H8.065L9.77 7.5H6.215Z" fill="currentColor"/>
</symbol>
<symbol id="linkEmbed" viewBox="0 0 24 24">
<path d="M16.2,10l-0.3,0.3c-0.4,0.4-0.4,1,0,1.4c0.4,0.4,1,0.4,1.4,0l0.3-0.3c0.5-0.5,1.5-0.5,2,0c0.3,0.3,0.4,0.6,0.4,1s-0.2,0.7-0.4,1l-2.7,2.7c-0.1,0.1-0.3,0.2-0.5,0.3c-0.3,0.1-0.7,0.1-1.1,0c-0.2-0.1-0.3-0.2-0.5-0.3c-0.4-0.4-1-0.4-1.4,0c-0.4,0.4-0.4,1,0,1.4c0.3,0.3,0.7,0.6,1.1,0.8c0.4,0.2,0.9,0.3,1.3,0.3c0.5,0,0.9-0.1,1.3-0.3c0.4-0.2,0.8-0.4,1.1-0.7l2.7-2.7c0.6-0.6,1-1.5,1-2.4c0-0.9-0.4-1.8-1-2.4C19.7,8.7,17.4,8.7,16.2,10z" />
<path d="M13.8,19.2l-0.3,0.3c-0.5,0.5-1.5,0.5-2,0c-0.3-0.3-0.4-0.6-0.4-1c0-0.4,0.2-0.7,0.4-1l2.7-2.7c0.1-0.1,0.3-0.2,0.5-0.3c0.3-0.1,0.7-0.1,1.1,0c0.2,0.1,0.3,0.2,0.5,0.3c0.4,0.4,1,0.4,1.4,0c0.4-0.4,0.4-1,0-1.4c-0.3-0.3-0.7-0.6-1.1-0.8c-0.8-0.4-1.8-0.4-2.6,0c-0.4,0.2-0.8,0.4-1.1,0.7L10,16.2c-0.6,0.6-1,1.5-1,2.4c0,0.9,0.4,1.8,1,2.4c0.6,0.6,1.5,1,2.4,1c0.9,0,1.8-0.4,2.4-1l0.3-0.3c0.4-0.4,0.4-1,0-1.4C14.8,18.9,14.2,18.9,13.8,19.2z" />
<path d="M7.5,2H5.2C4.4,2,3.6,2.3,3,3C2.3,3.6,2,4.4,2,5.2v2.2c0,0.6,0.4,1,1,1s1-0.4,1-1V5.2c0-0.3,0.1-0.6,0.4-0.9C4.6,4.1,4.9,4,5.2,4h2.2c0.6,0,1-0.4,1-1S8.1,2,7.5,2z" />
<path d="M7.5,20H5.2c-0.3,0-0.7-0.1-0.9-0.4C4.1,19.4,4,19.1,4,18.8v-2.2c0-0.6-0.4-1-1-1s-1,0.4-1,1v2.2c0,0.9,0.3,1.7,1,2.3c0.6,0.6,1.4,1,2.3,1h2.2c0.6,0,1-0.4,1-1S8.1,20,7.5,20z" />
<path d="M21,3c-0.6-0.6-1.4-1-2.3-1h-2.2c-0.6,0-1,0.4-1,1s0.4,1,1,1h2.2c0.3,0,0.6,0.1,0.9,0.4C19.9,4.6,20,4.9,20,5.2v2.2c0,0.6,0.4,1,1,1s1-0.4,1-1V5.2C22,4.4,21.7,3.6,21,3z" />
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 29 KiB

+4 -1
View File
@@ -69,6 +69,9 @@
"AddComment": "Přidat komentář",
"AddCommentPlaceholder": "Přidat komentář...",
"SetCellHighlightColor": "Nastavit barvu buňky",
"SetTextColor": "Nastavit barvu textu"
"SetTextColor": "Nastavit barvu textu",
"ConvertToLinkPreview": "Zobrazit jako odkaz",
"ConvertToEmbedPreview": "Zobrazit jako náhled obsahu",
"UnableToLoadEmbeddedContent": "Náhled odkazu nelze načíst kvůli nastavení oprávnění nebo nepodporovanému obsahu"
}
}
+4 -1
View File
@@ -68,6 +68,9 @@
"AddComment": "Kommentar hinzufügen",
"AddCommentPlaceholder": "Fügen Sie einen Kommentar hinzu...",
"SetCellHighlightColor": "Zellfarbe ändern",
"SetTextColor": "Textfarbe ändern"
"SetTextColor": "Textfarbe ändern",
"ConvertToLinkPreview": "Als Link anzeigen",
"ConvertToEmbedPreview": "Als Inhaltsvorschau anzeigen",
"UnableToLoadEmbeddedContent": "Die Linkvorschau konnte aufgrund von Berechtigungseinstellungen oder nicht unterstütztem Inhalt nicht geladen werden"
}
}
+5 -1
View File
@@ -69,6 +69,10 @@
"AddComment": "Add a comment",
"AddCommentPlaceholder": "Add a comment...",
"SetCellHighlightColor": "Set cell color",
"SetTextColor": "Set text color"
"SetTextColor": "Set text color",
"ConvertToLinkPreview": "Show as a link",
"ConvertToEmbedPreview": "Show as a content preview",
"UnableToLoadEmbeddedContent": "Link preview couldn't be loaded due to permission settings or unsupported content"
}
}
+4 -1
View File
@@ -59,6 +59,9 @@
"SeparatorLine": "Línea de separación",
"TodoList": "Lista de tareas",
"TodoItem": "Tarea pendiente",
"DrawingBoard": "Tablero de dibujos"
"DrawingBoard": "Tablero de dibujos",
"ConvertToLinkPreview": "Mostrar como enlace",
"ConvertToEmbedPreview": "Mostrar como vista previa de contenido",
"UnableToLoadEmbeddedContent": "No se pudo cargar la vista previa del enlace debido a la configuración de permisos o contenido no compatible"
}
}
+4 -1
View File
@@ -59,6 +59,9 @@
"Unset": "Non défini",
"Image": "Image",
"SeparatorLine": "Ligne de séparation",
"DrawingBoard": "Tableau de dessin"
"DrawingBoard": "Tableau de dessin",
"ConvertToLinkPreview": "Afficher comme lien",
"ConvertToEmbedPreview": "Afficher comme aperçu de contenu",
"UnableToLoadEmbeddedContent": "Laperçu du lien na pas pu être chargé en raison des paramètres dautorisation ou dun contenu non pris en charge"
}
}
+4 -1
View File
@@ -68,6 +68,9 @@
"AddComment": "Aggiungi commento",
"AddCommentPlaceholder": "Aggiungi un commento...",
"SetCellHighlightColor": "Cambia il colore delle celle",
"SetTextColor": "Cambia il colore del testo"
"SetTextColor": "Cambia il colore del testo",
"ConvertToLinkPreview": "Mostra come link",
"ConvertToEmbedPreview": "Mostra come anteprima del contenuto",
"UnableToLoadEmbeddedContent": "Impossibile caricare l'anteprima del link a causa delle impostazioni dei permessi o di contenuto non supportato"
}
}
+5 -1
View File
@@ -69,6 +69,10 @@
"AddComment": "コメントを追加",
"AddCommentPlaceholder": "コメントを追加...",
"SetCellHighlightColor": "セルの色を設定",
"SetTextColor": "テキストの色を設定"
"SetTextColor": "テキストの色を設定",
"ConvertToLinkPreview": "リンクとして表示",
"ConvertToEmbedPreview": "コンテンツプレビューとして表示",
"UnableToLoadEmbeddedContent": "リンクのプレビューを読み込めません。権限設定または非対応のコンテンツが原因です"
}
}
+4 -1
View File
@@ -59,6 +59,9 @@
"SeparatorLine": "linha separadora",
"TodoItem": "Tarefa",
"TodoList": "Lista de tarefas",
"DrawingBoard": "Quadro de desenho"
"DrawingBoard": "Quadro de desenho",
"ConvertToLinkPreview": "Mostrar como link",
"ConvertToEmbedPreview": "Mostrar como pré-visualização de conteúdo",
"UnableToLoadEmbeddedContent": "Não foi possível carregar a pré-visualização do link devido às permissões ou a conteúdo não suportado"
}
}
+4 -1
View File
@@ -69,6 +69,9 @@
"AddComment": "Добавить комментарий",
"AddCommentPlaceholder": "Добавьте комментарий...",
"SetCellHighlightColor": "Изменить цвет ячеек",
"SetTextColor": "Изменить цвет текста"
"SetTextColor": "Изменить цвет текста",
"ConvertToLinkPreview": "Показать как ссылку",
"ConvertToEmbedPreview": "Показать как превью контента",
"UnableToLoadEmbeddedContent": "Не удалось загрузить превью ссылки из-за настроек доступа или неподдерживаемого содержимого"
}
}
+5 -1
View File
@@ -61,6 +61,10 @@
"SeparatorLine": "分隔线",
"TodoItem": "待办事项",
"TodoList": "待办事项列表",
"DrawingBoard": "画板"
"DrawingBoard": "画板",
"ConvertToLinkPreview": "显示为链接",
"ConvertToEmbedPreview": "显示为内容预览",
"UnableToLoadEmbeddedContent": "由于权限设置或不支持的内容,无法加载链接预览"
}
}
+3 -1
View File
@@ -46,5 +46,7 @@ loadMetadata(textEditor.icon, {
MergeCells: `${icons}#union`,
SplitCells: `${icons}#divide`,
Brush: `${icons}#brush`,
TextStyle: `${icons}#textStyle`
TextStyle: `${icons}#textStyle`,
LinkPreview: `${icons}#link`,
EmbedPreview: `${icons}#linkEmbed`
})
+2 -1
View File
@@ -98,6 +98,7 @@
"tippy.js": "~6.3.7",
"@hcengineering/chunter": "^0.6.20",
"@tiptap/extension-text-align": "~2.11.0",
"@hcengineering/workbench": "^0.6.16"
"@hcengineering/workbench": "^0.6.16",
"@hcengineering/drive": "^0.6.0"
}
}
@@ -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('&')
}
+52 -31
View File
@@ -15,78 +15,88 @@
//
import { type Resources } from '@hcengineering/platform'
import { isTextStylingEnabled, openBackgroundColorOptions, openTextColorOptions } from './components/extension/colors'
import { downloadImage, expandImage, moreImageActions, openImage } from './components/extension/imageExt'
import { createInlineComment, shouldShowCreateInlineCommentAction } from './components/extension/inlineComment'
import { configureNote, isEditableNote } from './components/extension/note'
import {
isEditableTableActive,
isTableToolbarContext,
openTableOptions,
selectTable
} from './components/extension/table/table'
import { formatLink } from './kits/default-kit'
import { isEditable, isHeadingVisible } from './kits/editor-kit'
import {
openTableOptions,
isEditableTableActive,
isTableToolbarContext,
selectTable
} from './components/extension/table/table'
import { openImage, downloadImage, expandImage, moreImageActions } from './components/extension/imageExt'
import { configureNote, isEditableNote } from './components/extension/note'
import { createInlineComment, shouldShowCreateInlineCommentAction } from './components/extension/inlineComment'
import { isTextStylingEnabled, openBackgroundColorOptions, openTextColorOptions } from './components/extension/colors'
export { getTargetObjectFromUrl, getReferenceFromUrl, getReferenceLabel } from './components/extension/reference'
export { TodoItemExtension, TodoListExtension } from './components/extension/todo'
convertToEmbedPreviewAction,
convertToEmbedPreviewActionIsActive,
convertToLinkPreviewAction,
convertToLinkPreviewActionIsActive,
shouldShowConvertToEmbedPreviewAction,
shouldShowConvertToLinkPreviewAction,
shouldShowCopyPreviewLinkAction,
copyPreviewLinkAction
} from './components/extension/embed/embed'
export { TransformPastedContentExtension } from './components/extension/paste'
export { getReferenceFromUrl, getReferenceLabel, getTargetObjectFromUrl } from './components/extension/reference'
export { TodoItemExtension, TodoListExtension } from './components/extension/todo'
export * from '@hcengineering/presentation/src/types'
export type { EditorKitOptions } from './kits/editor-kit'
export { default as Collaboration } from './components/Collaboration.svelte'
export { default as CollaborationDiffViewer } from './components/CollaborationDiffViewer.svelte'
export { default as CollaborativeAttributeBox } from './components/CollaborativeAttributeBox.svelte'
export { default as CollaborativeAttributeSectionBox } from './components/CollaborativeAttributeSectionBox.svelte'
export { default as CollaborativeTextEditor } from './components/CollaborativeTextEditor.svelte'
export { default as CollaboratorEditor } from './components/CollaboratorEditor.svelte'
export * from './components/editor/actions'
export { default as FullDescriptionBox } from './components/FullDescriptionBox.svelte'
export { default as AttachIcon } from './components/icons/Attach.svelte'
export { default as TableIcon } from './components/icons/Table.svelte'
export { default as MarkupDiffViewer } from './components/MarkupDiffViewer.svelte'
export * from './components/node-view'
export { default as ReferenceInput } from './components/ReferenceInput.svelte'
export { default as StringDiffViewer } from './components/StringDiffViewer.svelte'
export { default as StyleButton } from './components/TextActionButton.svelte'
export { default as StyledTextArea } from './components/StyledTextArea.svelte'
export { default as StyledTextBox } from './components/StyledTextBox.svelte'
export { default as StyledTextEditor } from './components/StyledTextEditor.svelte'
export { default as StyleButton } from './components/TextActionButton.svelte'
export { default as TextEditor } from './components/TextEditor.svelte'
export { default as TextEditorToolbar } from './components/TextEditorToolbar.svelte'
export { default as AttachIcon } from './components/icons/Attach.svelte'
export { default as TableIcon } from './components/icons/Table.svelte'
export { default as TableOfContents } from './components/toc/TableOfContents.svelte'
export { default as TableOfContentsContent } from './components/toc/TableOfContentsContent.svelte'
export * from './components/editor/actions'
export * from './components/node-view'
export type { EditorKitOptions } from './kits/editor-kit'
export * from './utils'
export * from './command/deleteAttachment'
export { EmojiExtension } from './components/extension/emoji'
export { FocusExtension, type FocusOptions, type FocusStorage } from './components/extension/focus'
export { HeadingsExtension, type HeadingsOptions, type HeadingsStorage } from './components/extension/headings'
export { ImageExtension, type ImageOptions } from './components/extension/imageExt'
export { ImageUploadExtension, type ImageUploadOptions } from './components/extension/imageUploadExt'
export { InlinePopupExtension } from './components/extension/inlinePopup'
export { InlineToolbarExtension, type InlineStyleToolbarOptions } from './components/extension/inlineToolbar'
export {
IsEmptyContentExtension,
type IsEmptyContentOptions,
type IsEmptyContentStorage
} from './components/extension/isEmptyContent'
export {
highlightUpdateCommand,
NodeHighlightExtension,
NodeHighlightType,
type NodeHighlightExtensionOptions,
highlightUpdateCommand
type NodeHighlightExtensionOptions
} from './components/extension/nodeHighlight'
export {
NodeUuidExtension,
type NodeUuidOptions,
type NodeUuidStorage,
getNodeElement,
NodeUuidExtension,
nodeUuidName,
selectNode,
nodeUuidName
type NodeUuidOptions,
type NodeUuidStorage
} from './components/extension/nodeUuid'
export { InlinePopupExtension } from './components/extension/inlinePopup'
export { InlineToolbarExtension, type InlineStyleToolbarOptions } from './components/extension/inlineToolbar'
export { ImageExtension, type ImageOptions } from './components/extension/imageExt'
export { ImageUploadExtension, type ImageUploadOptions } from './components/extension/imageUploadExt'
export { EmojiExtension } from './components/extension/emoji'
export { ReferenceExtension, referenceConfig } from './components/extension/reference'
export * from './command/deleteAttachment'
export { createTiptapCollaborationData } from './provider/utils'
export { referenceConfig, ReferenceExtension } from './components/extension/reference'
export { type Provider } from './provider/types'
export { createTiptapCollaborationData } from './provider/utils'
export default async (): Promise<Resources> => ({
function: {
@@ -108,6 +118,17 @@ export default async (): Promise<Resources> => ({
CreateInlineComment: createInlineComment,
ShouldShowCreateInlineCommentAction: shouldShowCreateInlineCommentAction,
ShouldShowConvertToLinkPreviewAction: shouldShowConvertToLinkPreviewAction,
ConvertToLinkPreviewActionIsActive: convertToLinkPreviewActionIsActive,
ConvertToLinkPreviewAction: convertToLinkPreviewAction,
ShouldShowConvertToEmbedPreviewAction: shouldShowConvertToEmbedPreviewAction,
ConvertToEmbedPreviewActionIsActive: convertToEmbedPreviewActionIsActive,
ConvertToEmbedPreviewAction: convertToEmbedPreviewAction,
ShouldShowCopyPreviewLinkAction: shouldShowCopyPreviewLinkAction,
CopyPreviewLinkAction: copyPreviewLinkAction,
SetBackgroundColor: openBackgroundColorOptions,
SetTextColor: openTextColorOptions
}
@@ -50,6 +50,9 @@ import { type IndendOptions, IndentExtension, indentExtensionOptions } from '../
import TextAlign, { type TextAlignOptions } from '@tiptap/extension-text-align'
import { LinkUtilsExtension } from '../components/extension/link'
import { TransformPastedContentExtension } from '../components/extension/paste'
import { EmbedNode, type EmbedNodeOptions } from '../components/extension/embed/embed'
import { defaultYoutubeEmbedUrlOptions, YoutubeEmbedProvider } from '../components/extension/embed/providers/youtube'
import { defaultDriveEmbedOptions, DriveEmbedProvider } from '../components/extension/embed/providers/drive'
export interface EditorKitOptions extends DefaultKitOptions {
history?: false
@@ -82,6 +85,7 @@ export interface EditorKitOptions extends DefaultKitOptions {
isHidden?: () => boolean
}
| false
embed?: Partial<EmbedNodeOptions> | false
}
const headingLevels: Level[] = [1, 2, 3]
@@ -228,6 +232,19 @@ async function buildEditorKit (): Promise<Extension<EditorKitOptions, any>> {
staticKitExtensions.push([430, BackgroundColor.configure({ types: ['tableCell'] })])
}
if (mode === 'full' && this.options.embed !== false) {
staticKitExtensions.push([
450,
EmbedNode.configure({
providers: [
YoutubeEmbedProvider(defaultYoutubeEmbedUrlOptions),
DriveEmbedProvider(defaultDriveEmbedOptions)
],
...this.options.embed
})
])
}
staticKitExtensions.push([
500,
ListKeymapExtension.configure({
+8 -2
View File
@@ -106,7 +106,11 @@ export default plugin(textEditorId, {
TableOptions: '' as IntlString,
SelectTable: '' as IntlString,
SetCellHighlightColor: '' as IntlString,
SetTextColor: '' as IntlString
SetTextColor: '' as IntlString,
ConvertToLinkPreview: '' as IntlString,
ConvertToEmbedPreview: '' as IntlString,
UnableToLoadEmbeddedContent: '' as IntlString
},
icon: {
Header1: '' as Asset,
@@ -137,6 +141,8 @@ export default plugin(textEditorId, {
MergeCells: '' as Asset,
SplitCells: '' as Asset,
Brush: '' as Asset,
TextStyle: '' as Asset
TextStyle: '' as Asset,
LinkPreview: '' as Asset,
EmbedPreview: '' as Asset
}
})
+1 -1
View File
@@ -188,7 +188,7 @@ export interface ActiveDescriptor {
params?: any
}
export type TextEditorActionKind = 'text' | 'image' | 'table'
export type TextEditorActionKind = 'text' | 'image' | 'table' | 'preview'
/**
* Defines a text action for text action editor
@@ -32,6 +32,7 @@
export let accent: boolean = false
export let noOverflow: boolean = false
export let inlineReference: boolean = false
export let transparent: boolean = false
let _disabled = disabled || $restrictionStore.disableNavigation
$: _disabled = disabled || $restrictionStore.disableNavigation
@@ -70,6 +71,7 @@
{accent}
{noOverflow}
{inlineReference}
{transparent}
>
<slot />
</NavLink>
@@ -31,6 +31,7 @@
export let component: AnyComponent | undefined = undefined
export let disabled: boolean = false
export let onClick: ((event: MouseEvent) => void) | undefined = undefined
export let transparent: boolean = false
const client = getClient()
const hierarchy = client.getHierarchy()
@@ -124,7 +125,7 @@
data-label={displayTitle}
use:tooltip={docTooltip}
>
<DocNavLink object={doc} component={docComponent} {disabled} inlineReference {onClick}>
<DocNavLink object={doc} component={docComponent} {disabled} inlineReference {onClick} {transparent}>
{#if icon}<Icon {icon} size="small" />{' '}{:else}@{/if}{displayTitle}
</DocNavLink>
</span>
@@ -68,5 +68,8 @@
pre {
font-family: var(--mono-font);
white-space: pre !important;
word-wrap: nowrap !important;
font-size: 0.8125rem;
}
</style>