mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-03 22:05:06 +00:00
Live-sync for reference label (#8016)
Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
parent
69384ad4a1
commit
2fe638297c
@ -13,60 +13,60 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
import documentsPlugin, {
|
|
||||||
documentsId,
|
|
||||||
type Document,
|
|
||||||
type DocumentSpace,
|
|
||||||
DocumentState
|
|
||||||
} from '@hcengineering/controlled-documents'
|
|
||||||
import activity from '@hcengineering/activity'
|
import activity from '@hcengineering/activity'
|
||||||
import contact from '@hcengineering/contact'
|
import contact from '@hcengineering/contact'
|
||||||
|
import documentsPlugin, {
|
||||||
|
documentsId,
|
||||||
|
DocumentState,
|
||||||
|
type Document,
|
||||||
|
type DocumentSpace
|
||||||
|
} from '@hcengineering/controlled-documents'
|
||||||
import { type Builder } from '@hcengineering/model'
|
import { type Builder } from '@hcengineering/model'
|
||||||
import chunter from '@hcengineering/model-chunter'
|
import chunter from '@hcengineering/model-chunter'
|
||||||
import core from '@hcengineering/model-core'
|
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 request from '@hcengineering/model-request'
|
||||||
import tracker from '@hcengineering/model-tracker'
|
import tracker from '@hcengineering/model-tracker'
|
||||||
import { generateClassNotificationTypes } from '@hcengineering/model-notification'
|
|
||||||
import view, { classPresenter, createAction } from '@hcengineering/model-view'
|
import view, { classPresenter, createAction } from '@hcengineering/model-view'
|
||||||
import presentation from '@hcengineering/model-presentation'
|
|
||||||
import workbench from '@hcengineering/model-workbench'
|
import workbench from '@hcengineering/model-workbench'
|
||||||
import notification from '@hcengineering/notification'
|
import notification from '@hcengineering/notification'
|
||||||
import setting from '@hcengineering/setting'
|
import setting from '@hcengineering/setting'
|
||||||
import tags from '@hcengineering/tags'
|
import tags from '@hcengineering/tags'
|
||||||
import print from '@hcengineering/model-print'
|
|
||||||
import textEditor from '@hcengineering/text-editor'
|
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 { definePermissions } from './permissions'
|
||||||
|
import documents from './plugin'
|
||||||
|
import { defineSpaceType } from './spaceType'
|
||||||
import {
|
import {
|
||||||
|
TChangeControl,
|
||||||
|
TControlledDocument,
|
||||||
|
TControlledDocumentSnapshot,
|
||||||
|
TDocument,
|
||||||
|
TDocumentApprovalRequest,
|
||||||
|
TDocumentCategory,
|
||||||
|
TDocumentComment,
|
||||||
|
TDocumentMeta,
|
||||||
|
TDocumentRequest,
|
||||||
|
TDocumentReviewRequest,
|
||||||
|
TDocumentSnapshot,
|
||||||
TDocumentSpace,
|
TDocumentSpace,
|
||||||
TDocumentSpaceType,
|
TDocumentSpaceType,
|
||||||
TDocumentSpaceTypeDescriptor,
|
TDocumentSpaceTypeDescriptor,
|
||||||
TExternalSpace,
|
|
||||||
TOrgSpace,
|
|
||||||
TDocumentMeta,
|
|
||||||
TProjectDocument,
|
|
||||||
TProjectMeta,
|
|
||||||
TProject,
|
|
||||||
TDocument,
|
|
||||||
TDocumentSnapshot,
|
|
||||||
TControlledDocumentSnapshot,
|
|
||||||
THierarchyDocument,
|
|
||||||
TDocumentTemplate,
|
TDocumentTemplate,
|
||||||
TDocumentTraining,
|
TDocumentTraining,
|
||||||
TDocumentCategory,
|
TExternalSpace,
|
||||||
TControlledDocument,
|
THierarchyDocument,
|
||||||
TChangeControl,
|
TOrgSpace,
|
||||||
TDocumentRequest,
|
TProject,
|
||||||
TDocumentReviewRequest,
|
TProjectDocument,
|
||||||
TDocumentApprovalRequest,
|
TProjectMeta,
|
||||||
TTypeDocumentState,
|
|
||||||
TTypeControlledDocumentState,
|
TTypeControlledDocumentState,
|
||||||
TDocumentComment
|
TTypeDocumentState
|
||||||
} from './types'
|
} 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 { documentsId } from '@hcengineering/controlled-documents/src/index'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
@ -105,6 +105,10 @@ export function createModel (builder: Builder): void {
|
|||||||
titleProvider: documents.function.ControlledDocumentTitleProvider
|
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, {
|
builder.mixin(documents.class.DocumentApprovalRequest, core.class.Class, view.mixin.ObjectPresenter, {
|
||||||
presenter: documents.component.DocumentApprovalRequestPresenter
|
presenter: documents.component.DocumentApprovalRequestPresenter
|
||||||
})
|
})
|
||||||
|
@ -21,7 +21,7 @@ describe('dsl', () => {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
expect(jsonToHTML(doc)).toEqual(
|
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>'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -15,10 +15,18 @@
|
|||||||
|
|
||||||
import { Node, mergeAttributes } from '@tiptap/core'
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
import { getDataAttribute } from './utils'
|
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 {
|
export interface ReferenceOptions {
|
||||||
renderLabel: (props: { options: ReferenceOptions, node: any }) => string
|
renderLabel: (props: { options: ReferenceOptions, props: ReferenceNodeProps }) => string
|
||||||
suggestion: { char: string }
|
suggestion: { char?: string }
|
||||||
|
HTMLAttributes: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -28,23 +36,26 @@ export const ReferenceNode = Node.create<ReferenceOptions>({
|
|||||||
name: 'reference',
|
name: 'reference',
|
||||||
group: 'inline',
|
group: 'inline',
|
||||||
inline: true,
|
inline: true,
|
||||||
|
selectable: true,
|
||||||
|
atom: true,
|
||||||
|
draggable: true,
|
||||||
|
|
||||||
addAttributes () {
|
addAttributes () {
|
||||||
return {
|
return {
|
||||||
id: getDataAttribute('id'),
|
id: getDataAttribute('id'),
|
||||||
objectclass: getDataAttribute('objectclass'),
|
objectclass: getDataAttribute('objectclass'),
|
||||||
label: getDataAttribute('label'),
|
label: getDataAttribute('label')
|
||||||
class: { default: null }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addOptions () {
|
addOptions () {
|
||||||
return {
|
return {
|
||||||
renderLabel ({ options, node }) {
|
renderLabel ({ options, props }) {
|
||||||
// eslint-disable-next-line
|
// 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 }) {
|
renderHTML ({ node, HTMLAttributes }) {
|
||||||
const options = this.options
|
|
||||||
return [
|
return [
|
||||||
'span',
|
'span',
|
||||||
mergeAttributes(
|
mergeAttributes(
|
||||||
{
|
{
|
||||||
'data-type': this.name
|
'data-type': this.name,
|
||||||
|
class: 'antiMention'
|
||||||
},
|
},
|
||||||
|
this.options.HTMLAttributes,
|
||||||
HTMLAttributes
|
HTMLAttributes
|
||||||
),
|
),
|
||||||
this.options.renderLabel({ options, node })
|
this.options.renderLabel({
|
||||||
|
options: this.options,
|
||||||
|
props: node.attrs as ReferenceNodeProps
|
||||||
|
})
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
renderText ({ node }) {
|
renderText ({ node }) {
|
||||||
const options = this.options
|
const options = this.options
|
||||||
return options.renderLabel({ options, node })
|
return options.renderLabel({ options, props: node.attrs as ReferenceNodeProps })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -109,6 +109,7 @@ import {
|
|||||||
getAllDocumentStates,
|
getAllDocumentStates,
|
||||||
getControlledDocumentTitle,
|
getControlledDocumentTitle,
|
||||||
getDocumentMetaLinkFragment,
|
getDocumentMetaLinkFragment,
|
||||||
|
getDocumentMetaTitle,
|
||||||
getVisibleFilters,
|
getVisibleFilters,
|
||||||
isFolder,
|
isFolder,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
@ -457,6 +458,7 @@ export default async (): Promise<Resources> => ({
|
|||||||
CanPrintDocument: canPrintDocument,
|
CanPrintDocument: canPrintDocument,
|
||||||
DocumentIdentifierProvider: documentIdentifierProvider,
|
DocumentIdentifierProvider: documentIdentifierProvider,
|
||||||
ControlledDocumentTitleProvider: getControlledDocumentTitle,
|
ControlledDocumentTitleProvider: getControlledDocumentTitle,
|
||||||
|
DocumentMetaTitleProvider: getDocumentMetaTitle,
|
||||||
Comment: comment,
|
Comment: comment,
|
||||||
IsCommentVisible: isCommentVisible
|
IsCommentVisible: isCommentVisible
|
||||||
},
|
},
|
||||||
|
@ -246,6 +246,7 @@ export default mergeIds(documentsId, documents, {
|
|||||||
CanOpenDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
|
CanOpenDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
|
||||||
CanPrintDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
|
CanPrintDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
|
||||||
CanTransferDocument: '' 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>>
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -727,6 +727,18 @@ export async function getControlledDocumentTitle (
|
|||||||
return object.title
|
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 => {
|
export const getCurrentEmployee = (): Ref<Employee> | undefined => {
|
||||||
const currentAccount = getCurrentAccount()
|
const currentAccount = getCurrentAccount()
|
||||||
const person = (currentAccount as PersonAccount)?.person
|
const person = (currentAccount as PersonAccount)?.person
|
||||||
|
@ -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)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
@ -66,7 +66,6 @@
|
|||||||
TextEditorHandler
|
TextEditorHandler
|
||||||
} from '@hcengineering/text-editor'
|
} from '@hcengineering/text-editor'
|
||||||
import { EditorKitOptions, getEditorKit } from '../../src/kits/editor-kit'
|
import { EditorKitOptions, getEditorKit } from '../../src/kits/editor-kit'
|
||||||
import { Completion } from '../Completion'
|
|
||||||
import { deleteAttachment } from '../command/deleteAttachment'
|
import { deleteAttachment } from '../command/deleteAttachment'
|
||||||
import { textEditorCommandHandler } from '../commands'
|
import { textEditorCommandHandler } from '../commands'
|
||||||
import { Provider } from '../provider/types'
|
import { Provider } from '../provider/types'
|
||||||
@ -84,8 +83,9 @@
|
|||||||
import { InlineCommentCollaborationExtension } from './extension/inlineComment'
|
import { InlineCommentCollaborationExtension } from './extension/inlineComment'
|
||||||
import { LeftMenuExtension } from './extension/leftMenu'
|
import { LeftMenuExtension } from './extension/leftMenu'
|
||||||
import { mermaidOptions } from './extension/mermaid'
|
import { mermaidOptions } from './extension/mermaid'
|
||||||
|
import { ReferenceExtension, referenceConfig } from './extension/reference'
|
||||||
import { type FileAttachFunction } from './extension/types'
|
import { type FileAttachFunction } from './extension/types'
|
||||||
import { completionConfig, inlineCommandsConfig } from './extensions'
|
import { inlineCommandsConfig } from './extensions'
|
||||||
|
|
||||||
export let object: Doc
|
export let object: Doc
|
||||||
export let attribute: KeyedAttribute
|
export let attribute: KeyedAttribute
|
||||||
@ -495,8 +495,8 @@
|
|||||||
render: renderCursor,
|
render: renderCursor,
|
||||||
selectionRender: noSelectionRender
|
selectionRender: noSelectionRender
|
||||||
}),
|
}),
|
||||||
Completion.configure({
|
ReferenceExtension.configure({
|
||||||
...completionConfig,
|
...referenceConfig,
|
||||||
showDoc (event: MouseEvent, _id: string, _class: string) {
|
showDoc (event: MouseEvent, _id: string, _class: string) {
|
||||||
dispatch('open-document', { event, _id, _class })
|
dispatch('open-document', { event, _id, _class })
|
||||||
}
|
}
|
||||||
|
@ -15,23 +15,13 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { SearchResultDoc } from '@hcengineering/core'
|
import { SearchResultDoc } from '@hcengineering/core'
|
||||||
import { getResource } from '@hcengineering/platform'
|
import presentation, { SearchResult, reduceCalls, searchFor, type SearchItem } from '@hcengineering/presentation'
|
||||||
import presentation, {
|
|
||||||
SearchResult,
|
|
||||||
getClient,
|
|
||||||
reduceCalls,
|
|
||||||
searchFor,
|
|
||||||
type SearchItem
|
|
||||||
} from '@hcengineering/presentation'
|
|
||||||
import { Label, ListView, resizeObserver } from '@hcengineering/ui'
|
import { Label, ListView, resizeObserver } from '@hcengineering/ui'
|
||||||
import view from '@hcengineering/view'
|
|
||||||
import { createEventDispatcher } from 'svelte'
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
import { getReferenceLabel } from './extension/reference'
|
||||||
|
|
||||||
export let query: string = ''
|
export let query: string = ''
|
||||||
|
|
||||||
const client = getClient()
|
|
||||||
const hierarchy = client.getHierarchy()
|
|
||||||
|
|
||||||
let items: SearchItem[] = []
|
let items: SearchItem[] = []
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
@ -40,21 +30,11 @@
|
|||||||
let scrollContainer: HTMLElement
|
let scrollContainer: HTMLElement
|
||||||
let selection = 0
|
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> {
|
async function handleSelectItem (item: SearchResultDoc): Promise<void> {
|
||||||
const identifier = await getIdentifier(item)
|
const label = await getReferenceLabel(item.doc._class, item.doc._id)
|
||||||
dispatch('close', {
|
dispatch('close', {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
label: identifier,
|
label,
|
||||||
objectclass: item.doc._class
|
objectclass: item.doc._class
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -16,28 +16,27 @@
|
|||||||
import { Markup } from '@hcengineering/core'
|
import { Markup } from '@hcengineering/core'
|
||||||
import { Asset, IntlString } from '@hcengineering/platform'
|
import { Asset, IntlString } from '@hcengineering/platform'
|
||||||
import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text'
|
import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text'
|
||||||
|
import textEditor, { RefAction, TextEditorHandler } from '@hcengineering/text-editor'
|
||||||
import {
|
import {
|
||||||
AnySvelteComponent,
|
AnySvelteComponent,
|
||||||
Button,
|
Button,
|
||||||
ButtonKind,
|
ButtonKind,
|
||||||
handler,
|
|
||||||
registerFocus,
|
|
||||||
deviceOptionsStore as deviceInfo,
|
|
||||||
checkAdaptiveMatching,
|
checkAdaptiveMatching,
|
||||||
IconClose
|
deviceOptionsStore as deviceInfo,
|
||||||
|
handler,
|
||||||
|
IconClose,
|
||||||
|
registerFocus
|
||||||
} from '@hcengineering/ui'
|
} from '@hcengineering/ui'
|
||||||
import { createEventDispatcher } from 'svelte'
|
|
||||||
import { FocusPosition } from '@tiptap/core'
|
import { FocusPosition } from '@tiptap/core'
|
||||||
import { EditorView } from '@tiptap/pm/view'
|
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 TextEditor from './TextEditor.svelte'
|
||||||
import { defaultRefActions, getModelRefActions } from './editor/actions'
|
import { defaultRefActions, getModelRefActions } from './editor/actions'
|
||||||
import { completionConfig } from './extensions'
|
|
||||||
import { EmojiExtension } from './extension/emoji'
|
import { EmojiExtension } from './extension/emoji'
|
||||||
import { IsEmptyContentExtension } from './extension/isEmptyContent'
|
import { IsEmptyContentExtension } from './extension/isEmptyContent'
|
||||||
import view from '@hcengineering/view'
|
import { referenceConfig, ReferenceExtension } from './extension/reference'
|
||||||
import Send from './icons/Send.svelte'
|
import Send from './icons/Send.svelte'
|
||||||
|
|
||||||
export let content: Markup = EmptyMarkup
|
export let content: Markup = EmptyMarkup
|
||||||
@ -141,8 +140,8 @@
|
|||||||
focusManager?.setFocus(idx)
|
focusManager?.setFocus(idx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const completionPlugin = Completion.configure({
|
const completionPlugin = ReferenceExtension.configure({
|
||||||
...completionConfig,
|
...referenceConfig,
|
||||||
showDoc (event: MouseEvent, _id: string, _class: string) {
|
showDoc (event: MouseEvent, _id: string, _class: string) {
|
||||||
dispatch('open-document', { event, _id, _class })
|
dispatch('open-document', { event, _id, _class })
|
||||||
}
|
}
|
||||||
|
@ -2,14 +2,13 @@
|
|||||||
import { Markup } from '@hcengineering/core'
|
import { Markup } from '@hcengineering/core'
|
||||||
import { IntlString } from '@hcengineering/platform'
|
import { IntlString } from '@hcengineering/platform'
|
||||||
import { EmptyMarkup } from '@hcengineering/text'
|
import { EmptyMarkup } from '@hcengineering/text'
|
||||||
|
import textEditor from '@hcengineering/text-editor'
|
||||||
import { ButtonSize, Label } from '@hcengineering/ui'
|
import { ButtonSize, Label } from '@hcengineering/ui'
|
||||||
import { AnyExtension } from '@tiptap/core'
|
import { AnyExtension } from '@tiptap/core'
|
||||||
import { createEventDispatcher } from 'svelte'
|
import { createEventDispatcher } from 'svelte'
|
||||||
import textEditor from '@hcengineering/text-editor'
|
|
||||||
|
|
||||||
|
import { referenceConfig, ReferenceExtension } from './extension/reference'
|
||||||
import StyledTextEditor from './StyledTextEditor.svelte'
|
import StyledTextEditor from './StyledTextEditor.svelte'
|
||||||
import { Completion } from '../Completion'
|
|
||||||
import { completionConfig } from './extensions'
|
|
||||||
|
|
||||||
export let label: IntlString | undefined = undefined
|
export let label: IntlString | undefined = undefined
|
||||||
export let content: Markup | undefined
|
export let content: Markup | undefined
|
||||||
@ -53,8 +52,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function configureExtensions (): AnyExtension[] {
|
function configureExtensions (): AnyExtension[] {
|
||||||
const completionPlugin = Completion.configure({
|
const completionPlugin = ReferenceExtension.configure({
|
||||||
...completionConfig,
|
...referenceConfig,
|
||||||
showDoc (event: MouseEvent, _id: string, _class: string) {
|
showDoc (event: MouseEvent, _id: string, _class: string) {
|
||||||
dispatch('open-document', { event, _id, _class })
|
dispatch('open-document', { event, _id, _class })
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,6 @@
|
|||||||
import type { AnyExtension } from '@tiptap/core'
|
import type { AnyExtension } from '@tiptap/core'
|
||||||
import { createEventDispatcher } from 'svelte'
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
|
||||||
import { Completion } from '../Completion'
|
|
||||||
import StyledTextEditor from './StyledTextEditor.svelte'
|
import StyledTextEditor from './StyledTextEditor.svelte'
|
||||||
|
|
||||||
import { addTableHandler } from '../utils'
|
import { addTableHandler } from '../utils'
|
||||||
@ -29,8 +28,9 @@
|
|||||||
import { FocusExtension } from './extension/focus'
|
import { FocusExtension } from './extension/focus'
|
||||||
import { ImageUploadExtension } from './extension/imageUploadExt'
|
import { ImageUploadExtension } from './extension/imageUploadExt'
|
||||||
import { InlineCommandsExtension } from './extension/inlineCommands'
|
import { InlineCommandsExtension } from './extension/inlineCommands'
|
||||||
|
import { ReferenceExtension, referenceConfig } from './extension/reference'
|
||||||
import { type FileAttachFunction } from './extension/types'
|
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 label: IntlString | undefined = undefined
|
||||||
export let content: Markup
|
export let content: Markup
|
||||||
@ -179,8 +179,8 @@
|
|||||||
getFileUrl
|
getFileUrl
|
||||||
})
|
})
|
||||||
|
|
||||||
const completionPlugin = Completion.configure({
|
const completionPlugin = ReferenceExtension.configure({
|
||||||
...completionConfig,
|
...referenceConfig,
|
||||||
showDoc (event: MouseEvent, _id: string, _class: string) {
|
showDoc (event: MouseEvent, _id: string, _class: string) {
|
||||||
dispatch('open-document', { event, _id, _class })
|
dispatch('open-document', { event, _id, _class })
|
||||||
}
|
}
|
||||||
|
@ -13,14 +13,8 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
import { type Class, type Doc, type Ref } from '@hcengineering/core'
|
import { showPopup } from '@hcengineering/ui'
|
||||||
import { getMetadata, getResource } from '@hcengineering/platform'
|
import { Extension } from '@tiptap/core'
|
||||||
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 LinkPopup from '../LinkPopup.svelte'
|
import LinkPopup from '../LinkPopup.svelte'
|
||||||
|
|
||||||
export const LinkUtilsExtension = Extension.create<any>({
|
export const LinkUtilsExtension = Extension.create<any>({
|
||||||
@ -48,153 +42,6 @@ export const LinkUtilsExtension = Extension.create<any>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins () {
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -13,16 +13,15 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
import view from '@hcengineering/view'
|
|
||||||
import { type Editor, type Range } from '@tiptap/core'
|
|
||||||
import textEditor from '@hcengineering/text-editor'
|
import textEditor from '@hcengineering/text-editor'
|
||||||
import { IconScribble } from '@hcengineering/ui'
|
import { IconScribble } from '@hcengineering/ui'
|
||||||
|
import view from '@hcengineering/view'
|
||||||
|
import { type Editor, type Range } from '@tiptap/core'
|
||||||
|
|
||||||
import { type CompletionOptions } from '../Completion'
|
import { type InlineCommandsOptions } from './extension/inlineCommands'
|
||||||
import MentionList from './MentionList.svelte'
|
|
||||||
import { SvelteRenderer } from './node-view'
|
|
||||||
import type { SuggestionKeyDownProps, SuggestionProps } from './extension/suggestion'
|
import type { SuggestionKeyDownProps, SuggestionProps } from './extension/suggestion'
|
||||||
import InlineCommandsList from './InlineCommandsList.svelte'
|
import InlineCommandsList from './InlineCommandsList.svelte'
|
||||||
|
import { SvelteRenderer } from './node-view'
|
||||||
|
|
||||||
export const mInsertTable = [
|
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 = [
|
const inlineCommandsIds = [
|
||||||
'image',
|
'image',
|
||||||
'table',
|
'table',
|
||||||
@ -147,7 +103,7 @@ export type InlineCommandId = (typeof inlineCommandsIds)[number]
|
|||||||
export function inlineCommandsConfig (
|
export function inlineCommandsConfig (
|
||||||
handleSelect: (id: string, pos: number, targetItem?: MouseEvent | HTMLElement) => Promise<void>,
|
handleSelect: (id: string, pos: number, targetItem?: MouseEvent | HTMLElement) => Promise<void>,
|
||||||
excludedCommands: InlineCommandId[] = []
|
excludedCommands: InlineCommandId[] = []
|
||||||
): Partial<CompletionOptions> {
|
): Partial<InlineCommandsOptions> {
|
||||||
return {
|
return {
|
||||||
suggestion: {
|
suggestion: {
|
||||||
items: () => {
|
items: () => {
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
import { AnyComponent, LabelAndProps, themeStore, tooltip } from '@hcengineering/ui'
|
import { AnyComponent, LabelAndProps, themeStore, tooltip } from '@hcengineering/ui'
|
||||||
import view from '@hcengineering/view'
|
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'
|
import DocNavLink from './DocNavLink.svelte'
|
||||||
|
|
||||||
export let _id: Ref<Doc> | undefined = undefined
|
export let _id: Ref<Doc> | undefined = undefined
|
||||||
@ -91,7 +91,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateDocTitle (doc: Doc | undefined): Promise<void> {
|
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> {
|
async function updateDocTooltip (doc?: Doc): Promise<void> {
|
||||||
|
@ -194,7 +194,7 @@ test.describe('Documents tests', () => {
|
|||||||
await documentContentPage.checkLinkInTheText(contentLink, 'http://test/link/123456')
|
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 = {
|
const sourceDocument: NewDocument = {
|
||||||
title: `Reference Document Title-${generateId()}`,
|
title: `Reference Document Title-${generateId()}`,
|
||||||
space: 'Default'
|
space: 'Default'
|
||||||
@ -204,23 +204,43 @@ test.describe('Documents tests', () => {
|
|||||||
space: 'Default'
|
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.clickOnButtonCreateDocument()
|
||||||
await documentsPage.createDocument(targetDocument)
|
await documentsPage.createDocument(targetDocument)
|
||||||
await documentsPage.openDocument(targetDocument.title)
|
await documentsPage.openDocument(targetDocument.title)
|
||||||
await documentContentPage.checkDocumentTitle(targetDocument.title)
|
await documentContentPage.checkDocumentTitle(targetDocument.title)
|
||||||
const targetDocumentUrl = documentsPage.page.url()
|
const targetDocumentUrl = documentsPage.page.url()
|
||||||
|
|
||||||
await documentsPage.clickOnButtonCreateDocument()
|
await documentsPage.clickOnButtonCreateDocument()
|
||||||
await documentsPage.createDocument(sourceDocument)
|
await documentsPage.createDocument(sourceDocument)
|
||||||
await documentsPage.openDocument(sourceDocument.title)
|
await documentsPage.openDocument(sourceDocument.title)
|
||||||
await documentContentPage.checkDocumentTitle(sourceDocument.title)
|
await documentContentPage.checkDocumentTitle(sourceDocument.title)
|
||||||
|
|
||||||
await documentContentPage.addRandomLines(5)
|
await documentContentPage.addRandomLines(5)
|
||||||
await documentContentPage.addContentToTheNewLine(targetDocumentUrl)
|
await documentContentPage.addContentToTheNewLine(targetDocumentUrl)
|
||||||
await documentContentPage.addRandomLines(5)
|
await documentContentPage.addRandomLines(5)
|
||||||
await documentContentPage.checkReferenceInTheText(targetDocument.title)
|
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 }) => {
|
test('Locked document and checking URL', async ({ page, context }) => {
|
||||||
|
Loading…
Reference in New Issue
Block a user