diff --git a/.eslintrc.js b/.eslintrc.js index a489703f..0de6efc5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -50,7 +50,7 @@ module.exports = { 'es/no-nullish-coalescing-operators': 'off', 'es/no-optional-chaining': 'off', '@typescript-eslint/no-use-before-define': 'off', // TODO consider enabling this (currently it reports styles defined at the bottom of the file) - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], '@typescript-eslint/consistent-type-imports': [ 'error', { prefer: 'type-imports' }, diff --git a/WebExample/__tests__/codeblock.spec.ts b/WebExample/__tests__/codeblock.spec.ts new file mode 100644 index 00000000..a4241005 --- /dev/null +++ b/WebExample/__tests__/codeblock.spec.ts @@ -0,0 +1,287 @@ +import {test, expect} from '@playwright/test'; +import type {Locator, Page} from '@playwright/test'; +// eslint-disable-next-line import/no-relative-packages +import * as TEST_CONST from '../../example/src/testConstants'; +import {getElementValue, pressCmd, setCursorPosition, setupInput, testMarkdownContentStyle} from './utils'; + +const CODEBLOCK_DEFAULT_STYLE = + 'font-family: monospace; font-size: 20px; color: black; background-color: lightgray; border-color: gray; border-width: 1px; border-radius: 4px; border-style: solid; padding: 2px;'; + +async function testCodeblockStyle(page: Page, dimmensions: {height: number; width: number} | null, style: string | null = CODEBLOCK_DEFAULT_STYLE) { + if (style === null) { + await testMarkdownContentStyle({ + testContent: 'Codeblock', + style: 'margin: 0px; padding: 0px;', + page, + }); + return; + } + await testMarkdownContentStyle({ + testContent: 'Codeblock', + style, + dimmensions: dimmensions ?? undefined, + page, + }); +} + +async function getCodeblockElementCount(inputLocator: Locator) { + return inputLocator.locator(`span[data-type="codeblock"]`).count(); +} + +test.beforeEach(async ({page}) => { + await page.goto(TEST_CONST.LOCAL_URL, {waitUntil: 'load'}); +}); + +test.describe('modifying codeblock content', () => { + test('keep newlines when writing after opening syntax', async ({page}) => { + const inputLocator = await setupInput(page, 'clear'); + await inputLocator.focus(); + await inputLocator.pressSequentially('```\nCodeblock\nSample code line\n```'); + + await setCursorPosition(page, 0); + await inputLocator.pressSequentially('test'); + + expect(await getElementValue(inputLocator)).toEqual('```test\nCodeblock\nSample code line\n```'); + // Verify if the codeblock style wasn't applied + await testCodeblockStyle(page, null, null); + }); + + test('keep codeblock structure when writing in the empty last line', async ({page}) => { + const inputLocator = await setupInput(page, 'clear'); + await inputLocator.focus(); + await inputLocator.pressSequentially('```\nCodeblock\nSample code line\n\n```'); + + await setCursorPosition(page, 6, 0); + await inputLocator.pressSequentially('test'); + + expect(await getElementValue(inputLocator)).toEqual('```\nCodeblock\nSample code line\ntest\n```'); + await testCodeblockStyle(page, { + height: 84, + width: 198, + }); + }); + + test('allow writing after closing syntax', async ({page}) => { + const codeblockDimmensions = { + height: 58, + width: 198, + }; + const inputLocator = await setupInput(page, 'clear'); + await inputLocator.focus(); + await inputLocator.pressSequentially('```\nCodeblock\nSample code line\n```'); + + await setCursorPosition(page, 6); + await testCodeblockStyle(page, codeblockDimmensions); + await inputLocator.pressSequentially('test'); + + expect(await getElementValue(inputLocator)).toEqual('```\nCodeblock\nSample code line\n```test'); + // Verify if when typing after codeblock closing syntax, its height is not changed + await testCodeblockStyle(page, codeblockDimmensions); + }); + + test('remove whole codeblock', async ({page}) => { + const inputLocator = await setupInput(page, 'clear'); + await inputLocator.focus(); + await inputLocator.pressSequentially('```\nCodeblock\nSample code line\n```'); + + await pressCmd({inputLocator, command: 'a'}); + await inputLocator.press('Backspace'); + + expect(await getElementValue(inputLocator)).toEqual(''); + }); + + test('wrap content', async ({page}) => { + const LINE_TO_ADD = ' very long line of code that should be wrapped'; + const inputLocator = await setupInput(page, 'clear'); + await inputLocator.focus(); + await inputLocator.pressSequentially('```\nCodeblock\nSample code line\n```'); + + await setCursorPosition(page, 3); + await inputLocator.pressSequentially(LINE_TO_ADD); + + expect(await getElementValue(inputLocator)).toEqual(`\`\`\`\nCodeblock${LINE_TO_ADD}\nSample code line\n\`\`\``); + await testCodeblockStyle(page, { + height: 110, + width: 288, + }); + }); + + test('remove newline after opening syntax', async ({page}) => { + const inputLocator = await setupInput(page, 'clear'); + await inputLocator.focus(); + await inputLocator.pressSequentially('```\nCodeblock\nSample code line\n```'); + + await setCursorPosition(page, 2, 0); + await inputLocator.press('Backspace'); + + expect(await getElementValue(inputLocator)).toEqual('```Codeblock\nSample code line\n```'); + // Verify if the codeblock style wasn't applied + await testCodeblockStyle(page, null, null); + }); + + test('remove newline after opening syntax with single line content', async ({page}) => { + const inputLocator = await setupInput(page, 'clear'); + await inputLocator.focus(); + await inputLocator.pressSequentially('```\nCodeblock\n```'); + + await setCursorPosition(page, 2, 0); + await inputLocator.press('Backspace'); + + expect(await getElementValue(inputLocator)).toEqual('```Codeblock\n```'); + // Verify if the codeblock style wasn't applied + await testCodeblockStyle(page, null, null); + }); + + test('remove newline before closing syntax', async ({page}) => { + const inputLocator = await setupInput(page, 'clear'); + await inputLocator.focus(); + await inputLocator.pressSequentially('```\nCodeblock\nSample code line\n```'); + + await setCursorPosition(page, 6, 0); + await inputLocator.press('Backspace'); + + expect(await getElementValue(inputLocator)).toEqual('```\nCodeblock\nSample code line```'); + // Verify if the codeblock style wasn't applied + await testCodeblockStyle(page, null, null); + }); + + test('remove newline before closing syntax with one empty line at the end', async ({page}) => { + const inputLocator = await setupInput(page, 'clear'); + await inputLocator.focus(); + await inputLocator.pressSequentially('```\nCodeblock\nSample code line\n\n```'); + + await setCursorPosition(page, 6, 0); + await inputLocator.press('Backspace'); + + expect(await getElementValue(inputLocator)).toEqual('```\nCodeblock\nSample code line\n```'); + await testCodeblockStyle(page, { + height: 58, + width: 198, + }); + }); + + test('remove newline before closing syntax with two empy lines at the end', async ({page}) => { + const inputLocator = await setupInput(page, 'clear'); + await inputLocator.focus(); + await inputLocator.pressSequentially('```\nCodeblock\nSample code line\n\n\n```'); + + await setCursorPosition(page, 6, 0); + await inputLocator.press('Backspace'); + + expect(await getElementValue(inputLocator)).toEqual('```\nCodeblock\nSample code line\n\n```'); + await testCodeblockStyle(page, { + height: 84, + width: 198, + }); + }); + + test('remove newline before opening syntax', async ({page}) => { + const inputLocator = await setupInput(page, 'clear'); + await inputLocator.focus(); + await inputLocator.pressSequentially('\n\n```\nCodeblock\nSample code line\n```'); + + await setCursorPosition(page, 2, 0); + await inputLocator.press('Backspace'); + + expect(await getElementValue(inputLocator)).toEqual('\n```\nCodeblock\nSample code line\n```'); + await testCodeblockStyle(page, { + height: 58, + width: 198, + }); + }); + + test('remove newline between two codeblocks', async ({page}) => { + const inputLocator = await setupInput(page, 'clear'); + await inputLocator.focus(); + await inputLocator.pressSequentially('```\nCodeblock\nSample code line\n```\n```\nCodeblock\nSecond sample code line\n```'); + + await setCursorPosition(page, 7, 0); + await inputLocator.press('Backspace'); + + expect(await getElementValue(inputLocator)).toEqual('```\nCodeblock\nSample code line\n``````\nCodeblock\nSecond sample code line\n```'); + expect(await getCodeblockElementCount(inputLocator)).toEqual(1); + + await inputLocator.press('Enter'); + + expect(await getElementValue(inputLocator)).toEqual('```\nCodeblock\nSample code line\n```\n```\nCodeblock\nSecond sample code line\n```'); + expect(await getCodeblockElementCount(inputLocator)).toEqual(2); + }); +}); + +test('update codeblock dimensions when resizing the input', async ({page}) => { + await page.setViewportSize({width: 1280, height: 720}); + const inputLocator = await setupInput(page, 'clear'); + await inputLocator.focus(); + await inputLocator.pressSequentially('```\nCodeblock\nSample very long line of code that should be wrapped\n```'); + + await testCodeblockStyle(page, { + height: 110, + width: 288, + }); + + await inputLocator.evaluate((inputElement: HTMLInputElement) => { + const element = inputElement; + element.style.width = '500px'; + element.style.height = '200px'; + }); + await page.waitForTimeout(10); + + await testCodeblockStyle(page, { + height: 84, + width: 488, + }); +}); + +test.describe('scrolling into view', () => { + test('scroll to an empty codeblock line', async ({page}) => { + const inputLocator = await setupInput(page, 'clear'); + await inputLocator.focus(); + await inputLocator.evaluate((inputElement: HTMLInputElement) => { + const element = inputElement; + element.style.height = '100px'; + }); + await inputLocator.pressSequentially('```\nCodeblock start\n\n\n\n\n\n\n\n\nCodeblock end\n```'); + + await setCursorPosition(page, 4); + await inputLocator.blur(); + await inputLocator.evaluate((inputElement: HTMLInputElement) => { + const element = inputElement; + element.scrollTop = element.scrollHeight; + return element.scrollHeight; + }); + + await inputLocator.focus(); + const scrollTop = await inputLocator.evaluate((inputElement: HTMLInputElement) => { + const element = inputElement; + return element.scrollTop; + }); + + expect(scrollTop).toBeLessThanOrEqual(30); + }); + + test('scroll to the cursor after opening syntax', async ({page}) => { + const inputLocator = await setupInput(page, 'clear'); + await inputLocator.focus(); + await inputLocator.evaluate((inputElement: HTMLInputElement) => { + const element = inputElement; + element.style.height = '100px'; + }); + await inputLocator.pressSequentially('```\nCodeblock start\n\n\n\n\n\n\n\n\nCodeblock end\n```'); + + await setCursorPosition(page, 1); + await inputLocator.blur(); + await inputLocator.evaluate((inputElement: HTMLInputElement) => { + const element = inputElement; + element.scrollTop = element.scrollHeight; + return element.scrollHeight; + }); + + await inputLocator.focus(); + const scrollTop = await inputLocator.evaluate((inputElement: HTMLInputElement) => { + const element = inputElement; + return element.scrollTop; + }); + + expect(scrollTop).toBeLessThanOrEqual(25); + }); +}); diff --git a/WebExample/__tests__/styles.spec.ts b/WebExample/__tests__/styles.spec.ts index 562cf1fc..d54334a9 100644 --- a/WebExample/__tests__/styles.spec.ts +++ b/WebExample/__tests__/styles.spec.ts @@ -1,17 +1,7 @@ -import {test, expect} from '@playwright/test'; -import type {Page} from '@playwright/test'; +import {test} from '@playwright/test'; // eslint-disable-next-line import/no-relative-packages import * as TEST_CONST from '../../example/src/testConstants'; -import {setupInput, getElementStyle} from './utils'; - -const testMarkdownContentStyle = async ({testContent, style, page}: {testContent: string; style: string; page: Page}) => { - const inputLocator = await setupInput(page); - - const elementHandle = inputLocator.locator('span', {hasText: testContent}).last(); - const elementStyle = await getElementStyle(elementHandle); - - expect(elementStyle).toEqual(style); -}; +import {testMarkdownContentStyle} from './utils'; test.beforeEach(async ({page}) => { await page.goto(TEST_CONST.LOCAL_URL, {waitUntil: 'load'}); @@ -32,11 +22,21 @@ test.describe('markdown content styling', () => { }); test('inline code', async ({page}) => { - await testMarkdownContentStyle({testContent: 'inline code', style: 'font-family: monospace; font-size: 20px; color: black; background-color: lightgray;', page}); + await testMarkdownContentStyle({ + testContent: 'inline code', + style: + 'font-family: monospace; font-size: 20px; color: black; background-color: lightgray; border-color: gray; border-width: 1px; border-radius: 4px; border-style: solid; padding: 0px; line-height: 1.5;', + page, + }); }); test('codeblock', async ({page}) => { - await testMarkdownContentStyle({testContent: 'codeblock', style: 'font-family: monospace; font-size: 20px; color: black; background-color: lightgray;', page}); + await testMarkdownContentStyle({ + testContent: 'codeblock', + style: + 'font-family: monospace; font-size: 20px; color: black; background-color: lightgray; border-color: gray; border-width: 1px; border-radius: 4px; border-style: solid; padding: 2px;', + page, + }); }); test('mention-here', async ({page}) => { diff --git a/WebExample/__tests__/textManipulation.spec.ts b/WebExample/__tests__/textManipulation.spec.ts index c9cce0fa..5d4d58c8 100644 --- a/WebExample/__tests__/textManipulation.spec.ts +++ b/WebExample/__tests__/textManipulation.spec.ts @@ -132,7 +132,7 @@ test('cut content changes', async ({page, browserName}) => { expect(await getElementValue(inputLocator)).toBe(EXPECTED_CONTENT); - // Ckeck if there is no markdown elements after the cut operation + // Check if there is no markdown elements after the cut operation const spans = await inputLocator.locator('span[data-type="text"]'); expect(await spans.count()).toBe(1); }); diff --git a/WebExample/__tests__/utils.ts b/WebExample/__tests__/utils.ts index cd96d7bf..729af3f2 100644 --- a/WebExample/__tests__/utils.ts +++ b/WebExample/__tests__/utils.ts @@ -1,4 +1,5 @@ import type {Locator, Page} from '@playwright/test'; +import {expect} from '@playwright/test'; // eslint-disable-next-line import/no-relative-packages import * as TEST_CONST from '../../example/src/testConstants'; @@ -23,20 +24,29 @@ const getCursorPosition = async (elementHandle: Locator) => { return selection; }; -const setCursorPosition = ({startNode, endNode}: {startNode?: Element; endNode?: Element | null}) => { - if (!startNode?.firstChild || !endNode?.lastChild) { - return null; - } +const setCursorPosition = async (page: Page, elementIndex: number, offset?: number) => { + return page.evaluate( + // eslint-disable-next-line no-shadow + async ({elementIndex, offset}) => { + const filteredNode = Array.from(document.querySelectorAll('span[data-type="text"], span[data-type="br"]')); - const range = new Range(); - range.setStart(startNode.firstChild, 2); - range.setEnd(endNode.lastChild, endNode.lastChild.textContent?.length ?? 0); + const node = filteredNode[elementIndex]; - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); + if (node?.firstChild) { + const range = new Range(); + const offsetValue = offset ?? node.firstChild.textContent?.length ?? 0; + range.setStart(node.firstChild, offsetValue); + range.setEnd(node.firstChild, offsetValue); - return selection; + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } + + return filteredNode; + }, + {elementIndex, offset}, + ); }; const getElementStyle = async (elementHandle: Locator) => { @@ -73,4 +83,24 @@ const setSelection = async (page: Page) => { await page.click(`[data-testid="${TEST_CONST.CHANGE_SELECTION}"]`); }; -export {setupInput, getCursorPosition, setCursorPosition, getElementStyle, pressCmd, getElementValue, changeMarkdownStyle, setSelection}; +const testMarkdownContentStyle = async ({testContent, style, page, dimmensions}: {testContent: string; style: string; page: Page; dimmensions?: {height: number; width: number}}) => { + const inputLocator = await setupInput(page); + + const elementHandle = inputLocator.locator('span', {hasText: testContent}).last(); + const elementStyle = await getElementStyle(elementHandle); + + expect(elementStyle).toEqual(style); + + if (dimmensions && elementHandle) { + await elementHandle.waitFor({state: 'attached'}); + // We need to get styles from the parent element because every text node is wrapped additionally with a span element + const parentElementHandle = await elementHandle.evaluateHandle((element) => { + return element.parentElement; + }); + const elementDimmensions = await parentElementHandle.asElement()?.boundingBox(); + expect(Math.floor(elementDimmensions?.height ?? 0)).toEqual(dimmensions.height); + expect(Math.floor(elementDimmensions?.width ?? 0)).toEqual(dimmensions.width); + } +}; + +export {setupInput, getCursorPosition, setCursorPosition, getElementStyle, pressCmd, getElementValue, changeMarkdownStyle, setSelection, testMarkdownContentStyle}; diff --git a/src/MarkdownTextInputDecoratorViewNativeComponent.ts b/src/MarkdownTextInputDecoratorViewNativeComponent.ts index 73d46cb5..3f529251 100644 --- a/src/MarkdownTextInputDecoratorViewNativeComponent.ts +++ b/src/MarkdownTextInputDecoratorViewNativeComponent.ts @@ -3,6 +3,20 @@ import type {ColorValue, ViewProps} from 'react-native'; import type {Float, Int32} from 'react-native/Libraries/Types/CodegenTypes'; import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; +interface CodeBlockStyle { + fontFamily: string; + fontSize: Float; + color: ColorValue; + backgroundColor: ColorValue; + borderColor?: ColorValue; + borderWidth?: Float; + borderRadius?: Float; + borderStyle?: string; + padding?: Float; + paddingVertical?: Float; + paddingHorizontal?: Float; +} + interface MarkdownStyle { syntax: { color: ColorValue; @@ -22,18 +36,10 @@ interface MarkdownStyle { marginLeft: Float; paddingLeft: Float; }; - code: { - fontFamily: string; - fontSize: Float; - color: ColorValue; - backgroundColor: ColorValue; - }; - pre: { - fontFamily: string; - fontSize: Float; - color: ColorValue; - backgroundColor: ColorValue; + code: CodeBlockStyle & { + h1NestedFontSize?: Float; }; + pre: CodeBlockStyle; mentionHere: { color: ColorValue; backgroundColor: ColorValue; diff --git a/src/__tests__/parseExpensiMark.test.ts b/src/__tests__/parseExpensiMark.test.ts index 2998050b..c9e95828 100644 --- a/src/__tests__/parseExpensiMark.test.ts +++ b/src/__tests__/parseExpensiMark.test.ts @@ -225,8 +225,9 @@ test('inline code', () => { test('codeblock', () => { expect('```\nHello world!\n```').toBeParsedAs([ - {type: 'syntax', start: 0, length: 3}, - {type: 'pre', start: 3, length: 14}, + {type: 'codeblock', start: 0, length: 20}, + {type: 'syntax', start: 0, length: 4}, + {type: 'pre', start: 4, length: 13}, {type: 'syntax', start: 17, length: 3}, ]); }); diff --git a/src/__tests__/webParser.test.tsx b/src/__tests__/webParser.test.tsx index 8deeff92..fbc77fd0 100644 --- a/src/__tests__/webParser.test.tsx +++ b/src/__tests__/webParser.test.tsx @@ -175,7 +175,7 @@ test('inline code', () => { test('codeblock', () => { expect('```\nHello world!\n```').toBeParsedAsHTML( - '
```
Hello world!
```
```
Hello world!
```
*[data-type='emoji'], +.react-native-live-markdown-input-multiline *[data-type='code'] > *[data-type='emoji'] { + font-size: inherit !important; + line-height: inherit !important; + vertical-align: unset !important; +} + +.react-native-live-markdown-input-multiline *[data-type='line'] *[data-type='syntax']:has(+ *[data-type='pre']), +.react-native-live-markdown-input-multiline *[data-type='line'] *[data-type='pre'] + *[data-type='syntax'] { + display: block; + line-height: 1.3; } @keyframes react-native-live-markdown-spin { diff --git a/src/web/utils/blockUtils.ts b/src/web/utils/blockUtils.ts index 075ec12e..62b4349b 100644 --- a/src/web/utils/blockUtils.ts +++ b/src/web/utils/blockUtils.ts @@ -1,5 +1,6 @@ -import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; import type {InlineImagesInputProps, MarkdownRange} from '../../commonTypes'; +import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; +import {parseStringWithUnitToNumber} from '../../styleUtils'; import type {PartialMarkdownStyle} from '../../styleUtils'; import {addInlineImagePreview} from '../inputElements/inlineImage'; import type {NodeType, TreeNode} from './treeUtils'; @@ -51,12 +52,9 @@ function addStyleToBlock(targetElement: HTMLElement, type: NodeType, markdownSty }); break; case 'code': - Object.assign(node.style, markdownStyle.code); - break; case 'pre': - Object.assign(node.style, markdownStyle.pre); + addCodeBlockStyles(targetElement, type, markdownStyle, isMultiline); break; - case 'blockquote': Object.assign(node.style, { ...markdownStyle.blockquote, @@ -94,8 +92,42 @@ function addStyleToBlock(targetElement: HTMLElement, type: NodeType, markdownSty } } +function addCodeBlockStyles(targetElement: HTMLElement, type: NodeType, markdownStyle: PartialMarkdownStyle, isMultiline = true) { + const node = targetElement; + + const defaultCodePadding = markdownStyle.code?.padding ?? 0; + const codeHorizontalPadding = parseStringWithUnitToNumber(markdownStyle.code?.paddingHorizontal ?? defaultCodePadding).toString(); + const codeVerticalPadding = parseStringWithUnitToNumber(markdownStyle.code?.paddingVertical ?? defaultCodePadding).toString(); + + switch (type) { + case 'code': + Object.assign(node.style, { + ...markdownStyle.code, + fontSize: markdownStyle.code?.h1NestedFontSize && isChildOfMarkdownElement(node, 'h1') ? markdownStyle.code.h1NestedFontSize : markdownStyle.code?.fontSize, + padding: `${codeVerticalPadding}px ${codeHorizontalPadding}px`, + lineHeight: 1.5, + }); + break; + case 'pre': + if (isMultiline) { + Object.assign(node.style, { + ...markdownStyle.pre, + }); + } else { + Object.assign(node.style, { + ...markdownStyle.code, + padding: `${codeVerticalPadding}px ${codeHorizontalPadding}px`, + }); + } + break; + default: + break; + } +} + const BLOCK_MARKDOWN_TYPES = ['inline-image']; const FULL_LINE_MARKDOWN_TYPES = ['blockquote']; +const MULTILINE_MARKDOWN_TYPES = ['codeblock']; function isBlockMarkdownType(type: NodeType) { return BLOCK_MARKDOWN_TYPES.includes(type); @@ -103,7 +135,7 @@ function isBlockMarkdownType(type: NodeType) { function getFirstBlockMarkdownRange(ranges: MarkdownRange[]) { const blockMarkdownRange = ranges.find((r) => isBlockMarkdownType(r.type) || FULL_LINE_MARKDOWN_TYPES.includes(r.type)); - return FULL_LINE_MARKDOWN_TYPES.includes(blockMarkdownRange?.type || '') ? undefined : blockMarkdownRange; + return blockMarkdownRange && FULL_LINE_MARKDOWN_TYPES.includes(blockMarkdownRange.type) ? undefined : blockMarkdownRange; } function extendBlockStructure( @@ -125,4 +157,23 @@ function extendBlockStructure( return targetNode; } -export {addStyleToBlock, extendBlockStructure, isBlockMarkdownType, getFirstBlockMarkdownRange}; +function isDescendantOfMarkdownElement(node: HTMLElement, predicate: (type: string | null) => boolean): boolean { + let currentNode = node.parentNode; + while (currentNode && (currentNode as HTMLElement)?.contentEditable !== 'true') { + const elementType = (currentNode as HTMLElement).getAttribute?.('data-type'); + if (predicate(elementType)) { + return true; + } + currentNode = currentNode.parentNode; + } + return false; +} + +function isChildOfMarkdownElement(node: HTMLElement, elementType: NodeType): boolean { + return isDescendantOfMarkdownElement(node, (type) => type === elementType); +} +function isChildOfMultilineMarkdownElement(node: HTMLElement): boolean { + return isDescendantOfMarkdownElement(node, (type) => MULTILINE_MARKDOWN_TYPES.includes(type as NodeType)); +} + +export {addStyleToBlock, extendBlockStructure, isBlockMarkdownType, getFirstBlockMarkdownRange, isChildOfMarkdownElement, isChildOfMultilineMarkdownElement, MULTILINE_MARKDOWN_TYPES}; diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts index adc0a1b9..82eebc7f 100644 --- a/src/web/utils/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -1,4 +1,5 @@ import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; +import {isChildOfMultilineMarkdownElement} from './blockUtils'; import {findHTMLElementInTree, getTreeNodeByIndex} from './treeUtils'; import type {TreeNode} from './treeUtils'; @@ -53,10 +54,35 @@ function setCursorPosition(target: MarkdownTextInputElement, startIndex: number, } function scrollIntoView(target: MarkdownTextInputElement, node: TreeNode) { + let scrollTargetElement = node.element; const targetElement = target; - const orderIndex = Number(node.orderIndex.split(',')[0]); - const currentLine = target.tree.childNodes[orderIndex]?.element; - const scrollTargetElement = currentLine || node.element; + + if (!isChildOfMultilineMarkdownElement(node.element)) { + const orderIndex = Number(node.orderIndex.split(',')[0]); + const currentLine = target.tree.childNodes[orderIndex]?.element; + if (currentLine) { + scrollTargetElement = currentLine; + } + } else if (node.element.nodeName === 'BR') { + // Force scrolling BR into view + const selection = window.getSelection(); + if (selection) { + const range = document.createRange(); + + range.setStartBefore(node.element); + range.collapse(true); + + selection.addRange(range); + + // Scroll to caret + const span = document.createElement('span'); + span.textContent = '\u200B'; // zero-width space + range.insertNode(span); + span.scrollIntoView({block: 'center'}); + span.remove(); // cleanup + return; + } + } const caretRect = scrollTargetElement.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index 8bc59817..f79ee446 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -1,5 +1,7 @@ import type {CSSProperties} from 'react'; import type {MarkdownNativeEvent, MarkdownTextInputElement} from '../../MarkdownTextInput.web'; +import {isChildOfMarkdownElement, isChildOfMultilineMarkdownElement} from './blockUtils'; +import BrowserUtils from './browserUtils'; const ZERO_WIDTH_SPACE = '\u200B'; @@ -36,17 +38,33 @@ function normalizeValue(value: string) { return value.replaceAll('\r\n', '\n'); } -// Parses the HTML structure of a MarkdownTextInputElement to a plain text string. Used for getting the correct value of the input element. -function parseInnerHTMLToText(target: MarkdownTextInputElement, cursorPosition: number, inputType?: string): string { - // Returns the parent of a given node that is higher in the hierarchy and is of a different type than 'text', 'br' or 'line' - function getTopParentNode(node: ChildNode) { - let currentParentNode = node.parentNode; - while (currentParentNode && ['text', 'br', 'line'].includes(currentParentNode.parentElement?.getAttribute('data-type') || '')) { - currentParentNode = currentParentNode?.parentNode || null; - } - return currentParentNode; +/** + * Returns the parent of a given node that is higher in the hierarchy and is of a different type than 'text', 'br' or 'line' + */ +function getTopParentNode(node: ChildNode) { + let currentParentNode = node.parentNode; + while (currentParentNode && ['text', 'br', 'line'].includes(currentParentNode.parentElement?.getAttribute('data-type') || '')) { + currentParentNode = currentParentNode?.parentNode || null; + } + return currentParentNode; +} + +/** + * On Firefox, when breaking one codeblock, its syntax and the
after it can be merged into the closing syntax of the previous codeblock. + */ +function didTwoCodeblocksMerge(node: ChildNode | null) { + if (!node || !BrowserUtils.isFirefox) { + return; } + // To identify that two codeblock has merged, we check if current line ends with
tag, that previously was second codeblock's opening syntax line break + const hasPartOfBrokenCodeblock = node.lastChild?.lastChild?.lastChild?.lastChild?.nodeName === 'BR'; + return BrowserUtils.isFirefox && (node.lastChild as HTMLElement)?.getAttribute('data-type') === 'codeblock' && hasPartOfBrokenCodeblock; +} +/** + * Parses the HTML structure of a MarkdownTextInputElement to a plain text string. Used for getting the correct value of the input element. + */ +function parseInnerHTMLToText(target: MarkdownTextInputElement, cursorPosition: number, inputType?: string): string { const stack: ChildNode[] = [target]; let text = ''; let shouldAddNewline = false; @@ -57,7 +75,7 @@ function parseInnerHTMLToText(target: MarkdownTextInputElement, cursorPosition: } while (stack.length > 0) { - const node = stack.pop(); + const node = stack.pop() as HTMLElement; if (!node) { break; } @@ -72,7 +90,7 @@ function parseInnerHTMLToText(target: MarkdownTextInputElement, cursorPosition: } else { const firstChild = node.firstChild as HTMLElement; const containsEmptyBlockElement = firstChild?.getAttribute?.('data-type') === 'block' && firstChild.textContent === ''; - if (shouldAddNewline && !containsEmptyBlockElement) { + if (firstChild && shouldAddNewline && !containsEmptyBlockElement && !didTwoCodeblocksMerge(node.previousSibling)) { text += '\n'; shouldAddNewline = false; } @@ -81,11 +99,41 @@ function parseInnerHTMLToText(target: MarkdownTextInputElement, cursorPosition: } if (node.nodeType === Node.TEXT_NODE) { + let hasAddedNewline = false; + // Fix for codeblocks: Removing last codeblock newline, moves codeblock syntax too far into the codeblock content + // skipping one
after the codeblock syntax. We need to force parsing it before the text node is added. + if (node.parentElement && !node.parentElement.getAttribute?.('data-type') && isChildOfMarkdownElement(node, 'pre')) { + text += '\n'; + const nextBR = node.parentElement?.nextElementSibling?.firstElementChild ?? node.parentElement?.nextElementSibling; + if (nextBR && nextBR.tagName === 'BR') { + nextBR.remove(); + } + hasAddedNewline = true; + } + // Parse text nodes into text text += node.textContent; + + // Fix for codeblocks: If we are adding text at the end of a multiline markdown type element, we need to add a newline + // because the new text can replace the last
element and it will not be added to the text. + if ( + node.parentElement && + node.parentNode?.parentElement?.nextSibling && + !node.parentNode?.nextSibling && + isChildOfMultilineMarkdownElement(node) && + ((!hasAddedNewline && isChildOfMarkdownElement(node, 'br')) || (!node.parentElement.getAttribute?.('data-type') && isChildOfMarkdownElement(node, 'syntax'))) + ) { + text += '\n'; + } } else if (node.nodeName === 'BR') { const parentNode = getTopParentNode(node); - if (parentNode && parentNode.parentElement?.contentEditable !== 'true' && !!(node as HTMLElement).getAttribute('data-id')) { + + if ( + (parentNode && + parentNode.parentElement?.contentEditable !== 'true' && + !!((node as HTMLElement).getAttribute('data-id') || (node.parentElement as HTMLElement).getAttribute('data-type') === 'br')) || + (node.parentElement?.getAttribute('data-type') === 'text' && isChildOfMultilineMarkdownElement(node)) + ) { // Parse br elements into newlines only if their parent is not a child of the MarkdownTextInputElement (a paragraph when writing or a div when pasting). // It prevents adding extra newlines when entering text text += '\n';