mirror of
https://github.com/hcengineering/platform.git
synced 2025-03-15 02:23:12 +00:00
Automatic link to ref conversion (#8006)
Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
parent
9d87ce7ede
commit
a9d96d5d87
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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 ?? {})])
|
||||
|
@ -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()}`,
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user