Automatic link to ref conversion (#8006)

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Victor Ilyushchenko 2025-02-14 12:39:46 +03:00 committed by GitHub
parent 9d87ce7ede
commit a9d96d5d87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 198 additions and 7 deletions

View File

@ -88,6 +88,7 @@
"@hcengineering/theme": "^0.6.5",
"tippy.js": "~6.3.7",
"@hcengineering/chunter": "^0.6.20",
"@tiptap/extension-text-align": "~2.11.0"
"@tiptap/extension-text-align": "~2.11.0",
"@hcengineering/workbench": "^0.6.16"
}
}

View File

@ -13,12 +13,18 @@
// limitations under the License.
//
import { Extension } from '@tiptap/core'
import { showPopup } from '@hcengineering/ui'
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 LinkPopup from '../LinkPopup.svelte'
export const LinkShortcutsExtension = Extension.create<any>({
name: 'defaultShortcuts',
export const LinkUtilsExtension = Extension.create<any>({
name: 'linkUtils',
addKeyboardShortcuts () {
return {
@ -39,5 +45,156 @@ export const LinkShortcutsExtension = Extension.create<any>({
return true
}
}
},
addProseMirrorPlugins () {
return [ResolveReferenceUrlsPlugin(this.editor)]
}
})
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

@ -46,7 +46,7 @@ import { MermaidExtension, type MermaidOptions, mermaidOptions } from '../compon
import { DrawingBoardExtension, type DrawingBoardOptions } from '../components/extension/drawingBoard'
import { type IndendOptions, IndentExtension, indentExtensionOptions } from '../components/extension/indent'
import TextAlign, { type TextAlignOptions } from '@tiptap/extension-text-align'
import { LinkShortcutsExtension } from '../components/extension/link'
import { LinkUtilsExtension } from '../components/extension/link'
export interface EditorKitOptions extends DefaultKitOptions {
history?: false
@ -316,7 +316,7 @@ async function buildEditorKit (): Promise<Extension<EditorKitOptions, any>> {
])
}
staticKitExtensions.push([950, LinkShortcutsExtension.configure({})])
staticKitExtensions.push([950, LinkUtilsExtension.configure({})])
if (mode !== 'compact' && this.options.note !== false) {
staticKitExtensions.push([1000, NoteExtension.configure(this.options.note ?? {})])

View File

@ -194,6 +194,35 @@ test.describe('Documents tests', () => {
await documentContentPage.checkLinkInTheText(contentLink, 'http://test/link/123456')
})
test('Check Link to Reference conversion', async () => {
const sourceDocument: NewDocument = {
title: `Reference Document Title-${generateId()}`,
space: 'Default'
}
const targetDocument: NewDocument = {
title: `Reference Document Title-${generateId()}`,
space: 'Default'
}
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(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)
})
test('Locked document and checking URL', async ({ page, context }) => {
const newDocument: NewDocument = {
title: `New Document-${generateId()}`,

View File

@ -286,6 +286,10 @@ export class DocumentContentPage extends CommonPage {
await expect(this.page.locator('a', { hasText: text })).toHaveAttribute('href', link)
}
async checkReferenceInTheText (label: string): Promise<void> {
await expect(this.page.locator('span', { hasText: '@' + label })).toHaveAttribute('data-type', 'reference')
}
async executeMoreAction (action: string): Promise<void> {
await this.buttonMoreActions().click()
await this.selectFromDropdown(this.page, action)