Live-sync for reference label (#8016)

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Victor Ilyushchenko 2025-02-17 06:47:55 +03:00 committed by GitHub
parent 69384ad4a1
commit 2fe638297c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 523 additions and 493 deletions

View File

@ -13,60 +13,60 @@
// limitations under the License.
//
import documentsPlugin, {
documentsId,
type Document,
type DocumentSpace,
DocumentState
} from '@hcengineering/controlled-documents'
import activity from '@hcengineering/activity'
import contact from '@hcengineering/contact'
import documentsPlugin, {
documentsId,
DocumentState,
type Document,
type DocumentSpace
} from '@hcengineering/controlled-documents'
import { type Builder } from '@hcengineering/model'
import chunter from '@hcengineering/model-chunter'
import core from '@hcengineering/model-core'
import { generateClassNotificationTypes } from '@hcengineering/model-notification'
import presentation from '@hcengineering/model-presentation'
import print from '@hcengineering/model-print'
import request from '@hcengineering/model-request'
import tracker from '@hcengineering/model-tracker'
import { generateClassNotificationTypes } from '@hcengineering/model-notification'
import view, { classPresenter, createAction } from '@hcengineering/model-view'
import presentation from '@hcengineering/model-presentation'
import workbench from '@hcengineering/model-workbench'
import notification from '@hcengineering/notification'
import setting from '@hcengineering/setting'
import tags from '@hcengineering/tags'
import print from '@hcengineering/model-print'
import textEditor from '@hcengineering/text-editor'
import documents from './plugin'
import { type Class, type Doc, type Ref } from '@hcengineering/core'
import { type Action } from '@hcengineering/view'
import { definePermissions } from './permissions'
import documents from './plugin'
import { defineSpaceType } from './spaceType'
import {
TChangeControl,
TControlledDocument,
TControlledDocumentSnapshot,
TDocument,
TDocumentApprovalRequest,
TDocumentCategory,
TDocumentComment,
TDocumentMeta,
TDocumentRequest,
TDocumentReviewRequest,
TDocumentSnapshot,
TDocumentSpace,
TDocumentSpaceType,
TDocumentSpaceTypeDescriptor,
TExternalSpace,
TOrgSpace,
TDocumentMeta,
TProjectDocument,
TProjectMeta,
TProject,
TDocument,
TDocumentSnapshot,
TControlledDocumentSnapshot,
THierarchyDocument,
TDocumentTemplate,
TDocumentTraining,
TDocumentCategory,
TControlledDocument,
TChangeControl,
TDocumentRequest,
TDocumentReviewRequest,
TDocumentApprovalRequest,
TTypeDocumentState,
TExternalSpace,
THierarchyDocument,
TOrgSpace,
TProject,
TProjectDocument,
TProjectMeta,
TTypeControlledDocumentState,
TDocumentComment
TTypeDocumentState
} from './types'
import { defineSpaceType } from './spaceType'
import { type Class, type Doc, type Ref } from '@hcengineering/core'
import { type Action } from '@hcengineering/view'
export { documentsId } from '@hcengineering/controlled-documents/src/index'
export * from './types'
@ -105,6 +105,10 @@ export function createModel (builder: Builder): void {
titleProvider: documents.function.ControlledDocumentTitleProvider
})
builder.mixin(documents.class.DocumentMeta, core.class.Class, view.mixin.ObjectTitle, {
titleProvider: documents.function.ControlledDocumentTitleProvider
})
builder.mixin(documents.class.DocumentApprovalRequest, core.class.Class, view.mixin.ObjectPresenter, {
presenter: documents.component.DocumentApprovalRequestPresenter
})

View File

@ -21,7 +21,7 @@ describe('dsl', () => {
)
)
expect(jsonToHTML(doc)).toEqual(
'<p>Hello, <span data-type="reference" data-id="123" data-objectclass="world" data-label="World">@World</span></p><p>Check out <a target="_blank" rel="noopener noreferrer" class="cursor-pointer" href="https://example.com"><u>this link</u></a>.</p>'
'<p>Hello, <span data-type="reference" class="antiMention" data-id="123" data-objectclass="world" data-label="World">@World</span></p><p>Check out <a target="_blank" rel="noopener noreferrer" class="cursor-pointer" href="https://example.com"><u>this link</u></a>.</p>'
)
})
})

View File

