Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
62 changes: 62 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,68 @@ test.describe('Link', () => {
await editor.switchMode('wysiwyg');
});

for (const text of ['a', '1', 'я', ' a ']) {
test(`should save a single-character link for ${JSON.stringify(text)} @wysiwyg`, async ({
editor,
actions,
page,
}) => {
await editor.fill(`before ${text} after`);
await editor.press('ControlOrMeta+End');
await actions.pressFocused('ArrowLeft', ' after'.length);
await actions.pressFocused('Shift+ArrowLeft', text.length);
await actions.pressFocused('ControlOrMeta+k');

await expect(page.locator('.g-md-link-tooltip-view')).toBeVisible();
Comment thread
ReFFaT marked this conversation as resolved.
Outdated
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(text.trim());
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 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();
});

for (const text of [' ', ' ', '\u00a0']) {
test(`should ignore whitespace-only link selection ${JSON.stringify(text)} @wysiwyg`, async ({
editor,
actions,
page,
}) => {
await editor.fill(`before${text}after`);
await editor.press('ControlOrMeta+End');
await actions.pressFocused('ArrowLeft', 'after'.length);
await actions.pressFocused('Shift+ArrowLeft', text.length);
await editor.assertMainToolbarButtonDisabled('Link');
await actions.pressFocused('ControlOrMeta+k');

await expect(page.locator('.g-md-link-tooltip-view')).toBeHidden();
await expect(editor.getBySelectorInContenteditable('a')).toHaveCount(0);
await expect(editor.getBySelectorInContenteditable('p')).toHaveText(
`before${text}after`,
);
});
}

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,101 @@
import {EditorState, TextSelection} from 'prosemirror-state';
import {builders} from 'prosemirror-test-builder';
import {DecorationSet} from 'prosemirror-view';
import {describe, expect, it, vi} from 'vitest';

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

import {addEmptyLink} from './linkEnhanceActions';

const deps = new ExtensionsManager({
extensions: (builder) => builder.use(BaseSchemaSpecs, {}).use(LinkSpecs).use(ImageSpecs),
}).buildDeps();
const {schema} = deps;
const {doc, p, link} = builders<'doc' | 'p' | 'link'>(schema, {
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);
expect(result).toBeInstanceOf(DecorationSet);
return result instanceof DecorationSet ? result.find() : [];
}

describe('addEmptyLink', () => {
it.each([' ', ' ', '\t', '\u00a0', '\u2009', ' \t\u00a0 '])(
'should reject whitespace-only selection %j without dispatching',
(text) => {
const document = doc(p('before <from>', text, '<to> after'));
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();
},
);

it.each(['a', '1', 'я', 'a ', ' a', ' a ', 'word', ' word '])(
'should provide a tooltip anchor for selected text %j',
(text) => {
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: document.tag.from + text.length - text.trimStart().length,
to: document.tag.to - (text.length - text.trimEnd().length),
}),
]);
},
);

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,9 @@ export const addEmptyLink: Command = (state, dispatch) => {
const {$from, $to} = selection;
// text selection inside one text node
if ($from.parent !== $to.parent) return false;
const selectedText = state.doc.textBetween($from.pos, $to.pos);
if (selection instanceof TextSelection && !selectedText.trim()) return false;
Comment thread
ReFFaT marked this conversation as resolved.
Outdated

let tr = state.tr;
const toggleResult = toggleMark(linkMarkType, {
[LinkAttr.Href]: '',
Expand All @@ -40,7 +43,6 @@ export const addEmptyLink: Command = (state, dispatch) => {
tr.setMeta(imageRendererKey, meta);
}
} else {
const selectedText = state.doc.textBetween($from.pos, $to.pos);
const countOfWhitespacesAtEnd = selectedText.length - selectedText.trimEnd().length;
tr.setSelection(TextSelection.create(tr.doc, $to.pos - countOfWhitespacesAtEnd - 1));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@ const className = 'pm-link-focus-deco';
const placement: PopupPlacement = ['bottom-start', 'bottom-end', 'top-start', 'top-end'];

function getTextNode(state: EditorState) {
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 (
!(state.selection instanceof TextSelection) ||
!state.selection.empty ||
!isMarkActive(state, linkType(state.schema))
(!isMarkActive(state, markType) && !nextLink?.attrs[LinkAttr.IsPlaceholder])
)
return null;

Expand All @@ -35,9 +39,9 @@ function getTextNode(state: EditorState) {
const textNodes = findChildrenByMark(parent?.node, linkType(state.schema));

const textNode = textNodes.find((n: {node: Node; pos: number}) => {
const start = n.pos + parent.pos;
const start = n.pos + parent.pos + 1;
Comment thread
ReFFaT marked this conversation as resolved.
Outdated
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;
Expand Down