Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
41 changes: 41 additions & 0 deletions demo/tests/visual-tests/playground/Link.visual.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,47 @@ test.describe('Link', () => {
await editor.switchMode('wysiwyg');
});

test('should save a single-character link @wysiwyg', async ({editor, actions, page}) => {
await editor.fill('before a after');
await editor.press('ControlOrMeta+End');
await actions.pressFocused('ArrowLeft', ' after'.length);
await actions.pressFocused('Shift+ArrowLeft');
await expect(page.locator('.playground__pm-selection')).toHaveText(
/TextSelection\s*From: 8\s*To: 9/,
);
await actions.pressFocused('ControlOrMeta+k');

await expect(page.locator('.g-md-link-tooltip-view')).toBeVisible();
await page.locator('.g-md-link-tooltip-view input').fill('https://example.com');
await actions.pressFocused('Enter');

const link = editor.getBySelectorInContenteditable('a');
await expect(link).toHaveText('a');
await expect(link).toHaveAttribute('href', 'https://example.com');
await expect(page.locator('.g-md-link-tooltip-view')).toBeHidden();
});

test('should cancel a single-character link without leaving an empty link @wysiwyg', async ({
editor,
actions,
page,
}) => {
await editor.fill('before a after');
await editor.press('ControlOrMeta+End');
await actions.pressFocused('ArrowLeft', ' after'.length);
await actions.pressFocused('Shift+ArrowLeft');
await expect(page.locator('.playground__pm-selection')).toHaveText(
/TextSelection\s*From: 8\s*To: 9/,
);
await actions.pressFocused('ControlOrMeta+k');
await expect(page.locator('.g-md-link-tooltip-view')).toBeVisible();
await actions.pressFocused('Escape');

await expect(page.locator('.g-md-link-tooltip-view')).toBeHidden();
await expect(editor.getBySelectorInContenteditable('a')).toHaveCount(0);
await expect(editor.getByTextInContenteditable('before a after')).toBeVisible();
});

