mirror of
https://github.com/hcengineering/platform.git
synced 2025-06-09 09:20:54 +00:00
Generalized implementation of the text editor toolbar
Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
parent
b6e8fdd409
commit
fd0b9157bd
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
boundary: boundary ?? element,
|
||||
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">
|
||||
|
@ -64,6 +64,7 @@
|
||||
if (blockMouseEvents) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
editor.view.focus()
|
||||
}
|
||||
|
||||
const handler = action.action
|
||||
|
@ -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,11 @@
|
||||
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
|
||||
}
|
||||
toolbar: { boundary }
|
||||
}),
|
||||
Placeholder.configure({ placeholder: placeHolderStr }),
|
||||
...extensions
|
||||
@ -235,10 +222,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} />
|
||||
|
@ -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>
|
@ -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>
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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,161 @@ 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 })
|
||||
|
||||
void imgElement.decode().finally(() => {
|
||||
setLoadingState(view, container, false)
|
||||
})
|
||||
})
|
||||
}
|
||||
} 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 +315,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 +340,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')
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
@ -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
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
@ -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,
|
||||
|
@ -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: 0.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: 0.5rem 0;
|
||||
|
||||
.table-button {
|
||||
width: 1.25rem;
|
||||
@ -186,7 +204,7 @@
|
||||
}
|
||||
|
||||
&__row {
|
||||
bottom: -0.25rem;
|
||||
bottom: -1rem;
|
||||
left: var(--table-offscreen-spacing);
|
||||
right: var(--table-offscreen-spacing);
|
||||
|
||||
@ -196,11 +214,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-toolbar-container {
|
||||
position: absolute;
|
||||
top: -1.5rem;
|
||||
right: var(--table-offscreen-spacing);
|
||||
z-index: 200;
|
||||
}
|
||||
</style>
|
||||
|
@ -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>
|
@ -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, 0]
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -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,35 @@
|
||||
//
|
||||
-->
|
||||
<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
|
||||
|
||||
const actionsQuery = createQuery()
|
||||
const actionCtx: ActionContext = {
|
||||
$: actionCtx = {
|
||||
mode: 'full',
|
||||
tag: 'embed-toolbar'
|
||||
}
|
||||
tag: cursor?.tag ?? 'none'
|
||||
} satisfies ActionContext
|
||||
|
||||
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 +58,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 +75,24 @@
|
||||
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
|
||||
$: console.log(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 +106,7 @@
|
||||
size="small"
|
||||
{actionCtx}
|
||||
listenCursorUpdate
|
||||
blockMouseEvents={false}
|
||||
blockMouseEvents={true}
|
||||
tooltipOptions={{ direction: 'top' }}
|
||||
/>
|
||||
{/each}
|
||||
@ -147,13 +131,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>
|
@ -0,0 +1,696 @@
|
||||
//
|
||||
// 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'
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 tippyAppendTo = options.popupContainer ?? document.body
|
||||
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: tippyAppendTo,
|
||||
zIndex: 10000,
|
||||
offset: viewOptions?.offset,
|
||||
popperOptions: {
|
||||
modifiers: [
|
||||
{
|
||||
name: 'preventOverflow',
|
||||
options: {
|
||||
boundary: options.boundary ?? view.dom,
|
||||
padding: 16
|
||||
}
|
||||
},
|
||||
{
|
||||
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 }
|
||||
})
|
||||
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')
|
||||
view.dispatch(view.state.tr.setMeta('loadingState', loading))
|
||||
} 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'
|
||||
}
|
||||
}
|
||||
|
||||
return cursor
|
||||
}
|
||||
}
|
||||
|
||||
export function eqCursors (c1: ToolbarCursor<any> | null, c2: ToolbarCursor<any> | null): boolean {
|
||||
return deepEqual(c1, c2)
|
||||
}
|
@ -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,
|
||||
|
@ -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,15 @@ async function buildEditorKit (): Promise<Extension<EditorKitOptions, any>> {
|
||||
})
|
||||
])
|
||||
}
|
||||
if (this.options.toolbar !== false) {
|
||||
staticKitExtensions.push([
|
||||
310,
|
||||
ToolbarExtension.configure({
|
||||
providers: [],
|
||||
...this.options.toolbar
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
if (mode === 'compact') {
|
||||
staticKitExtensions.push([400, ParagraphExtension.configure()])
|
||||
@ -287,16 +263,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 +293,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) {
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user