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",
|
"@hcengineering/theme": "^0.6.5",
|
||||||
"tippy.js": "~6.3.7",
|
"tippy.js": "~6.3.7",
|
||||||
"@hcengineering/chunter": "^0.6.20",
|
"@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.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
import { Extension } from '@tiptap/core'
|
import { type Class, type Doc, type Ref } from '@hcengineering/core'
|
||||||
import { showPopup } from '@hcengineering/ui'
|
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'
|
import LinkPopup from '../LinkPopup.svelte'
|
||||||
|
|
||||||
export const LinkShortcutsExtension = Extension.create<any>({
|
export const LinkUtilsExtension = Extension.create<any>({
|
||||||
name: 'defaultShortcuts',
|
name: 'linkUtils',
|
||||||
|
|
||||||
addKeyboardShortcuts () {
|
addKeyboardShortcuts () {
|
||||||
return {
|
return {
|
||||||
@ -39,5 +45,156 @@ export const LinkShortcutsExtension = Extension.create<any>({
|
|||||||
return true
|
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 { DrawingBoardExtension, type DrawingBoardOptions } from '../components/extension/drawingBoard'
|
||||||
import { type IndendOptions, IndentExtension, indentExtensionOptions } from '../components/extension/indent'
|
import { type IndendOptions, IndentExtension, indentExtensionOptions } from '../components/extension/indent'
|
||||||
import TextAlign, { type TextAlignOptions } from '@tiptap/extension-text-align'
|
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 {
|
export interface EditorKitOptions extends DefaultKitOptions {
|
||||||
history?: false
|
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) {
|
if (mode !== 'compact' && this.options.note !== false) {
|
||||||
staticKitExtensions.push([1000, NoteExtension.configure(this.options.note ?? {})])
|
staticKitExtensions.push([1000, NoteExtension.configure(this.options.note ?? {})])
|
||||||
|
@ -194,6 +194,35 @@ 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 () => {
|
||||||
|
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 }) => {
|
test('Locked document and checking URL', async ({ page, context }) => {
|
||||||
const newDocument: NewDocument = {
|
const newDocument: NewDocument = {
|
||||||
title: `New Document-${generateId()}`,
|
title: `New Document-${generateId()}`,
|
||||||
|
@ -286,6 +286,10 @@ export class DocumentContentPage extends CommonPage {
|
|||||||
await expect(this.page.locator('a', { hasText: text })).toHaveAttribute('href', link)
|
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> {
|
async executeMoreAction (action: string): Promise<void> {
|
||||||
await this.buttonMoreActions().click()
|
await this.buttonMoreActions().click()
|
||||||
await this.selectFromDropdown(this.page, action)
|
await this.selectFromDropdown(this.page, action)
|
||||||
|
Loading…
Reference in New Issue
Block a user