test('should insert via toolbar @wysiwyg', async ({browserName, editor, actions, wait}) => {
test.skip(browserName === 'webkit', 'fillFocused does not work correctly in webkit');

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {EditorState, TextSelection} from 'prosemirror-state';
import {builders} from 'prosemirror-test-builder';
import {DecorationSet, EditorView} from 'prosemirror-view';
import {describe, expect, it, vi} from 'vitest';

import {ExtensionsManager} from '../../../../core';
import {BaseNode, BaseSchemaSpecs} from '../../../base/specs';
import {BreakNodeName, BreaksSpecs} from '../../Breaks/BreaksSpecs';
import {ImageSpecs} from '../../Image/ImageSpecs';
import {LinkAttr, LinkSpecs, linkMarkName} from '../LinkSpecs';
import {linkTooltipPlugin} from '../plugins/LinkTooltipPlugin';

import {addEmptyLink, linkActionSpec2} from './linkEnhanceActions';

const deps = new ExtensionsManager({
extensions: (builder) =>
builder.use(BaseSchemaSpecs, {}).use(LinkSpecs).use(ImageSpecs).use(BreaksSpecs, {}),
}).buildDeps();
const {schema} = deps;
const {doc, p, link, image, br} = builders<'doc' | 'p' | 'link' | 'image' | 'br'>(schema, {
image: {nodeType: 'image', src: 'image.png'},
br: {nodeType: BreakNodeName.HardBreak},
doc: {nodeType: BaseNode.Doc},
p: {nodeType: BaseNode.Paragraph},
link: {markType: linkMarkName, [LinkAttr.Href]: 'https://example.com'},
});
const tooltip = linkTooltipPlugin(deps);

function decorations(state: EditorState) {
const result = tooltip.props.decorations?.call(tooltip, state);
return result instanceof DecorationSet ? result.find() : [];
}

describe('addEmptyLink', () => {
it.each([
['two inline images', () => doc(p('before <from>', image(), image(), '<to> after'))],
[
'an image surrounded by whitespace',
() => doc(p('before <from> ', image(), ' <to> after')),
],
] as const)('should keep link creation enabled for %s', (_name, createDocument) => {
const document = createDocument();
let state = EditorState.create({
schema,
doc: document,
selection: TextSelection.create(document, document.tag.from, document.tag.to),
});

expect(addEmptyLink(state)).toBe(true);
expect(
addEmptyLink(state, (tr) => {
state = state.apply(tr);
}),
).toBe(true);

const objects: {pos: number; size: number}[] = [];
state.doc.nodesBetween(document.tag.from, document.tag.to, (node, pos) => {
if (node.isInline && !node.isText) {
expect(schema.marks[linkMarkName].isInSet(node.marks)?.attrs).toMatchObject({
[LinkAttr.Href]: '',
[LinkAttr.IsPlaceholder]: true,
});
objects.push({pos, size: node.nodeSize});
}
});
expect(objects.length).toBeGreaterThan(0);
const last = objects[objects.length - 1];
expect(decorations(state)).toEqual([
expect.objectContaining({from: last.pos, to: last.pos + last.size}),
]);
});

it.each([
['a space', () => doc(p('before<from> <to>after'))],
['whitespace and a line break', () => doc(p('before<from> ', br(), ' \u00a0<to>after'))],
] as const)('should reject %s without dispatching', (_name, createDocument) => {
const document = createDocument();
const state = EditorState.create({
schema,
doc: document,
selection: TextSelection.create(document, document.tag.from, document.tag.to),
});
const dispatch = vi.fn();

expect(addEmptyLink(state)).toBe(false);
expect(addEmptyLink(state, dispatch)).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
const view = new EditorView(null, {state});
try {
expect(linkActionSpec2(deps).isEnable(state, undefined, view)).toBe(false);
} finally {
view.destroy();
}
});

it.each([
['a', 8, 9],
[' a ', 9, 10],
['word', 8, 12],
[' word ', 9, 13],
] as const)('should provide a tooltip anchor for selected text %j', (text, from, to) => {
const document = doc(p('before <from>', text, '<to> after'));
let state = EditorState.create({
schema,
doc: document,
selection: TextSelection.create(document, document.tag.from, document.tag.to),
});

expect(
addEmptyLink(state, (tr) => {
state = state.apply(tr);
}),
).toBe(true);
expect(decorations(state)).toEqual([
expect.objectContaining({
from,
to,
}),
]);
});

it('should anchor a new single-character link next to an existing link', () => {
const document = doc(p(link('existing'), '<from>a<to> after'));
let state = EditorState.create({
schema,
doc: document,
selection: TextSelection.create(document, document.tag.from, document.tag.to),
});

expect(
addEmptyLink(state, (tr) => {
state = state.apply(tr);
}),
).toBe(true);
expect(decorations(state)).toEqual([
expect.objectContaining({from: document.tag.from, to: document.tag.to}),
]);
});

it('should keep a saved link tooltip hidden at its start', () => {
const document = doc(p('before ', link('<cursor>a'), ' after'));
const state = EditorState.create({
schema,
doc: document,
selection: TextSelection.create(document, document.tag.cursor),
});

expect(decorations(state)).toEqual([]);
});
});
Comment thread
ReFFaT marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const addEmptyLink: Command = (state, dispatch) => {
const {$from, $to} = selection;
// text selection inside one text node
if ($from.parent !== $to.parent) return false;
if (selection instanceof TextSelection && isWhitespaceOnlySelection(selection)) return false;

let tr = state.tr;
const toggleResult = toggleMark(linkMarkType, {
[LinkAttr.Href]: '',
Expand All @@ -40,7 +42,8 @@ export const addEmptyLink: Command = (state, dispatch) => {
tr.setMeta(imageRendererKey, meta);
}
} else {
const selectedText = state.doc.textBetween($from.pos, $to.pos);
// Preserve inline objects so whitespace before them is not counted as trailing.
const selectedText = state.doc.textBetween($from.pos, $to.pos, '', '\uFFFC');
Comment thread
ReFFaT marked this conversation as resolved.
Outdated
const countOfWhitespacesAtEnd = selectedText.length - selectedText.trimEnd().length;
tr.setSelection(TextSelection.create(tr.doc, $to.pos - countOfWhitespacesAtEnd - 1));
}
Expand All @@ -56,3 +59,11 @@ export const linkActionSpec2 = (deps: ExtensionDeps): ActionSpec => ({
isEnable: addLinkCmd2(deps),
run: addLinkCmd2(deps),
});

function isWhitespaceOnlySelection({from, to, $from}: TextSelection): boolean {
// Inline objects count as content; line breaks do not.
const text = $from.doc.textBetween(from, to, '', (node) =>
node.type.spec.isBreak ? '' : '\uFFFC',
);
return !text.trim();
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,30 +19,32 @@ const className = 'pm-link-focus-deco';
const placement: PopupPlacement = ['bottom-start', 'bottom-end', 'top-start', 'top-end'];

function getTextNode(state: EditorState) {
if (
!(state.selection instanceof TextSelection) ||
!state.selection.empty ||
!isMarkActive(state, linkType(state.schema))
)
return null;
if (!(state.selection instanceof TextSelection) || !state.selection.empty) return null;

const markType = linkType(state.schema);
// A one-character placeholder leaves the cursor at the start of a non-inclusive mark.
const nextNode = state.selection.$from.nodeAfter;
const nextLink = nextNode && findMark(nextNode, markType);
if (!isMarkActive(state, markType) && !nextLink?.attrs[LinkAttr.IsPlaceholder]) return null;

const parent = findParentNode(Boolean)(state.selection);

if (!parent) {
return null;
}

const textNodes = findChildrenByMark(parent?.node, linkType(state.schema));
const contentStart = parent.pos + 1;
const textNodes = findChildrenByMark(parent.node, markType);

const textNode = textNodes.find((n: {node: Node; pos: number}) => {
const start = n.pos + parent.pos;
const start = n.pos + contentStart;
const end = start + n.node.nodeSize;
return start <= state.selection.from && end >= state.selection.from;
return start <= state.selection.from && end > state.selection.from;
})!;

if (!textNode) return null;

const absolutePos = textNode.pos + parent.pos + 1;
const absolutePos = textNode.pos + contentStart;

return {
...textNode,
Expand Down
Loading