Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b88619f
feat: added new formats
pkaramon Apr 13, 2026
c1ee88c
feat: added conflicting and blocking styles
pkaramon Apr 13, 2026
e3fc98f
fix: handle bold style with headings blocking style edge case
pkaramon Apr 13, 2026
1fb1b31
fix(htmlStyle): strip bold marks when bold prop in htmlStyle changes
pkaramon Apr 14, 2026
f55dec3
chore(EnrichedCodeBlock): improve comment
pkaramon Apr 14, 2026
5dafa99
feat(web): merge adjacent codeblocks and blockquotes
pkaramon Apr 14, 2026
887ec48
refactor: organize ProseMirror plugins
pkaramon Apr 14, 2026
b74f0c6
fix: ensure initial html is set via transaction
pkaramon Apr 14, 2026
151da29
fix(example-web): toolbar scrolling
pkaramon Apr 14, 2026
5dddf77
Merge branch 'main' into @pkaramon/feat-web-header-blockquote-codeblock
pkaramon Apr 14, 2026
d4efac9
feat: changed keybinds for codeblock & blockquote to match native ver…
pkaramon Apr 14, 2026
93a3922
test: added a visual test
pkaramon Apr 15, 2026
4f3239f
test: added more tests
pkaramon Apr 15, 2026
ce1b6cd
refactor: extract common things in web tests
pkaramon Apr 15, 2026
5b97d05
refactor: clean up htmlStyleToCSSVars
pkaramon Apr 15, 2026
da2b79d
test: added a test for conflicting styles
pkaramon Apr 15, 2026
a630f23
fix: tiny fixes
pkaramon Apr 15, 2026
85b7ef1
refactor: cleanup stripBoldInStyledHeadings
pkaramon Apr 15, 2026
50b2f73
Merge branch 'main' into @pkaramon/feat-web-header-blockquote-codeblock
pkaramon Apr 15, 2026
5150a84
fix: inline code should not have padding
pkaramon Apr 15, 2026
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
21 changes: 21 additions & 0 deletions .playwright/helpers/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Locator } from '@playwright/test';

export async function copySelectionFrom(locator: Locator): Promise<void> {
await locator.click();
await locator.click({ clickCount: 3 });
await locator.page().keyboard.press('ControlOrMeta+C');
}

export async function pasteInto(locator: Locator): Promise<void> {
await locator.click();
await locator.click({ clickCount: 3 });
await locator.page().keyboard.press('ControlOrMeta+V');
}

export async function copyAndPasteBetween(
source: Locator,
dest: Locator
): Promise<void> {
await copySelectionFrom(source);
await pasteInto(dest);
}
5 changes: 5 additions & 0 deletions .playwright/helpers/toolbar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { Page } from '@playwright/test';

export function toolbarButton(page: Page, id: string) {
return page.locator(`[data-testid="toolbar-button-${id}"]`);
}
45 changes: 45 additions & 0 deletions .playwright/helpers/visual-regression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { expect, type Locator, type Page } from '@playwright/test';

export const visualRegressionSelectors = {
editor: '[data-testid="visual-regression-editor"]',
editorInner: '[data-testid="visual-regression-editor"] .eti-editor',
htmlInput: '[data-testid="visual-regression-html-input"]',
setValueButton: '[data-testid="visual-regression-set-value-button"]',
editorHtmlOutput: '[data-testid="visual-regression-editor-html-output"]',
htmlStyleOverride: '[data-testid="visual-regression-html-style-override"]',
} as const;

export function editorLocator(page: Page): Locator {
return page.locator(visualRegressionSelectors.editorInner);
}

export async function gotoVisualRegression(page: Page): Promise<void> {
await page.goto('/visual-regression');
await page.waitForSelector(visualRegressionSelectors.editorInner);
}

export async function getSerializedHtml(page: Page): Promise<string> {
return (
(await page
.locator(visualRegressionSelectors.editorHtmlOutput)
.textContent()) ?? ''
);
}

export async function setEditorHtml(page: Page, html: string): Promise<void> {
await page.fill(visualRegressionSelectors.htmlInput, html);
await page.click(visualRegressionSelectors.setValueButton);
await expect
.poll(async () => {
const t = await getSerializedHtml(page);
return t.startsWith('<html>');
})
.toBe(true);
}

