diff --git a/models/text-editor/package.json b/models/text-editor/package.json index 39bfbc9703..ee52ffd003 100644 --- a/models/text-editor/package.json +++ b/models/text-editor/package.json @@ -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" } } diff --git a/models/text-editor/src/index.ts b/models/text-editor/src/index.ts index a7a3025f01..93f85ac061 100644 --- a/models/text-editor/src/index.ts +++ b/models/text-editor/src/index.ts @@ -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 + }) } diff --git a/models/text-editor/src/plugin.ts b/models/text-editor/src/plugin.ts index 2039564663..6653ef41c5 100644 --- a/models/text-editor/src/plugin.ts +++ b/models/text-editor/src/plugin.ts @@ -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, CreateInlineComment: '' as Resource, + ShouldShowCreateInlineCommentAction: '' as Resource, + ShouldShowConvertToLinkPreviewAction: '' as Resource, + ConvertToLinkPreviewActionIsActive: '' as Resource, + ConvertToLinkPreviewAction: '' as Resource, + + ShouldShowConvertToEmbedPreviewAction: '' as Resource, + ConvertToEmbedPreviewActionIsActive: '' as Resource, + ConvertToEmbedPreviewAction: '' as Resource, + + ShouldShowCopyPreviewLinkAction: '' as Resource, + CopyPreviewLinkAction: '' as Resource, + SetBackgroundColor: '' as Resource, SetTextColor: '' as Resource } diff --git a/packages/presentation/src/components/FilePreview.svelte b/packages/presentation/src/components/FilePreview.svelte index 393e2298c8..c7e6d00c56 100644 --- a/packages/presentation/src/components/FilePreview.svelte +++ b/packages/presentation/src/components/FilePreview.svelte @@ -36,6 +36,7 @@ export let metadata: BlobMetadata | undefined export let props: Record = {} export let fit: boolean = false + export let embedded: boolean = false let download: HTMLAnchorElement let parentWidth: number @@ -90,7 +91,8 @@
(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 @@
diff --git a/packages/presentation/src/components/NavLink.svelte b/packages/presentation/src/components/NavLink.svelte index 8ba99a9739..8b9180cb22 100644 --- a/packages/presentation/src/components/NavLink.svelte +++ b/packages/presentation/src/components/NavLink.svelte @@ -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} diff --git a/packages/presentation/src/components/markup/ObjectNode.svelte b/packages/presentation/src/components/markup/ObjectNode.svelte index ce2657dfde..a5cc70af18 100644 --- a/packages/presentation/src/components/markup/ObjectNode.svelte +++ b/packages/presentation/src/components/markup/ObjectNode.svelte @@ -25,6 +25,7 @@ export let _id: Ref | undefined = undefined export let _class: Ref> | undefined = undefined export let title: string = '' + export let transparent: boolean = false const client = getClient() const hierarchy = client.getHierarchy() @@ -56,7 +57,7 @@ {#if !doc && title} - + {#if icon}{' '}{:else}@{/if}{title} {:else if doc} @@ -65,7 +66,8 @@ showLoading={false} props={{ object: doc, - title + title, + transparent }} /> {/if} diff --git a/packages/presentation/src/index.ts b/packages/presentation/src/index.ts index 70bd72bbbc..87632357be 100644 --- a/packages/presentation/src/index.ts +++ b/packages/presentation/src/index.ts @@ -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' diff --git a/packages/text/src/kits/server-kit.ts b/packages/text/src/kits/server-kit.ts index 96b69abf83..16fe35eb97 100644 --- a/packages/text/src/kits/server-kit.ts +++ b/packages/text/src/kits/server-kit.ts @@ -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({ NoteBaseExtension, TextStyle.configure({}), TextColor.configure({}), - BackgroundColor.configure({ types: ['tableCell'] }) + BackgroundColor.configure({ types: ['tableCell'] }), + EmbedNode.configure({}) ] } }) diff --git a/packages/text/src/nodes/embed.ts b/packages/text/src/nodes/embed.ts new file mode 100644 index 0000000000..401c1e02ca --- /dev/null +++ b/packages/text/src/nodes/embed.ts @@ -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({ + 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)]] + } +}) diff --git a/packages/text/src/nodes/index.ts b/packages/text/src/nodes/index.ts index 09a0ba37bf..11bd4eb863 100644 --- a/packages/text/src/nodes/index.ts +++ b/packages/text/src/nodes/index.ts @@ -21,4 +21,5 @@ export * from './file' export * from './codeblock' export * from './comment' export * from './markdown' +export * from './embed' export { getDataAttribute } from './utils' diff --git a/packages/theme/styles/_text-editor.scss b/packages/theme/styles/_text-editor.scss index da3f26d79e..f65518ba63 100644 --- a/packages/theme/styles/_text-editor.scss +++ b/packages/theme/styles/_text-editor.scss @@ -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; -} +} \ No newline at end of file diff --git a/packages/theme/styles/common.scss b/packages/theme/styles/common.scss index 12ac82ea9f..77f7eb7cbe 100644 --- a/packages/theme/styles/common.scss +++ b/packages/theme/styles/common.scss @@ -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); diff --git a/plugins/text-editor-assets/assets/icons.svg b/plugins/text-editor-assets/assets/icons.svg index cf22f53862..3a49b4ec2b 100644 --- a/plugins/text-editor-assets/assets/icons.svg +++ b/plugins/text-editor-assets/assets/icons.svg @@ -229,4 +229,11 @@ + + + + + + + \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/cs.json b/plugins/text-editor-assets/lang/cs.json index 2d150d799f..9e0561b193 100644 --- a/plugins/text-editor-assets/lang/cs.json +++ b/plugins/text-editor-assets/lang/cs.json @@ -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" } } \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/de.json b/plugins/text-editor-assets/lang/de.json index 636a723a78..0fd3fb30db 100644 --- a/plugins/text-editor-assets/lang/de.json +++ b/plugins/text-editor-assets/lang/de.json @@ -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" } } \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/en.json b/plugins/text-editor-assets/lang/en.json index ef07f9d501..83a381eea2 100644 --- a/plugins/text-editor-assets/lang/en.json +++ b/plugins/text-editor-assets/lang/en.json @@ -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" } } \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/es.json b/plugins/text-editor-assets/lang/es.json index bbf920d1d4..04e5ebd908 100644 --- a/plugins/text-editor-assets/lang/es.json +++ b/plugins/text-editor-assets/lang/es.json @@ -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" } } \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/fr.json b/plugins/text-editor-assets/lang/fr.json index 0c706df49a..f54f67363d 100644 --- a/plugins/text-editor-assets/lang/fr.json +++ b/plugins/text-editor-assets/lang/fr.json @@ -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": "L’aperçu du lien n’a pas pu être chargé en raison des paramètres d’autorisation ou d’un contenu non pris en charge" } } \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/it.json b/plugins/text-editor-assets/lang/it.json index fba8e60590..7d4bf5a30b 100644 --- a/plugins/text-editor-assets/lang/it.json +++ b/plugins/text-editor-assets/lang/it.json @@ -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" } } \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/ja.json b/plugins/text-editor-assets/lang/ja.json index 1778027c22..e1b9bf8edf 100644 --- a/plugins/text-editor-assets/lang/ja.json +++ b/plugins/text-editor-assets/lang/ja.json @@ -69,6 +69,10 @@ "AddComment": "コメントを追加", "AddCommentPlaceholder": "コメントを追加...", "SetCellHighlightColor": "セルの色を設定", - "SetTextColor": "テキストの色を設定" + "SetTextColor": "テキストの色を設定", + + "ConvertToLinkPreview": "リンクとして表示", + "ConvertToEmbedPreview": "コンテンツプレビューとして表示", + "UnableToLoadEmbeddedContent": "リンクのプレビューを読み込めません。権限設定または非対応のコンテンツが原因です" } } diff --git a/plugins/text-editor-assets/lang/pt.json b/plugins/text-editor-assets/lang/pt.json index 90462ba43e..2a0e1e6601 100644 --- a/plugins/text-editor-assets/lang/pt.json +++ b/plugins/text-editor-assets/lang/pt.json @@ -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" } } diff --git a/plugins/text-editor-assets/lang/ru.json b/plugins/text-editor-assets/lang/ru.json index a7ff096820..3e14c9f703 100644 --- a/plugins/text-editor-assets/lang/ru.json +++ b/plugins/text-editor-assets/lang/ru.json @@ -69,6 +69,9 @@ "AddComment": "Добавить комментарий", "AddCommentPlaceholder": "Добавьте комментарий...", "SetCellHighlightColor": "Изменить цвет ячеек", - "SetTextColor": "Изменить цвет текста" + "SetTextColor": "Изменить цвет текста", + "ConvertToLinkPreview": "Показать как ссылку", + "ConvertToEmbedPreview": "Показать как превью контента", + "UnableToLoadEmbeddedContent": "Не удалось загрузить превью ссылки из-за настроек доступа или неподдерживаемого содержимого" } } \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/zh.json b/plugins/text-editor-assets/lang/zh.json index 364dc8e9a0..22968463b5 100644 --- a/plugins/text-editor-assets/lang/zh.json +++ b/plugins/text-editor-assets/lang/zh.json @@ -61,6 +61,10 @@ "SeparatorLine": "分隔线", "TodoItem": "待办事项", "TodoList": "待办事项列表", - "DrawingBoard": "画板" + "DrawingBoard": "画板", + + "ConvertToLinkPreview": "显示为链接", + "ConvertToEmbedPreview": "显示为内容预览", + "UnableToLoadEmbeddedContent": "由于权限设置或不支持的内容,无法加载链接预览" } } diff --git a/plugins/text-editor-assets/src/index.ts b/plugins/text-editor-assets/src/index.ts index 11d480ced4..26edb96031 100644 --- a/plugins/text-editor-assets/src/index.ts +++ b/plugins/text-editor-assets/src/index.ts @@ -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` }) diff --git a/plugins/text-editor-resources/package.json b/plugins/text-editor-resources/package.json index 1f76281667..317feead99 100644 --- a/plugins/text-editor-resources/package.json +++ b/plugins/text-editor-resources/package.json @@ -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" } } diff --git a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte index c5f20d7137..2985b0afed 100644 --- a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte +++ b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte @@ -488,6 +488,10 @@ drawingBoard: { getSavedBoard }, + embed: { + boundary: boundary ?? element, + popupContainer: editorPopupContainer + }, ...kitOptions }), ...optionalExtensions, diff --git a/plugins/text-editor-resources/src/components/TextActionButton.svelte b/plugins/text-editor-resources/src/components/TextActionButton.svelte index 7405371e40..47b0e6f0cf 100644 --- a/plugins/text-editor-resources/src/components/TextActionButton.svelte +++ b/plugins/text-editor-resources/src/components/TextActionButton.svelte @@ -13,22 +13,37 @@ // limitations under the License. --> + +{#if cursor && actions.length > 0} +
+
+ {#if showSrc} + {#if !reference} + {cursor.src} + {/if} + {#if reference} + + {/if} + {#if reference} +
+ {/if} + {/if} + {#each Object.values(categories) as category, index} + {#if index > 0} +
+ {/if} + + {#each category as [_, action]} + + {/each} + {/each} +
+
+{/if} + + diff --git a/plugins/text-editor-resources/src/components/extension/embed/embed.ts b/plugins/text-editor-resources/src/components/extension/embed/embed.ts new file mode 100644 index 0000000000..6ab7c92356 --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/embed/embed.ts @@ -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 +export type EmbedNodeProviderConstructor = (options: T) => EmbedNodeProvider + +export const EmbedNode = BaseEmbedNode.extend({ + 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({ + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const cursor = getEmbedControlCursor(editor) + + const src = cursor?.src + if (typeof src !== 'string') return + + await copyTextToClipboard(src) +} + +export async function convertToLinkPreviewActionIsActive (editor: Editor): Promise { + const cursor = getEmbedControlCursor(editor) + return cursor?.node !== undefined && isLink(cursor.node) +} + +export async function convertToEmbedPreviewActionIsActive (editor: Editor): Promise { + 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 => { + 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) +} diff --git a/plugins/text-editor-resources/src/components/extension/embed/providers/drive.ts b/plugins/text-editor-resources/src/components/extension/embed/providers/drive.ts new file mode 100644 index 0000000000..d2b0d14eff --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/embed/providers/drive.ts @@ -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 = (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 }) + 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((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() + } + } + } +} diff --git a/plugins/text-editor-resources/src/components/extension/embed/providers/youtube.ts b/plugins/text-editor-resources/src/components/extension/embed/providers/youtube.ts new file mode 100644 index 0000000000..f117214785 --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/embed/providers/youtube.ts @@ -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 = (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 diff --git a/plugins/text-editor-resources/src/components/extension/reference.ts b/plugins/text-editor-resources/src/components/extension/reference.ts index 661ea19be0..204f1eede6 100644 --- a/plugins/text-editor-resources/src/components/extension/reference.ts +++ b/plugins/text-editor-resources/src/components/extension/reference.ts @@ -179,7 +179,10 @@ export const ReferenceExtension = ReferenceNode.extend, 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) ?? undefined + const objectclass = (url.searchParams?.get('_class') as Ref>) ?? undefined + + if (id === undefined || objectclass === undefined) return + + return { label, id, objectclass } +} + +function makeQuery (obj: Record): string { + return Object.keys(obj) + .filter((it) => it[1] != null) + .map(function (k) { + return encodeURIComponent(k) + '=' + encodeURIComponent(obj[k] as string | number | boolean) + }) + .join('&') +} diff --git a/plugins/text-editor-resources/src/index.ts b/plugins/text-editor-resources/src/index.ts index 4bf34cff25..bbea2ea608 100644 --- a/plugins/text-editor-resources/src/index.ts +++ b/plugins/text-editor-resources/src/index.ts @@ -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 => ({ function: { @@ -108,6 +118,17 @@ export default async (): Promise => ({ CreateInlineComment: createInlineComment, ShouldShowCreateInlineCommentAction: shouldShowCreateInlineCommentAction, + ShouldShowConvertToLinkPreviewAction: shouldShowConvertToLinkPreviewAction, + ConvertToLinkPreviewActionIsActive: convertToLinkPreviewActionIsActive, + ConvertToLinkPreviewAction: convertToLinkPreviewAction, + + ShouldShowConvertToEmbedPreviewAction: shouldShowConvertToEmbedPreviewAction, + ConvertToEmbedPreviewActionIsActive: convertToEmbedPreviewActionIsActive, + ConvertToEmbedPreviewAction: convertToEmbedPreviewAction, + + ShouldShowCopyPreviewLinkAction: shouldShowCopyPreviewLinkAction, + CopyPreviewLinkAction: copyPreviewLinkAction, + SetBackgroundColor: openBackgroundColorOptions, SetTextColor: openTextColorOptions } diff --git a/plugins/text-editor-resources/src/kits/editor-kit.ts b/plugins/text-editor-resources/src/kits/editor-kit.ts index 92d0ed2ce7..b46ff446f6 100644 --- a/plugins/text-editor-resources/src/kits/editor-kit.ts +++ b/plugins/text-editor-resources/src/kits/editor-kit.ts @@ -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 | false } const headingLevels: Level[] = [1, 2, 3] @@ -228,6 +232,19 @@ async function buildEditorKit (): Promise> { 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({ diff --git a/plugins/text-editor/src/plugin.ts b/plugins/text-editor/src/plugin.ts index 79f49ac029..4eccb16a79 100644 --- a/plugins/text-editor/src/plugin.ts +++ b/plugins/text-editor/src/plugin.ts @@ -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 } }) diff --git a/plugins/text-editor/src/types.ts b/plugins/text-editor/src/types.ts index 1dba79e5d7..8590260cac 100644 --- a/plugins/text-editor/src/types.ts +++ b/plugins/text-editor/src/types.ts @@ -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 diff --git a/plugins/view-resources/src/components/DocNavLink.svelte b/plugins/view-resources/src/components/DocNavLink.svelte index 135d68721d..d0bafc992f 100644 --- a/plugins/view-resources/src/components/DocNavLink.svelte +++ b/plugins/view-resources/src/components/DocNavLink.svelte @@ -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} > diff --git a/plugins/view-resources/src/components/ObjectMention.svelte b/plugins/view-resources/src/components/ObjectMention.svelte index 1facdd095d..7828c67cc5 100644 --- a/plugins/view-resources/src/components/ObjectMention.svelte +++ b/plugins/view-resources/src/components/ObjectMention.svelte @@ -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} > - + {#if icon}{' '}{:else}@{/if}{displayTitle} diff --git a/plugins/view-resources/src/components/viewer/TextViewer.svelte b/plugins/view-resources/src/components/viewer/TextViewer.svelte index a207eeb648..96e505a39e 100644 --- a/plugins/view-resources/src/components/viewer/TextViewer.svelte +++ b/plugins/view-resources/src/components/viewer/TextViewer.svelte @@ -68,5 +68,8 @@ pre { font-family: var(--mono-font); + white-space: pre !important; + word-wrap: nowrap !important; + font-size: 0.8125rem; }