Skip to content

Commit a55bef9

Browse files
committed
feat(editor): link plugin no longer invoke window but instead expose onClick handler
1 parent f4839af commit a55bef9

5 files changed

Lines changed: 238 additions & 57 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {
2+
LINK_PLUGIN_DEFAULT_OPTIONS,
3+
LinkPlugin,
4+
createQalmaEditor,
5+
} from '@qalma/editor';
6+
import type {
7+
LinkClickEvent,
8+
QalmaEditorController,
9+
QalmaPlugin,
10+
} from '@qalma/editor';
11+
import { describe, expect, it } from 'vitest';
12+
13+
const LINK_CONTENT =
14+
'<p><a href="https://angular.dev" target="_blank" rel="noopener noreferrer">Angular</a></p>';
15+
16+
describe('LinkPlugin', () => {
17+
it('exposes configurable link click defaults and validation', () => {
18+
const onClick = (): void => undefined;
19+
const configured = LinkPlugin.configure({ onClick });
20+
21+
expect(LINK_PLUGIN_DEFAULT_OPTIONS).toEqual({
22+
allowedProtocols: ['http', 'https', 'mailto', 'tel'],
23+
allowRelativeLinks: true,
24+
defaultTarget: '_blank',
25+
defaultRel: 'noopener noreferrer',
26+
onClick: null,
27+
});
28+
expect(LinkPlugin.options).toEqual(LINK_PLUGIN_DEFAULT_OPTIONS);
29+
expect(configured.options.onClick).toBe(onClick);
30+
expect(() =>
31+
LinkPlugin.configure({
32+
onClick: 'open' as never,
33+
}),
34+
).toThrow('LinkPlugin onClick must be a function or null.');
35+
});
36+
37+
it('leaves link click navigation to consumer-owned behavior', () => {
38+
const defaultMounted = mountLinkEditor([LinkPlugin]);
39+
const defaultEvent = new MouseEvent('click', {
40+
bubbles: true,
41+
cancelable: true,
42+
});
43+
44+
try {
45+
expect(defaultMounted.link.dispatchEvent(defaultEvent)).toBe(true);
46+
expect(defaultEvent.defaultPrevented).toBe(false);
47+
} finally {
48+
defaultMounted.editor.unmount(defaultMounted.host);
49+
defaultMounted.host.remove();
50+
}
51+
52+
const captured: LinkClickEvent[] = [];
53+
const configuredMounted = mountLinkEditor([
54+
LinkPlugin.configure({
55+
onClick: (event) => {
56+
captured.push(event);
57+
},
58+
}),
59+
]);
60+
const configuredEvent = new MouseEvent('click', {
61+
bubbles: true,
62+
cancelable: true,
63+
});
64+
65+
try {
66+
expect(configuredMounted.link.dispatchEvent(configuredEvent)).toBe(false);
67+
expect(configuredEvent.defaultPrevented).toBe(true);
68+
69+
const [linkClick] = captured;
70+
71+
if (!linkClick) {
72+
throw new Error('Expected link click event.');
73+
}
74+
75+
expect(linkClick.event).toBe(configuredEvent);
76+
expect(linkClick.element).toBe(configuredMounted.link);
77+
expect(linkClick.href).toBe('https://angular.dev');
78+
expect(linkClick.target).toBe('_blank');
79+
expect(linkClick.rel).toBe('noopener noreferrer');
80+
expect(linkClick.text).toBe('Angular');
81+
} finally {
82+
configuredMounted.editor.unmount(configuredMounted.host);
83+
configuredMounted.host.remove();
84+
}
85+
});
86+
});
87+
88+
function mountLinkEditor(plugins: readonly QalmaPlugin[]): {
89+
editor: QalmaEditorController;
90+
host: HTMLElement;
91+
link: HTMLAnchorElement;
92+
} {
93+
const editor = createQalmaEditor({
94+
content: LINK_CONTENT,
95+
plugins,
96+
});
97+
const host = document.createElement('div');
98+
99+
document.body.append(host);
100+
editor.mount(host);
101+
102+
const link = host.querySelector<HTMLAnchorElement>('a[href]');
103+
104+
if (!link) {
105+
editor.unmount(host);
106+
host.remove();
107+
108+
throw new Error('Expected editor link.');
109+
}
110+
111+
return {
112+
editor,
113+
host,
114+
link,
115+
};
116+
}