export async function setHtmlStyleOverride(
page: Page,
json: string
): Promise<void> {
await page.fill(visualRegressionSelectors.htmlStyleOverride, json);
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified .playwright/screenshots/inline-styles.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
79 changes: 79 additions & 0 deletions .playwright/tests/codeblockInlineStyles.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test';

import { copyAndPasteBetween } from '../helpers/clipboard';
import {
editorLocator,
getSerializedHtml,
gotoVisualRegression,
setEditorHtml,
} from '../helpers/visual-regression';
import { toolbarButton } from '../helpers/toolbar';

const INLINE_MARK_TAG = /<\s*(b|i|u|s|code)\b/i;

function htmlInsideCodeblock(serialized: string): string {
const m = serialized.match(/<codeblock[^>]*>([\s\S]*?)<\/codeblock>/i);
return m ? m[1] : '';
}

test.describe('codeblock inline styles', () => {
test.beforeEach(async ({ page }) => {
await gotoVisualRegression(page);
});

test('inline toolbar controls are disabled inside code block', async ({
page,
}) => {
await setEditorHtml(
page,
'<html><codeblock><p>Inside code</p></codeblock></html>'
);

await page.locator('.eti-editor codeblock p').click();

for (const key of [
'bold',
'italic',
'underline',
'strikeThrough',
'inlineCode',
] as const) {
await expect(toolbarButton(page, key)).toBeDisabled();
}
});

test('setValue strips inline styles inside code block', async ({ page }) => {
await setEditorHtml(
page,
'<html><codeblock><p><b>bold</b> <i>italic</i> <u>underline</u> <s>strike</s> <code>inline</code></p></codeblock></html>'
);

const html = await getSerializedHtml(page);
expect(htmlInsideCodeblock(html)).not.toMatch(INLINE_MARK_TAG);

await expect(editorLocator(page)).toHaveScreenshot(
'codeblock-inline-styles-setvalue-stripped.png'
);
});

test('paste into code block strips copied inline styles', async ({
page,
}) => {
await setEditorHtml(
page,
'<html><p><b>pasteMe</b></p><codeblock><p>placeholder</p></codeblock></html>'
);

await copyAndPasteBetween(
page.locator('.eti-editor p').first(),
page.locator('.eti-editor codeblock p')
);

const htmlAfterPaste = await getSerializedHtml(page);
expect(htmlInsideCodeblock(htmlAfterPaste)).not.toMatch(INLINE_MARK_TAG);

await expect(editorLocator(page)).toHaveScreenshot(
'codeblock-inline-styles-paste-stripped.png'
);
});
});
137 changes: 137 additions & 0 deletions .playwright/tests/conflictingBlockStyles.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { test, expect, type Page } from '@playwright/test';

import { toolbarButton } from '../helpers/toolbar';
import {
getSerializedHtml,
gotoVisualRegression,
setEditorHtml,
} from '../helpers/visual-regression';

function hasOpeningTag(html: string, tag: string): boolean {
return new RegExp(`<${tag}(?:\\s|>)`, 'i').test(html);
}

async function waitForOpeningTagInSerializedHtml(
page: Page,
tag: string
): Promise<void> {
await expect
.poll(async () => hasOpeningTag(await getSerializedHtml(page), tag))
.toBe(true);
}

type ToolbarKey = 'h1' | 'h2' | 'h3' | 'blockQuote' | 'codeBlock';

const CASES: readonly {
name: string;
html: string;
focusSelector: string;
click: ToolbarKey;
expectTag: string;
notTags: readonly string[];
expectText: string;
}[] = [
{
name: 'h1 → blockquote',
html: '<html><h1>Heading</h1></html>',
focusSelector: '.eti-editor h1',
click: 'blockQuote',
expectTag: 'blockquote',
notTags: ['h1'],
expectText: 'Heading',
},
{
name: 'h1 → codeblock',
html: '<html><h1>Heading</h1></html>',
focusSelector: '.eti-editor h1',
click: 'codeBlock',
expectTag: 'codeblock',
notTags: ['h1'],
expectText: 'Heading',
},
{
name: 'h2 → blockquote',
html: '<html><h2>Heading</h2></html>',
focusSelector: '.eti-editor h2',
click: 'blockQuote',
expectTag: 'blockquote',
notTags: ['h2'],
expectText: 'Heading',
},
{
name: 'blockquote → h1',
html: '<html><blockquote><p>Quote</p></blockquote></html>',
focusSelector: '.eti-editor blockquote p',
click: 'h1',
expectTag: 'h1',
notTags: ['blockquote'],
expectText: 'Quote',
},
{
name: 'codeblock → blockquote',
html: '<html><codeblock><p>Code</p></codeblock></html>',
focusSelector: '.eti-editor codeblock p',
click: 'blockQuote',
expectTag: 'blockquote',
notTags: ['codeblock'],
expectText: 'Code',
},
{
name: 'blockquote → codeblock',
html: '<html><blockquote><p>Quote</p></blockquote></html>',
focusSelector: '.eti-editor blockquote p',
click: 'codeBlock',
expectTag: 'codeblock',
notTags: ['blockquote'],
expectText: 'Quote',
},
{
name: 'h1 → h2 (heading swap)',
html: '<html><h1>Heading</h1></html>',
focusSelector: '.eti-editor h1',
click: 'h2',
expectTag: 'h2',
notTags: ['h1'],
expectText: 'Heading',
},
{
name: 'h3 → blockquote',
html: '<html><h3>Heading</h3></html>',
focusSelector: '.eti-editor h3',
click: 'blockQuote',
expectTag: 'blockquote',
notTags: ['h3'],
expectText: 'Heading',
},
];

test.describe('conflicting block styles (toolbar replaces active block)', () => {
test.beforeEach(async ({ page }) => {
await gotoVisualRegression(page);
});

for (const {
name,
html,
focusSelector,
click: toolbarKey,
expectTag,
notTags,
expectText,
} of CASES) {
test(name, async ({ page }) => {
await setEditorHtml(page, html);

await page.locator(focusSelector).click();
await toolbarButton(page, toolbarKey).click();

await waitForOpeningTagInSerializedHtml(page, expectTag);

const out = await getSerializedHtml(page);
for (const t of notTags) {
expect(hasOpeningTag(out, t)).toBe(false);
}
expect(out).toContain(expectText);
});
}
});
Loading
Loading