@ -15,10 +15,18 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { getDataAttribute } from './utils'
import { Class, Doc, Ref } from '@hcengineering/core'
export interface ReferenceNodeProps {
id: Ref<Doc>
objectclass: Ref<Class<Doc>>
label: string
}
export interface ReferenceOptions {
renderLabel: (props: { options: ReferenceOptions, node: any }) => string
suggestion: { char: string }
renderLabel: (props: { options: ReferenceOptions, props: ReferenceNodeProps }) => string
suggestion: { char?: string }
HTMLAttributes: Record<string, any>
}
/**
@ -28,23 +36,26 @@ export const ReferenceNode = Node.create<ReferenceOptions>({
name: 'reference',
group: 'inline',
inline: true,
selectable: true,
atom: true,
draggable: true,
addAttributes () {
return {
id: getDataAttribute('id'),
objectclass: getDataAttribute('objectclass'),
label: getDataAttribute('label'),
class: { default: null }
label: getDataAttribute('label')
}
},
addOptions () {
return {
renderLabel ({ options, node }) {
renderLabel ({ options, props }) {
// eslint-disable-next-line
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
return `${options.suggestion.char}${props.label ?? props.id}`
},
suggestion: { char: '@' }
suggestion: { char: '@' },
HTMLAttributes: {}
}
},
@ -72,21 +83,25 @@ export const ReferenceNode = Node.create<ReferenceOptions>({
},
renderHTML ({ node, HTMLAttributes }) {
const options = this.options
return [
'span',
mergeAttributes(
{
'data-type': this.name
'data-type': this.name,
class: 'antiMention'
},
this.options.HTMLAttributes,
HTMLAttributes
),
this.options.renderLabel({ options, node })
this.options.renderLabel({
options: this.options,
props: node.attrs as ReferenceNodeProps
})
]
},
renderText ({ node }) {
const options = this.options
return options.renderLabel({ options, node })
return options.renderLabel({ options, props: node.attrs as ReferenceNodeProps })
}
})

View File

@ -109,6 +109,7 @@ import {
getAllDocumentStates,
getControlledDocumentTitle,
getDocumentMetaLinkFragment,
getDocumentMetaTitle,
getVisibleFilters,
isFolder,
renameFolder,
@ -457,6 +458,7 @@ export default async (): Promise<Resources> => ({
CanPrintDocument: canPrintDocument,
DocumentIdentifierProvider: documentIdentifierProvider,
ControlledDocumentTitleProvider: getControlledDocumentTitle,
DocumentMetaTitleProvider: getDocumentMetaTitle,
Comment: comment,
IsCommentVisible: isCommentVisible
},

View File

@ -246,6 +246,7 @@ export default mergeIds(documentsId, documents, {
CanOpenDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanPrintDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanTransferDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
ControlledDocumentTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>
ControlledDocumentTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>,
DocumentMetaTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>
}
})

View File

@ -727,6 +727,18 @@ export async function getControlledDocumentTitle (
return object.title
}
export async function getDocumentMetaTitle (
client: Client,
ref: Ref<DocumentMeta>,
doc?: DocumentMeta
): Promise<string> {
const object = doc ?? (await client.findOne(documents.class.DocumentMeta, { _id: ref }))
if (object === undefined) return ''
return object.title
}
export const getCurrentEmployee = (): Ref<Employee> | undefined => {
const currentAccount = getCurrentAccount()
const person = (currentAccount as PersonAccount)?.person

View File

@ -1,179 +0,0 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { getDataAttribute } from './utils'
import Suggestion, { type SuggestionOptions } from './components/extension/suggestion'
export interface CompletionOptions {
HTMLAttributes: Record<string, any>
renderLabel: (props: { options: CompletionOptions, node: any }) => string
suggestion: Omit<SuggestionOptions, 'editor'>
showDoc?: (event: MouseEvent, _id: string, _class: string) => void
}
export function clickHandler (opt: CompletionOptions): Plugin {
return new Plugin({
key: new PluginKey('completion-handleClickLink'),
props: {
handleClick: (view, pos, event) => {
if (event.button !== 0) {
return false
}
const link = (event.target as HTMLElement)?.closest('span')
if (link != null) {
const _class = link.getAttribute('data-objectclass')
const _id = link.getAttribute('data-id')
if (_id != null && _class != null) {
opt.showDoc?.(event, _id, _class)
}
}
return false
}
}
})
}
export const Completion = Node.create<CompletionOptions>({
name: 'reference',
addOptions () {
return {
HTMLAttributes: {},
renderLabel ({ options, node }) {
// eslint-disable-next-line
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
},
suggestion: {
char: '@',
allowSpaces: true,
// pluginKey: CompletionPluginKey,
command: ({ editor, range, props }) => {
// increase range.to by one when the next node is of type "text"
// and starts with a space character
const nodeAfter = editor.view.state.selection.$to.nodeAfter
const overrideSpace = nodeAfter?.text?.startsWith(' ')
if (overrideSpace !== undefined && overrideSpace) {
// eslint-disable-next-line
range.to += 1
}
if (props !== null) {
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: this.name,
attrs: props
},
{
type: 'text',
text: ' '
}
])
.run()
}
},
allow: ({ editor, range }) => {
if (range.from > editor.state.doc.content.size) return false
const $from = editor.state.doc.resolve(range.from)
const type = editor.schema.nodes[this.name]
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
return !!$from.parent.type.contentMatch.matchType(type)
}
}
}
},
group: 'inline',
inline: true,
selectable: true,
atom: true,
draggable: true,
addAttributes () {
return {
id: getDataAttribute('id'),
label: getDataAttribute('label'),
objectclass: getDataAttribute('objectclass')
}
},
parseHTML () {
return [
{
tag: `span[data-type="${this.name}"]`
}
]
},
renderHTML ({ node, HTMLAttributes }) {
return [
'span',
mergeAttributes(
{
'data-type': this.name,
class: 'antiMention'
},
this.options.HTMLAttributes,
HTMLAttributes
),
this.options.renderLabel({
options: this.options,
node
})
]
},
renderText ({ node }) {
return this.options.renderLabel({
options: this.options,
node
})
},
addKeyboardShortcuts () {
return {
Backspace: () =>
this.editor.commands.command(({ tr, state }) => {
let isMention = false
const { selection } = state
const { empty, anchor } = selection
if (!empty) {
return false
}
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
if (node.type.name === this.name) {
isMention = true
// eslint-disable-next-line
tr.insertText(this.options.suggestion.char || '', pos, pos + node.nodeSize)
return false
}
})
return isMention
})
}
},
addProseMirrorPlugins () {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion
}),
clickHandler(this.options)
]
}
})

View File

@ -66,7 +66,6 @@
TextEditorHandler
} from '@hcengineering/text-editor'
import { EditorKitOptions, getEditorKit } from '../../src/kits/editor-kit'
import { Completion } from '../Completion'
import { deleteAttachment } from '../command/deleteAttachment'
import { textEditorCommandHandler } from '../commands'
import { Provider } from '../provider/types'
@ -84,8 +83,9 @@
import { InlineCommentCollaborationExtension } from './extension/inlineComment'
import { LeftMenuExtension } from './extension/leftMenu'
import { mermaidOptions } from './extension/mermaid'
import { ReferenceExtension, referenceConfig } from './extension/reference'
import { type FileAttachFunction } from './extension/types'
import { completionConfig, inlineCommandsConfig } from './extensions'
import { inlineCommandsConfig } from './extensions'
export let object: Doc
export let attribute: KeyedAttribute
@ -495,8 +495,8 @@
render: renderCursor,
selectionRender: noSelectionRender
}),
Completion.configure({
...completionConfig,
ReferenceExtension.configure({
...referenceConfig,
showDoc (event: MouseEvent, _id: string, _class: string) {
dispatch('open-document', { event, _id, _class })
}

View File

@ -15,23 +15,13 @@
-->
<script lang="ts">
import { SearchResultDoc } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import presentation, {
SearchResult,
getClient,
reduceCalls,
searchFor,
type SearchItem
} from '@hcengineering/presentation'
import presentation, { SearchResult, reduceCalls, searchFor, type SearchItem } from '@hcengineering/presentation'
import { Label, ListView, resizeObserver } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
import { getReferenceLabel } from './extension/reference'
export let query: string = ''
const client = getClient()
const hierarchy = client.getHierarchy()
let items: SearchItem[] = []
const dispatch = createEventDispatcher()
@ -40,21 +30,11 @@
let scrollContainer: HTMLElement
let selection = 0
async function getIdentifier (item: SearchResultDoc): Promise<string | undefined> {
const identifierProvider = hierarchy.classHierarchyMixin(item.doc._class, view.mixin.ObjectIdentifier)
if (identifierProvider === undefined) {
return item.shortTitle ?? item.title
}
const resource = await getResource(identifierProvider.provider)
return await resource(client, item.id)
}
async function handleSelectItem (item: SearchResultDoc): Promise<void> {
const identifier = await getIdentifier(item)
const label = await getReferenceLabel(item.doc._class, item.doc._id)
dispatch('close', {
id: item.id,
label: identifier,
label,
objectclass: item.doc._class
})
}

View File

@ -16,28 +16,27 @@
import { Markup } from '@hcengineering/core'
import { Asset, IntlString } from '@hcengineering/platform'
import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text'
import textEditor, { RefAction, TextEditorHandler } from '@hcengineering/text-editor'
import {
AnySvelteComponent,
Button,
ButtonKind,
handler,
registerFocus,
deviceOptionsStore as deviceInfo,
checkAdaptiveMatching,
IconClose
deviceOptionsStore as deviceInfo,
handler,
IconClose,
registerFocus
} from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { FocusPosition } from '@tiptap/core'
import { EditorView } from '@tiptap/pm/view'
import textEditor, { RefAction, TextEditorHandler } from '@hcengineering/text-editor'
import { createEventDispatcher } from 'svelte'
import { Completion } from '../Completion'
import view from '@hcengineering/view'
import TextEditor from './TextEditor.svelte'
import { defaultRefActions, getModelRefActions } from './editor/actions'
import { completionConfig } from './extensions'
import { EmojiExtension } from './extension/emoji'
import { IsEmptyContentExtension } from './extension/isEmptyContent'
import view from '@hcengineering/view'
import { referenceConfig, ReferenceExtension } from './extension/reference'
import Send from './icons/Send.svelte'
export let content: Markup = EmptyMarkup
@ -141,8 +140,8 @@
focusManager?.setFocus(idx)
}
}
const completionPlugin = Completion.configure({
...completionConfig,
const completionPlugin = ReferenceExtension.configure({
...referenceConfig,
showDoc (event: MouseEvent, _id: string, _class: string) {
dispatch('open-document', { event, _id, _class })
}

View File

@ -2,14 +2,13 @@
import { Markup } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { EmptyMarkup } from '@hcengineering/text'
import textEditor from '@hcengineering/text-editor'
import { ButtonSize, Label } from '@hcengineering/ui'
import { AnyExtension } from '@tiptap/core'
import { createEventDispatcher } from 'svelte'
import textEditor from '@hcengineering/text-editor'
import { referenceConfig, ReferenceExtension } from './extension/reference'
import StyledTextEditor from './StyledTextEditor.svelte'
import { Completion } from '../Completion'
import { completionConfig } from './extensions'
export let label: IntlString | undefined = undefined
export let content: Markup | undefined
@ -53,8 +52,8 @@
}
function configureExtensions (): AnyExtension[] {
const completionPlugin = Completion.configure({
...completionConfig,
const completionPlugin = ReferenceExtension.configure({
...referenceConfig,
showDoc (event: MouseEvent, _id: string, _class: string) {
dispatch('open-document', { event, _id, _class })
}

View File

@ -21,7 +21,6 @@
import type { AnyExtension } from '@tiptap/core'
import { createEventDispatcher } from 'svelte'
import { Completion } from '../Completion'
import StyledTextEditor from './StyledTextEditor.svelte'
import { addTableHandler } from '../utils'
@ -29,8 +28,9 @@
import { FocusExtension } from './extension/focus'
import { ImageUploadExtension } from './extension/imageUploadExt'
import { InlineCommandsExtension } from './extension/inlineCommands'
import { ReferenceExtension, referenceConfig } from './extension/reference'
import { type FileAttachFunction } from './extension/types'
import { completionConfig, InlineCommandId, inlineCommandsConfig } from './extensions'
import { InlineCommandId, inlineCommandsConfig } from './extensions'
export let label: IntlString | undefined = undefined
export let content: Markup
@ -179,8 +179,8 @@
getFileUrl
})
const completionPlugin = Completion.configure({
...completionConfig,
const completionPlugin = ReferenceExtension.configure({
...referenceConfig,
showDoc (event: MouseEvent, _id: string, _class: string) {
dispatch('open-document', { event, _id, _class })
}

View File

@ -13,14 +13,8 @@
// limitations under the License.
//
import { type Class, type Doc, type Ref } from '@hcengineering/core'
import { getMetadata, getResource } from '@hcengineering/platform'
import presentation, { getClient } from '@hcengineering/presentation'
import { parseLocation, showPopup } from '@hcengineering/ui'
import view from '@hcengineering/view'
import workbench, { type Application } from '@hcengineering/workbench'
import { type Editor, Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { showPopup } from '@hcengineering/ui'
import { Extension } from '@tiptap/core'
import LinkPopup from '../LinkPopup.svelte'
export const LinkUtilsExtension = Extension.create<any>({
@ -48,153 +42,6 @@ export const LinkUtilsExtension = Extension.create<any>({
},
addProseMirrorPlugins () {
return [ResolveReferenceUrlsPlugin(this.editor)]
return []
}
})
export interface LinkToReferencePluginState {
references: Map<string, { id: Ref<Doc>, objectclass: Ref<Class<Doc>>, label: string }>
queue: Set<string>
}
const resolveReferencePluginKey = new PluginKey<LinkToReferencePluginState>('linkToReference')
interface ReferenceProps {
id: Ref<Doc>
objectclass: Ref<Class<Doc>>
label: string
}
export function ResolveReferenceUrlsPlugin (editor: Editor): Plugin<LinkToReferencePluginState> {
return new Plugin<LinkToReferencePluginState>({
key: resolveReferencePluginKey,
appendTransaction: (transactions, oldState, newState) => {
if (transactions[0]?.getMeta('linkToReference') === undefined) return undefined
if (editor.schema.nodes.reference === undefined) return
const references = resolveReferencePluginKey.getState(newState)?.references ?? new Map()
const { tr } = newState
tr.doc.descendants((node, pos) => {
if (!node.isText || !node.marks.some((m) => m.type.name === 'link')) return
const url = node.textContent
const mapping = references.get(url)
if (mapping === undefined) return
const replacementNode = editor.schema.nodes.reference.create(mapping)
const mpos = tr.mapping.map(pos)
tr.replaceWith(mpos, mpos + node.nodeSize, replacementNode)
})
if (tr.steps.length > 0) return tr
},
state: {
init () {
return {
references: new Map(),
queue: new Set()
}
},
apply (tr, prev, oldState, newState) {
if (tr.getMeta('linkToReference') !== undefined) {
const references = tr.getMeta('linkToReference').references as LinkToReferencePluginState['references']
const urls = new Set(references.keys())
return {
queue: new Set(Array.from(prev.queue).filter((url) => !urls.has(url))),
references: new Map([...prev.references, ...references])
}
}
if (!tr.docChanged || oldState.doc.eq(newState.doc)) return prev
const urls: string[] = []
tr.doc.descendants((node) => {
if (!node.isText || !node.marks.some((m) => m.type.name === 'link')) return
const url = node.textContent
const hasNoMapping = prev.references.has(url) && prev.references.get(url) === undefined
if (prev.queue.has(url) || hasNoMapping) return
urls.push(url)
})
const promises = urls.map(async (url) => {
try {
return [url, await getReferenceFromUrl(url)] as const
} catch {
return [url, undefined] as const
}
})
if (promises.length > 0) {
void Promise.all(promises).then((references) => {
editor.view.dispatch(editor.state.tr.setMeta('linkToReference', { references: new Map(references) }))
})
}
return {
references: prev.references,
queue: new Set([...prev.queue, ...urls])
}
}
}
})
}
async function getReferenceFromUrl (text: string): Promise<ReferenceProps | undefined> {
const client = getClient()
const hierarchy = client.getHierarchy()
const url = new URL(text)
const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin
if (url.origin !== frontUrl) return
const location = parseLocation(url)
const appAlias = (location.path[2] ?? '').trim()
if (!(appAlias.length > 0)) return
const excludedApps = getMetadata(workbench.metadata.ExcludedApplications) ?? []
const apps: Application[] = client
.getModel()
.findAllSync<Application>(workbench.class.Application, { hidden: false, _id: { $nin: excludedApps } })
const app = apps.find((p) => p.alias === appAlias)
if (app?.locationResolver === undefined) return
const locationResolverFn = await getResource(app.locationResolver)
const resolvedLocation = await locationResolverFn(location)
const locationParts = decodeURIComponent(resolvedLocation?.loc?.fragment ?? '').split('|')
const id = locationParts[1] as Ref<Doc>
const objectclass = locationParts[2] as Ref<Class<Doc>>
if (id === undefined || objectclass === undefined) return
const linkProviders = client.getModel().findAllSync(view.mixin.LinkIdProvider, {})
const linkProvider = linkProviders.find(({ _id }) => hierarchy.isDerived(objectclass, _id))
const _id: Ref<Doc> | undefined =
linkProvider !== undefined ? (await (await getResource(linkProvider.decode))(id)) ?? id : id
let label = ''
const labelProvider = hierarchy.classHierarchyMixin(objectclass, view.mixin.ObjectIdentifier)
if (labelProvider !== undefined) {
const resource = await getResource(labelProvider.provider)
label = await resource(client, _id)
} else {
const titleMixin = hierarchy.classHierarchyMixin(objectclass, view.mixin.ObjectTitle)
if (titleMixin === undefined) return
const titleProviderFn = await getResource(titleMixin.titleProvider)
label = await titleProviderFn(client, _id)
}
return {
id: _id,
objectclass,
label
}
}

View File

@ -0,0 +1,374 @@
import { mergeAttributes, type Editor } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import MentionList from '../MentionList.svelte'
import { SvelteRenderer } from '../node-view'
import { ReferenceNode, type ReferenceNodeProps, type ReferenceOptions } from '@hcengineering/text'
import Suggestion, { type SuggestionKeyDownProps, type SuggestionOptions, type SuggestionProps } from './suggestion'
import { type Class, type Doc, type Ref } from '@hcengineering/core'
import { getMetadata, getResource } from '@hcengineering/platform'
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
import { parseLocation } from '@hcengineering/ui'
import view from '@hcengineering/view'
import workbench, { type Application } from '@hcengineering/workbench'
export interface ReferenceExtensionOptions extends ReferenceOptions {
suggestion: Omit<SuggestionOptions, 'editor'>
showDoc?: (event: MouseEvent, _id: string, _class: string) => void
}
export const ReferenceExtension = ReferenceNode.extend<ReferenceExtensionOptions>({
addOptions () {
return {
HTMLAttributes: {},
renderLabel ({ options, props }) {
// eslint-disable-next-line
return `${options.suggestion.char}${props.label ?? props.id}`
},
suggestion: {
char: '@',
allowSpaces: true,
command: ({ editor, range, props }) => {
// increase range.to by one when the next node is of type "text"
// and starts with a space character
const nodeAfter = editor.view.state.selection.$to.nodeAfter
const overrideSpace = nodeAfter?.text?.startsWith(' ')
if (overrideSpace !== undefined && overrideSpace) {
// eslint-disable-next-line
range.to += 1
}
if (props !== null) {
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: this.name,
attrs: props
},
{
type: 'text',
text: ' '
}
])
.run()
}
},
allow: ({ editor, range }) => {
if (range.from > editor.state.doc.content.size) return false
const $from = editor.state.doc.resolve(range.from)
const type = editor.schema.nodes[this.name]
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
return !!$from.parent.type.contentMatch.matchType(type)
}
}
}
},
addNodeView () {
return ({ node, HTMLAttributes }) => {
const span = document.createElement('span')
span.setAttribute('data-type', this.name)
span.className = 'antimention'
const attributes = mergeAttributes(
{
'data-type': this.name,
class: 'antiMention'
},
this.options.HTMLAttributes,
HTMLAttributes
)
span.addEventListener('click', (event) => {
if (event.button !== 0) return
const link = (event.target as HTMLElement)?.closest('span')
if (link != null) {
const _class = link.getAttribute('data-objectclass')
const _id = link.getAttribute('data-id')
if (_id != null && _class != null) {
this.options.showDoc?.(event, _id, _class)
}
}
})
Object.entries(attributes).forEach(([key, value]) => {
span.setAttribute(key, value)
})
const query = createQuery(true)
const options = this.options
const renderLabel = (props: ReferenceNodeProps): void => {
span.setAttribute('data-label', props.label)
span.innerText = options.renderLabel({ options, props: props ?? node.attrs })
}
const id = node.attrs.id
const objectclass: Ref<Class<Doc>> = node.attrs.objectclass
renderLabel({ id, objectclass, label: node.attrs.label })
if (id !== undefined && objectclass !== undefined) {
query.query(objectclass, { _id: id }, async (result) => {
const obj = result[0]
if (obj === undefined) return
const label = await getReferenceLabel(objectclass, id, obj)
if (label === '') return
renderLabel({ id, objectclass, label })
})
}
return {
dom: span,
update (node, decorations) {
renderLabel({ id, objectclass, label: node.attrs.label })
return true
},
destroy () {
query.unsubscribe()
}
}
}
},
addKeyboardShortcuts () {
return {
Backspace: () =>
this.editor.commands.command(({ tr, state }) => {
let isMention = false
const { selection } = state
const { empty, anchor } = selection
if (!empty) {
return false
}
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
if (node.type.name === this.name) {
isMention = true
// eslint-disable-next-line
tr.insertText(this.options.suggestion.char || '', pos, pos + node.nodeSize)
return false
}
})
return isMention
})
}
},
addProseMirrorPlugins () {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion
}),
// ReferenceClickHandler(this.options),
ResolveReferenceUrlsPlugin(this.editor)
]
}
})
/**
* @public
*/
export const referenceConfig: Partial<ReferenceExtensionOptions> = {
HTMLAttributes: {
class: 'reference'
},
suggestion: {
items: async () => {
return []
},
render: () => {
let component: any
return {
onStart: (props: SuggestionProps) => {
component = new SvelteRenderer(MentionList, {
element: document.body,
props: {
...props,
close: () => {
component?.destroy()
}
}
})
},
onUpdate (props: SuggestionProps) {
component?.updateProps(props)
},
onKeyDown (props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') {
props.event.stopPropagation()
}
return component?.onKeyDown(props)
},
onExit () {
component?.destroy()
}
}
}
}
}
export interface ResolveReferenceUrlsPluginState {
references: Map<string, { id: Ref<Doc>, objectclass: Ref<Class<Doc>>, label: string }>
queue: Set<string>
}
const resolveReferencePluginKey = new PluginKey<ResolveReferenceUrlsPluginState>('linkToReference')
export function ResolveReferenceUrlsPlugin (editor: Editor): Plugin<ResolveReferenceUrlsPluginState> {
return new Plugin<ResolveReferenceUrlsPluginState>({
key: resolveReferencePluginKey,
appendTransaction: (transactions, oldState, newState) => {
if (transactions[0]?.getMeta('linkToReference') === undefined) return undefined
if (editor.schema.nodes.reference === undefined) return
const references = resolveReferencePluginKey.getState(newState)?.references ?? new Map()
const { tr } = newState
tr.doc.descendants((node, pos) => {
if (!node.isText || !node.marks.some((m) => m.type.name === 'link')) return
const url = node.textContent
const mapping = references.get(url)
if (mapping === undefined) return
const replacementNode = editor.schema.nodes.reference.create(mapping)
const mpos = tr.mapping.map(pos)
tr.replaceWith(mpos, mpos + node.nodeSize, replacementNode)
})
if (tr.steps.length > 0) return tr
},
state: {
init () {
return {
references: new Map(),
queue: new Set()
}
},
apply (tr, prev, oldState, newState) {
if (tr.getMeta('linkToReference') !== undefined) {
const references = tr.getMeta('linkToReference').references as ResolveReferenceUrlsPluginState['references']
const urls = new Set(references.keys())
return {
queue: new Set(Array.from(prev.queue).filter((url) => !urls.has(url))),
references: new Map([...prev.references, ...references])
}
}
if (!tr.docChanged || oldState.doc.eq(newState.doc)) return prev
const urls: string[] = []
tr.doc.descendants((node) => {
if (!node.isText || !node.marks.some((m) => m.type.name === 'link')) return
const url = node.textContent
const hasNoMapping = prev.references.has(url) && prev.references.get(url) === undefined
if (prev.queue.has(url) || hasNoMapping) return
urls.push(url)
})
const promises = urls.map(async (url) => {
try {
return [url, await getReferenceFromUrl(url)] as const
} catch {
return [url, undefined] as const
}
})
if (promises.length > 0) {
void Promise.all(promises).then((references) => {
editor.view.dispatch(editor.state.tr.setMeta('linkToReference', { references: new Map(references) }))
})
}
return {
references: prev.references,
queue: new Set([...prev.queue, ...urls])
}
}
}
})
}
export async function getReferenceLabel<T extends Doc> (
objectclass: Ref<Class<T>>,
id: Ref<T>,
doc?: T
): Promise<string> {
const client = getClient()
const hierarchy = client.getHierarchy()
const labelProvider = hierarchy.classHierarchyMixin(objectclass as Ref<Class<Doc>>, view.mixin.ObjectIdentifier)
const labelProviderFn = labelProvider !== undefined ? await getResource(labelProvider.provider) : undefined
const titleMixin = hierarchy.classHierarchyMixin(objectclass as Ref<Class<Doc>>, view.mixin.ObjectTitle)
const titleProviderFn = titleMixin !== undefined ? await getResource(titleMixin.titleProvider) : undefined
const identifier = (await labelProviderFn?.(client, id, doc)) ?? ''
const title = (await titleProviderFn?.(client, id, doc)) ?? ''
const label = identifier !== '' && title !== '' && identifier !== title ? `${identifier} ${title}` : title ?? ''
return label
}
export async function getReferenceFromUrl (text: string): Promise<ReferenceNodeProps | undefined> {
const client = getClient()
const hierarchy = client.getHierarchy()
const url = new URL(text)
const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin
if (url.origin !== frontUrl) return
const location = parseLocation(url)
const appAlias = (location.path[2] ?? '').trim()
if (!(appAlias.length > 0)) return
const excludedApps = getMetadata(workbench.metadata.ExcludedApplications) ?? []
const apps: Application[] = client
.getModel()
.findAllSync<Application>(workbench.class.Application, { hidden: false, _id: { $nin: excludedApps } })
const app = apps.find((p) => p.alias === appAlias)
if (app?.locationResolver === undefined) return
const locationResolverFn = await getResource(app.locationResolver)
const resolvedLocation = await locationResolverFn(location)
const locationParts = decodeURIComponent(resolvedLocation?.loc?.fragment ?? '').split('|')
const id = locationParts[1] as Ref<Doc>
const objectclass = locationParts[2] as Ref<Class<Doc>>
if (id === undefined || objectclass === undefined) return
const linkProviders = client.getModel().findAllSync(view.mixin.LinkIdProvider, {})
const linkProvider = linkProviders.find(({ _id }) => hierarchy.isDerived(objectclass, _id))
const _id: Ref<Doc> | undefined =
linkProvider !== undefined ? (await (await getResource(linkProvider.decode))(id)) ?? id : id
const label = await getReferenceLabel(objectclass, id)
if (label === '') return
return {
id: _id,
objectclass,
label
}
}

