Generalized implementation of the text editor toolbar (#9147)

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Victor Ilyushchenko 2025-06-05 07:05:21 +03:00 committed by GitHub
parent fcb67d20f8
commit f21996252b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1307 additions and 1113 deletions

View File

@ -1122,6 +1122,7 @@ export function defineSearch (builder: Builder): void {
export function defineTextActions (builder: Builder): void {
// Comment category
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
tags: ['text'],
action: documents.function.Comment,
icon: chunter.icon.Chunter,
visibilityTester: documents.function.IsCommentVisible,

View File

@ -14,30 +14,28 @@
//
import { DOMAIN_MODEL } from '@hcengineering/core'
import { type Builder, Model } from '@hcengineering/model'
import { Model, type Builder } from '@hcengineering/model'
import core, { TDoc } from '@hcengineering/model-core'
import { getEmbeddedLabel, type Asset, type IntlString, type Resource } from '@hcengineering/platform'
import {
type ActiveDescriptor,
type ExtensionCreator,
type TextEditorExtensionFactory,
type RefInputAction,
type RefInputActionItem,
type TextEditorAction,
type TextActionActiveFunction,
type TextActionFunction,
type TextActionVisibleFunction,
type TextActionActiveFunction,
type ActiveDescriptor,
type TogglerDescriptor,
type TextEditorActionKind
type TextEditorAction,
type TextEditorExtensionFactory,
type TogglerDescriptor
} from '@hcengineering/text-editor'
import view from '@hcengineering/view'
import textEditor from './plugin'
// 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 { textEditorId } from '@hcengineering/text-editor'
export { textEditorOperation } from './migration'
export { default } from './plugin'
export { textEditorId } from '@hcengineering/text-editor'
export type { RefInputAction, RefInputActionItem }
@Model(textEditor.class.RefInputActionItem, core.class.Doc, DOMAIN_MODEL)
@ -57,7 +55,7 @@ export class TTextEditorExtensionFactory extends TDoc implements TextEditorExten
@Model(textEditor.class.TextEditorAction, core.class.Doc, DOMAIN_MODEL)
export class TTextEditorAction extends TDoc implements TextEditorAction {
kind?: TextEditorActionKind
tags?: string[]
action!: TogglerDescriptor | Resource<TextActionFunction>
visibilityTester?: Resource<TextActionVisibleFunction>
icon!: Asset
@ -127,7 +125,7 @@ function createImageAlignmentAction (builder: Builder, align: 'center' | 'left'
}
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'image',
tags: ['image'],
action: {
command: 'setImageAlignment',
params: {
@ -171,7 +169,7 @@ function createTextAlignmentAction (builder: Builder, align: 'center' | 'left' |
}
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'text',
tags: ['text'],
action: {
command: 'setTextAlign',
params: align
@ -367,7 +365,7 @@ export function createModel (builder: Builder): void {
// Table cell category
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'table',
tags: ['table', 'tableCell'],
action: textEditor.function.SetBackgroundColor,
icon: textEditor.icon.Brush,
visibilityTester: textEditor.function.IsTableToolbarContext,
@ -378,7 +376,7 @@ export function createModel (builder: Builder): void {
// Table category
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'table',
tags: ['table'],
action: textEditor.function.SelectTable,
icon: textEditor.icon.SelectTable,
visibilityTester: textEditor.function.IsTableToolbarContext,
@ -388,7 +386,7 @@ export function createModel (builder: Builder): void {
})
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'table',
tags: ['table'],
action: textEditor.function.OpenTableOptions,
icon: textEditor.icon.TableProps,
visibilityTester: textEditor.function.IsTableToolbarContext,
@ -404,7 +402,7 @@ export function createModel (builder: Builder): void {
// Image view category
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'image',
tags: ['image'],
action: textEditor.function.OpenImage,
icon: textEditor.icon.ScaleOut,
label: textEditor.string.ViewImage,
@ -413,7 +411,7 @@ export function createModel (builder: Builder): void {
})
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'image',
tags: ['image'],
action: textEditor.function.ExpandImage,
icon: textEditor.icon.Expand,
label: textEditor.string.ViewOriginal,
@ -422,7 +420,7 @@ export function createModel (builder: Builder): void {
})
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'image',
tags: ['image'],
action: textEditor.function.DownloadImage,
icon: textEditor.icon.Download,
label: textEditor.string.Download,
@ -431,7 +429,7 @@ export function createModel (builder: Builder): void {
})
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'image',
tags: ['image'],
action: textEditor.function.MoreImageActions,
visibilityTester: textEditor.function.IsEditable,
icon: textEditor.icon.MoreH,
@ -465,7 +463,7 @@ export function createModel (builder: Builder): void {
})
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'preview',
tags: ['embed'],
action: textEditor.function.CopyPreviewLinkAction,
icon: view.icon.Copy,
visibilityTester: textEditor.function.ShouldShowCopyPreviewLinkAction,
@ -475,7 +473,7 @@ export function createModel (builder: Builder): void {
})
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'preview',
tags: ['embed'],
action: textEditor.function.ConvertToLinkPreviewAction,
icon: textEditor.icon.LinkPreview,
visibilityTester: textEditor.function.ShouldShowConvertToLinkPreviewAction,
@ -486,7 +484,7 @@ export function createModel (builder: Builder): void {
})
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'preview',
tags: ['embed'],
action: textEditor.function.ConvertToEmbedPreviewAction,
icon: textEditor.icon.EmbedPreview,
visibilityTester: textEditor.function.ShouldShowConvertToEmbedPreviewAction,

View File

@ -16,6 +16,39 @@ import type { Blob, Ref } from '@hcengineering/core'
import { Node, mergeAttributes } from '@tiptap/core'
import { getDataAttribute } from './utils'
/**
* @public
*/
export type ImageAlignment = 'center' | 'left' | 'right'
export interface ImageAlignmentOptions {
align?: ImageAlignment
}
export interface ImageSizeOptions {
height?: number | string
width?: number | string
}
declare module '@tiptap/core' {
export interface Commands<ReturnType> {
image: {
/**
* Add an image
*/
setImage: (options: { src: string, alt?: string, title?: string }) => ReturnType
/**
* Set image alignment
*/
setImageAlignment: (options: ImageAlignmentOptions) => ReturnType
/**
* Set image size
*/
setImageSize: (options: ImageSizeOptions) => ReturnType
}
}
}
/**
* @public
*/
@ -110,60 +143,5 @@ export const ImageNode = Node.create<ImageOptions>({
}
return ['div', divAttributes, ['img', imgAttributes]]
},
addNodeView () {
return ({ node, HTMLAttributes }) => {
const container = document.createElement('div')
const imgElement = document.createElement('img')
container.append(imgElement)
const divAttributes = {
class: 'text-editor-image-container',
'data-type': this.name,
'data-align': node.attrs.align
}
for (const [k, v] of Object.entries(divAttributes)) {
if (v !== null) {
container.setAttribute(k, v)
}
}
const imgAttributes = mergeAttributes(
{
'data-type': this.name
},
this.options.HTMLAttributes,
HTMLAttributes
)
for (const [k, v] of Object.entries(imgAttributes)) {
if (k !== 'src' && k !== 'srcset' && v !== null) {
imgElement.setAttribute(k, v)
}
}
const fileId = imgAttributes['file-id']
if (fileId != null) {
const setBrokenImg = setTimeout(() => {
imgElement.src = this.options.loadingImgSrc ?? `platform://platform/files/workspace/?file=${fileId}`
}, 500)
if (fileId != null) {
void this.options.getBlobRef(fileId).then((val) => {
clearTimeout(setBrokenImg)
imgElement.src = val.src
imgElement.srcset = val.srcset
})
}
} else {
if (imgAttributes.srcset != null) {
imgElement.srcset = imgAttributes.srcset
}
if (imgAttributes.src != null) {
imgElement.src = imgAttributes.src
}
}
return {
dom: container
}
}
}
})

View File

@ -296,6 +296,8 @@
style:min-width={options?.props?.minWidth}
style:min-height={options?.props?.minHeight}
style:transform={options?.props?.transform}
data-block-editor-blur="true"
data-block-cursor-update="true"
use:resizeObserver={(element) => {
clientWidth = element.clientWidth
clientHeight = element.clientHeight

View File

@ -128,11 +128,15 @@
let tabs: Tab[]
let content: HTMLElement
$: tabs = [
{
label: documentRes.string.ContentTab,
component: EditDocContent,
props: {}
props: {
boundary: content
}
},
{
label: documentRes.string.ReasonAndImpact,
@ -307,6 +311,7 @@
{#if $controlledDocument !== null && attribute !== undefined}
<Panel
bind:innerWidth
bind:content
isHeader={false}
object={$controlledDocument}
customAside={sideBar}

View File

@ -55,6 +55,8 @@
import DocumentPrintTitlePage from '../print/DocumentPrintTitlePage.svelte'
import DocumentTitle from './DocumentTitle.svelte'
export let boundary: HTMLElement | undefined = undefined
const client = getClient()
const hierarchy = client.getHierarchy()
const user = getCollaborationUser()
@ -279,6 +281,7 @@
object={$controlledDocument}
{attribute}
{user}
{boundary}
readonly={!$isEditable}
editorAttributes={{ style: 'padding: 0 2em; margin: 0 -2em;' }}
overflow="none"

View File

@ -72,7 +72,6 @@
import { createLocalProvider, createRemoteProvider } from '../provider/utils'
import { addTableHandler } from '../utils'
import TextEditorToolbar from './TextEditorToolbar.svelte'
import { noSelectionRender, renderCursor } from './editor/collaboration'
import { defaultEditorAttributes } from './editor/editorProps'
import { SavedBoard } from './extension/drawingBoard'
@ -468,17 +467,8 @@
history: false,
submit: false,
toolbar: {
element: textToolbarElement,
boundary,
isHidden: () => !showToolbar
},
image: {
toolbar: {
element: imageToolbarElement,
boundary,
appendTo: () => boundary ?? element,
isHidden: () => !showToolbar
}
popupContainer: editorPopupContainer
},
mermaid: {
...mermaidOptions,
@ -488,10 +478,6 @@
drawingBoard: {
getSavedBoard
},
embed: {
boundary: boundary ?? element,
popupContainer: editorPopupContainer
},
...kitOptions
}),
...optionalExtensions,
@ -585,23 +571,6 @@
</div>
{/if}
<TextEditorToolbar
bind:toolbar={textToolbarElement}
visible={showToolbar}
{editor}
formatButtonSize={buttonSize}
on:focus={handleFocus}
/>
<TextEditorToolbar
bind:toolbar={imageToolbarElement}
kind="image"
visible={showToolbar}
{editor}
formatButtonSize={buttonSize}
on:focus={handleFocus}
/>
<div class="textInput">
<div class="select-text" class:hidden={loading} style="width: 100%;" bind:this={element} />
<!-- <div class="collaborationUsers-container flex-col flex-gap-2 pt-2">

View File

@ -64,6 +64,7 @@
if (blockMouseEvents) {
event.preventDefault()
event.stopPropagation()
editor.view.focus()
}
const handler = action.action

View File

@ -27,7 +27,6 @@
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import { deleteAttachment } from '../command/deleteAttachment'
import TextEditorToolbar from './TextEditorToolbar.svelte'
import { defaultEditorAttributes } from './editor/editorProps'
import { getEditorKit } from '../../src/kits/editor-kit'
@ -141,8 +140,6 @@
let needFocus = false
let focused = false
let posFocus: FocusPosition | undefined = undefined
let textToolbarElement: HTMLElement
let imageToolbarElement: HTMLElement
export function focus (position?: FocusPosition): void {
posFocus = position
@ -173,21 +170,10 @@
mode: 'compact',
file: canEmbedFiles ? {} : false,
dropcursor,
image: canEmbedImages
? {
toolbar: {
element: imageToolbarElement,
boundary
}
}
: false,
image: canEmbedImages ? {} : false,
drawingBoard: false,
textAlign: false,
submit: supportSubmit ? { submit } : false,
toolbar: {
element: textToolbarElement,
boundary
}
submit: supportSubmit ? { submit } : false
}),
Placeholder.configure({ placeholder: placeHolderStr }),
...extensions
@ -235,10 +221,4 @@
}
</script>
<TextEditorToolbar bind:toolbar={textToolbarElement} {editor} on:focus={handleFocus} />
{#if canEmbedImages}
<TextEditorToolbar bind:toolbar={imageToolbarElement} kind="image" {editor} on:focus={handleFocus} />
{/if}
<div class="select-text" style="width: 100%;" bind:this={element} />

View File

@ -1,155 +0,0 @@
<!--
// Copyright © 2020 Anticrm Platform Contributors.
// Copyright © 2021, 2022, 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 { IconSize } from '@hcengineering/ui'
import { Editor } from '@tiptap/core'
import textEditor, {
type TextEditorAction,
type ActionContext,
type TextEditorActionKind
} from '@hcengineering/text-editor'
import { createQuery } from '@hcengineering/presentation'
import { getResource } from '@hcengineering/platform'
import { onDestroy, onMount } from 'svelte'
import { inlineToolbarKey } from './extension/inlineToolbar'
import TextActionButton from './TextActionButton.svelte'
export let formatButtonSize: IconSize = 'small'
export let editor: Editor
export let toolbar: HTMLElement | null
export let visible: boolean = true
export let kind: TextEditorActionKind = 'text'
const actionsQuery = createQuery()
$: actionCtx = editor?.extensionManager.extensions.find((ext) => ext.name === inlineToolbarKey)?.options.ctx
let actions: TextEditorAction[]
$: actionsQuery.query(textEditor.class.TextEditorAction, {}, (result) => {
actions = result.filter((action) => action.kind === kind || (kind === 'text' && action.kind === undefined))
})
let visibleActions: TextEditorAction[]
$: void getVisibleActions(editor, actions, actionCtx)
async function getVisibleActions (
e: Editor | undefined,
actions: TextEditorAction[],
ctx: ActionContext
): Promise<void> {
const newVisibleActions = []
if (e !== undefined && actions !== undefined) {
for (const action of actions) {
const tester = action.visibilityTester
if (typeof action.action !== 'string') {
const { command } = action.action
if ((editor.commands as any)[command] === undefined) {
continue
}
}
if (tester === undefined) {
newVisibleActions.push(action)
continue
}
const testerFunc = await getResource(tester)
if (await testerFunc(e, ctx)) {
newVisibleActions.push(action)
}
}
}
visibleActions = newVisibleActions
}
$: categories = visibleActions.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])
})
let selecting = false
function handleMouseDown (event: MouseEvent): void {
function handleMouseMove (): void {
if (editor !== undefined && !editor.state.selection.empty) {
selecting = true
document.removeEventListener('mousemove', handleMouseMove)
}
}
function handleMouseUp (): void {
selecting = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
if (editor !== undefined && visible && visibleActions.length > 0) {
if (event.target !== null && toolbar !== null && !toolbar.contains(event.target as Node)) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
}
}
onMount(() => {
document.addEventListener('mousedown', handleMouseDown)
})
onDestroy(() => {
document.removeEventListener('mousedown', handleMouseDown)
})
</script>
<div bind:this={toolbar} class="p-2" style="visibility: hidden;">
{#if editor && visible && !selecting && visibleActions.length > 0}
<div class="text-editor-toolbar buttons-group xsmall-gap">
{#each Object.values(categories) as category, index}
{#if index > 0}
<div class="buttons-divider" />
{/if}
{#each category as [_, action]}
<TextActionButton {action} {editor} size={formatButtonSize} {actionCtx} on:focus />
{/each}
{/each}
</div>
{/if}
</div>
<style lang="scss">
.text-editor-toolbar {
padding: 0.25rem;
background-color: var(--theme-comp-header-color);
border-radius: 0.5rem;
box-shadow: var(--button-shadow);
z-index: 1;
}
</style>

View File

@ -0,0 +1,54 @@
<!--
//
// 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.
//
-->
<script lang="ts">
import { ObjectNode } from '@hcengineering/presentation'
import { NodeViewProps } from '../../node-view'
import { parseReferenceUrl } from '../reference'
import { EmbedCursor, shouldShowLink } from './embed'
export let editor: NodeViewProps['editor']
export let cursor: EmbedCursor | null = null
$: showSrc = shouldShowLink(cursor)
$: reference = cursor?.props.src !== undefined ? parseReferenceUrl(cursor.props.src) : undefined
</script>
{#if cursor && showSrc}
{#if !reference}
<a class="link" href={cursor.props.src} target="_blank">{cursor.props.src}</a>
{:else}
<ObjectNode _id={reference.id} _class={reference.objectclass} title={reference.label} transparent />
<div class="buttons-divider" />
{/if}
{/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);
&:hover {
color: var(--theme-link-color);
}
}
</style>

View File

@ -17,21 +17,25 @@ 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 { type EditorState, 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'
import { AddMarkStep } from '@tiptap/pm/transform'
import { buildReferenceUrl, parseReferenceUrl } from '../reference'
import {
CursorSource,
getToolbarControlPluginState,
getToolbarCursor,
registerToolbarProvider,
type ResolveCursorProps,
setLoadingState,
type ToolbarCursor,
updateCursor
} from '../toolbar/toolbar'
import EmbedToolbarHead from './EmbedToolbarHead.svelte'
export interface EmbedNodeOptions {
providers: EmbedNodeProvider[]
boundary?: HTMLElement
popupContainer?: HTMLElement
}
export interface EmbedNodeViewHandle {
@ -47,6 +51,8 @@ export interface EmbedNodeProvider {
export type EmbedNodeView = (editor: Editor, root: HTMLDivElement) => EmbedNodeViewHandle | undefined
export type EmbedNodeProviderConstructor<T> = (options: T) => EmbedNodeProvider
export type EmbedCursor = ToolbarCursor<EmbedCursorProps>
export const EmbedNode = BaseEmbedNode.extend<EmbedNodeOptions>({
addOptions () {
return {
@ -135,23 +141,15 @@ export const EmbedNode = BaseEmbedNode.extend<EmbedNodeOptions>({
})
export interface EmbedControlState {
cursor: EmbedControlCursor | null
providers: EmbedNodeProvider[]
debounce: {
updateCursor: DebouncedCaller
}
}
export interface EmbedControlCursor {
from: number
to: number
node: Node
export interface EmbedCursorProps {
src: string
selected?: boolean
}
export interface EmbedControlTxMeta {
cursor?: EmbedControlCursor | null
cursor?: EmbedCursorProps | null
}
const embedControlPluginKey = new PluginKey('embedControlPlugin')
@ -163,232 +161,59 @@ export function EmbedControlPlugin (editor: Editor, options: EmbedNodeOptions):
state: {
init () {
return {
cursor: null,
providers: options.providers,
debounce: {
updateCursor: new DebouncedCaller(250)
}
providers: options.providers
}
},
apply (tr, prev, oldState, newState) {
const meta = tr.getMeta(embedControlPluginKey) as EmbedControlTxMeta
if (meta?.cursor !== undefined) {
return { ...prev, cursor: meta.cursor }
}
if (tr.docChanged) {
const cursor = prev.cursor
const fromSelection = cursor === null || cursor.selected === true
const from = fromSelection ? newState.selection.from : tr.mapping.map(cursor.from, -1)
const newCursor = resolveCursor(newState.doc.resolve(from))
if (newCursor !== null && (cursor?.selected === true || fromSelection)) {
newCursor.selected = true
}
updateCursor(tr, newCursor, prev)
return { ...prev, cursor: newCursor }
}
if (!oldState.selection.eq(newState.selection)) {
const $pos = newState.doc.resolve(newState.selection.from)
const cursor = resolveCursor($pos)
if (cursor !== null) {
cursor.selected = true
updateCursor(tr, cursor, prev)
return { ...prev, cursor }
} else if (prev.cursor !== null && prev.cursor.selected === true) {
updateCursor(tr, null, prev)
return { ...prev, cursor: null }
}
}
return prev
}
},
view (view) {
let cursor: EmbedControlCursor | null = null
let blockToolbarUpdate = false
let rect: DOMRect = getReferenceRect(view, 0, 0)
const getReferenceClientRect = (): DOMRect => {
const from = cursor?.from ?? 0
const to = cursor?.to ?? 0
blockToolbarUpdate = false
view.state.doc.nodesBetween(from, to, (node, pos) => {
const element = view.nodeDOM(pos)
if (!(element instanceof HTMLElement)) return
if (element.dataset.loading === 'true') {
blockToolbarUpdate = true
return false
}
})
if (blockToolbarUpdate) {
return rect
}
const newRect = getReferenceRect(view, from, to)
rect = newRect
return newRect
}
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 }
})
renderer.updateProps({ editor, cursor })
const updateToolbar = (): void => {
getReferenceClientRect()
killPendingMouseEvents()
if (blockToolbarUpdate) return
if (!tippynode.state.isShown && cursor !== null) {
tippynode.show()
}
if (tippynode.state.isShown && cursor === null) {
tippynode.hide()
}
renderer.updateProps({ editor, cursor })
tippynode.setProps({})
}
const killPendingMouseEvents = (): void => {
const pluginState = getEmbedControlState(editor)
pluginState?.debounce.updateCursor.call(() => {
/* reset pending mouse move event handling */
})
}
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-end',
hideOnClick: 'toggle',
onDestroy: () => {},
appendTo: () => options.popupContainer ?? document.body,
zIndex: 10000,
popperOptions: {
modifiers: [
{
name: 'flip',
options: {
fallbackPlacements: ['top-start', 'bottom-end', 'bottom-start']
}
}
]
}
}))
editor.on('transaction', ({ editor, transaction }) => {
cursor = getEmbedControlCursor(editor)
const meta = transaction.getMeta(embedControlPluginKey) as EmbedControlTxMeta
const loadingState = transaction.getMeta('loadingState') as boolean | undefined
if (meta?.cursor !== undefined || loadingState !== undefined) {
if (loadingState === true) {
updateToolbar()
} else {
requestAnimationFrame(() => {
updateToolbar()
})
}
}
})
editor.on('blur', (e) => {
const target = e.event.relatedTarget
const ignore = scanForDataMarker(target as HTMLElement | null, 'blockEditorBlur')
if (!ignore) {
view.dispatch(updateCursor(view.state.tr, null))
}
})
return {
destroy () {
tippynode.destroy()
window.removeEventListener('mousemove', listener)
}
}
registerToolbarProvider<EmbedCursorProps>(view, { name: 'embed', resolveCursor, priority: 50 })
return {}
},
appendTransaction: (transactions, oldState, newState) => {
// editor.on('transaction', ...) fires for root transactions
// but not for internal transactions appended via plugins,
// so we need to propagate metadata to the rest of transactions.
let meta: EmbedControlTxMeta | undefined
for (const tx of transactions) {
meta = (tx.getMeta(embedControlPluginKey) as EmbedControlTxMeta) ?? meta
}
if (meta !== undefined) {
for (const tx of transactions) {
setMeta(tx, meta)
}
if (transactions.length < 2) {
return null
}
const rest = transactions.slice(1)
for (const tx of rest) {
if (tx.steps.length !== 1) continue
const step = tx.steps[0]
if (!(step instanceof AddMarkStep)) continue
if (step.mark.type.name !== 'link') continue
if (transactions.length > 1) {
const rest = transactions.slice(1)
for (const tx of rest) {
if (tx.steps.length !== 1) continue
const step = tx.steps[0]
if (!(step instanceof AddMarkStep)) continue
if (step.mark.type.name !== 'link') continue
const src = step.mark.attrs.href as string | undefined
if (src === undefined) continue
const src = step.mark.attrs.href as string | undefined
if (src === undefined) continue
const $pos = newState.doc.resolve(step.from)
const index = $pos.index()
const parent = $pos.parent
if ($pos.depth !== 1 || parent.type.name !== 'paragraph') continue
const $pos = newState.doc.resolve(step.from)
const index = $pos.index()
const parent = $pos.parent
if ($pos.depth !== 1 || parent.type.name !== 'paragraph') continue
let canConvert = true
for (let i = 0; i < parent.childCount; i++) {
const child = parent.child(i)
if (i === index) continue
if (child.type.name !== 'text') {
canConvert = false
break
}
if (child.textContent.trim() !== '') {
canConvert = false
break
}
let canConvert = true
for (let i = 0; i < parent.childCount; i++) {
const child = parent.child(i)
if (i === index) continue
if (child.type.name !== 'text') {
canConvert = false
break
}
if (child.textContent.trim() !== '') {
canConvert = false
break
}
}
if (canConvert) {
const embedTx = tryAutoEmbedUrl(newState, options.providers, step, src)
if (embedTx !== undefined) {
return embedTx
}
if (canConvert) {
const embedTx = tryAutoEmbedUrl(newState, options.providers, step, src)
if (embedTx !== undefined) {
return embedTx
}
}
}
return null
}
})
}
@ -404,57 +229,7 @@ function tryAutoEmbedUrl (
const embedNode = state.schema.nodes.embed.create({ src })
const fragment = Fragment.from(embedNode)
const tr = state.tr
return replacePreviewContent({ from, to }, fragment, tr, true)
}
function scanForDataMarker (target: HTMLElement | null, field: string): boolean {
while (target != null) {
if (target.dataset[field] === 'true') {
return true
}
target = target.parentElement
}
return false
}
function updateCursorFromMouseEvent (view: EditorView, event: MouseEvent): void {
const state = embedControlPluginKey.getState(view.state) as EmbedControlState
const prevCursor = state?.cursor ?? null
const target = event?.target as HTMLElement | null
const blockCursorUpdate = scanForDataMarker(target, 'blockCursorUpdate')
const disableCursor = scanForDataMarker(target, 'disableCursor')
if (blockCursorUpdate) return
const coords = { left: event.clientX, top: event.clientY }
const newCursor = disableCursor ? null : resolveCursor(resolveCursorPositionFromCoords(view, coords))
if (prevCursor?.selected === true && newCursor === null) {
return
}
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)
})
return replacePreviewContent({ from, to }, fragment, state, CursorSource.Selection)
}
function getNodeUrl (node?: Node | null): string | undefined {
@ -506,8 +281,8 @@ function resolveCursorChildNode ($pos?: ResolvedPos): { node: Node | null, index
return nodeAfter ?? nodeBefore
}
function resolveCursor ($pos?: ResolvedPos): EmbedControlCursor | null {
if ($pos === undefined) return null
function resolveCursor (props: ResolveCursorProps): EmbedCursor | null {
const $pos = props.editorState.doc.resolve(props.range.from)
const child = resolveCursorChildNode($pos)
const node = child?.node ?? null
@ -521,31 +296,20 @@ function resolveCursor ($pos?: ResolvedPos): EmbedControlCursor | null {
if (src === undefined) return null
return {
from,
to,
node,
src
source: props.source,
tag: 'embed',
range: { from, to },
props: { src },
nodes: [{ node, pos: $pos.posAtIndex(child.index) }],
requireAnchoring: node.type.name === 'reference',
viewOptions: {
head: {
component: EmbedToolbarHead
}
}
}
}
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')
@ -558,41 +322,17 @@ function isLink (node: Node, strict: boolean = false): boolean {
return false
}
function setMeta (tr: Transaction, meta: EmbedControlTxMeta): Transaction {
return tr.setMeta(embedControlPluginKey, meta).setMeta('contextCursorUpdate', true)
}
function updateCursor (tr: Transaction, cursor: EmbedControlCursor | null, state?: EmbedControlState): Transaction {
state?.debounce?.updateCursor.call(() => {
/* reset pending mouse move event handling */
})
return setMeta(tr, { cursor })
}
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 cursor = getEmbedControlCursor(editor.state)
if (cursor?.nodes[0] === undefined) return false
const canEmbed = await shouldShowConvertToEmbedPreviewAction(editor, context)
if (!canEmbed && isLink(cursor.node, true)) {
if (!canEmbed && isLink(cursor.nodes[0].node, true)) {
return false
}
@ -604,27 +344,23 @@ export async function shouldShowConvertToEmbedPreviewAction (editor: Editor, con
return false
}
if (context.tag !== 'embed-toolbar') {
return false
}
const cursor = getEmbedControlCursor(editor.state)
if (cursor?.nodes[0] === undefined) return false
const cursor = getEmbedControlCursor(editor)
if (cursor?.node === undefined) return false
const url = getNodeUrl(cursor.node)
const url = getNodeUrl(cursor.nodes[0].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 cursor = getEmbedControlCursor(editor.state)
if (cursor?.nodes[0] === undefined) return
const node = cursor.node
const node = cursor.nodes[0].node
if (node.type.name !== 'embed') return
const ref = parseReferenceUrl(cursor.src)
const ref = parseReferenceUrl(cursor.props.src)
const schema = editor.schema
let fragment: Fragment
@ -633,24 +369,24 @@ export async function convertToLinkPreviewAction (editor: Editor, event: MouseEv
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 textNode = schema.text(cursor.props.src)
const linkMark = schema.marks.link.create({ href: cursor.props.src })
const textWithLink = textNode.mark([linkMark])
fragment = Fragment.from(textWithLink)
}
const from = cursor.from
const to = cursor.to
const from = cursor.range.from
const to = cursor.range.to
const tr = replacePreviewContent({ from, to }, fragment, editor.state.tr)
const tr = replacePreviewContent({ from, to }, fragment, editor.state, CursorSource.Selection)
editor.view.dispatch(tr)
}
export async function convertToEmbedPreviewAction (editor: Editor, event: MouseEvent): Promise<void> {
const cursor = getEmbedControlCursor(editor)
if (cursor?.node === undefined) return
const cursor = getEmbedControlCursor(editor.state)
if (cursor?.nodes[0] === undefined) return
const node = cursor.node
const node = cursor.nodes[0].node
if (!isLink(node)) return
@ -660,24 +396,23 @@ export async function convertToEmbedPreviewAction (editor: Editor, event: MouseE
const embedNode = editor.schema.nodes.embed.create({ src })
const fragment = Fragment.from(embedNode)
const from = cursor.from
const to = cursor.to
const from = cursor.range.from
const to = cursor.range.to
const tr = editor.state.tr
replacePreviewContent({ from, to }, fragment, tr, true)
const tr = replacePreviewContent({ from, to }, fragment, editor.state, CursorSource.Selection)
editor.view.focus()
editor.view.dispatch(tr)
}
export function shouldShowLink (cursor: EmbedControlCursor | null): boolean {
export function shouldShowLink (cursor: EmbedCursor | null): boolean {
if (cursor === null) return false
if (cursor.node.type.name === 'text' && cursor.src !== cursor.node.textContent) {
if (cursor.nodes[0].node.type.name === 'text' && cursor.props.src !== cursor.nodes[0].node.textContent) {
return true
}
if (cursor.node.type.name === 'embed') {
if (cursor.nodes[0].node.type.name === 'embed') {
return true
}
@ -685,13 +420,13 @@ export function shouldShowLink (cursor: EmbedControlCursor | null): boolean {
}
export async function shouldShowCopyPreviewLinkAction (editor: Editor, context: ActionContext): Promise<boolean> {
const cursor = getEmbedControlCursor(editor)
const cursor = getEmbedControlCursor(editor.state)
if (!shouldShowLink(cursor)) {
return false
}
if (parseReferenceUrl(cursor?.src ?? '') !== undefined) {
if (parseReferenceUrl(cursor?.props.src ?? '') !== undefined) {
return false
}
@ -699,31 +434,38 @@ export async function shouldShowCopyPreviewLinkAction (editor: Editor, context:
}
export async function copyPreviewLinkAction (editor: Editor, event: MouseEvent): Promise<void> {
const cursor = getEmbedControlCursor(editor)
const cursor = getEmbedControlCursor(editor.state)
const src = cursor?.src
const src = cursor?.props.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)
const cursor = getEmbedControlCursor(editor.state)
return cursor?.nodes[0] !== undefined && isLink(cursor.nodes[0].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'
const cursor = getEmbedControlCursor(editor.state)
if (cursor?.nodes[0] === undefined) return false
return cursor.nodes[0].node.type.name === 'embed'
}
export function replacePreviewContent (
{ from, to }: Range,
fragment: Fragment,
tr: Transaction,
selected: boolean = false
editorState: EditorState,
source: CursorSource
): Transaction {
const tr = editorState.tr
const toolbarState = getToolbarControlPluginState(editorState)
if (toolbarState === undefined) {
return tr
}
const slice = new Slice(fragment, 0, 0)
tr.replaceRange(from, to, slice)
@ -743,10 +485,15 @@ export function replacePreviewContent (
tr.setSelection(selection)
const cursor = resolveCursor(tr.doc.resolve(isOnlyBlockContent ? start : end))
if (selected && cursor !== null) {
cursor.selected = true
}
const pos = tr.doc.resolve(isOnlyBlockContent ? start : end)
editorState = editorState.apply(tr)
const cursor = resolveCursor({
editorState,
state: toolbarState,
range: { from: pos.pos, to: pos.pos },
source
})
updateCursor(tr, cursor)
@ -780,47 +527,12 @@ const StubEmbedNodeView: EmbedNodeView = (editor: Editor, root: HTMLElement) =>
}
}
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 getEmbedControlCursor (editorState: EditorState): EmbedCursor | null {
const cursor = getToolbarCursor(editorState)
if (cursor?.tag !== 'embed') return null
return cursor as EmbedCursor
}
function minmax (value = 0, min = 0, max = 0): number {
return Math.min(Math.max(value, min), max)
}
export function setLoadingState (view: EditorView, element: HTMLElement, loading: boolean): void {
if (loading) {
element.setAttribute('data-loading', 'true')
} else {
element.removeAttribute('data-loading')
}
view.dispatch(view.state.tr.setMeta('loadingState', loading))
export function getEmbedControlState (editor: Editor): EmbedControlState | undefined {
return embedControlPluginKey.getState(editor.view.state) as EmbedControlState | undefined
}

View File

@ -25,7 +25,8 @@ import {
import { type Editor } from '@tiptap/core'
import { SvelteRenderer } from '../../../node-view'
import { parseReferenceUrl } from '../../reference'
import { setLoadingState, type EmbedNodeProviderConstructor } from '../embed'
import { type EmbedNodeProviderConstructor } from '../embed'
import { setLoadingState } from '../../toolbar/toolbar'
export interface DriveEmbedOptions {
_x?: number

View File

@ -22,6 +22,8 @@ export const YoutubeEmbedProvider: EmbedNodeProviderConstructor<YoutubeEmbedUrlO
if (url === undefined) return
return (editor: Editor, root: HTMLDivElement) => {
root.setAttribute('data-block-toolbar-mouse-lock', 'true')
const iframe = document.createElement('iframe')
iframe.src = url
for (const key in options.iframe) {

View File

@ -14,55 +14,21 @@
//
import { getEmbeddedLabel } from '@hcengineering/platform'
import { FilePreviewPopup, getFileUrl } from '@hcengineering/presentation'
import { ImageNode, type ImageOptions as ImageNodeOptions } from '@hcengineering/text'
import { ImageNode, type ImageOptions } from '@hcengineering/text'
import textEditor from '@hcengineering/text-editor'
import { getEventPositionElement, SelectPopup, showPopup } from '@hcengineering/ui'
import { type Editor, nodeInputRule } from '@tiptap/core'
import { type BubbleMenuOptions } from '@tiptap/extension-bubble-menu'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { InlinePopupExtension } from './inlinePopup'
/**
* @public
*/
export type ImageAlignment = 'center' | 'left' | 'right'
/**
* @public
*/
export type ImageOptions = ImageNodeOptions & {
toolbar?: Omit<BubbleMenuOptions, 'pluginKey'> & {
isHidden?: () => boolean
}
}
export interface ImageAlignmentOptions {
align?: ImageAlignment
}
export interface ImageSizeOptions {
height?: number | string
width?: number | string
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
image: {
/**
* Add an image
*/
setImage: (options: { src: string, alt?: string, title?: string }) => ReturnType
/**
* Set image alignment
*/
setImageAlignment: (options: ImageAlignmentOptions) => ReturnType
/**
* Set image size
*/
setImageSize: (options: ImageSizeOptions) => ReturnType
}
}
}
import { type Editor, mergeAttributes, nodeInputRule } from '@tiptap/core'
import { type ResolvedPos, type Node } from '@tiptap/pm/model'
import { type EditorState, Plugin, PluginKey } from '@tiptap/pm/state'
import {
CursorSource,
getToolbarCursor,
registerToolbarProvider,
setLoadingState,
type ResolveCursorProps,
type ToolbarCursor
} from './toolbar/toolbar'
import { notEmpty } from '@hcengineering/core'
/**
* @public
@ -105,21 +71,25 @@ export const ImageExtension = ImageNode.extend<ImageOptions>({
setImageAlignment:
(options) =>
({ chain, tr }) => {
const { from } = tr.selection
({ chain, tr, state }) => {
const cursor = getCursor(state)
if (cursor === null) return false
return chain()
.setNodeSelection(cursor.range.from)
.updateAttributes(this.name, { ...options })
.setNodeSelection(from)
.setNodeSelection(cursor.range.from)
.run()
},
setImageSize:
(options) =>
({ chain, tr }) => {
const { from } = tr.selection
({ chain, tr, state }) => {
const cursor = getCursor(state)
if (cursor === null) return false
return chain()
.setNodeSelection(cursor.range.from)
.updateAttributes(this.name, { ...options })
.setNodeSelection(from)
.setNodeSelection(cursor.range.from)
.run()
}
}
@ -169,49 +139,157 @@ export const ImageExtension = ImageNode.extend<ImageOptions>({
)
}
}
})
}),
ImageToolbarPlugin()
]
},
addExtensions () {
return [
InlinePopupExtension.configure({
...this.options.toolbar,
shouldShow: ({ editor, view, state, oldState, from, to }) => {
if (this.options.toolbar?.isHidden?.() === true) {
return false
}
addNodeView () {
const imageSrcCache = new Map<string, { src: string, srcset: string }>()
if (editor.isDestroyed) {
return false
}
return ({ view, node, HTMLAttributes }) => {
const container = document.createElement('div')
const imgElement = document.createElement('img')
container.append(imgElement)
const divAttributes = {
class: 'text-editor-image-container',
'data-type': this.name,
'data-align': node.attrs.align
}
// For some reason shouldShow might be called after dismount and
// after destroying the editor. We should handle this just no to have
// any errors in runtime
const editorElement = editor.view.dom
if (editorElement === null || editorElement === undefined) {
return false
}
setLoadingState(view, container, true)
const setImageProps = (src: string | null, srcset: string | null): void => {
if (src != null) imgElement.src = src
if (srcset != null) imgElement.srcset = srcset
void imgElement.decode().finally(() => {
setLoadingState(view, container, false)
})
}
// When clicking on a element inside the bubble menu the editor "blur" event
// is called and the bubble menu item is focussed. In this case we should
// consider the menu as part of the editor and keep showing the menu
const isChildOfMenu = editorElement.contains(document.activeElement)
const hasEditorFocus = view.hasFocus() || isChildOfMenu
if (!hasEditorFocus) {
return false
}
return editor.isActive('image')
for (const [k, v] of Object.entries(divAttributes)) {
if (v !== null) {
container.setAttribute(k, v)
}
})
]
}
const imgAttributes = mergeAttributes(
{
'data-type': this.name
},
this.options.HTMLAttributes,
HTMLAttributes
)
for (const [k, v] of Object.entries(imgAttributes)) {
if (k !== 'src' && k !== 'srcset' && v !== null) {
imgElement.setAttribute(k, v)
}
}
const fileId = imgAttributes['file-id']
if (fileId !== null && imageSrcCache.has(fileId)) {
const cached = imageSrcCache.get(fileId)
setImageProps(cached?.src ?? null, cached?.srcset ?? null)
}
if (fileId != null) {
const setBrokenImg = setTimeout(() => {
imgElement.src = this.options.loadingImgSrc ?? `platform://platform/files/workspace/?file=${fileId}`
}, 500)
if (fileId != null) {
void this.options.getBlobRef(fileId).then((val) => {
clearTimeout(setBrokenImg)
setImageProps(val.src, val.srcset)
imageSrcCache.set(fileId, { src: val.src, srcset: val.srcset })
})
}
} else {
setImageProps(imgAttributes.src ?? null, imgAttributes.srcset ?? null)
}
container.setAttribute('data-toolbar-prevent-anchoring', 'true')
imgElement.setAttribute('data-toolbar-anchor', 'true')
return {
dom: container
}
}
}
})
function ImageToolbarPlugin (): Plugin {
return new Plugin({
key: new PluginKey('image-toolbar'),
view: (view) => {
registerToolbarProvider(view, { name: 'image', resolveCursor, priority: 40 })
return {}
}
})
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ImageToolbarCursorProps {}
function resolveCursorChildNode ($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 children = [parent.childAfter(offset)].filter(notEmpty)
const node = children.find((n) => n.node?.type.name === ImageExtension.name)
return node ?? null
}
function resolveCursor (props: ResolveCursorProps): ToolbarCursor<ImageToolbarCursorProps> | null {
if (props.source === CursorSource.Selection && props.range.to - props.range.from !== 1) {
return null
}
if (props.range.to - props.range.from > 1) {
return null
}
const $pos = props.editorState.doc.resolve(props.range.from)
const child = resolveCursorChildNode($pos)
if (child == null) return null
if (child.node === null) return null
const pos = $pos.posAtIndex(child.index)
const newCursor: ToolbarCursor<ImageToolbarCursorProps> = {
tag: 'image',
props: {},
nodes: [{ node: child.node, pos }],
range: { from: pos, to: pos + child.node.nodeSize },
source: props.source,
requireAnchoring: true,
anchor: props.anchor,
viewOptions: {
style: 'regular'
}
}
return newCursor
}
function getImageNodeFromCursor (state: EditorState): Node | null {
const cursor = getCursor(state)
const node = cursor?.nodes[0].node
if (node === undefined || node.type.name !== ImageExtension.name) {
return null
}
return node
}
export async function openImage (editor: Editor): Promise<void> {
const attributes = editor.getAttributes('image')
const node = getImageNodeFromCursor(editor.state)
if (node === null) return
const attributes = node.attrs
const fileId = attributes['file-id'] ?? attributes.src
const fileName = attributes.alt ?? ''
const fileType = attributes['data-file-type'] ?? 'image/*'
@ -233,8 +311,19 @@ export async function openImage (editor: Editor): Promise<void> {
})
}
function getCursor (state: EditorState): ToolbarCursor<ImageToolbarCursorProps> | null {
const cursor = getToolbarCursor<ImageToolbarCursorProps>(state)
if (cursor === null || cursor.tag !== 'image') {
return null
}
return cursor
}
export async function downloadImage (editor: Editor): Promise<void> {
const attributes = editor.getAttributes('image')
const node = getImageNodeFromCursor(editor.state)
if (node === null) return
const attributes = node.attrs
const fileId = attributes['file-id'] ?? attributes.src
const href = getFileUrl(fileId)
@ -247,7 +336,10 @@ export async function downloadImage (editor: Editor): Promise<void> {
}
export async function expandImage (editor: Editor): Promise<void> {
const attributes = editor.getAttributes('image')
const node = getImageNodeFromCursor(editor.state)
if (node === null) return
const attributes = node.attrs
const fileId = attributes['file-id'] ?? attributes.src
const url = getFileUrl(fileId)
window.open(url, '_blank')

View File

@ -1,17 +0,0 @@
import { type Extension } from '@tiptap/core'
import BubbleMenu, { type BubbleMenuOptions } from '@tiptap/extension-bubble-menu'
export const InlinePopupExtension: Extension<BubbleMenuOptions> = BubbleMenu.extend({
addOptions () {
return {
...this.parent?.(),
pluginKey: 'inline-popup',
element: null as any,
tippyOptions: {
maxWidth: '46rem',
zIndex: 500,
appendTo: () => document.body
}
}
}
})

View File

@ -1,65 +0,0 @@
import { Extension, isTextSelection } from '@tiptap/core'
import { type BubbleMenuOptions } from '@tiptap/extension-bubble-menu'
import { PluginKey } from '@tiptap/pm/state'
import { type ActionContext } from '@hcengineering/text-editor'
import { InlinePopupExtension } from './inlinePopup'
export const inlineToolbarKey = 'toolbar'
export type InlineStyleToolbarOptions = BubbleMenuOptions & {
isHidden?: () => boolean
ctx?: ActionContext
}
export const InlineToolbarExtension = Extension.create<InlineStyleToolbarOptions>({
name: inlineToolbarKey,
pluginKey: new PluginKey('inline-style-toolbar'),
addExtensions () {
const options: InlineStyleToolbarOptions = this.options
return [
InlinePopupExtension.configure({
...options,
shouldShow: ({ editor, view, state, oldState, from, to }) => {
if (this.options.isHidden?.() === true) {
return false
}
if (editor.isDestroyed) {
return false
}
// For some reason shouldShow might be called after dismount and
// after destroying the editor. We should handle this just no to have
// any errors in runtime
const editorElement = editor.view.dom
if (editorElement === null || editorElement === undefined) {
return false
}
// When clicking on a element inside the bubble menu the editor "blur" event
// is called and the bubble menu item is focussed. In this case we should
// consider the menu as part of the editor and keep showing the menu
const isChildOfMenu = editorElement.contains(document.activeElement)
const hasEditorFocus = view.hasFocus() || isChildOfMenu
if (!hasEditorFocus) {
return false
}
const { doc, selection } = state
const { empty } = selection
const textSelection = isTextSelection(state.selection)
// Sometime check for `empty` is not enough.
// Doubleclick an empty paragraph returns a node size of 2.
// So we check also for an empty text size.
const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && textSelection
return textSelection && !empty && !isEmptyTextBlock
}
})
]
}
})

View File

@ -93,6 +93,8 @@ export const ReferenceExtension = ReferenceNode.extend<ReferenceExtensionOptions
'data-id': node.attrs.id,
'data-objectclass': node.attrs.objectclass,
'data-label': node.attrs.label,
'data-toolbar-anchor': 'true',
'data-toolbar-prevent-anchoring': 'true',
class: 'antiMention'
},
this.options.HTMLAttributes,

View File

@ -20,7 +20,8 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '../../node-view'
import { findTable, insertColumn, insertRow } from './utils'
import { TableMap, updateColumnsOnResize } from '@tiptap/pm/tables'
import TableToolbar from './TableToolbar.svelte'
import { getToolbarCursor, setToolbarMeta } from '../toolbar/toolbar'
import { getTableCursor } from './table'
export let node: NodeViewProps['node']
export let getPos: NodeViewProps['getPos']
@ -84,12 +85,30 @@
onDestroy(() => {
editor.off('selectionUpdate', handleSelectionUpdate)
})
function onScroll (event: Event): void {
if (editor === undefined) return
const editorState = editor.state
const currCursor = getToolbarCursor<any>(editorState)
if (currCursor === null) return
const table = findTable(editorState.selection)
if (table === undefined) return
const target = event.target
if (!(target instanceof HTMLElement)) return
const tableScrollOffset = target.scrollLeft
const cursor = { ...currCursor, props: { ...currCursor.props, scrollOffset: tableScrollOffset } }
editor.view.dispatch(setToolbarMeta(editorState.tr, { cursor }))
}
</script>
<!-- prettier-ignore -->
<NodeViewWrapper class="table-node-wrapper" data-drag-handle>
<div class="table-wrapper" class:table-selected={editable && focused}>
<div class="table-scroller">
<div class="table-scroller" on:scroll={(e) => { onScroll(e) }}>
<table class={className} bind:this={tableElement}>
<colgroup bind:this={colgroupElement} />
<NodeViewContent as="tbody" />
@ -97,9 +116,6 @@
--></div><!-- https://github.com/sveltejs/svelte/issues/12765
--><div class="table-toolbar-components" contenteditable="false">
{#if editable && focused}
<div class="table-toolbar-container">
<TableToolbar {editor} />
</div>
<!-- add col button -->
<div class="table-button-container table-button-container__col flex">
<div class="w-full h-full flex showOnHover">
@ -134,7 +150,9 @@
margin: 0 calc(var(--table-offscreen-spacing) * -1);
.table-scroller {
padding: 1.25rem var(--table-offscreen-spacing);
padding: 1.5rem 0;
padding-left: var(--table-offscreen-spacing);
margin-right: var(--table-offscreen-spacing);
overflow-x: scroll;
scrollbar-width: auto;
}
@ -178,7 +196,7 @@
right: calc(var(--table-offscreen-spacing) - 1.5rem);
top: 0;
bottom: 0;
margin: 1.25rem 0;
margin: 1.5rem 0;
.table-button {
width: 1.25rem;
@ -196,11 +214,4 @@
}
}
}
.table-toolbar-container {
position: absolute;
top: -1.5rem;
right: var(--table-offscreen-spacing);
z-index: 200;
}
</style>

View File

@ -1,90 +0,0 @@
<!--
//
// 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 { createQuery } from '@hcengineering/presentation'
import TextActionButton from '../../TextActionButton.svelte'
import { getResource } from '@hcengineering/platform'
export let editor: NodeViewProps['editor']
const actionsQuery = createQuery()
const actionCtx: ActionContext = {
mode: 'full',
tag: 'table-toolbar'
}
let actions: TextEditorAction[] = []
async function updateActions (newActions: TextEditorAction[], ctx: ActionContext): Promise<void> {
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
}
actionsQuery.query(textEditor.class.TextEditorAction, { kind: 'table' }, (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])
})
</script>
<div class="table-toolbar flex" contenteditable="false">
<div class="text-editor-toolbar buttons-group xsmall-gap">
{#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} blockMouseEvents={false} />
{/each}
{/each}
</div>
</div>
<style lang="scss">
.table-toolbar {
padding: 0.25rem;
background-color: var(--theme-comp-header-color);
border-radius: 0.5rem;
box-shadow: var(--button-shadow);
}
</style>

View File

@ -19,7 +19,7 @@ import { type Editor } from '@tiptap/core'
import TiptapTable from '@tiptap/extension-table'
import { CellSelection, TableMap } from '@tiptap/pm/tables'
import { Plugin, type Transaction } from '@tiptap/pm/state'
import { type EditorState, Plugin, TextSelection, type Transaction } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import AddColAfter from '../../icons/table/AddColAfter.svelte'
import AddColBefore from '../../icons/table/AddColBefore.svelte'
@ -29,6 +29,13 @@ import DeleteCol from '../../icons/table/DeleteCol.svelte'
import DeleteRow from '../../icons/table/DeleteRow.svelte'
import DeleteTable from '../../icons/table/DeleteTable.svelte'
import { SvelteNodeViewRenderer } from '../../node-view'
import {
getToolbarCursor,
type NodeWithPos,
registerToolbarProvider,
type ResolveCursorProps,
type ToolbarCursor
} from '../toolbar/toolbar'
import TableNodeView from './TableNodeView.svelte'
import { TableSelection } from './types'
import { findTable, isTableSelected, selectTable as selectTableNode } from './utils'
@ -48,10 +55,70 @@ export const Table = TiptapTable.extend({
return SvelteNodeViewRenderer(TableNodeView, {})
},
addProseMirrorPlugins () {
return [...(this.parent?.() ?? []), tableSelectionHighlight(), cleanupBrokenTables()]
return [...(this.parent?.() ?? []), tableSelectionHighlight(), cleanupBrokenTables(), TableToolbarPlugin()]
}
})
function TableToolbarPlugin (): Plugin {
return new Plugin({
view: (view) => {
registerToolbarProvider<any>(view, { name: 'table', resolveCursor, priority: 30 })
return {}
}
})
}
export interface TableCursorProps {
tableScrollOffset?: number
}
export type TableCursor = ToolbarCursor<TableCursorProps>
export function getTableCursor (state: EditorState): TableCursor | null {
const cursor = getToolbarCursor<TableCursorProps>(state)
if (cursor === null || cursor.tag !== 'table') {
return null
}
return cursor
}
function resolveCursor (props: ResolveCursorProps): TableCursor | null {
const selection = props.editorState.selection
const table = findTable(selection)
if (table === undefined) return null
if (selection instanceof TextSelection && !selection.empty) {
return null
}
const range =
selection instanceof CellSelection
? { from: selection.from, to: selection.to }
: { from: table.pos, to: table.pos + table.node.nodeSize }
let nodes: NodeWithPos[] = [{ node: table.node, pos: table.pos }]
if (selection instanceof CellSelection) {
nodes = []
selection.forEachCell((node, pos) => {
nodes.push({ node, pos })
})
}
const cursor: TableCursor = {
source: props.source,
tag: 'table',
range,
props: {},
nodes,
viewOptions: {
offset: selection instanceof CellSelection ? [0, 12] : [0, -12]
}
}
return cursor
}
function handleDelete (editor: Editor): boolean {
const { selection } = editor.state.tr
if (selection instanceof TableSelection && isTableSelected(selection)) {
@ -244,9 +311,9 @@ export async function selectTable (editor: Editor, event: MouseEvent): Promise<v
}
export async function isEditableTableActive (editor: Editor): Promise<boolean> {
return editor.isEditable && editor.isActive('table')
return editor.isEditable && getTableCursor(editor.state) !== null
}
export async function isTableToolbarContext (editor: Editor, context: ActionContext): Promise<boolean> {
return editor.isEditable && editor.isActive('table') && context.tag === 'table-toolbar'
return editor.isEditable && getTableCursor(editor.state) !== null
}

View File

@ -1,6 +1,6 @@
<!--
//
// Copyright © 2023, 2024 Hardcore Engineering Inc.
// 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
@ -15,34 +15,33 @@
//
-->
<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'
import { createQuery } from '@hcengineering/presentation'
import textEditor, { ActionContext, TextEditorAction } from '@hcengineering/text-editor'
import { NodeViewProps } from '../../node-view'
import TextActionButton from '../../TextActionButton.svelte'
import { type ToolbarCursor } from './toolbar'
import { Component } from '@hcengineering/ui'
export let editor: NodeViewProps['editor']
export let cursor: EmbedControlCursor | null = null
export let cursor: ToolbarCursor<any> | null = null
export let context: ActionContext
$: actionCtx = { ...context, tag: cursor?.tag ?? 'none' }
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 (!(action.tags ?? ['text']).includes(ctx.tag ?? 'text')) {
continue
}
if (tester === undefined) {
out.push(action)
continue
@ -57,24 +56,12 @@
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)
actionsQuery.query(textEditor.class.TextEditorAction, {}, (result) => {
allActions = [...result]
})
$: void updateActions(allActions, actionCtx)
$: categories = actions.reduce<[number, TextEditorAction][][]>((acc, action) => {
const { category, index } = action
if (acc[category] === undefined) acc[category] = []
@ -86,29 +73,23 @@
category.sort((a, b) => a[0] - b[0])
})
$: showSrc = shouldShowLink(cursor)
$: reference = cursor?.src !== undefined ? parseReferenceUrl(cursor.src) : undefined
$: style = cursor?.viewOptions?.style ?? 'contrast'
$: head = cursor?.viewOptions?.head
</script>
{#if cursor && actions.length > 0}
<div
class="embed-toolbar flex theme-dark"
class:reference={showSrc && !!reference}
class="toolbar flex theme-dark"
contenteditable="false"
tabindex="-1"
class:theme-dark={style === 'contrast'}
class:toolbar-contrast={style === 'contrast'}
class:toolbar-regular={style === 'regular'}
data-block-editor-blur="true"
>
<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 head}
<Component is={head.component} props={{ editor, cursor, ...head.props }} />
{/if}
{#each Object.values(categories) as category, index}
{#if index > 0}
@ -122,7 +103,7 @@
size="small"
{actionCtx}
listenCursorUpdate
blockMouseEvents={false}
blockMouseEvents={true}
tooltipOptions={{ direction: 'top' }}
/>
{/each}
@ -147,13 +128,21 @@
}
}
.embed-toolbar {
.toolbar {
position: relative;
padding: 0.25rem;
background-color: var(--primary-button-default);
border-radius: 0.5rem;
box-shadow: var(--button-shadow);
--theme-link-color: var(--theme-content-color);
&.toolbar-contrast {
background-color: var(--primary-button-default);
--theme-link-color: var(--theme-content-color);
box-shadow: var(--button-shadow);
}
&.toolbar-regular {
background-color: var(--theme-comp-header-color);
--theme-link-color: var(--theme-content-color);
box-shadow: var(--button-shadow);
}
}
</style>

View File

@ -0,0 +1,700 @@
//
// 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 { notEmpty } from '@hcengineering/core'
import { type AnySvelteComponentWithProps, DebouncedCaller } from '@hcengineering/ui'
import { type Editor, Extension, type Range } from '@tiptap/core'
import { type Node } from '@tiptap/pm/model'
import { type EditorState, Plugin, PluginKey, TextSelection, type Transaction } from '@tiptap/pm/state'
import { type EditorView } from '@tiptap/pm/view'
import { deepEqual } from 'fast-equals'
import tippy, { type Placement, type Props as TippyProps } from 'tippy.js'
import { SvelteRenderer } from '../../node-view'
import EditorToolbar from './EditorToolbar.svelte'
import { type ActionContext } from '@hcengineering/text-editor'
export interface ToolbarCursor<T> {
source: CursorSource
tag: string
range: Range
props: T
requireAnchoring?: boolean
anchor?: HTMLElement
nodes: NodeWithPos[]
viewOptions?: ToolbarViewOptions
}
export interface ResolveCursorProps {
editorState: EditorState
state: ToolbarControlPluginState
range: Range
source: CursorSource
anchor?: HTMLElement
}
export interface ToolbarProvider<T> {
name: string
resolveCursor: (props: ResolveCursorProps) => ToolbarCursor<T> | null
priority: number
}
export type ToolbarPlacement = Placement
export type ToolbarStyle = 'regular' | 'contrast'
export interface ToolbarViewOptions {
placement?: Placement
fallbackPlacements?: Placement[]
style?: ToolbarStyle
head?: AnySvelteComponentWithProps
offset?: [number, number]
floating?: boolean
}
const defaultToolbarViewOptions: ToolbarViewOptions = {
placement: 'top-end',
fallbackPlacements: ['top-start', 'bottom-end', 'bottom-start'],
style: 'contrast',
offset: [0, 12]
}
export interface ToolbarOptions {
providers: Array<ToolbarProvider<any>>
boundary?: HTMLElement
popupContainer?: HTMLElement
context: ActionContext
}
export enum CursorSource {
Selection = 'selection',
Mouse = 'mouse'
}
export interface SelectionCursorContext {
type: CursorSource.Selection
range: Range
}
export const ToolbarExtension = Extension.create<ToolbarOptions>({
addProseMirrorPlugins () {
return [ToolbarControlPlugin(this.editor, this.options)]
}
})
export interface ToolbarControlTxMeta {
cursor?: ToolbarCursor<any> | null
site?: string
providers?: Array<ToolbarProvider<any>>
}
export const toolbarPluginKey = new PluginKey('dynamicToolbar')
export interface ToolbarControlPluginState {
providers: Array<ToolbarProvider<any>>
cursor: ToolbarCursor<any> | null
debounce: {
updateCursor: DebouncedCaller
}
}
export interface NodeWithPos {
node: Node
pos: number
}
export function ToolbarControlPlugin (editor: Editor, options: ToolbarOptions): Plugin {
return new Plugin<ToolbarControlPluginState>({
key: toolbarPluginKey,
state: {
init: () => {
return {
providers: [...options.providers],
cursor: null,
debounce: {
updateCursor: new DebouncedCaller(250)
}
}
},
apply (tr, prevPluginState, oldState, newState) {
const prev = { ...prevPluginState }
const meta = tr.getMeta(toolbarPluginKey) as ToolbarControlTxMeta
if (meta?.providers !== undefined) {
prev.providers = [...prev.providers, ...meta.providers]
prev.providers.sort((a, b) => b.priority - a.priority)
}
if (meta?.cursor !== undefined) {
return { ...prev, cursor: meta.cursor }
}
if (tr.docChanged) {
const prevCursor = prev.cursor
const source = prevCursor?.source ?? CursorSource.Selection
const range: Range = {
from:
source === CursorSource.Selection
? newState.selection.from
: tr.mapping.map(prevCursor?.range.from ?? 0, -1),
to:
source === CursorSource.Selection ? newState.selection.to : tr.mapping.map(prevCursor?.range.to ?? 0, -1)
}
const newCursor = resolveCursor({
editorState: newState,
state: prev,
range,
source
})
updateCursor(tr, newCursor, prev, 'docChanged')
return { ...prev, cursor: newCursor }
}
if (!oldState.selection.eq(newState.selection)) {
const range = { from: newState.selection.from, to: newState.selection.to }
const cursor = resolveCursor({
editorState: newState,
state: prev,
range,
source: CursorSource.Selection
})
if (cursor !== null) {
updateCursor(tr, cursor, prev, 'selectionChanged-newCursor')
return { ...prev, cursor }
} else if (prev.cursor !== null) {
updateCursor(tr, null, prev, 'selectionChanged-cursorReset')
return { ...prev, cursor: null }
}
}
return prev
}
},
view (view) {
let currCursor: ToolbarCursor<any> | null = null
let prevCursor: ToolbarCursor<any> | null = null
let isCursorRangeLoading = false
let rect: DOMRect = getReferenceRectFromRange(view, 0, 0)
const getTippyProps = (viewOptions?: ToolbarViewOptions): Partial<TippyProps> => ({
delay: [0, 0],
duration: [0, 0],
getReferenceClientRect,
inertia: true,
content: container,
maxWidth: 800,
interactive: true,
trigger: 'manual',
placement: viewOptions?.placement,
hideOnClick: 'toggle',
appendTo: viewOptions?.floating !== true ? options.popupContainer ?? document.body : document.body,
zIndex: 10000,
offset: viewOptions?.offset,
popperOptions: {
modifiers: [
{
name: 'preventOverflow',
options: {
boundary: options.boundary ?? document.body,
padding: 16,
altAxis: viewOptions?.floating === true,
tether: viewOptions?.floating !== true
}
},
{
name: 'flip',
options: {
fallbackPlacements: viewOptions?.fallbackPlacements
}
}
]
}
})
const getReferenceClientRect = (): DOMRect => {
if (currCursor === null) {
return rect
}
const from = currCursor.range.from
const to = currCursor.range.to
isCursorRangeLoading = scanForLoadingState(view, { from, to })
if (currCursor?.requireAnchoring === true) {
currCursor.anchor = scanForAnchor(view, currCursor.range)
}
if (isCursorRangeLoading) {
return rect
}
const nodes = currCursor.nodes ?? []
const newRect =
currCursor.anchor !== undefined
? currCursor.anchor.getBoundingClientRect()
: getReferenceRectFromNodes(view, nodes) ?? getReferenceRectFromRange(view, from, to)
rect = newRect
return newRect
}
const mousemove = (event: MouseEvent): void => {
handleMouseMove(view, event)
}
let isMouseDown = false
const mousedown = (event: MouseEvent): void => {
if (event.target instanceof HTMLElement) {
const [blockToolbarMouseLock] = scanMarker(event.target, 'blockToolbarMouseLock')
if (blockToolbarMouseLock) {
return
}
}
prevCursor = currCursor
isMouseDown = true
updateToolbar()
}
const mouseup = (event: MouseEvent): void => {
isMouseDown = false
updateToolbar()
}
const container = document.createElement('div')
container.dataset.blockCursorUpdate = 'true'
const renderer = new SvelteRenderer(EditorToolbar, {
element: container,
props: { editor, cursor: currCursor, context: options.context }
})
renderer.updateProps({ editor, cursor: currCursor })
const updateToolbar = (): void => {
getReferenceClientRect()
killPendingMouseEvents()
const isSwapping = prevCursor !== null && currCursor !== null && prevCursor.tag !== currCursor.tag
const shouldRespectMouseDown = !eqCursors(prevCursor, currCursor)
const shouldShow = !isCursorRangeLoading && currCursor !== null && !(shouldRespectMouseDown && isMouseDown)
if (isSwapping) {
tippyNode.hide()
}
const viewOptions = currCursor?.viewOptions
tippyNode.setProps(getTippyProps(viewOptions))
if (!tippyNode.state.isShown && shouldShow) {
requestAnimationFrame(() => {
tippyNode.show()
})
}
if (tippyNode.state.isShown && !shouldShow) {
tippyNode.hide()
}
renderer.updateProps({ editor, cursor: currCursor })
}
const killPendingMouseEvents = (): void => {
const pluginState = getToolbarControlPluginState(editor.state)
pluginState?.debounce.updateCursor.call(() => {
/* reset pending mouse move event handling */
})
}
const tippyNode = tippy(view.dom, getTippyProps())
editor.on('transaction', ({ editor, transaction: tr }) => {
const meta = tr.getMeta(toolbarPluginKey) as ToolbarControlTxMeta
const loadingState = tr.getMeta('loadingState') as boolean | undefined
if (meta?.cursor !== undefined && !eqCursors(currCursor, meta.cursor)) {
prevCursor = currCursor
currCursor = meta.cursor !== null ? { ...meta.cursor } : null
}
if (meta?.cursor !== undefined || loadingState !== undefined) {
if (loadingState === true) {
updateToolbar()
} else {
requestAnimationFrame(() => {
updateToolbar()
})
}
} else if (tr.docChanged) {
updateToolbar()
}
})
editor.on('blur', (e) => {
const target = e.event.relatedTarget
const [ignore] = scanMarker(target as HTMLElement | null, 'blockEditorBlur')
if (!ignore) {
view.dispatch(updateCursor(view.state.tr, null, getToolbarControlPluginState(view.state), 'blur'))
}
})
registerToolbarProvider(view, GeneralToolbarProvider)
window.addEventListener('mousemove', mousemove)
window.addEventListener('mousedown', mousedown)
window.addEventListener('mouseup', mouseup)
return {
destroy () {
tippyNode.destroy()
window.removeEventListener('mousemove', mousemove)
window.removeEventListener('mousedown', mousedown)
window.removeEventListener('mouseup', mouseup)
}
}
},
appendTransaction: (transactions, oldState, newState) => {
// editor.on('transaction', ...) fires for root transactions
// but not for internal transactions appended via plugins,
// so we need to propagate metadata to the rest of transactions.
let meta: ToolbarControlTxMeta | undefined
for (const tx of transactions) {
meta = (tx.getMeta(toolbarPluginKey) as ToolbarControlTxMeta) ?? meta
}
if (meta !== undefined) {
for (const tx of transactions) {
setToolbarMeta(tx, meta)
}
}
return null
}
})
}
export function registerToolbarProvider<T> (view: EditorView, provider: ToolbarProvider<T>): void {
const state = toolbarPluginKey.getState(view.state) as ToolbarControlPluginState | undefined
if (state === undefined) return
const meta: ToolbarControlTxMeta = { providers: [provider] }
view.dispatch(view.state.tr.setMeta(toolbarPluginKey, meta))
}
function handleMouseMove (view: EditorView, event: MouseEvent): void {
const state = toolbarPluginKey.getState(view.state) as ToolbarControlPluginState | undefined
if (state === undefined) return
state.debounce.updateCursor.call(() => {
updateCursorFromMouseEvent(view, event)
})
}
export function getToolbarControlPluginState (editorState: EditorState): ToolbarControlPluginState | undefined {
return toolbarPluginKey.getState(editorState) as ToolbarControlPluginState | undefined
}
export function getToolbarCursor<T> (editorState: EditorState): ToolbarCursor<T> | null {
const state = getToolbarControlPluginState(editorState)
return state?.cursor ?? null
}
function resolveCursor (props: ResolveCursorProps): ToolbarCursor<any> | null {
const providers = props.state.providers
const priority = [CursorSource.Mouse, CursorSource.Selection]
let cursors = providers.map((provider) => provider.resolveCursor(props)).filter(notEmpty)
cursors = priority.map((p) => cursors.filter((c) => c.source === p)).flat()
const cursor = cursors[0] ?? null
if (cursor !== null) {
cursor.viewOptions = { ...defaultToolbarViewOptions, ...cursor.viewOptions }
}
return cursor
}
export function updateCursor (
tr: Transaction,
cursor: ToolbarCursor<any> | null,
state?: ToolbarControlPluginState,
site?: string
): Transaction {
state?.debounce?.updateCursor.call(() => {
/* reset pending mouse move event handling */
})
if (eqCursors(state?.cursor ?? null, cursor)) {
return tr
}
return setToolbarMeta(tr, { cursor, site })
}
export function setToolbarMeta (tr: Transaction, meta: ToolbarControlTxMeta): Transaction {
return tr.setMeta(toolbarPluginKey, meta).setMeta('contextCursorUpdate', true)
}
function updateCursorFromMouseEvent (view: EditorView, event: MouseEvent): void {
const state = toolbarPluginKey.getState(view.state) as ToolbarControlPluginState | undefined
if (state === undefined) return
const prevCursor = state?.cursor ?? null
const target = event?.target as HTMLElement | null
const [blockCursorUpdate] = scanMarker(target, 'blockCursorUpdate')
const [disableCursor] = scanMarker(target, 'disableCursor')
const anchorElement = target !== null ? findAnchorElement(target, true) : undefined
if (blockCursorUpdate) return
const coords = { left: event.clientX, top: event.clientY }
const range = resolveCursorPositionFromCoords(view, coords)
if (range === undefined && prevCursor !== null) {
const isLoading = scanForLoadingState(view, prevCursor.range)
if (isLoading) return
}
let newCursor =
disableCursor || range === undefined
? null
: resolveCursor({
editorState: view.state,
state,
range,
source: CursorSource.Mouse,
anchor: anchorElement
})
if (newCursor?.requireAnchoring === true && anchorElement === undefined) {
newCursor = null
}
if (newCursor === null) {
if (prevCursor?.source === CursorSource.Selection) {
return
}
const selection = view.state.selection
newCursor = resolveCursor({
editorState: view.state,
state,
range: { from: selection.from, to: selection.to },
source: CursorSource.Selection
})
}
if (eqCursors(prevCursor, newCursor)) {
return
}
view.dispatch(updateCursor(view.state.tr, newCursor, state, 'mousemove-newCursor'))
}
function scanForLoadingState (view: EditorView, range: Range): boolean {
let isLoading = false
view.state.doc.nodesBetween(range.from, range.to, (node, pos) => {
const element = view.nodeDOM(pos)
if (!(element instanceof HTMLElement)) return
if (element.dataset.loading === 'true') {
isLoading = true
return false
}
})
return isLoading
}
function scanForAnchor (view: EditorView, range: Range): HTMLElement | undefined {
let anchor: HTMLElement | undefined
view.state.doc.nodesBetween(range.from, range.to, (node, pos) => {
const element = view.nodeDOM(pos)
if (!(element instanceof HTMLElement)) return
anchor = findAnchorElement(element)
if (anchor !== undefined) {
return false
}
})
return anchor
}
function findAnchorElement (target: HTMLElement, requireHover: boolean = false): HTMLElement | undefined {
const [, tagetSelfAnchor] = scanMarker(target, 'toolbarAnchor')
if (tagetSelfAnchor !== null) {
return tagetSelfAnchor
}
const [, anchoringRoot] = scanMarker(target, 'toolbarPreventAnchoring')
if (anchoringRoot === null) {
return
}
const matches: HTMLElement[] = []
anchoringRoot?.querySelectorAll('[data-toolbar-anchor="true"]').forEach((el) => {
if (requireHover && !el.matches(':hover')) {
return
}
matches.push(el as HTMLElement)
})
return matches[0]
}
function resolveCursorPositionFromCoords (view: EditorView, coords: { left: number, top: number }): Range | 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 { from: $pos.pos, to: $pos.pos }
}
function scanMarker (target: HTMLElement | null, field: string): [boolean, HTMLElement | null] {
while (target != null) {
if (target.dataset[field] === 'true') {
return [true, target]
}
target = target.parentElement
}
return [false, null]
}
function getReferenceRectFromRange (view: EditorView, from: number, to: number): DOMRect {
const docSize = view.state.doc.content.size
const clampedFrom = minmax(from, 0, docSize)
const clampedTo = minmax(to, 0, docSize)
const startCoords = view.coordsAtPos(clampedFrom)
const endCoords = view.coordsAtPos(clampedTo, -1)
const bounds = {
top: Math.min(startCoords.top, endCoords.top),
bottom: Math.max(startCoords.bottom, endCoords.bottom),
left: Math.min(startCoords.left, endCoords.left),
right: Math.max(startCoords.right, endCoords.right)
}
const rectData = {
...bounds,
width: bounds.right - bounds.left,
height: bounds.bottom - bounds.top,
x: bounds.left,
y: bounds.top
}
return {
...rectData,
toJSON: () => rectData
}
}
function getReferenceRectFromNodes (view: EditorView, nodes: NodeWithPos[]): DOMRect | undefined {
if (nodes.length < 1) {
return
}
const bounds = {
top: 0,
left: 0,
right: 0,
bottom: 0
}
let foundReference = false
for (const n of nodes) {
const dom = view.nodeDOM(n.pos) as HTMLElement | null
if (dom === null || !(dom instanceof HTMLElement)) continue
const rect = dom.getBoundingClientRect()
bounds.top = bounds.top === 0 ? rect.top : Math.min(bounds.top, rect.top)
bounds.left = bounds.left === 0 ? rect.left : Math.min(bounds.left, rect.left)
bounds.right = bounds.right === 0 ? rect.right : Math.max(bounds.right, rect.right)
bounds.bottom = bounds.bottom === 0 ? rect.bottom : Math.max(bounds.bottom, rect.bottom)
foundReference = true
}
if (!foundReference) {
return
}
const rectData = {
...bounds,
width: bounds.right - bounds.left,
height: bounds.bottom - bounds.top,
x: bounds.left,
y: bounds.top
}
return {
...rectData,
toJSON: () => rectData
}
}
function minmax (value = 0, min = 0, max = 0): number {
return Math.min(Math.max(value, min), max)
}
export function setLoadingState (view: EditorView, element: HTMLElement, loading: boolean): void {
if (loading) {
element.setAttribute('data-loading', 'true')
} else {
element.removeAttribute('data-loading')
requestAnimationFrame(() => {
view.dispatch(view.state.tr.setMeta('loadingState', loading))
})
}
}
export const GeneralToolbarProvider: ToolbarProvider<any> = {
name: 'text-general',
priority: 100,
resolveCursor: ({ editorState, state, range, source, anchor }) => {
if (source !== CursorSource.Selection) {
return null
}
const selection = editorState.selection
if (selection.empty || !(selection instanceof TextSelection)) {
return null
}
const cursor: ToolbarCursor<any> = {
source,
tag: 'text',
range: { from: selection.from, to: selection.to },
props: {},
nodes: [],
viewOptions: {
placement: 'top',
fallbackPlacements: ['bottom'],
style: 'regular',
floating: true
}
}
return cursor
}
}
export function eqCursors (c1: ToolbarCursor<any> | null, c2: ToolbarCursor<any> | null): boolean {
return deepEqual(c1, c2)
}

View File

@ -61,7 +61,6 @@ 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 TableOfContents } from './components/toc/TableOfContents.svelte'
export { default as TableOfContentsContent } from './components/toc/TableOfContentsContent.svelte'
export type { EditorKitOptions } from './kits/editor-kit'
@ -71,10 +70,8 @@ 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 { ImageExtension } 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,

View File

@ -20,6 +20,7 @@ import {
CodeExtension,
codeOptions,
CommentNode,
type ImageOptions,
InlineCommentMark,
MarkdownNode,
TextColor,
@ -31,28 +32,28 @@ import { type Level } from '@tiptap/extension-heading'
import TableHeader from '@tiptap/extension-table-header'
import 'prosemirror-codemark/dist/codemark.css'
import { EditableExtension } from '../components/extension/editable'
import TextAlign, { type TextAlignOptions } from '@tiptap/extension-text-align'
import { CodeBlockHighlighExtension, codeBlockHighlightOptions } from '../components/extension/codeblock'
import { NoteExtension, type NoteOptions } from '../components/extension/note'
import { DrawingBoardExtension, type DrawingBoardOptions } from '../components/extension/drawingBoard'
import { EditableExtension } from '../components/extension/editable'
import { EmbedNode, type EmbedNodeOptions } from '../components/extension/embed/embed'
import { defaultDriveEmbedOptions, DriveEmbedProvider } from '../components/extension/embed/providers/drive'
import { defaultYoutubeEmbedUrlOptions, YoutubeEmbedProvider } from '../components/extension/embed/providers/youtube'
import { FileExtension, type FileOptions } from '../components/extension/fileExt'
import { HardBreakExtension } from '../components/extension/hardBreak'
import { ImageExtension, type ImageOptions } from '../components/extension/imageExt'
import { InlineToolbarExtension } from '../components/extension/inlineToolbar'
import { ImageExtension } from '../components/extension/imageExt'
import { type IndendOptions, IndentExtension, indentExtensionOptions } from '../components/extension/indent'
import { LinkUtilsExtension } from '../components/extension/link'
import { ListKeymapExtension } from '../components/extension/listkeymap'
import { MermaidExtension, type MermaidOptions, mermaidOptions } from '../components/extension/mermaid'
import { NodeUuidExtension } from '../components/extension/nodeUuid'
import { NoteExtension, type NoteOptions } from '../components/extension/note'
import { ParagraphExtension } from '../components/extension/paragraph'
import { TransformPastedContentExtension } from '../components/extension/paste'
import { SubmitExtension, type SubmitOptions } from '../components/extension/submit'
import { Table, TableCell, TableRow } from '../components/extension/table'
import { ToolbarExtension, type ToolbarOptions } from '../components/extension/toolbar/toolbar'
import { DefaultKit, type DefaultKitOptions } from './default-kit'
import { MermaidExtension, type MermaidOptions, mermaidOptions } from '../components/extension/mermaid'
import { DrawingBoardExtension, type DrawingBoardOptions } from '../components/extension/drawingBoard'
import { type IndendOptions, IndentExtension, indentExtensionOptions } from '../components/extension/indent'
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
@ -77,14 +78,7 @@ export interface EditorKitOptions extends DefaultKitOptions {
objectId?: Ref<Doc>
objectClass?: Ref<Class<Doc>>
objectSpace?: Ref<Space>
toolbar?:
| {
element?: HTMLElement
boundary?: HTMLElement
appendTo?: HTMLElement | (() => HTMLElement)
isHidden?: () => boolean
}
| false
toolbar?: Partial<ToolbarOptions> | false
embed?: Partial<EmbedNodeOptions> | false
}
@ -105,33 +99,6 @@ export const tableKitExtensions: KitExtension[] = [
[40, TableCell.configure({})]
]
function getTippyOptions (
boundary?: HTMLElement,
appendTo?: HTMLElement | (() => HTMLElement),
placement?: string,
offset?: number[]
): any {
return {
zIndex: 100000,
placement,
offset,
popperOptions: {
modifiers: [
{
name: 'preventOverflow',
options: {
boundary,
padding: 8,
altAxis: true,
tether: false
}
}
]
},
...(appendTo !== undefined ? { appendTo } : {})
}
}
/**
* KitExtensionCreator is a tuple of an index and an ExtensionCreator.
*/
@ -221,6 +188,21 @@ async function buildEditorKit (): Promise<Extension<EditorKitOptions, any>> {
})
])
}
if (this.options.toolbar !== false) {
staticKitExtensions.push([
310,
ToolbarExtension.configure({
providers: [],
context: {
mode,
objectId: this.options.objectId,
objectClass: this.options.objectClass,
objectSpace: this.options.objectSpace
},
...this.options.toolbar
})
])
}
if (mode === 'compact') {
staticKitExtensions.push([400, ParagraphExtension.configure()])
@ -287,16 +269,6 @@ async function buildEditorKit (): Promise<Extension<EditorKitOptions, any>> {
...this.options.image
}
if (this.options.image?.toolbar !== undefined) {
imageOptions.toolbar = {
...this.options.image?.toolbar,
tippyOptions: getTippyOptions(
this.options.image?.toolbar?.boundary,
this.options.image?.toolbar?.appendTo
)
}
}
staticKitExtensions.push([800, ImageExtension.configure(imageOptions)])
}
@ -327,24 +299,6 @@ async function buildEditorKit (): Promise<Extension<EditorKitOptions, any>> {
)
])
}
if (this.options.toolbar !== false) {
staticKitExtensions.push([
900,
InlineToolbarExtension.configure({
tippyOptions: getTippyOptions(this.options.toolbar?.boundary, this.options.toolbar?.appendTo),
element: this.options.toolbar?.element,
isHidden: this.options.toolbar?.isHidden,
ctx: {
mode,
objectId: this.options.objectId,
objectClass: this.options.objectClass,
objectSpace: this.options.objectSpace
}
})
])
}
staticKitExtensions.push([950, LinkUtilsExtension.configure({})])
if (mode !== 'compact' && this.options.note !== false) {

View File

@ -188,13 +188,11 @@ export interface ActiveDescriptor {
params?: any
}
export type TextEditorActionKind = 'text' | 'image' | 'table' | 'preview'
/**
* Defines a text action for text action editor
*/
export interface TextEditorAction extends Doc {
kind?: TextEditorActionKind
tags?: string[]
action: TogglerDescriptor | Resource<TextActionFunction>
visibilityTester?: Resource<TextActionVisibleFunction>
icon: Asset

View File

@ -312,11 +312,12 @@ test.describe('Content in the Documents tests', () => {
})
test('Checking styles in a Document', async ({ page, browser, request }) => {
const content: string = [...new Array(20).keys()].map((index) => `Line ${index + 1}`).join('\n')
const content: string = [...new Array(21).keys()].map((index) => `Line ${index}`).join('\n')
const testLink: string = 'http://test/link/123456'
const testNote: string = 'Test Note'
await documentContentPage.addContentToTheNewLine(content, false)
await documentContentPage.applyToolbarCommand('Line 0', 'btnH1')
await documentContentPage.applyToolbarCommand('Line 1', 'btnH1')
await documentContentPage.applyToolbarCommand('Line 2', 'btnH2')
await documentContentPage.applyToolbarCommand('Line 3', 'btnH3')

View File

@ -229,14 +229,18 @@ export class DocumentContentPage extends CommonPage {
await this.buttonOnToolbar(id).click()
}
async selectLine (text: string): Promise<void> {
async selectLine (text: string, shallow: boolean = true): Promise<void> {
const loc: Locator = this.page.locator('p', { hasText: text }).first()
await expect(loc).toBeVisible()
await loc.click({ clickCount: 3 })
if (shallow) {
await loc.click({ clickCount: 3 })
} else {
await loc.selectText()
}
}
async applyToolbarCommand (text: string, btnId: string): Promise<void> {
await this.selectLine(text)
await this.selectLine(text, false)
await this.clickButtonOnTooltip(btnId)
}