mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-30 11:49:42 +02:00
Support for embedding links and references in text editor (#9003)
Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
@@ -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,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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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({})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)]]
|
||||
}
|
||||
})
|
||||
@@ -21,4 +21,5 @@ export * from './file'
|
||||
export * from './codeblock'
|
||||
export * from './comment'
|
||||
export * from './markdown'
|
||||
export * from './embed'
|
||||
export { getDataAttribute } from './utils'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,10 @@
|
||||
"AddComment": "コメントを追加",
|
||||
"AddCommentPlaceholder": "コメントを追加...",
|
||||
"SetCellHighlightColor": "セルの色を設定",
|
||||
"SetTextColor": "テキストの色を設定"
|
||||
"SetTextColor": "テキストの色を設定",
|
||||
|
||||
"ConvertToLinkPreview": "リンクとして表示",
|
||||
"ConvertToEmbedPreview": "コンテンツプレビューとして表示",
|
||||
"UnableToLoadEmbeddedContent": "リンクのプレビューを読み込めません。権限設定または非対応のコンテンツが原因です"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
"AddComment": "Добавить комментарий",
|
||||
"AddCommentPlaceholder": "Добавьте комментарий...",
|
||||
"SetCellHighlightColor": "Изменить цвет ячеек",
|
||||
"SetTextColor": "Изменить цвет текста"
|
||||
"SetTextColor": "Изменить цвет текста",
|
||||
"ConvertToLinkPreview": "Показать как ссылку",
|
||||
"ConvertToEmbedPreview": "Показать как превью контента",
|
||||
"UnableToLoadEmbeddedContent": "Не удалось загрузить превью ссылки из-за настроек доступа или неподдерживаемого содержимого"
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,10 @@
|
||||
"SeparatorLine": "分隔线",
|
||||
"TodoItem": "待办事项",
|
||||
"TodoList": "待办事项列表",
|
||||
"DrawingBoard": "画板"
|
||||
"DrawingBoard": "画板",
|
||||
|
||||
"ConvertToLinkPreview": "显示为链接",
|
||||
"ConvertToEmbedPreview": "显示为内容预览",
|
||||
"UnableToLoadEmbeddedContent": "由于权限设置或不支持的内容,无法加载链接预览"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
})
|
||||
|
||||
@@ -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('&')
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user