Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { $getSelection, $isRangeSelection } from 'lexical'
import type { ToolbarGroup } from '../../toolbars/types.js'
import type { ClientFeature } from '../../typesClient.js'
import type { LinkFields } from '../nodes/types.js'
import type { ExclusiveLinkCollectionsProps } from '../server/index.js'
import type { AutoLinkFields, ExclusiveLinkCollectionsProps } from '../server/index.js'

import { LinkIcon } from '../../../lexical/ui/icons/Link/index.js'
import { getSelectedNode } from '../../../lexical/utils/getSelectedNode.js'
Expand All @@ -24,6 +24,9 @@ import { TOGGLE_LINK_WITH_MODAL_COMMAND } from './plugins/floatingLinkEditor/Lin
import { LinkPlugin } from './plugins/link/index.js'

export type ClientProps = {
autoLinks?: {
fields?: AutoLinkFields
}
defaultLinkType?: string
defaultLinkURL?: string
disableAutoLinks?: 'creationOnly' | true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { LinkFields } from '../../../nodes/types.js'
import type { AutoLinkFields } from '../../../server/index.js'

type Args = {
matchFields?: Partial<LinkFields>
staticFields?: AutoLinkFields
url: string
}

export function buildAutoLinkFields({ matchFields, staticFields, url }: Args): LinkFields {
return {
newTab: false,
...stripReservedAutoLinkFields(staticFields),
...stripReservedAutoLinkFields(matchFields),
linkType: 'custom',
url,
} as LinkFields
}

function stripReservedAutoLinkFields(fields?: AutoLinkFields | Partial<LinkFields>) {
if (!fields) {
return undefined
}

const { doc: _doc, linkType: _linkType, text: _text, url: _url, ...autoLinkFields } = fields

return autoLinkFields
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ import type { ClientProps } from '../../index.js'

import { $createAutoLinkNode, $isAutoLinkNode, AutoLinkNode } from '../../../nodes/AutoLinkNode.js'
import { $isLinkNode } from '../../../nodes/LinkNode.js'
import { buildAutoLinkFields } from './buildAutoLinkFields.js'

type ChangeHandler = (url: null | string, prevUrl: null | string) => void

interface LinkMatcherResult {
fields?: LinkFields
fields?: Partial<LinkFields>
index: number
length: number
text: string
Expand Down Expand Up @@ -174,16 +175,13 @@ function extractMatchingNodes(
}

function $createAutoLinkNode_(
clientProps: ClientProps,
nodes: TextNode[],
startIndex: number,
endIndex: number,
match: LinkMatcherResult,
): TextNode | undefined {
const fields = {
linkType: 'custom',
url: match.url,
...match.fields,
} as LinkFields
const fields = getAutoLinkFields({ clientProps, match })

const linkNode = $createAutoLinkNode({ fields })
if (nodes.length === 1) {
Expand Down Expand Up @@ -258,6 +256,7 @@ function $createAutoLinkNode_(
}

function $handleLinkCreation(
clientProps: ClientProps,
nodes: TextNode[],
matchers: LinkMatcher[],
onChange: ChangeHandler,
Expand Down Expand Up @@ -290,6 +289,7 @@ function $handleLinkCreation(
const actualMatchStart = invalidMatchEnd + matchStart - matchingOffset
const actualMatchEnd = invalidMatchEnd + matchEnd - matchingOffset
const remainingTextNode = $createAutoLinkNode_(
clientProps,
matchingNodes,
actualMatchStart,
actualMatchEnd,
Expand All @@ -309,6 +309,7 @@ function $handleLinkCreation(
}

function handleLinkEdit(
clientProps: ClientProps,
linkNode: AutoLinkNode,
matchers: LinkMatcher[],
onChange: ChangeHandler,
Expand Down Expand Up @@ -343,16 +344,19 @@ function handleLinkEdit(

const url = linkNode.getFields()?.url
if (url !== match?.url) {
const flds = linkNode.getFields()
flds.url = match?.url
linkNode.setFields(flds)
const fields = getAutoLinkFields({
clientProps,
match,
})
linkNode.setFields(fields)
onChange(match.url, url ?? null)
}
}

// Bad neighbors are edits in neighbor nodes that make AutoLinks incompatible.
// Given the creation preconditions, these can only be simple text nodes.
function handleBadNeighbors(
clientProps: ClientProps,
textNode: TextNode,
matchers: LinkMatcher[],
onChange: ChangeHandler,
Expand All @@ -367,18 +371,32 @@ function handleBadNeighbors(
: false
if (!startsWithSeparator(text) || startsWithTLD(text, isEmailURI)) {
previousSibling.append(textNode)
handleLinkEdit(previousSibling, matchers, onChange)
handleLinkEdit(clientProps, previousSibling, matchers, onChange)
onChange(null, previousSibling.getFields()?.url ?? null)
}
}

if ($isAutoLinkNode(nextSibling) && !endsWithSeparator(text)) {
replaceWithChildren(nextSibling)
handleLinkEdit(nextSibling, matchers, onChange)
handleLinkEdit(clientProps, nextSibling, matchers, onChange)
onChange(null, nextSibling.getFields()?.url ?? null)
}
}

function getAutoLinkFields({
clientProps,
match,
}: {
clientProps: ClientProps
match: LinkMatcherResult
}): LinkFields {
return buildAutoLinkFields({
matchFields: match.fields,
staticFields: clientProps.autoLinks?.fields,
url: match.url,
})
}

function replaceWithChildren(node: ElementNode): LexicalNode[] {
const children = node.getChildren()
const childrenLength = children.length
Expand Down Expand Up @@ -408,6 +426,7 @@ function getTextNodesToMatch(textNode: TextNode): TextNode[] {
function useAutoLink(
editor: LexicalEditor,
matchers: LinkMatcher[],
clientProps: ClientProps,
onChange?: ChangeHandler,
): void {
useEffect(() => {
Expand All @@ -426,21 +445,21 @@ function useAutoLink(
const parent = textNode.getParentOrThrow()
const previous = textNode.getPreviousSibling()
if ($isAutoLinkNode(parent)) {
handleLinkEdit(parent, matchers, onChangeWrapped)
handleLinkEdit(clientProps, parent, matchers, onChangeWrapped)
} else if (!$isLinkNode(parent)) {
if (
textNode.isSimpleText() &&
(startsWithSeparator(textNode.getTextContent()) || !$isAutoLinkNode(previous))
) {
const textNodesToMatch = getTextNodesToMatch(textNode)
$handleLinkCreation(textNodesToMatch, matchers, onChangeWrapped)
$handleLinkCreation(clientProps, textNodesToMatch, matchers, onChangeWrapped)
}

handleBadNeighbors(textNode, matchers, onChangeWrapped)
handleBadNeighbors(clientProps, textNode, matchers, onChangeWrapped)
}
}),
)
}, [editor, matchers, onChange])
}, [clientProps, editor, matchers, onChange])
}

const URL_REGEX =
Expand All @@ -458,10 +477,10 @@ const MATCHERS = [
}),
]

export const AutoLinkPlugin: PluginComponent<ClientProps> = () => {
export const AutoLinkPlugin: PluginComponent<ClientProps> = ({ clientProps }) => {
const [editor] = useLexicalComposerContext()

useAutoLink(editor, MATCHERS)
useAutoLink(editor, MATCHERS, clientProps)

return null
}
23 changes: 23 additions & 0 deletions packages/richtext-lexical/src/features/link/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
Field,
FieldAffectingData,
FieldSchemaMap,
JsonValue,
SanitizedConfig,
} from 'payload'

Expand All @@ -25,6 +26,21 @@ import { i18n } from './i18n.js'
import { transformExtraFields } from './transformExtraFields.js'
import { linkValidation } from './validate.js'

export type { LinkFields, SerializedAutoLinkNode, SerializedLinkNode } from '../nodes/types.js'

type ReservedAutoLinkFieldNames = 'doc' | 'linkType' | 'text' | 'url'

export type AutoLinkFields = {
[K in ReservedAutoLinkFieldNames]?: never
} & Partial<Record<string, JsonValue>>

export type AutoLinksProps = {
/**
* Static, serializable field values merged into newly created auto link nodes.
* Reserved keys like `doc`, `linkType`, `text`, and `url` are ignored.
*/
fields?: AutoLinkFields
}
export type ExclusiveLinkCollectionsProps =
| {
/**
Expand All @@ -48,6 +64,10 @@ export type ExclusiveLinkCollectionsProps =
}

export type LinkFeatureServerProps = {
/**
* Controls the fields stored on automatically-created URL and email links.
*/
autoLinks?: AutoLinksProps
/**
* Disables the automatic creation of links from URLs pasted into the editor, as well
* as auto link nodes.
Expand Down Expand Up @@ -146,6 +166,9 @@ export const LinkFeature = createServerFeature<
return {
ClientFeature: '@payloadcms/richtext-lexical/client#LinkFeatureClient',
clientFeatureProps: {
autoLinks: {
fields: props.autoLinks?.fields,
},
defaultLinkType,
defaultLinkURL,
disableAutoLinks: props.disableAutoLinks,
Expand Down
7 changes: 6 additions & 1 deletion packages/richtext-lexical/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,7 +946,12 @@ export {
export { $createLinkNode, $isLinkNode, LinkNode } from './features/link/nodes/LinkNode.js'

export type { LinkFields } from './features/link/nodes/types.js'
export { LinkFeature, type LinkFeatureServerProps } from './features/link/server/index.js'
export {
type AutoLinkFields,
type AutoLinksProps,
LinkFeature,
type LinkFeatureServerProps,
} from './features/link/server/index.js'

export { ChecklistFeature } from './features/lists/checklist/server/index.js'
export { OrderedListFeature } from './features/lists/orderedList/server/index.js'
Expand Down
52 changes: 39 additions & 13 deletions test/lexical/collections/LexicalLinkFeature/e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,36 @@
import { expect, test } from '@playwright/test'
import { lexicalLinkFeatureSlug } from 'lexical/slugs.js'
import path from 'path'
import { fileURLToPath } from 'url'

import type { Config } from '../../payload-types.js'

import { ensureCompilationIsDone, waitForFormReady } from '../../../__helpers/e2e/helpers.js'
import { AdminUrlUtil } from '../../../__helpers/shared/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../__helpers/shared/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { lexicalLinkFeatureSlug } from '../../slugs.js'
import { LexicalHelpers } from '../utils.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
const dirname = path.resolve(currentFolder, '../../')

const { beforeAll, beforeEach, describe } = test

// Unlike the other suites, this one runs in parallel, as they run on the `lexical-fully-featured/create` URL and are "pure" tests
// PLEASE do not reset the database or perform any operations that modify it in this file.
// TODO: Enable parallel mode again when ensureCompilationIsDone is extracted into a playwright hook. Otherwise,
// it runs multiple times in parallel, for each single test, which causes the tests to fail occasionally in CI.
// test.describe.configure({ mode: 'parallel' })

const { serverURL } = await initPayloadE2ENoConfig({
dirname,
})
const initResult = await initPayloadE2ENoConfig<Config>({ dirname })
const { serverURL } = initResult

describe('Lexical Link Feature', () => {
beforeAll(async ({ browser }, testInfo) => {
test.describe('Lexical Link Feature', () => {
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit

await ensureCompilationIsDone({ browser, serverURL })
})
beforeEach(async ({ page }) => {
test.beforeEach(async ({ page }) => {
const url = new AdminUrlUtil(serverURL, lexicalLinkFeatureSlug)
const lexical = new LexicalHelpers(page)
await page.goto(url.create)
Expand Down Expand Up @@ -71,6 +70,33 @@ describe('Lexical Link Feature', () => {
await expect(checkboxField).toBeChecked()
})

test('should open pasted autolinks in a new tab', async ({ page }) => {
const lexical = new LexicalHelpers(page)
const pastedURL = 'https://example.com'
const pastedEmail = 'test@example.com'

await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await page.evaluate(
async ([email, url]) => {
await navigator.clipboard.writeText(`${url} ${email} `)
},
[pastedEmail, pastedURL],
)

await page.keyboard.press(`ControlOrMeta+v`)

const links = lexical.editor.locator('a')

await expect(links).toHaveCount(2)
await expect(links.nth(0)).toHaveAttribute('href', pastedURL)
await expect(links.nth(0)).toHaveAttribute('rel', /noopener/)
await expect(links.nth(0)).toHaveAttribute('target', '_blank')

await expect(links.nth(1)).toHaveAttribute('href', `mailto:${pastedEmail}`)
await expect(links.nth(1)).toHaveAttribute('rel', /noopener/)
await expect(links.nth(1)).toHaveAttribute('target', '_blank')
})

test('long link on right stays within editor bounds', async ({ page }) => {
const lexical = new LexicalHelpers(page)

Expand Down Expand Up @@ -110,9 +136,9 @@ describe('Lexical Link Feature', () => {
new MouseEvent('mouseover', {
bubbles: true,
cancelable: true,
view: window,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
view: window,
}),
)
}
Expand Down Expand Up @@ -178,9 +204,9 @@ describe('Lexical Link Feature', () => {
new MouseEvent('mouseover', {
bubbles: true,
cancelable: true,
view: window,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
view: window,
}),
)
}
Expand Down Expand Up @@ -243,9 +269,9 @@ describe('Lexical Link Feature', () => {
new MouseEvent('mouseover', {
bubbles: true,
cancelable: true,
view: window,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
view: window,
}),
)
}
Expand Down Expand Up @@ -312,9 +338,9 @@ describe('Lexical Link Feature', () => {
new MouseEvent('mouseover', {
bubbles: true,
cancelable: true,
view: window,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
view: window,
}),
)
}
Expand Down
Loading
Loading