View File

@ -13,16 +13,15 @@
// limitations under the License.
//
import view from '@hcengineering/view'
import { type Editor, type Range } from '@tiptap/core'
import textEditor from '@hcengineering/text-editor'
import { IconScribble } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { type Editor, type Range } from '@tiptap/core'
import { type CompletionOptions } from '../Completion'
import MentionList from './MentionList.svelte'
import { SvelteRenderer } from './node-view'
import { type InlineCommandsOptions } from './extension/inlineCommands'
import type { SuggestionKeyDownProps, SuggestionProps } from './extension/suggestion'
import InlineCommandsList from './InlineCommandsList.svelte'
import { SvelteRenderer } from './node-view'
export const mInsertTable = [
{
@ -87,49 +86,6 @@ export const mInsertTable = [
}
]
/**
* @public
*/
export const completionConfig: Partial<CompletionOptions> = {
HTMLAttributes: {
class: 'reference'
},
suggestion: {
items: async () => {
return []
},
render: () => {
let component: any
return {
onStart: (props: SuggestionProps) => {
component = new SvelteRenderer(MentionList, {
element: document.body,
props: {
...props,
close: () => {
component?.destroy()
}
}
})
},
onUpdate (props: SuggestionProps) {
component?.updateProps(props)
},
onKeyDown (props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') {
props.event.stopPropagation()
}
return component?.onKeyDown(props)
},
onExit () {
component?.destroy()
}
}
}
}
}
const inlineCommandsIds = [
'image',
'table',
@ -147,7 +103,7 @@ export type InlineCommandId = (typeof inlineCommandsIds)[number]
export function inlineCommandsConfig (
handleSelect: (id: string, pos: number, targetItem?: MouseEvent | HTMLElement) => Promise<void>,
excludedCommands: InlineCommandId[] = []
): Partial<CompletionOptions> {
): Partial<InlineCommandsOptions> {
return {
suggestion: {
items: () => {

View File

@ -19,7 +19,7 @@
import { AnyComponent, LabelAndProps, themeStore, tooltip } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { getDocIdentifier } from '../utils'
import { getReferenceLabel } from '@hcengineering/text-editor-resources/src/components/extension/reference'
import DocNavLink from './DocNavLink.svelte'
export let _id: Ref<Doc> | undefined = undefined
@ -91,7 +91,7 @@
}
async function updateDocTitle (doc: Doc | undefined): Promise<void> {
docTitle = doc ? await getDocIdentifier(client, doc._id, doc._class, doc) : undefined
docTitle = doc ? await getReferenceLabel(doc._class, doc._id, doc) : undefined
}
async function updateDocTooltip (doc?: Doc): Promise<void> {

View File

@ -194,7 +194,7 @@ test.describe('Documents tests', () => {
await documentContentPage.checkLinkInTheText(contentLink, 'http://test/link/123456')
})
test('Check Link to Reference conversion', async () => {
test('Check Link to Reference conversion and sync', async () => {
const sourceDocument: NewDocument = {
title: `Reference Document Title-${generateId()}`,
space: 'Default'
@ -204,23 +204,43 @@ test.describe('Documents tests', () => {
space: 'Default'
}
await leftSideMenuPage.clickDocuments()
await test.step('Create two documents and reference one from the other', async () => {
await leftSideMenuPage.clickDocuments()
await documentsPage.clickOnButtonCreateDocument()
await documentsPage.createDocument(targetDocument)
await documentsPage.openDocument(targetDocument.title)
await documentContentPage.checkDocumentTitle(targetDocument.title)
const targetDocumentUrl = documentsPage.page.url()
await documentsPage.clickOnButtonCreateDocument()
await documentsPage.createDocument(targetDocument)
await documentsPage.openDocument(targetDocument.title)
await documentContentPage.checkDocumentTitle(targetDocument.title)
const targetDocumentUrl = documentsPage.page.url()
await documentsPage.clickOnButtonCreateDocument()
await documentsPage.createDocument(sourceDocument)
await documentsPage.openDocument(sourceDocument.title)
await documentContentPage.checkDocumentTitle(sourceDocument.title)
await documentsPage.clickOnButtonCreateDocument()
await documentsPage.createDocument(sourceDocument)
await documentsPage.openDocument(sourceDocument.title)
await documentContentPage.checkDocumentTitle(sourceDocument.title)
await documentContentPage.addRandomLines(5)
await documentContentPage.addContentToTheNewLine(targetDocumentUrl)
await documentContentPage.addRandomLines(5)
await documentContentPage.checkReferenceInTheText(targetDocument.title)
await documentContentPage.addRandomLines(5)
await documentContentPage.addContentToTheNewLine(targetDocumentUrl)
await documentContentPage.addRandomLines(5)
await documentContentPage.checkReferenceInTheText(targetDocument.title)
await leftSideMenuPage.clickDocuments()
})
await test.step('Rename the document and check sync', async () => {
await leftSideMenuPage.clickDocuments()
const targetDocumentSecondTitle = `Updated Reference Document Title-${generateId()}`
await documentsPage.openDocument(targetDocument.title)
await documentContentPage.checkDocumentTitle(targetDocument.title)
await documentContentPage.updateDocumentTitle(targetDocumentSecondTitle)
await documentContentPage.checkDocumentTitle(targetDocumentSecondTitle)
await documentsPage.openDocument(sourceDocument.title)
await documentContentPage.checkDocumentTitle(sourceDocument.title)
await documentContentPage.checkReferenceInTheText(targetDocumentSecondTitle)
})
})
test('Locked document and checking URL', async ({ page, context }) => {