apps/docs/src/content/docs/link.md

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ const editor = createQalmaEditor({
1919

2020
## Commands and query
2121

22-
| Contract | Description |
23-
| -------- | ----------- |
24-
| `setLink` | Applies a link to the selection or stores it for future typed text. |
25-
| `selectLink` | Selects an existing link by current state, document range, or anchor element. |
26-
| `unsetLink` | Removes the active link mark. |
27-
| `query&lt;LinkState&gt;('link')` | Returns link range, attributes, and text for the active or selected link. |
22+
| Contract | Description |
23+
| -------------------------------- | ----------------------------------------------------------------------------- |
24+
| `setLink` | Applies a link to the selection or stores it for future typed text. |
25+
| `selectLink` | Selects an existing link by current state, document range, or anchor element. |
26+
| `unsetLink` | Removes the active link mark. |
27+
| `query&lt;LinkState&gt;('link')` | Returns link range, attributes, and text for the active or selected link. |
2828

2929
`setLink` accepts a string or an object.
3030

@@ -64,17 +64,19 @@ const editor = createQalmaEditor({
6464
allowRelativeLinks: true,
6565
defaultTarget: '_blank',
6666
defaultRel: 'noopener noreferrer',
67+
onClick: null,
6768
}),
6869
],
6970
});
7071
```
7172

72-
| Option | Default | Description |
73-
| ------ | ------- | ----------- |
74-
| `allowedProtocols` | `['http', 'https', 'mailto', 'tel']` | Protocol allow-list. Entries are names without `:`. |
75-
| `allowRelativeLinks` | `true` | Allows links without a protocol. |
76-
| `defaultTarget` | `'_blank'` | Applied when command values or pasted DOM do not provide a target. Only `'_blank'` is preserved. |
77-
| `defaultRel` | `'noopener noreferrer'` | Applied when command values or pasted DOM do not provide `rel`. Empty strings become `null`. |
73+
| Option | Default | Description |
74+
| -------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
75+
| `allowedProtocols` | `['http', 'https', 'mailto', 'tel']` | Protocol allow-list. Entries are names without `:`. |
76+
| `allowRelativeLinks` | `true` | Allows links without a protocol. |
77+
| `defaultTarget` | `'_blank'` | Applied when command values or pasted DOM do not provide a target. Only `'_blank'` is preserved. |
78+
| `defaultRel` | `'noopener noreferrer'` | Applied when command values or pasted DOM do not provide `rel`. Empty strings become `null`. |
79+
| `onClick` | `null` | Optional callback for editor link clicks. When configured, Qalma prevents the native click and passes the link event to the host app. |
7880

7981
Invalid or empty hrefs make `setLink` return `false`.
8082

@@ -118,10 +120,36 @@ editor.execute('selectLink', { from: link.from, to: link.to });
118120

119121
## Click behavior
120122

121-
The plugin handles clicks on editor links. It prevents the editor click,
122-
normalizes the href with the same options, and opens `_blank` links with
123-
`window.open(href, '_blank', 'noopener,noreferrer')`. Other links assign
124-
`window.location.href`.
123+
The plugin does not navigate on editor link clicks by default. Navigation,
124+
routing, analytics, previews, and confirmation flows stay in your app.
125125

126-
If your product needs a different link-click policy, wrap the editor or build a
127-
custom plugin that handles those DOM events first.
126+
Configure `onClick` when you want Qalma to turn editor link clicks into a
127+
consumer-owned event. Qalma normalizes the href with the same options, prevents
128+
the native click, and passes the DOM event, anchor element, href, target, rel,
129+
and text to your callback.
130+
131+
```typescript
132+
import { LinkClickHandler, LinkPlugin } from '@qalma/editor';
133+
134+
const handleLinkClick: LinkClickHandler = ({ href, target }) => {
135+
if (target === '_blank') {
136+
window.open(href, '_blank', 'noopener,noreferrer');
137+
138+
return;
139+
}
140+
141+
window.location.assign(href);
142+
};
143+
144+
const editor = createQalmaEditor({
145+
plugins: [
146+
LinkPlugin.configure({
147+
onClick: handleLinkClick,
148+
}),
149+
],
150+
});
151+
```
152+
153+
That example intentionally keeps navigation in application code. A routed
154+
Angular app can call its router, open a product-specific preview, or ignore the
155+
click entirely.

apps/sandbox/src/app/app.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1142,13 +1142,15 @@ describe('App', () => {
11421142
allowRelativeLinks: true,
11431143
defaultTarget: '_blank',
11441144
defaultRel: 'noopener noreferrer',
1145+
onClick: null,
11451146
});
11461147
expect(LinkPlugin.options).toEqual(LINK_PLUGIN_DEFAULT_OPTIONS);
11471148
expect(configured.options).toEqual({
11481149
allowedProtocols: ['http', 'https', 'mailto', 'tel'],
11491150
allowRelativeLinks: false,
11501151
defaultTarget: '_blank',
11511152
defaultRel: 'noopener noreferrer',
1153+
onClick: null,
11521154
});
11531155
expect(() =>
11541156
LinkPlugin.configure({
@@ -1157,6 +1159,11 @@ describe('App', () => {
11571159
).toThrowError(
11581160
'LinkPlugin allowedProtocols must include at least one protocol.',
11591161
);
1162+
expect(() =>
1163+
LinkPlugin.configure({
1164+
onClick: 'open' as never,
1165+
}),
1166+
).toThrowError('LinkPlugin onClick must be a function or null.');
11601167
});
11611168

11621169
it('should expose configurable image defaults and validation', () => {

libs/editor/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ plugin instance with merged options, e.g.
112112
| `ListsPlugin` | `toggleBulletList`, `toggleOrderedList`, `splitListItem`, `liftListItem`, `sinkListItem` |
113113
| `TaskListPlugin` | `toggleTaskList`, `toggleTaskItemChecked`, `setTaskItemChecked`, `splitTaskItem`, `liftTaskItem`, `sinkTaskItem` |
114114
| `CodeBlockPlugin` | `toggleCodeBlock`, `setCodeBlockLanguage` |
115-
| `LinkPlugin` | `setLink`, `unsetLink` |
115+
| `LinkPlugin` | `setLink`, `selectLink`, `unsetLink` |
116116
| `ImagePlugin` | `insertImage`, `updateImage` |
117117
| `MentionPlugin` | `insertMention` |
118118
| `SlashCommandPlugin` | `deleteSlashCommand`, `dismissSlashCommand` |
@@ -130,7 +130,7 @@ Read each plugin's source under `src/lib/plugins` for configuration options
130130
(e.g. `HeadingsPlugin.configure({ levels: [1, 2, 3] })`,
131131
`MentionPlugin.configure({ trigger: '@' })`,
132132
`SlashCommandPlugin.configure({ trigger: '/' })`,
133-
`LinkPlugin.configure({ allowedProtocols: [...] })`).
133+
`LinkPlugin.configure({ allowedProtocols: [...], onClick })`).
134134

135135
## Learn more
136136

0 commit comments

Comments
 (0)