diff --git a/plugins/text-editor-resources/package.json b/plugins/text-editor-resources/package.json index 4ef3d4ad4d..d8f589ab21 100644 --- a/plugins/text-editor-resources/package.json +++ b/plugins/text-editor-resources/package.json @@ -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" } } diff --git a/plugins/text-editor-resources/src/components/extension/link.ts b/plugins/text-editor-resources/src/components/extension/link.ts index 4f67a800e8..388803b5c3 100644 --- a/plugins/text-editor-resources/src/components/extension/link.ts +++ b/plugins/text-editor-resources/src/components/extension/link.ts @@ -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({ - name: 'defaultShortcuts', +export const LinkUtilsExtension = Extension.create({ + name: 'linkUtils', addKeyboardShortcuts () { return { @@ -39,5 +45,156 @@ export const LinkShortcutsExtension = Extension.create({ return true } } + }, + + addProseMirrorPlugins () { + return [ResolveReferenceUrlsPlugin(this.editor)] } }) + +export interface LinkToReferencePluginState { + references: Map, objectclass: Ref>, label: string }> + queue: Set +} + +const resolveReferencePluginKey = new PluginKey('linkToReference') + +interface ReferenceProps { + id: Ref + objectclass: Ref> + label: string +} + +export function ResolveReferenceUrlsPlugin (editor: Editor): Plugin { + return new Plugin({ + 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 { + 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(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 + const objectclass = locationParts[2] as Ref> + 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 | 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 + } +} diff --git a/plugins/text-editor-resources/src/kits/editor-kit.ts b/plugins/text-editor-resources/src/kits/editor-kit.ts index f59213e70f..9510daeb78 100644 --- a/plugins/text-editor-resources/src/kits/editor-kit.ts +++ b/plugins/text-editor-resources/src/kits/editor-kit.ts @@ -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> { ]) } - 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 ?? {})]) diff --git a/tests/sanity/tests/documents/documents.spec.ts b/tests/sanity/tests/documents/documents.spec.ts index 37ed9b4d03..ddb93513d3 100644 --- a/tests/sanity/tests/documents/documents.spec.ts +++ b/tests/sanity/tests/documents/documents.spec.ts @@ -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()}`, diff --git a/tests/sanity/tests/model/documents/document-content-page.ts b/tests/sanity/tests/model/documents/document-content-page.ts index 11c7cb237b..f454f0fb6b 100644 --- a/tests/sanity/tests/model/documents/document-content-page.ts +++ b/tests/sanity/tests/model/documents/document-content-page.ts @@ -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 { + await expect(this.page.locator('span', { hasText: '@' + label })).toHaveAttribute('data-type', 'reference') + } + async executeMoreAction (action: string): Promise { await this.buttonMoreActions().click() await this.selectFromDropdown(this.page, action)