From d79ad0897cb1ce58521e96a3412d269cd4e77614 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 3 Apr 2026 10:13:47 -0700 Subject: [PATCH 01/50] Puppeteer test for thought background color --- src/e2e/puppeteer/__tests__/color.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/e2e/puppeteer/__tests__/color.ts b/src/e2e/puppeteer/__tests__/color.ts index dcd77083d73..a3885a4232b 100644 --- a/src/e2e/puppeteer/__tests__/color.ts +++ b/src/e2e/puppeteer/__tests__/color.ts @@ -341,3 +341,21 @@ it('Can change the background color of a note to match its thought', async () => const note = await getFirstNoteText() expect(note).toBe('Note') }) + +it('Can change the background color of a thought that already has the same background color applied to part of its text', async () => { + const text = + 'a long enough thought where a tap will fall outside of the formatting' + await paste(` + - ${text} + `) + + // change the background color on the thought + await clickThought('a long enough thought where a tap will fall outside of the formatting') + await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') + await click('[aria-label="background color swatches"] [aria-label="red"]') + + const note = await getEditingText() + expect(note).toBe( + 'a long enough thought where a tap will fall outside of the formatting', + ) +}) From 10b43faa6a9fc77b9c50fb28c43f592d60dcda20 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 3 Apr 2026 10:14:33 -0700 Subject: [PATCH 02/50] getSelectedHexColor and getSelectedHexBackgroundColor in selection helper --- src/device/selection.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/device/selection.ts b/src/device/selection.ts index 8f20905b5d0..5a6dd8e28f7 100644 --- a/src/device/selection.ts +++ b/src/device/selection.ts @@ -3,6 +3,7 @@ import { isHTMLElement } from 'framer-motion' import SplitResult from '../@types/SplitResult' import { ALLOWED_FORMATTING_TAGS } from '../constants' +import rgbToHex from '../util/rgbToHex' export type SelectionOptionsType = { offset?: number @@ -511,3 +512,24 @@ export const isNear = ( return x >= left && y >= top && x <= right && y <= bottom } + +/** Returns the node that contains the start of the selection, or null if there is no selection. */ +const getSelectedElementNode = (): Node | null => { + const selection = window.getSelection() + const node = selection && selection.rangeCount ? selection.getRangeAt(0).startContainer : null + return (node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement) ?? null +} + +/** Returns the hex color of the current selection, or null if there is no selection. */ +export const getSelectedHexColor = () => { + const container = getSelectedElementNode() + return container instanceof HTMLElement && container.hasAttribute('color') + ? rgbToHex(container.getAttribute('color')!) + : null +} + +/** Returns the hex background color of the current selection, or null if there is no selection. */ +export const getSelectedHexBackgroundColor = () => { + const container = getSelectedElementNode() + return container instanceof HTMLElement ? rgbToHex(container.style.backgroundColor) : null +} From 1f4c36fd627859c2aa3046c67f0f745ee0abb74f Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 3 Apr 2026 13:09:21 -0700 Subject: [PATCH 03/50] ColorPicker: Allow commandStateStore to handle background-color --- src/components/ColorPicker.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 2f2df0b87b6..220e7f27ac9 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -58,7 +58,6 @@ const ColorSwatch: FC<{ document.execCommand('foreColor') always sets the color as hex whether the value is rgb or hex. And document.execCommand('backColor') always sets the background with the rgb */ const colorRegex = /color="#([0-9a-fA-F]{6})"/g - const bgColorRegex = /background-color:\s*(rgb\(\d{1,3},\s?\d{1,3},\s?\d{1,3}\))/g const textHexColor = color ? addAlphaToHex(rgbToHex(themeColor[color])) : undefined const backHexColor = backgroundColor ? addAlphaToHex(rgbToHex(themeColor[backgroundColor])) : undefined if ( @@ -70,7 +69,7 @@ const ColorSwatch: FC<{ ) { const colorMatches = currentEditableValue.match(colorRegex) || [] - let matchColor, match + let matchColor // Get the colors and background colors used in current thought's value const fgColors: Set = new Set() if (colorMatches) { @@ -80,14 +79,7 @@ const ColorSwatch: FC<{ matchColor = fgColors.size > 1 ? null : fgColors.values().next().value } - const bgColors: Set = new Set() - while ((match = bgColorRegex.exec(currentEditableValue)) !== null) if (match[1]) bgColors.add(match[1]) - const matchBgColor = bgColors.size > 1 ? null : bgColors.values().next().value - - return !!( - (textHexColor && textHexColor === (matchColor && addAlphaToHex(rgbToHex(matchColor)))) || - (backHexColor && backHexColor === (matchBgColor && addAlphaToHex(rgbToHex(matchBgColor)))) - ) + return !!(textHexColor && textHexColor === (matchColor && addAlphaToHex(rgbToHex(matchColor)))) } return !!( (textHexColor && textHexColor === commandStateColor) || From 62a02afe2782f1558c714238631832c878322214 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 3 Apr 2026 13:09:59 -0700 Subject: [PATCH 04/50] Revert "getSelectedHexColor and getSelectedHexBackgroundColor in selection helper" This reverts commit 10b43faa6a9fc77b9c50fb28c43f592d60dcda20. --- src/device/selection.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/device/selection.ts b/src/device/selection.ts index 5a6dd8e28f7..8f20905b5d0 100644 --- a/src/device/selection.ts +++ b/src/device/selection.ts @@ -3,7 +3,6 @@ import { isHTMLElement } from 'framer-motion' import SplitResult from '../@types/SplitResult' import { ALLOWED_FORMATTING_TAGS } from '../constants' -import rgbToHex from '../util/rgbToHex' export type SelectionOptionsType = { offset?: number @@ -512,24 +511,3 @@ export const isNear = ( return x >= left && y >= top && x <= right && y <= bottom } - -/** Returns the node that contains the start of the selection, or null if there is no selection. */ -const getSelectedElementNode = (): Node | null => { - const selection = window.getSelection() - const node = selection && selection.rangeCount ? selection.getRangeAt(0).startContainer : null - return (node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement) ?? null -} - -/** Returns the hex color of the current selection, or null if there is no selection. */ -export const getSelectedHexColor = () => { - const container = getSelectedElementNode() - return container instanceof HTMLElement && container.hasAttribute('color') - ? rgbToHex(container.getAttribute('color')!) - : null -} - -/** Returns the hex background color of the current selection, or null if there is no selection. */ -export const getSelectedHexBackgroundColor = () => { - const container = getSelectedElementNode() - return container instanceof HTMLElement ? rgbToHex(container.style.backgroundColor) : null -} From d0412c31a8db070f5ca1f48c5beef2ee81f8d991 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 3 Apr 2026 13:50:53 -0700 Subject: [PATCH 05/50] Fix Puppeteer test --- src/e2e/puppeteer/__tests__/color.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/e2e/puppeteer/__tests__/color.ts b/src/e2e/puppeteer/__tests__/color.ts index a3885a4232b..47a33fabc5c 100644 --- a/src/e2e/puppeteer/__tests__/color.ts +++ b/src/e2e/puppeteer/__tests__/color.ts @@ -349,13 +349,12 @@ it('Can change the background color of a thought that already has the same backg - ${text} `) - // change the background color on the thought - await clickThought('a long enough thought where a tap will fall outside of the formatting') + // change the background color on the thought twice to avoid inconstencies between paste and normal user interaction await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') await click('[aria-label="background color swatches"] [aria-label="red"]') + await click('[aria-label="background color swatches"] [aria-label="red"]') + console.log(await page.evaluate(() => document.querySelector('[data-editable="true"]')?.innerHTML)) - const note = await getEditingText() - expect(note).toBe( - 'a long enough thought where a tap will fall outside of the formatting', - ) + const thought = await getEditingText() + expect(thought).toBe('a long enough thought where a tap will fall outside of the formatting') }) From d08f60b72f36e2c92d27a8b52f452c2ceb2b48f9 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 3 Apr 2026 13:57:06 -0700 Subject: [PATCH 06/50] Remove logging statement --- src/e2e/puppeteer/__tests__/color.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/e2e/puppeteer/__tests__/color.ts b/src/e2e/puppeteer/__tests__/color.ts index 47a33fabc5c..5898aaa5d21 100644 --- a/src/e2e/puppeteer/__tests__/color.ts +++ b/src/e2e/puppeteer/__tests__/color.ts @@ -353,7 +353,6 @@ it('Can change the background color of a thought that already has the same backg await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') await click('[aria-label="background color swatches"] [aria-label="red"]') await click('[aria-label="background color swatches"] [aria-label="red"]') - console.log(await page.evaluate(() => document.querySelector('[data-editable="true"]')?.innerHTML)) const thought = await getEditingText() expect(thought).toBe('a long enough thought where a tap will fall outside of the formatting') From 650164aa4277c4db6f1aed500044f2caf5a9235e Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Apr 2026 16:44:06 -0700 Subject: [PATCH 07/50] Fix Puppeteer test --- src/e2e/puppeteer/__tests__/color.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/e2e/puppeteer/__tests__/color.ts b/src/e2e/puppeteer/__tests__/color.ts index 5898aaa5d21..cf3033b2804 100644 --- a/src/e2e/puppeteer/__tests__/color.ts +++ b/src/e2e/puppeteer/__tests__/color.ts @@ -349,11 +349,12 @@ it('Can change the background color of a thought that already has the same backg - ${text} `) - // change the background color on the thought twice to avoid inconstencies between paste and normal user interaction + // change the background color on the thought await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') await click('[aria-label="background color swatches"] [aria-label="red"]') - await click('[aria-label="background color swatches"] [aria-label="red"]') const thought = await getEditingText() - expect(thought).toBe('a long enough thought where a tap will fall outside of the formatting') + expect(thought).toBe( + 'a long enough thought where a tap will fall outside of the formatting', + ) }) From 1f03f2898c1d6bf1794763fd215bca54ec1b5d63 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Apr 2026 17:28:48 -0700 Subject: [PATCH 08/50] Update extractColors to only match colors that start at the beginning of the matched text --- src/util/__tests__/getCommandState.ts | 8 ++++---- src/util/getCommandState.ts | 9 ++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/util/__tests__/getCommandState.ts b/src/util/__tests__/getCommandState.ts index e72b5ad1ec9..42cea41f38e 100644 --- a/src/util/__tests__/getCommandState.ts +++ b/src/util/__tests__/getCommandState.ts @@ -138,7 +138,7 @@ it.skip('text and background color on span tag', () => { it('fully styled thought', () => { expect( getCommandState( - 'text', + 'text', ), ).toStrictEqual({ bold: true, @@ -146,7 +146,7 @@ it('fully styled thought', () => { underline: true, strikethrough: true, code: true, - foreColor: 'rgb(255, 0, 0)', + foreColor: '#000000', backColor: 'rgb(0, 0, 255)', }) }) @@ -154,7 +154,7 @@ it('fully styled thought', () => { it('fully styled thought without text content', () => { expect( getCommandState( - '', + '', ), ).toStrictEqual({ bold: true, @@ -162,7 +162,7 @@ it('fully styled thought without text content', () => { underline: true, strikethrough: true, code: true, - foreColor: 'rgb(255, 0, 0)', + foreColor: '#000000', backColor: 'rgb(0, 0, 255)', }) }) diff --git a/src/util/getCommandState.ts b/src/util/getCommandState.ts index 896c6aafa7c..9acb27ffa20 100644 --- a/src/util/getCommandState.ts +++ b/src/util/getCommandState.ts @@ -23,11 +23,14 @@ const tags = { /** Extracts the foreground and background colors from the given string. * Returns an object with foreColor and backColor properties. - * If the string does not contain a font or span tag, undefined is returned. + * The colors must apply to the entire string, so other tags are permitted + * before the color tag, but the color tag must encompass the entire text (#3904). + * Otherwise, undefined is returned. */ const extractColors = (savedValue: string) => { - const foreColorRegex = /<(?:span|font)[^>]*\s(?:color=["']?([^"']+)["']?[^>]*>)/i - const backColorRegex = /<(?:span|font)[^>]*\sstyle=["'][^"']*background-color:\s*([^;"']+)/i + const foreColorRegex = /^(?:<(?:b|i|u|strike|code)>)*<(?:span|font)[^>]*\s(?:color=["']?([^"']+)["']?[^>]*>)/i + const backColorRegex = + /^(?:<(?:b|i|u|strike|code)>)*<(?:span|font)[^>]*\sstyle=["'][^"']*background-color:\s*([^;"']+)/i // Attempt to extract the font color const foreColorMatch = savedValue.match(foreColorRegex) From 231ab7f3ff3ee9b3241a4c160bb24b12929acb42 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 8 Apr 2026 16:19:23 -0700 Subject: [PATCH 09/50] Update extractColors to also require a closing tag at the end of the text --- src/util/getCommandState.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/util/getCommandState.ts b/src/util/getCommandState.ts index 9acb27ffa20..7190aab6e16 100644 --- a/src/util/getCommandState.ts +++ b/src/util/getCommandState.ts @@ -28,9 +28,10 @@ const tags = { * Otherwise, undefined is returned. */ const extractColors = (savedValue: string) => { - const foreColorRegex = /^(?:<(?:b|i|u|strike|code)>)*<(?:span|font)[^>]*\s(?:color=["']?([^"']+)["']?[^>]*>)/i + const foreColorRegex = + /^(?:<(?:b|i|u|strike|code)>)*<(?:span|font)[^>]*\s(?:color=["']?([^"']+)["']?[^>]*>).*<\/(?:span|font)>(?:<\/(?:b|i|u|strike|code)>)*$/i const backColorRegex = - /^(?:<(?:b|i|u|strike|code)>)*<(?:span|font)[^>]*\sstyle=["'][^"']*background-color:\s*([^;"']+)/i + /^(?:<(?:b|i|u|strike|code)>)*<(?:span|font)[^>]*\sstyle=["'][^"']*background-color:\s*([^;"']+).*<\/(?:span|font)>(?:<\/(?:b|i|u|strike|code)>)*$/i // Attempt to extract the font color const foreColorMatch = savedValue.match(foreColorRegex) From c20cd3f14ab96461bb42cca421e01d3f50922c82 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 8 Apr 2026 16:30:12 -0700 Subject: [PATCH 10/50] skip-tdd for getCommandState unit tests --- src/util/__tests__/getCommandState.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/util/__tests__/getCommandState.ts b/src/util/__tests__/getCommandState.ts index 42cea41f38e..d1af916fed4 100644 --- a/src/util/__tests__/getCommandState.ts +++ b/src/util/__tests__/getCommandState.ts @@ -135,6 +135,7 @@ it.skip('text and background color on span tag', () => { }) }) +// /skip-tdd it('fully styled thought', () => { expect( getCommandState( @@ -151,6 +152,7 @@ it('fully styled thought', () => { }) }) +// /skip-tdd it('fully styled thought without text content', () => { expect( getCommandState( From 946857056f0c125b2739565311a7c628f76c42a5 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 8 Apr 2026 16:34:09 -0700 Subject: [PATCH 11/50] Remove skip-tdd directive --- src/util/__tests__/getCommandState.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/util/__tests__/getCommandState.ts b/src/util/__tests__/getCommandState.ts index d1af916fed4..42cea41f38e 100644 --- a/src/util/__tests__/getCommandState.ts +++ b/src/util/__tests__/getCommandState.ts @@ -135,7 +135,6 @@ it.skip('text and background color on span tag', () => { }) }) -// /skip-tdd it('fully styled thought', () => { expect( getCommandState( @@ -152,7 +151,6 @@ it('fully styled thought', () => { }) }) -// /skip-tdd it('fully styled thought without text content', () => { expect( getCommandState( From dc67368c7d03a3fb475133c93fa1a1d0692c423a Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 8 Apr 2026 16:45:21 -0700 Subject: [PATCH 12/50] Add skip-tdd --- src/util/__tests__/getCommandState.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/util/__tests__/getCommandState.ts b/src/util/__tests__/getCommandState.ts index 42cea41f38e..f6d24551213 100644 --- a/src/util/__tests__/getCommandState.ts +++ b/src/util/__tests__/getCommandState.ts @@ -135,6 +135,7 @@ it.skip('text and background color on span tag', () => { }) }) +// /skip-tdd it('fully styled thought', () => { expect( getCommandState( From fa3a3b4f7137bbb03505029aa747855072f87d4d Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 8 Apr 2026 16:53:25 -0700 Subject: [PATCH 13/50] Add a test for a partially-styled thought with color --- src/util/__tests__/getCommandState.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/util/__tests__/getCommandState.ts b/src/util/__tests__/getCommandState.ts index f6d24551213..21f8e00b009 100644 --- a/src/util/__tests__/getCommandState.ts +++ b/src/util/__tests__/getCommandState.ts @@ -135,6 +135,22 @@ it.skip('text and background color on span tag', () => { }) }) +it('partially styled thought with color and background color', () => { + expect( + getCommandState( + 'text but only partly', + ), + ).toStrictEqual({ + bold: true, + italic: true, + underline: true, + strikethrough: true, + code: true, + foreColor: undefined, + backColor: undefined, + }) +}) + // /skip-tdd it('fully styled thought', () => { expect( From 093056c913f9f5c8ede8ca69d82b4cd7bd143f2f Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 8 Apr 2026 17:12:12 -0700 Subject: [PATCH 14/50] Clarify getCommandState comment --- src/util/getCommandState.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/util/getCommandState.ts b/src/util/getCommandState.ts index 7190aab6e16..c4537925cfd 100644 --- a/src/util/getCommandState.ts +++ b/src/util/getCommandState.ts @@ -23,8 +23,8 @@ const tags = { /** Extracts the foreground and background colors from the given string. * Returns an object with foreColor and backColor properties. - * The colors must apply to the entire string, so other tags are permitted - * before the color tag, but the color tag must encompass the entire text (#3904). + * The colors must apply to the entire string, so other tags are permitted before the color tag + * or after the closing color tag, but the color tag must encompass the entire text (#3904). * Otherwise, undefined is returned. */ const extractColors = (savedValue: string) => { From 9ea0dbeabe4dbd9b26eeff80cad2abbdceff5970 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Thu, 9 Apr 2026 13:39:22 -0700 Subject: [PATCH 15/50] Add Puppeteer test for undoing background color changes --- src/e2e/puppeteer/__tests__/undo.ts | 61 +++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/e2e/puppeteer/__tests__/undo.ts b/src/e2e/puppeteer/__tests__/undo.ts index e75f78d41dd..817d3f37655 100644 --- a/src/e2e/puppeteer/__tests__/undo.ts +++ b/src/e2e/puppeteer/__tests__/undo.ts @@ -1,10 +1,12 @@ import { KnownDevices } from 'puppeteer' import newThoughtCommand from '../../../commands/newThought' +import click from '../helpers/click' import clickThought from '../helpers/clickThought' import exportThoughts from '../helpers/exportThoughts' import gesture from '../helpers/gesture' import getEditingText from '../helpers/getEditingText' import keyboard from '../helpers/keyboard' +import paste from '../helpers/paste' import press from '../helpers/press' import { page } from '../setup' @@ -68,3 +70,62 @@ it('Undo Select All + Categorize chained command in one step', async () => { expect(highlightedCount).toBe(0) }) + +it('Should revert background color changes back to previous values', async () => { + const importText = ` + - Lorem Ipsum Dolor Sit Amet` + + await paste(importText) + + // open the ColorPicker + await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') + + const thought = await page.$('[aria-label=thought] [data-editable=true]') + const boundingBox = await thought?.boundingBox() + + if (!boundingBox) throw new Error('boundingBox not found') + + const y = boundingBox.y + boundingBox.height / 2 + + // get a position near the left edge of the thought + const leftX = boundingBox.x + 1 + + // double click to select the first word + await page.mouse.click(leftX, y, { clickCount: 2 }) + + // set the first word's background color to green + await click('[aria-label="background color swatches"] [aria-label="green"]') + + // dismiss the existing selection range + await page.mouse.click(leftX, y) + + // get a position near the right edge of the thought + const rightX = boundingBox.x + boundingBox.width - 36 + + // double click to select the last word + await page.mouse.click(rightX, y, { clickCount: 2 }) + + // set the last word's background color to green + await click('[aria-label="background color swatches"] [aria-label="green"]') + + // dismiss the existing selection range + await page.mouse.click(rightX, y) + + // get a position at the center of the thought + const centerX = boundingBox.x + boundingBox.width / 2 + + // click to place the caret in the center of the thought + await page.mouse.click(centerX, y) + + // set the entire thought's background color to red + await click('[aria-label="background color swatches"] [aria-label="red"]') + + // undo + await press('z', { meta: true }) + + // now the first and last words should have a green background again + const text = await getEditingText() + expect(text).toBe( + 'Lorem Ipsum Dolor Sit Amet', + ) +}) From 165f9782a070c9852ef5b73480f2252ee8ce982b Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 10 Apr 2026 12:33:06 -0700 Subject: [PATCH 16/50] Revert getCommandState tests, add nested color test --- src/util/__tests__/getCommandState.ts | 39 +++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/util/__tests__/getCommandState.ts b/src/util/__tests__/getCommandState.ts index 21f8e00b009..6b91351f485 100644 --- a/src/util/__tests__/getCommandState.ts +++ b/src/util/__tests__/getCommandState.ts @@ -151,11 +151,10 @@ it('partially styled thought with color and background color', () => { }) }) -// /skip-tdd it('fully styled thought', () => { expect( getCommandState( - 'text', + 'text', ), ).toStrictEqual({ bold: true, @@ -163,7 +162,7 @@ it('fully styled thought', () => { underline: true, strikethrough: true, code: true, - foreColor: '#000000', + foreColor: 'rgb(255, 0, 0)', backColor: 'rgb(0, 0, 255)', }) }) @@ -171,7 +170,7 @@ it('fully styled thought', () => { it('fully styled thought without text content', () => { expect( getCommandState( - '', + '', ), ).toStrictEqual({ bold: true, @@ -179,7 +178,37 @@ it('fully styled thought without text content', () => { underline: true, strikethrough: true, code: true, - foreColor: '#000000', + foreColor: 'rgb(255, 0, 0)', + backColor: 'rgb(0, 0, 255)', + }) +}) + +it('nested colors should use the inner one', () => { + expect(getCommandState('text')).toStrictEqual( + { + bold: false, + italic: false, + underline: false, + strikethrough: false, + code: false, + foreColor: 'rgb(0, 0, 255)', + backColor: undefined, + }, + ) +}) + +it('nested background colors should use the inner one', () => { + expect( + getCommandState( + 'text', + ), + ).toStrictEqual({ + bold: false, + italic: false, + underline: false, + strikethrough: false, + code: false, + foreColor: undefined, backColor: 'rgb(0, 0, 255)', }) }) From 2be767cd15a2f5c369e9c69c3602f7a6406f10d6 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 10 Apr 2026 12:48:18 -0700 Subject: [PATCH 17/50] Replace regex-based extractColors with DOMParser --- src/util/getCommandState.ts | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/util/getCommandState.ts b/src/util/getCommandState.ts index c4537925cfd..74da8968ea0 100644 --- a/src/util/getCommandState.ts +++ b/src/util/getCommandState.ts @@ -23,23 +23,22 @@ const tags = { /** Extracts the foreground and background colors from the given string. * Returns an object with foreColor and backColor properties. - * The colors must apply to the entire string, so other tags are permitted before the color tag - * or after the closing color tag, but the color tag must encompass the entire text (#3904). + * A color is only returned if its span or font tag wraps all of the text in savedValue (#3904). * Otherwise, undefined is returned. */ -const extractColors = (savedValue: string) => { - const foreColorRegex = - /^(?:<(?:b|i|u|strike|code)>)*<(?:span|font)[^>]*\s(?:color=["']?([^"']+)["']?[^>]*>).*<\/(?:span|font)>(?:<\/(?:b|i|u|strike|code)>)*$/i - const backColorRegex = - /^(?:<(?:b|i|u|strike|code)>)*<(?:span|font)[^>]*\sstyle=["'][^"']*background-color:\s*([^;"']+).*<\/(?:span|font)>(?:<\/(?:b|i|u|strike|code)>)*$/i +const extractColors = (savedValue: string): { foreColor: string | undefined; backColor: string | undefined } => { + const doc = new DOMParser().parseFromString(savedValue, 'text/html') + const tags = Array.from(doc.body.querySelectorAll('span, font')).filter( + el => el.textContent === doc.body.textContent, + ) - // Attempt to extract the font color - const foreColorMatch = savedValue.match(foreColorRegex) - const foreColor = foreColorMatch ? foreColorMatch[1].trim() : undefined + let foreColor: string | undefined + let backColor: string | undefined - // Attempt to extract the background-color from span - const backColorMatch = savedValue.match(backColorRegex) - const backColor = backColorMatch ? backColorMatch[1].trim() : undefined + for (const el of tags) { + foreColor = el.getAttribute('color') || el.style.color || foreColor + backColor = el.style.backgroundColor || backColor + } return { foreColor, backColor } } From 1691f4ce42cb4acc86cbbcb6830d7a3deccb1adf Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 10 Apr 2026 13:23:41 -0700 Subject: [PATCH 18/50] Clean up color test --- src/e2e/puppeteer/__tests__/color.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/e2e/puppeteer/__tests__/color.ts b/src/e2e/puppeteer/__tests__/color.ts index cf3033b2804..91a52435ff8 100644 --- a/src/e2e/puppeteer/__tests__/color.ts +++ b/src/e2e/puppeteer/__tests__/color.ts @@ -343,10 +343,8 @@ it('Can change the background color of a note to match its thought', async () => }) it('Can change the background color of a thought that already has the same background color applied to part of its text', async () => { - const text = - 'a long enough thought where a tap will fall outside of the formatting' await paste(` - - ${text} + - some formatted text `) // change the background color on the thought @@ -354,7 +352,5 @@ it('Can change the background color of a thought that already has the same backg await click('[aria-label="background color swatches"] [aria-label="red"]') const thought = await getEditingText() - expect(thought).toBe( - 'a long enough thought where a tap will fall outside of the formatting', - ) + expect(thought).toBe('some formatted text') }) From a8095817abbec25ac79a4ec4c4da37c1ca478d52 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 13 Apr 2026 15:50:43 -0700 Subject: [PATCH 19/50] Allow commandStateStore to handle foreground color --- src/components/ColorPicker.tsx | 31 +------------------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 220e7f27ac9..62418bf9caa 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -5,12 +5,8 @@ import { token } from '../../styled-system/tokens' import { formatSelectionActionCreator as formatSelection } from '../actions/formatSelection' import { isTouch } from '../browser' import { ColorToken } from '../colors.config' -import * as selection from '../device/selection' -import getThoughtById from '../selectors/getThoughtById' -import noteValue from '../selectors/noteValue' import themeColors from '../selectors/themeColors' import commandStateStore from '../stores/commandStateStore' -import head from '../util/head' import rgbToHex from '../util/rgbToHex' import Popover from './Popover' import TextColorIcon from './icons/TextColor' @@ -48,39 +44,14 @@ const ColorSwatch: FC<{ size = size || fontSize * 1.2 const selected = useSelector(state => { - const currentEditableValue = - (!!state.cursor && - (state.noteFocus ? noteValue(state, state.cursor) : getThoughtById(state, head(state.cursor))?.value)) || - '' const themeColor = themeColors(state) - /* Define the color and background color regex to get the current color of current thought or note + /* Compare the swatch color to the command state color. document.execCommand('foreColor') adds the color attribute with hex and document.execCommand('backColor') adds the background-color attribute with the rgb document.execCommand('foreColor') always sets the color as hex whether the value is rgb or hex. And document.execCommand('backColor') always sets the background with the rgb */ - const colorRegex = /color="#([0-9a-fA-F]{6})"/g const textHexColor = color ? addAlphaToHex(rgbToHex(themeColor[color])) : undefined const backHexColor = backgroundColor ? addAlphaToHex(rgbToHex(themeColor[backgroundColor])) : undefined - if ( - (!commandStateColor && !commandStateBackgroundColor) || - (commandStateColor === '#ccccccff' && commandStateBackgroundColor === '#333333ff') || - (commandStateColor === addAlphaToHex(rgbToHex(themeColor.fg)) && - commandStateBackgroundColor === addAlphaToHex(rgbToHex(themeColor.bg)) && - !selection.isThought()) - ) { - const colorMatches = currentEditableValue.match(colorRegex) || [] - let matchColor - // Get the colors and background colors used in current thought's value - const fgColors: Set = new Set() - if (colorMatches) { - // colorMatches will be like this : [color="#ee82ee", color="#ff823e"] and match.slice(7, -1) will be #ee82ee - // If the thought is colored with many colors, matchColor will be null and if the thought is colored with one color, matchColor will be that color - colorMatches.forEach(match => fgColors.add(match.slice(7, -1))) - matchColor = fgColors.size > 1 ? null : fgColors.values().next().value - } - - return !!(textHexColor && textHexColor === (matchColor && addAlphaToHex(rgbToHex(matchColor)))) - } return !!( (textHexColor && textHexColor === commandStateColor) || (backHexColor && backHexColor === commandStateBackgroundColor) From 290e4e57a1c8605369ef8387d65ce2f2f1853cc5 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 14 Apr 2026 15:10:15 -0700 Subject: [PATCH 20/50] Replace regex-based removeTags with DOMParser --- src/actions/formatSelection.ts | 53 +++++++--------------------------- 1 file changed, 10 insertions(+), 43 deletions(-) diff --git a/src/actions/formatSelection.ts b/src/actions/formatSelection.ts index e909ed15331..d5d2f64a38e 100644 --- a/src/actions/formatSelection.ts +++ b/src/actions/formatSelection.ts @@ -1,7 +1,6 @@ /* eslint-disable import/prefer-default-export */ import Thunk from '../@types/Thunk' import { ColorToken } from '../colors.config' -import { ALLOWED_ATTR } from '../constants' import * as selection from '../device/selection' import getThoughtById from '../selectors/getThoughtById' import noteValue from '../selectors/noteValue' @@ -12,7 +11,6 @@ import themeColors from '../selectors/themeColors' import { updateCommandState } from '../stores/commandStateStore' import suppressFocusStore from '../stores/suppressFocus' import head from '../util/head' -import rgbToHex from '../util/rgbToHex' import strip from '../util/strip' import { editThoughtActionCreator as editThought } from './editThought' import { setDescendantActionCreator as setDescendant } from './setDescendant' @@ -70,38 +68,6 @@ export const formatSelectionActionCreator = if (command === 'backColor') { if (color === 'bg') { - /** Function to check if a style(background) should be removed based on the color and background-color. */ - const shouldRemoveStyle = (styleString: string) => { - const styleLower = styleString.toLowerCase() - const colorMatch = styleLower.match(/background-color\s*:\s*([^;]+);?/) - const elementColor = colorMatch ? colorMatch[1].trim() : null - const isSameColor = elementColor && rgbToHex(elementColor) === rgbToHex(colors.bg) - if (elementColor && isSameColor) return true - return false - } - - /** Function to collect tag names without significant attributes. */ - const collectTagsWithoutAttributes = (text: string, pattern: RegExp): string[] => - Array.from(text.matchAll(pattern)) - // Filter out tags that lack meaningful attributes - .filter(([, , attributes]) => { - const meaningfulAttributes = ALLOWED_ATTR.map(attr => `${attr}=`) - // Return true if attributes are absent or do not contain any meaningful attributes - return !attributes || !meaningfulAttributes.some(attr => attributes.includes(attr)) - }) - // Map to extract the tag names from matches - .map(([, tagName]) => tagName) - - /** Function to create a new text string with specified tags and their content removed. */ - const removeTags = (text: string, tags: string[]): string => - // Use reduce to accumulate a new string without the unwanted tags - tags.reduce((acc, tagName) => { - const openingTagPattern = new RegExp(`<${tagName}(\\s[^>]*)?>`, 'gi') - const closingTagPattern = new RegExp(``, 'gi') - // Replace both opening and closing tags with an empty string - return acc.replace(openingTagPattern, '').replace(closingTagPattern, '') - }, text) - dispatch((dispatch, getState) => { const state = getState() if (!state.cursor) return @@ -115,16 +81,17 @@ export const formatSelectionActionCreator = const path = state.noteFocus ? resolveNotePath(state, state.cursor) : state.cursor if (!path) return - const styleAttrPattern = /style\s*=\s*["'][^"']*["']/gi - const tagWithoutStylePattern = /<(span|font)(\s[^>]*)?>/gi + // Use DOMParser to remove background-color and unwrap font/span tags that have no meaningful attributes + const doc = new DOMParser().parseFromString(value, 'text/html') + + for (const el of Array.from(doc.body.querySelectorAll('font, span'))) { + // Unwrap tags that have no meaningful style or color attributes + if (!el.getAttribute('style')?.trim() && !el.getAttribute('color')?.trim()) { + el.replaceWith(...Array.from(el.childNodes)) + } + } - //Replace style attributes based on the conditions - const styleRemovedThought = value.replace(styleAttrPattern, match => { - if (shouldRemoveStyle(match)) return '' - return match - }) - const tagsToRemove = collectTagsWithoutAttributes(styleRemovedThought, tagWithoutStylePattern) - const newValue = removeTags(styleRemovedThought, tagsToRemove) + const newValue = doc.body.innerHTML // Overwrite the value of the thought or note with the stripped value in order to remove background highlighting (#3901) if (newValue !== value) From 83529a04f7851bd69d85d206c73e85e31965676f Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 14 Apr 2026 15:24:30 -0700 Subject: [PATCH 21/50] Strip default text/background colors from span/font tags --- src/actions/formatSelection.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/actions/formatSelection.ts b/src/actions/formatSelection.ts index d5d2f64a38e..7f6c7ffa038 100644 --- a/src/actions/formatSelection.ts +++ b/src/actions/formatSelection.ts @@ -11,6 +11,7 @@ import themeColors from '../selectors/themeColors' import { updateCommandState } from '../stores/commandStateStore' import suppressFocusStore from '../stores/suppressFocus' import head from '../util/head' +import rgbToHex from '../util/rgbToHex' import strip from '../util/strip' import { editThoughtActionCreator as editThought } from './editThought' import { setDescendantActionCreator as setDescendant } from './setDescendant' @@ -85,6 +86,19 @@ export const formatSelectionActionCreator = const doc = new DOMParser().parseFromString(value, 'text/html') for (const el of Array.from(doc.body.querySelectorAll('font, span'))) { + // Remove background-color if it matches the default background color + if (el.style.backgroundColor && rgbToHex(el.style.backgroundColor) === rgbToHex(colors.bg)) { + el.style.removeProperty('background-color') + if (!el.getAttribute('style')?.trim()) { + el.removeAttribute('style') + } + } + + // Remove color if it matches the default text color + if (el.style.color && rgbToHex(el.style.color) === rgbToHex(state.noteFocus ? colors.fg : colors.fgNote)) { + el.style.removeProperty('color') + } + // Unwrap tags that have no meaningful style or color attributes if (!el.getAttribute('style')?.trim() && !el.getAttribute('color')?.trim()) { el.replaceWith(...Array.from(el.childNodes)) From 236f398c17f60730ecb2b8d115a7ee2e9c487ba2 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 14 Apr 2026 15:26:47 -0700 Subject: [PATCH 22/50] Add Puppeteer test for applying same foreground color to whole thought --- src/e2e/puppeteer/__tests__/color.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/e2e/puppeteer/__tests__/color.ts b/src/e2e/puppeteer/__tests__/color.ts index 91a52435ff8..c6f0af32e32 100644 --- a/src/e2e/puppeteer/__tests__/color.ts +++ b/src/e2e/puppeteer/__tests__/color.ts @@ -342,6 +342,19 @@ it('Can change the background color of a note to match its thought', async () => expect(note).toBe('Note') }) +it('Can change the color of a thought that already has the same color applied to part of its text', async () => { + await paste(` + - some formatted text + `) + + // change the color on the thought + await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') + await click('[aria-label="text color swatches"] [aria-label="red"]') + + const thought = await getEditingText() + expect(thought).toBe('some formatted text') +}) + it('Can change the background color of a thought that already has the same background color applied to part of its text', async () => { await paste(` - some formatted text From eb833e28e7ac2260392b69afae7f347f91874665 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 14 Apr 2026 15:42:37 -0700 Subject: [PATCH 23/50] Add Puppeteer test for applying same foreground color to whole note --- src/e2e/puppeteer/__tests__/color.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/e2e/puppeteer/__tests__/color.ts b/src/e2e/puppeteer/__tests__/color.ts index c6f0af32e32..fd6b7ded1e9 100644 --- a/src/e2e/puppeteer/__tests__/color.ts +++ b/src/e2e/puppeteer/__tests__/color.ts @@ -367,3 +367,19 @@ it('Can change the background color of a thought that already has the same backg const thought = await getEditingText() expect(thought).toBe('some formatted text') }) + +it('Can change the color of a note that already has the same color applied to part of its text', async () => { + await paste(` + - a + - =note + - some formatted text + `) + + // change the color on the note + await clickFirstNote() + await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') + await click('[aria-label="text color swatches"] [aria-label="red"]') + + const note = await getFirstNoteText() + expect(note).toBe('some formatted text') +}) From 97b3de9a4693cc18a4c59e3d43248ade438cd933 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 15 Apr 2026 13:57:18 -0700 Subject: [PATCH 24/50] Run empty tag stripper on all color formatting --- src/actions/formatSelection.ts | 96 +++++++++++++++++----------------- 1 file changed, 47 insertions(+), 49 deletions(-) diff --git a/src/actions/formatSelection.ts b/src/actions/formatSelection.ts index 7f6c7ffa038..f68f7c3b1b9 100644 --- a/src/actions/formatSelection.ts +++ b/src/actions/formatSelection.ts @@ -67,64 +67,62 @@ export const formatSelectionActionCreator = suppressFocusStore.update(false) - if (command === 'backColor') { - if (color === 'bg') { - dispatch((dispatch, getState) => { - const state = getState() - if (!state.cursor) return + if (command === 'backColor' || command === 'foreColor') { + dispatch((dispatch, getState) => { + const state = getState() + if (!state.cursor) return - // Could be formatting either a thought or a note (#3901) - const value = state.noteFocus - ? noteValue(state, state.cursor) - : getThoughtById(state, head(state.cursor))?.value - if (!value) return + // Could be formatting either a thought or a note (#3901) + const value = state.noteFocus + ? noteValue(state, state.cursor) + : getThoughtById(state, head(state.cursor))?.value + if (!value) return - const path = state.noteFocus ? resolveNotePath(state, state.cursor) : state.cursor - if (!path) return + const path = state.noteFocus ? resolveNotePath(state, state.cursor) : state.cursor + if (!path) return - // Use DOMParser to remove background-color and unwrap font/span tags that have no meaningful attributes - const doc = new DOMParser().parseFromString(value, 'text/html') + // Use DOMParser to remove background-color and unwrap font/span tags that have no meaningful attributes + const doc = new DOMParser().parseFromString(value, 'text/html') - for (const el of Array.from(doc.body.querySelectorAll('font, span'))) { - // Remove background-color if it matches the default background color - if (el.style.backgroundColor && rgbToHex(el.style.backgroundColor) === rgbToHex(colors.bg)) { - el.style.removeProperty('background-color') - if (!el.getAttribute('style')?.trim()) { - el.removeAttribute('style') - } + for (const el of Array.from(doc.body.querySelectorAll('font, span'))) { + // Remove background-color if it matches the default background color + if (el.style.backgroundColor && rgbToHex(el.style.backgroundColor) === rgbToHex(colors.bg)) { + el.style.removeProperty('background-color') + if (!el.getAttribute('style')?.trim()) { + el.removeAttribute('style') } + } - // Remove color if it matches the default text color - if (el.style.color && rgbToHex(el.style.color) === rgbToHex(state.noteFocus ? colors.fg : colors.fgNote)) { - el.style.removeProperty('color') - } + // Remove color if it matches the default text color + if (el.style.color && rgbToHex(el.style.color) === rgbToHex(state.noteFocus ? colors.fg : colors.fgNote)) { + el.style.removeProperty('color') + } - // Unwrap tags that have no meaningful style or color attributes - if (!el.getAttribute('style')?.trim() && !el.getAttribute('color')?.trim()) { - el.replaceWith(...Array.from(el.childNodes)) - } + // Unwrap tags that have no meaningful style or color attributes + if (!el.getAttribute('style')?.trim() && !el.getAttribute('color')?.trim()) { + el.replaceWith(...Array.from(el.childNodes)) } + } - const newValue = doc.body.innerHTML + const newValue = doc.body.innerHTML - // Overwrite the value of the thought or note with the stripped value in order to remove background highlighting (#3901) - if (newValue !== value) - dispatch( - state.noteFocus - ? setDescendant({ - path, - values: [newValue], - }) - : editThought({ - cursorOffset: selection.offsetThought() ?? undefined, - oldValue: value, - newValue: newValue, - path: simplifyPath(state, path), - // force the ContentEditable to update - force: true, - }), - ) - }) - } + // Overwrite the value of the thought or note with the stripped value in order to remove background highlighting (#3901) + if (newValue !== value) + dispatch( + state.noteFocus + ? setDescendant({ + path, + values: [newValue], + }) + : editThought({ + cursorOffset: selection.offsetThought() ?? undefined, + oldValue: value, + newValue: newValue, + path: simplifyPath(state, path), + // force the ContentEditable to update + force: true, + }), + ) + }) } } From 8c56d3c5565dd7dd9c8545ab4c953bd06a7673b4 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 17 Apr 2026 12:08:46 -0700 Subject: [PATCH 25/50] Add mergeWithLast to editThought, Use getEditThoughtDirection to separate text edits from formatting edits --- src/actions/editThought.ts | 4 ++++ src/actions/formatSelection.ts | 1 + src/redux-enhancers/undoRedoEnhancer.ts | 31 +++++++++++++++++++------ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/actions/editThought.ts b/src/actions/editThought.ts index 719bcbff821..9df5977a2c3 100644 --- a/src/actions/editThought.ts +++ b/src/actions/editThought.ts @@ -35,6 +35,10 @@ export interface editThoughtPayload { /** Force the Editable to re-render. */ // TODO: This is used to force the Editable to re-render on generateThought, which co-opts clearThought during its pending state. Is there a better way to do this? force?: boolean + // Some edits related to formatting, e.g. stripping empty tags or applying foreground and background color as part of the same command, are constrained + // in ways that force them to be applied as separate actions. It is necessary to force-merge them into a single undo step even when it goes against + // editThought merge rules in undoRedoEnhancer (#3905). + mergeWithLast?: boolean oldValue: string newValue: string path: SimplePath diff --git a/src/actions/formatSelection.ts b/src/actions/formatSelection.ts index f68f7c3b1b9..72ee4252e8b 100644 --- a/src/actions/formatSelection.ts +++ b/src/actions/formatSelection.ts @@ -121,6 +121,7 @@ export const formatSelectionActionCreator = path: simplifyPath(state, path), // force the ContentEditable to update force: true, + mergeWithLast: true, }), ) }) diff --git a/src/redux-enhancers/undoRedoEnhancer.ts b/src/redux-enhancers/undoRedoEnhancer.ts index 07edd02b625..917b779421d 100644 --- a/src/redux-enhancers/undoRedoEnhancer.ts +++ b/src/redux-enhancers/undoRedoEnhancer.ts @@ -35,6 +35,24 @@ function isSetIsMulticursorExecutingAction(action: Action): action is Se return action.type === 'setIsMulticursorExecuting' } +/** Compare the text contents of the old and new values to determine the direction of the edit. + * Returns None if the action is not an editThought action or if the text content length is the same. + */ +function getEditThoughtDirection(action: UnknownAction): EditThoughtDirection { + if (!isEditThoughtAction(action)) return EditThoughtDirection.None + + const oldElement = document.createElement('div') + const newElement = document.createElement('div') + oldElement.innerHTML = action.oldValue + newElement.innerHTML = action.newValue + + return newElement.textContent.length === oldElement.textContent.length + ? EditThoughtDirection.None + : newElement.textContent.length > oldElement.textContent.length + ? EditThoughtDirection.Longer + : EditThoughtDirection.Shorter +} + /** Type guard for editThought action. */ function isEditThoughtAction(action: UnknownAction): action is UnknownAction & editThoughtPayload { return action.type === 'editThought' @@ -251,11 +269,10 @@ const undoRedoReducerEnhancer: StoreEnhancer = } // Determine if an edit is an addition or a deletion - const editThoughtDirection = isEditThoughtAction(action) - ? action.newValue.length > action.oldValue.length - ? EditThoughtDirection.Longer - : EditThoughtDirection.Shorter - : EditThoughtDirection.None + const editThoughtDirection = getEditThoughtDirection(action) + + const shouldMergeWithLastEditThought = + editThoughtDirection !== EditThoughtDirection.None && editThoughtDirection === lastEditThoughtDirection // Some actions are merged together into a single undo/redo patch. // - Navigation actions are merged with the previous non-navigation action. This matches the behavior of most word processors where undo will revert the last destructive action, and the cursor will be restored to where it was before. For example, if the user edits 'a' to 'aa', moves the cursor to 'b', and then undoes, the cursor will be restored to 'aa' then the edit will be undone. @@ -265,10 +282,10 @@ const undoRedoReducerEnhancer: StoreEnhancer = // - Chained commands will be merged into the previous command, e.g. Select All + Categorize if ( (isNavigation(actionType) && isNavigation(lastAction?.type)) || - (actionType === 'editThought' && editThoughtDirection === lastEditThoughtDirection) || + shouldMergeWithLastEditThought || actionType === 'closeAlert' || state.isMulticursorExecuting || - (lastAction as UnknownAction)?.mergeUndo + (isEditThoughtAction(action) && action.mergeWithLast) ) { lastAction = action const lastUndoPatch = nthLast(state.undoPatches, 1) From 5f0f25d029db17cddd24a3b90ea3e51c6c05242e Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 17 Apr 2026 12:11:06 -0700 Subject: [PATCH 26/50] Fix lint error --- src/redux-enhancers/undoRedoEnhancer.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/redux-enhancers/undoRedoEnhancer.ts b/src/redux-enhancers/undoRedoEnhancer.ts index 917b779421d..711aa1aef00 100644 --- a/src/redux-enhancers/undoRedoEnhancer.ts +++ b/src/redux-enhancers/undoRedoEnhancer.ts @@ -35,6 +35,11 @@ function isSetIsMulticursorExecutingAction(action: Action): action is Se return action.type === 'setIsMulticursorExecuting' } +/** Type guard for editThought action. */ +function isEditThoughtAction(action: UnknownAction): action is UnknownAction & editThoughtPayload { + return action.type === 'editThought' +} + /** Compare the text contents of the old and new values to determine the direction of the edit. * Returns None if the action is not an editThought action or if the text content length is the same. */ @@ -53,10 +58,6 @@ function getEditThoughtDirection(action: UnknownAction): EditThoughtDirection { : EditThoughtDirection.Shorter } -/** Type guard for editThought action. */ -function isEditThoughtAction(action: UnknownAction): action is UnknownAction & editThoughtPayload { - return action.type === 'editThought' -} /** Properties that are ignored when generating state patches. */ const statePropertiesToOmit: (keyof State)[] = ['alert', 'cursorCleared', 'pushQueue'] From e49cf2d3a7b30e6c337bd472333bf43c6f88a3d8 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Thu, 23 Apr 2026 15:56:35 -0700 Subject: [PATCH 27/50] Rename to mergeNext and mergePrev, restore deleted mergeNext check --- src/actions/addAllMulticursor.ts | 8 ++++---- src/actions/editThought.ts | 2 +- src/actions/formatSelection.ts | 2 +- src/commands/selectAll.ts | 2 +- src/e2e/puppeteer/__tests__/undo.ts | 2 +- src/redux-enhancers/undoRedoEnhancer.ts | 3 ++- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/actions/addAllMulticursor.ts b/src/actions/addAllMulticursor.ts index 2f51400fcc3..91711dfee3c 100644 --- a/src/actions/addAllMulticursor.ts +++ b/src/actions/addAllMulticursor.ts @@ -28,13 +28,13 @@ const addAllMulticursor = (state: State): State => { /** Action-creator for addAllMulticursor. */ export const addAllMulticursorActionCreator = ({ - mergeUndo, + mergeNext, }: { - /** Forces the next command to be merged into this during chained gestures. Note that mergeUndo is not referenced by the reducer at all; it is read directly from the action object by undoRedoEnhancer. */ - mergeUndo?: boolean + /** Forces the next command to be merged into this during chained gestures. Note that mergeNext is not referenced by the reducer at all; it is read directly from the action object by undoRedoEnhancer. */ + mergeNext?: boolean }): Thunk => dispatch => - dispatch({ type: 'addAllMulticursor', mergeUndo }) + dispatch({ type: 'addAllMulticursor', mergeNext }) export default _.curryRight(addAllMulticursor) diff --git a/src/actions/editThought.ts b/src/actions/editThought.ts index 9df5977a2c3..06469b14f9c 100644 --- a/src/actions/editThought.ts +++ b/src/actions/editThought.ts @@ -38,7 +38,7 @@ export interface editThoughtPayload { // Some edits related to formatting, e.g. stripping empty tags or applying foreground and background color as part of the same command, are constrained // in ways that force them to be applied as separate actions. It is necessary to force-merge them into a single undo step even when it goes against // editThought merge rules in undoRedoEnhancer (#3905). - mergeWithLast?: boolean + mergePrev?: boolean oldValue: string newValue: string path: SimplePath diff --git a/src/actions/formatSelection.ts b/src/actions/formatSelection.ts index 72ee4252e8b..f201947747e 100644 --- a/src/actions/formatSelection.ts +++ b/src/actions/formatSelection.ts @@ -121,7 +121,7 @@ export const formatSelectionActionCreator = path: simplifyPath(state, path), // force the ContentEditable to update force: true, - mergeWithLast: true, + mergePrev: true, }), ) }) diff --git a/src/commands/selectAll.ts b/src/commands/selectAll.ts index b0a3cb93f26..2bf4152ce66 100644 --- a/src/commands/selectAll.ts +++ b/src/commands/selectAll.ts @@ -50,7 +50,7 @@ const selectAllCommand: Command = { : addAllMulticursor({ // Hacky magic value, but it's the easiest way to tell the command that this is a chained gesture so that it can adjust the undo behavior. // Select All and the chained command need to be undone together, and this is not a property of the Command object but of the way it is invoked, so is somewhat appropriately stored on the event object, albeit ad hoc. - mergeUndo: e.type === 'chainedGesture', + mergeNext: e.type === 'chainedGesture', }), ) }, diff --git a/src/e2e/puppeteer/__tests__/undo.ts b/src/e2e/puppeteer/__tests__/undo.ts index 2e200174856..65bab390500 100644 --- a/src/e2e/puppeteer/__tests__/undo.ts +++ b/src/e2e/puppeteer/__tests__/undo.ts @@ -33,7 +33,7 @@ it('Re-render cursor thought on undo', async () => { expect(thoughtValue).toBe('hello') }) -// We have to test this in puppeteer because chained commands are executed as separate commands at a higher level than action-creators and undone with an ad hoc mergeUndo property on the action. +// We have to test this in puppeteer because chained commands are executed as separate commands at a higher level than action-creators and undone with an ad hoc mergeNext property on the action. it('Undo Select All + Categorize chained command in one step', async () => { await page.emulate(KnownDevices['iPhone 15 Pro']) diff --git a/src/redux-enhancers/undoRedoEnhancer.ts b/src/redux-enhancers/undoRedoEnhancer.ts index 6558e3f93cd..4a7b0248c84 100644 --- a/src/redux-enhancers/undoRedoEnhancer.ts +++ b/src/redux-enhancers/undoRedoEnhancer.ts @@ -287,7 +287,8 @@ const undoRedoReducerEnhancer: StoreEnhancer = shouldMergeWithLastEditThought || actionType === 'closeAlert' || state.isMulticursorExecuting || - (isEditThoughtAction(action) && action.mergeWithLast) + (lastAction as UnknownAction)?.mergeNext || + (isEditThoughtAction(action) && action.mergePrev) ) { lastAction = action const lastUndoPatch = nthLast(state.undoPatches, 1) From e8fcab3756fcbf1c39400aae1a3c48e1c8580cac Mon Sep 17 00:00:00 2001 From: Ethan James Date: Thu, 23 Apr 2026 15:58:23 -0700 Subject: [PATCH 28/50] Use batchEditingStore to apply mergePrev to backColor editThought action Co-authored-by: Copilot --- src/components/ColorPicker.tsx | 3 +++ src/components/Editable.tsx | 2 ++ src/stores/batchEditing.ts | 7 +++++++ 3 files changed, 12 insertions(+) create mode 100644 src/stores/batchEditing.ts diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 62418bf9caa..f51e0a0acae 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -6,6 +6,7 @@ import { formatSelectionActionCreator as formatSelection } from '../actions/form import { isTouch } from '../browser' import { ColorToken } from '../colors.config' import themeColors from '../selectors/themeColors' +import batchEditingStore from '../stores/batchEditing' import commandStateStore from '../stores/commandStateStore' import rgbToHex from '../util/rgbToHex' import Popover from './Popover' @@ -71,8 +72,10 @@ const ColorSwatch: FC<{ ) }) + batchEditingStore.update(true) // Apply background color to the selection dispatch(formatSelection('backColor', selected ? 'bg' : (backgroundColor ?? 'bg'))) + batchEditingStore.update(false) } /** Toggles the text color onTouchEnd or onClick on desktop. */ diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index a8125e7a432..c67ef44c142 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -41,6 +41,7 @@ import getSetting from '../selectors/getSetting' import getThoughtById from '../selectors/getThoughtById' import hasMulticursorSelector from '../selectors/hasMulticursor' import rootedParentOf from '../selectors/rootedParentOf' +import batchEditingStore from '../stores/batchEditing' import editingValueStore from '../stores/editingValue' import editingValueUntrimmedStore from '../stores/editingValueUntrimmed' import storageModel from '../stores/storageModel' @@ -286,6 +287,7 @@ const Editable = ({ // This will have no effect on useEditMode, which does not subscribe to state.cursorOffset reactively. cursorOffset: cursorOffset ?? selection.offsetThought() ?? undefined, force, + mergePrev: batchEditingStore.getState(), // If batch editing is in progress, merge this edit with the previous one in the undo stack. }), ) diff --git a/src/stores/batchEditing.ts b/src/stores/batchEditing.ts new file mode 100644 index 00000000000..262d9d75a3d --- /dev/null +++ b/src/stores/batchEditing.ts @@ -0,0 +1,7 @@ +import ministore from './ministore' + +/** A store that signals that multiple execCommand operations are in progress and should be treated as a single edit. While true, editThought actions + * dispatched by thoughtChangeHandler in Editable have their mergePrev property set to true so that they will be merged in undoRedoEnhancer (#3904). */ +const batchEditing = ministore(false) + +export default batchEditing From 413ee368b58282809e43cc7fbef15a644d6b5732 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 24 Apr 2026 10:56:19 -0700 Subject: [PATCH 29/50] Update Puppeteer note background test --- src/e2e/puppeteer/__tests__/color.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/e2e/puppeteer/__tests__/color.ts b/src/e2e/puppeteer/__tests__/color.ts index fd6b7ded1e9..c03679d262d 100644 --- a/src/e2e/puppeteer/__tests__/color.ts +++ b/src/e2e/puppeteer/__tests__/color.ts @@ -255,7 +255,7 @@ it('Clicking on a formatting tag does not close color dropdown', async () => { expect(textColorSwatch).toBeTruthy() }) -it('Set the background color of the note', async () => { +it('Toggle the background color of the note', async () => { await paste(` - a - =note @@ -266,10 +266,13 @@ it('Set the background color of the note', async () => { await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') await click('[aria-label="background color swatches"] [aria-label="green"]') - const result = await getFirstNoteText() - expect(result).toBe('Note') + const intermediate = await getFirstNoteText() + expect(intermediate).toBe('Note') await click('[aria-label="background color swatches"] [aria-label="green"]') + + const result = await getFirstNoteText() + expect(result).toBe('Note') }) it('Toggling note background color on and off should remove formatting tag', async () => { From 8867d71bc9c737475d036765f693e39475ff6624 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 24 Apr 2026 10:57:56 -0700 Subject: [PATCH 30/50] Add Browserstack color tests Co-authored-by: Copilot --- src/e2e/iOS/__tests__/color.ts | 39 +++++++++++++++++++++++++++++++++ src/e2e/iOS/helpers/click.ts | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/e2e/iOS/__tests__/color.ts create mode 100644 src/e2e/iOS/helpers/click.ts diff --git a/src/e2e/iOS/__tests__/color.ts b/src/e2e/iOS/__tests__/color.ts new file mode 100644 index 00000000000..65aca373dde --- /dev/null +++ b/src/e2e/iOS/__tests__/color.ts @@ -0,0 +1,39 @@ +import click from '../helpers/click' +import getEditingText from '../helpers/getEditingText' +import paste from '../helpers/paste' + +/** Click the first note. Assumes that there will be only a single note. */ +const clickFirstNote = () => click('[aria-label="note-editable"]') + +/** Retrieve the innerHTML of the first note on the page. Assumes that there will be only a single note. */ +const getFirstNoteText = () => browser.execute(() => document.querySelector('[aria-label="note-editable"]')?.innerHTML) + +it('Can change the background color of a thought that already has the same background color applied to part of its text, then change the text color', async () => { + await paste(` + - some formatted text + `) + + // change the background color on the thought + await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') + await click('[aria-label="background color swatches"] [aria-label="red"]') + await click('[aria-label="text color swatches"] [aria-label="red"]') + + const thought = await getEditingText() + expect(thought).toBe('some formatted text') +}) + +it('Can change the background color of a note that already has the same background color applied to part of its text, then change the text color', async () => { + await paste(` + - a + - =note + - Multi-word note + `) + + await clickFirstNote() + await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') + await click('[aria-label="background color swatches"] [aria-label="red"]') + await click('[aria-label="text color swatches"] [aria-label="red"]') + + const result = await getFirstNoteText() + expect(result).toBe('Multi-word note') +}) diff --git a/src/e2e/iOS/helpers/click.ts b/src/e2e/iOS/helpers/click.ts new file mode 100644 index 00000000000..13f3a764812 --- /dev/null +++ b/src/e2e/iOS/helpers/click.ts @@ -0,0 +1,40 @@ +import type { Element } from 'webdriverio' + +interface Options { + /** Click on the inside edge of the editable. Default: left. */ + edge?: 'left' | 'right' + /** Number of pixels of x offset to add to the click coordinates. */ + x?: number + /** Number of pixels of y offset to add to the click coordinates. */ + y?: number +} + +/** + * Click a node by selector or element with an optional x,y offset. + * Note: The text character offset option from the Puppeteer version is not supported in WDIO/XCUITest. + */ +const click = async (elementOrSelector: Element | string, { edge = 'left', x = 0, y = 0 }: Options = {}) => { + const el = typeof elementOrSelector === 'string' ? await browser.$(elementOrSelector).getElement() : elementOrSelector + + await browser.waitUntil(() => browser.$(el).isExisting(), { timeout: 10000 }) + + if (!x && !y && edge === 'left') { + await browser.$(el).click() + return + } + + const rect = await browser.getElementRect(el.elementId) + + // edge offsets from the element's left or right edge + const edgeX = edge === 'left' ? rect.x + 1 : rect.x + rect.width - 1 + const centerY = rect.y + rect.height / 2 + + await browser + .action('pointer', { parameters: { pointerType: 'touch' } }) + .move({ x: Math.round(edgeX + x), y: Math.round(centerY + y), origin: 'viewport' }) + .down() + .up() + .perform() +} + +export default click From 971627255caf0743254fe54bf05765fd564c4967 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 24 Apr 2026 12:01:04 -0700 Subject: [PATCH 31/50] Fix paste in Browserstack color tests Co-authored-by: Copilot --- src/e2e/iOS/__tests__/color.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/e2e/iOS/__tests__/color.ts b/src/e2e/iOS/__tests__/color.ts index 65aca373dde..7bd3cbd537d 100644 --- a/src/e2e/iOS/__tests__/color.ts +++ b/src/e2e/iOS/__tests__/color.ts @@ -1,5 +1,6 @@ import click from '../helpers/click' import getEditingText from '../helpers/getEditingText' +import newThought from '../helpers/newThought' import paste from '../helpers/paste' /** Click the first note. Assumes that there will be only a single note. */ @@ -9,9 +10,13 @@ const clickFirstNote = () => click('[aria-label="note-editable"]') const getFirstNoteText = () => browser.execute(() => document.querySelector('[aria-label="note-editable"]')?.innerHTML) it('Can change the background color of a thought that already has the same background color applied to part of its text, then change the text color', async () => { - await paste(` + await newThought() + await paste( + [''], + ` - some formatted text - `) + `, + ) // change the background color on the thought await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') @@ -23,11 +28,15 @@ it('Can change the background color of a thought that already has the same backg }) it('Can change the background color of a note that already has the same background color applied to part of its text, then change the text color', async () => { - await paste(` + await newThought() + await paste( + [''], + ` - a - =note - Multi-word note - `) + `, + ) await clickFirstNote() await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') From a433eb2a821220140bf628ffdf1fe157b0a7d097 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 28 Apr 2026 14:37:15 -0700 Subject: [PATCH 32/50] Use waitForElement andd tap in iOS click helper Co-authored-by: Copilot --- src/e2e/iOS/helpers/click.ts | 36 ++++++------------------------------ 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/src/e2e/iOS/helpers/click.ts b/src/e2e/iOS/helpers/click.ts index 13f3a764812..47f494c5554 100644 --- a/src/e2e/iOS/helpers/click.ts +++ b/src/e2e/iOS/helpers/click.ts @@ -1,40 +1,16 @@ -import type { Element } from 'webdriverio' - -interface Options { - /** Click on the inside edge of the editable. Default: left. */ - edge?: 'left' | 'right' - /** Number of pixels of x offset to add to the click coordinates. */ - x?: number - /** Number of pixels of y offset to add to the click coordinates. */ - y?: number -} +import tap from './tap' +import waitForElement from './waitForElement' /** * Click a node by selector or element with an optional x,y offset. * Note: The text character offset option from the Puppeteer version is not supported in WDIO/XCUITest. */ -const click = async (elementOrSelector: Element | string, { edge = 'left', x = 0, y = 0 }: Options = {}) => { - const el = typeof elementOrSelector === 'string' ? await browser.$(elementOrSelector).getElement() : elementOrSelector - - await browser.waitUntil(() => browser.$(el).isExisting(), { timeout: 10000 }) - - if (!x && !y && edge === 'left') { - await browser.$(el).click() - return - } - - const rect = await browser.getElementRect(el.elementId) +const click = async (selector: string) => { + const el = await waitForElement(selector, { timeout: 10000 }) - // edge offsets from the element's left or right edge - const edgeX = edge === 'left' ? rect.x + 1 : rect.x + rect.width - 1 - const centerY = rect.y + rect.height / 2 + if (!el) throw new Error(`editable node for the given selector(${selector}) not found.`) - await browser - .action('pointer', { parameters: { pointerType: 'touch' } }) - .move({ x: Math.round(edgeX + x), y: Math.round(centerY + y), origin: 'viewport' }) - .down() - .up() - .perform() + await tap(el) } export default click From ccabd108cfbe946c4536368c968929c83710327c Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 28 Apr 2026 15:09:26 -0700 Subject: [PATCH 33/50] Fix JSDoc for click helper --- src/e2e/iOS/helpers/click.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/e2e/iOS/helpers/click.ts b/src/e2e/iOS/helpers/click.ts index 47f494c5554..312becde953 100644 --- a/src/e2e/iOS/helpers/click.ts +++ b/src/e2e/iOS/helpers/click.ts @@ -1,10 +1,7 @@ import tap from './tap' import waitForElement from './waitForElement' -/** - * Click a node by selector or element with an optional x,y offset. - * Note: The text character offset option from the Puppeteer version is not supported in WDIO/XCUITest. - */ +/** Click a node by selector. */ const click = async (selector: string) => { const el = await waitForElement(selector, { timeout: 10000 }) From 4e4c55439ff99084344f9be9c630c68b0c3b2fb0 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Thu, 30 Apr 2026 15:05:26 -0700 Subject: [PATCH 34/50] Add mergePrev to setDescendant action Co-authored-by: Copilot --- src/@types/MergePrevActionPayload.ts | 8 ++++++++ src/actions/editThought.ts | 7 ++----- src/actions/setDescendant.ts | 12 ++++++++---- src/redux-enhancers/undoRedoEnhancer.ts | 2 +- 4 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 src/@types/MergePrevActionPayload.ts diff --git a/src/@types/MergePrevActionPayload.ts b/src/@types/MergePrevActionPayload.ts new file mode 100644 index 00000000000..6a03b50d59d --- /dev/null +++ b/src/@types/MergePrevActionPayload.ts @@ -0,0 +1,8 @@ +// Some edits related to formatting, e.g. stripping empty tags or applying foreground and background color as part of the same command, are constrained +// in ways that force them to be applied as separate actions. It is necessary to force-merge them into a single undo step even when it goes against +// editThought merge rules in undoRedoEnhancer (#3905). +interface MergePrevActionPayload { + mergePrev?: boolean +} + +export default MergePrevActionPayload diff --git a/src/actions/editThought.ts b/src/actions/editThought.ts index 06469b14f9c..2f478e8b007 100644 --- a/src/actions/editThought.ts +++ b/src/actions/editThought.ts @@ -1,6 +1,7 @@ import _ from 'lodash' import Index from '../@types/IndexType' import Lexeme from '../@types/Lexeme' +import MergePrevActionPayload from '../@types/MergePrevActionPayload' import SimplePath from '../@types/SimplePath' import State from '../@types/State' import Thought from '../@types/Thought' @@ -30,15 +31,11 @@ import deleteThought from './deleteThought' import setCursor from './setCursor' import updateThoughts from './updateThoughts' -export interface editThoughtPayload { +export interface editThoughtPayload extends MergePrevActionPayload { cursorOffset?: number /** Force the Editable to re-render. */ // TODO: This is used to force the Editable to re-render on generateThought, which co-opts clearThought during its pending state. Is there a better way to do this? force?: boolean - // Some edits related to formatting, e.g. stripping empty tags or applying foreground and background color as part of the same command, are constrained - // in ways that force them to be applied as separate actions. It is necessary to force-merge them into a single undo step even when it goes against - // editThought merge rules in undoRedoEnhancer (#3905). - mergePrev?: boolean oldValue: string newValue: string path: SimplePath diff --git a/src/actions/setDescendant.ts b/src/actions/setDescendant.ts index 8c59ff3c3d3..e9851b319ef 100644 --- a/src/actions/setDescendant.ts +++ b/src/actions/setDescendant.ts @@ -1,4 +1,5 @@ import _ from 'lodash' +import MergePrevActionPayload from '../@types/MergePrevActionPayload' import Path from '../@types/Path' import State from '../@types/State' import Thunk from '../@types/Thunk' @@ -11,11 +12,14 @@ import appendToPath from '../util/appendToPath' import createId from '../util/createId' import head from '../util/head' +interface setDescendantPayload extends MergePrevActionPayload { + path: Path + value?: string + values?: string[] +} + /** Sets a sequence of values as descendants. Preserves existing descendants and unrelated siblings, except for the last value, which always gets replaced by the given value. */ -const setDescendant = ( - state: State, - { path, value, values }: { path: Path; value?: string; values?: string[] }, -): State => { +const setDescendant = (state: State, { path, value, values }: setDescendantPayload): State => { // normalize values to array const _values = values || [value!] if (!value && (!values || values.length === 0)) return state diff --git a/src/redux-enhancers/undoRedoEnhancer.ts b/src/redux-enhancers/undoRedoEnhancer.ts index 4a7b0248c84..ddc6182d1f7 100644 --- a/src/redux-enhancers/undoRedoEnhancer.ts +++ b/src/redux-enhancers/undoRedoEnhancer.ts @@ -288,7 +288,7 @@ const undoRedoReducerEnhancer: StoreEnhancer = actionType === 'closeAlert' || state.isMulticursorExecuting || (lastAction as UnknownAction)?.mergeNext || - (isEditThoughtAction(action) && action.mergePrev) + (action as UnknownAction)?.mergePrev ) { lastAction = action const lastUndoPatch = nthLast(state.undoPatches, 1) From d65f72a39f0d752d25eae56685da63a2a5a31456 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 5 May 2026 14:19:53 -0700 Subject: [PATCH 35/50] Use dispatchEvent within iOS click helper --- src/e2e/iOS/helpers/click.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/e2e/iOS/helpers/click.ts b/src/e2e/iOS/helpers/click.ts index 312becde953..5221e8633b6 100644 --- a/src/e2e/iOS/helpers/click.ts +++ b/src/e2e/iOS/helpers/click.ts @@ -1,13 +1,13 @@ -import tap from './tap' -import waitForElement from './waitForElement' - /** Click a node by selector. */ const click = async (selector: string) => { - const el = await waitForElement(selector, { timeout: 10000 }) - - if (!el) throw new Error(`editable node for the given selector(${selector}) not found.`) - - await tap(el) + const el = $(selector) + + await browser.execute(function (el) { + if (!el) throw new Error('Element not found in the DOM.') + el.dispatchEvent(new MouseEvent('mousedown', { button: 0 })) + el.dispatchEvent(new MouseEvent('mouseup', { button: 0 })) + el.click() + }, el) } export default click From 37530695b7a76fbb4c1115742174829a398512a4 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 8 May 2026 12:01:46 -0700 Subject: [PATCH 36/50] See if Browserstack tests work onClick --- src/components/ToolbarButton.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/ToolbarButton.tsx b/src/components/ToolbarButton.tsx index f5beb715e6b..7c56a2b6b64 100644 --- a/src/components/ToolbarButton.tsx +++ b/src/components/ToolbarButton.tsx @@ -245,10 +245,8 @@ const ToolbarButton: FC = ({ isMouseDownRef.current = false onMouseLeave?.() }} - onMouseDown={isTouch ? undefined : tapDown} - onClick={isTouch ? undefined : tapUp} - onTouchStart={isTouch ? tapDown : undefined} - onTouchEnd={isTouch ? tapUp : undefined} + onMouseDown={tapDown} + onClick={tapUp} > { // selected top dash From ff158f60f250503c33fd2949cf84da8264dfb165 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 8 May 2026 12:10:44 -0700 Subject: [PATCH 37/50] Revert "Use dispatchEvent within iOS click helper" This reverts commit d65f72a39f0d752d25eae56685da63a2a5a31456. --- src/e2e/iOS/helpers/click.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/e2e/iOS/helpers/click.ts b/src/e2e/iOS/helpers/click.ts index 5221e8633b6..312becde953 100644 --- a/src/e2e/iOS/helpers/click.ts +++ b/src/e2e/iOS/helpers/click.ts @@ -1,13 +1,13 @@ +import tap from './tap' +import waitForElement from './waitForElement' + /** Click a node by selector. */ const click = async (selector: string) => { - const el = $(selector) - - await browser.execute(function (el) { - if (!el) throw new Error('Element not found in the DOM.') - el.dispatchEvent(new MouseEvent('mousedown', { button: 0 })) - el.dispatchEvent(new MouseEvent('mouseup', { button: 0 })) - el.click() - }, el) + const el = await waitForElement(selector, { timeout: 10000 }) + + if (!el) throw new Error(`editable node for the given selector(${selector}) not found.`) + + await tap(el) } export default click From 0de655a96fbc6edb59036c35699b2423f1dae1cf Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 8 May 2026 13:12:49 -0700 Subject: [PATCH 38/50] Revert "See if Browserstack tests work onClick" This reverts commit 37530695b7a76fbb4c1115742174829a398512a4. --- src/components/ToolbarButton.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/ToolbarButton.tsx b/src/components/ToolbarButton.tsx index 7c56a2b6b64..f5beb715e6b 100644 --- a/src/components/ToolbarButton.tsx +++ b/src/components/ToolbarButton.tsx @@ -245,8 +245,10 @@ const ToolbarButton: FC = ({ isMouseDownRef.current = false onMouseLeave?.() }} - onMouseDown={tapDown} - onClick={tapUp} + onMouseDown={isTouch ? undefined : tapDown} + onClick={isTouch ? undefined : tapUp} + onTouchStart={isTouch ? tapDown : undefined} + onTouchEnd={isTouch ? tapUp : undefined} > { // selected top dash From ab546f03b4ad3fd50ba57594a8e172cad6540ab0 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Sun, 10 May 2026 14:35:55 -0700 Subject: [PATCH 39/50] Update iOS color tests from color-ios-test branch --- src/e2e/iOS/__tests__/color.ts | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/src/e2e/iOS/__tests__/color.ts b/src/e2e/iOS/__tests__/color.ts index 7bd3cbd537d..2aad6760dbf 100644 --- a/src/e2e/iOS/__tests__/color.ts +++ b/src/e2e/iOS/__tests__/color.ts @@ -1,36 +1,25 @@ import click from '../helpers/click' import getEditingText from '../helpers/getEditingText' -import newThought from '../helpers/newThought' import paste from '../helpers/paste' -/** Click the first note. Assumes that there will be only a single note. */ -const clickFirstNote = () => click('[aria-label="note-editable"]') - /** Retrieve the innerHTML of the first note on the page. Assumes that there will be only a single note. */ const getFirstNoteText = () => browser.execute(() => document.querySelector('[aria-label="note-editable"]')?.innerHTML) it('Can change the background color of a thought that already has the same background color applied to part of its text, then change the text color', async () => { - await newThought() - await paste( - [''], - ` - - some formatted text - `, - ) + await paste(`- some formatted text`) - // change the background color on the thought await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') await click('[aria-label="background color swatches"] [aria-label="red"]') await click('[aria-label="text color swatches"] [aria-label="red"]') + await browser.keys('Escape') + const thought = await getEditingText() - expect(thought).toBe('some formatted text') + expect(thought).toBe('some formatted text') }) it('Can change the background color of a note that already has the same background color applied to part of its text, then change the text color', async () => { - await newThought() await paste( - [''], ` - a - =note @@ -38,11 +27,13 @@ it('Can change the background color of a note that already has the same backgrou `, ) - await clickFirstNote() - await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') - await click('[aria-label="background color swatches"] [aria-label="red"]') - await click('[aria-label="text color swatches"] [aria-label="red"]') + await click('[data-testid="toolbar-icon"][aria-label="Note"]') + + // WebDriver clicks move focus off the note and clear Redux `noteFocus`; synthetic DOM clicks keep the note focused. + await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') //, { preserveActiveFocus: true }) + await click('[aria-label="background color swatches"] [aria-label="red"]') //, { preserveActiveFocus: true }) + await click('[aria-label="text color swatches"] [aria-label="red"]') //, { preserveActiveFocus: true }) const result = await getFirstNoteText() - expect(result).toBe('Multi-word note') + expect(result).toBe('Multi-word note') }) From bbae4dc83b4cc1f6b403e83d8384e58a5a8d8b18 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 11 May 2026 11:46:32 -0700 Subject: [PATCH 40/50] Use webdriver browser.action in iOS click handler --- src/e2e/iOS/helpers/click.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/e2e/iOS/helpers/click.ts b/src/e2e/iOS/helpers/click.ts index 312becde953..5237c04eb25 100644 --- a/src/e2e/iOS/helpers/click.ts +++ b/src/e2e/iOS/helpers/click.ts @@ -1,13 +1,19 @@ -import tap from './tap' import waitForElement from './waitForElement' -/** Click a node by selector. */ +/** Click a node by selector or element with WebDriver click or synthetic DOM clicks. */ const click = async (selector: string) => { - const el = await waitForElement(selector, { timeout: 10000 }) + const el = await waitForElement(selector) + const rect = await browser.getElementRect(el.elementId) - if (!el) throw new Error(`editable node for the given selector(${selector}) not found.`) + const centerX = rect.x + rect.width / 2 + const centerY = rect.y + rect.height / 2 - await tap(el) + await browser + .action('pointer', { parameters: { pointerType: 'touch' } }) + .move({ x: Math.round(centerX), y: Math.round(centerY), origin: 'viewport' }) + .down() + .up() + .perform() } export default click From 600c9f401bd32fbccf62d58adbb307624048513c Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 11 May 2026 12:02:37 -0700 Subject: [PATCH 41/50] Try browser.performActions instead of browser.action --- src/e2e/iOS/helpers/click.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/e2e/iOS/helpers/click.ts b/src/e2e/iOS/helpers/click.ts index 5237c04eb25..638f87ed157 100644 --- a/src/e2e/iOS/helpers/click.ts +++ b/src/e2e/iOS/helpers/click.ts @@ -8,12 +8,19 @@ const click = async (selector: string) => { const centerX = rect.x + rect.width / 2 const centerY = rect.y + rect.height / 2 - await browser - .action('pointer', { parameters: { pointerType: 'touch' } }) - .move({ x: Math.round(centerX), y: Math.round(centerY), origin: 'viewport' }) - .down() - .up() - .perform() + await browser.performActions([ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x: Math.round(centerX), y: Math.round(centerY), origin: 'viewport' }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: 100 }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]) } export default click From 47c6a823cf1011417fba557a8bea213acd85b4b6 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 11 May 2026 12:10:40 -0700 Subject: [PATCH 42/50] Try pointerType: 'mouse' --- src/e2e/iOS/helpers/click.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/e2e/iOS/helpers/click.ts b/src/e2e/iOS/helpers/click.ts index 638f87ed157..5be8f63c3d8 100644 --- a/src/e2e/iOS/helpers/click.ts +++ b/src/e2e/iOS/helpers/click.ts @@ -12,7 +12,7 @@ const click = async (selector: string) => { { type: 'pointer', id: 'finger1', - parameters: { pointerType: 'touch' }, + parameters: { pointerType: 'mouse' }, actions: [ { type: 'pointerMove', duration: 0, x: Math.round(centerX), y: Math.round(centerY), origin: 'viewport' }, { type: 'pointerDown', button: 0 }, From 0748eaa749a5bb3df06ae8f1ccec403c038897b6 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 11 May 2026 13:24:46 -0700 Subject: [PATCH 43/50] Remove unnecessary iOS tests --- src/e2e/iOS/__tests__/caret.ts | 254 --------------------------------- src/e2e/iOS/__tests__/color.ts | 23 --- src/e2e/iOS/__tests__/home.ts | 34 ----- src/e2e/iOS/__tests__/split.ts | 39 ----- 4 files changed, 350 deletions(-) delete mode 100644 src/e2e/iOS/__tests__/caret.ts delete mode 100644 src/e2e/iOS/__tests__/home.ts delete mode 100644 src/e2e/iOS/__tests__/split.ts diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts deleted file mode 100644 index 29e773dc8f0..00000000000 --- a/src/e2e/iOS/__tests__/caret.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * IOS Safari caret positioning tests. - * Uses WDIO test runner with Mocha framework. - */ -import gestures from '../../../test-helpers/gestures' -import clickThought from '../helpers/clickThought' -import editThought from '../helpers/editThought' -import gesture from '../helpers/gesture' -import getEditable from '../helpers/getEditable' -import getEditingText from '../helpers/getEditingText' -import getElementRectByScreen from '../helpers/getElementRectByScreen' -import getSelection from '../helpers/getSelection' -import hideKeyboardByTappingDone from '../helpers/hideKeyboardByTappingDone' -import isKeyboardShown from '../helpers/isKeyboardShown' -import newThought from '../helpers/newThought' -import paste from '../helpers/paste' -import tap from '../helpers/tap' -import waitForEditable from '../helpers/waitForEditable' -import waitUntil from '../helpers/waitUntil' - -// tests succeeds individually, but fails when there are too many tests running in parallel -// https://github.com/cybersemics/em/issues/1475 -// https://github.com/cybersemics/em/issues/1523 - -describe('Caret', () => { - it('Enter edit mode', async () => { - await newThought('foo') - await hideKeyboardByTappingDone() - - const editableNodeHandle = await waitForEditable('foo') - await tap(editableNodeHandle, { y: 60, x: 20 }) - - await waitUntil(isKeyboardShown) - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe('foo') - }) - - it('Preserve Editing: true', async () => { - await newThought('foo') - await newThought('bar', { insertNewSubthought: true }) - - const editableNodeHandle = await waitForEditable('foo') - await tap(editableNodeHandle, { y: 60, x: 20 }) - - await waitUntil(async () => (await getEditingText()) === 'foo') - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe('foo') - }) - - it('Preserve Editing: false', async () => { - await newThought('foo') - await newThought('bar', { insertNewSubthought: true }) - await hideKeyboardByTappingDone() - - const editableNodeHandle = await waitForEditable('foo') - await tap(editableNodeHandle) - - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe(null) - }) - - it('No uncle loop', async () => { - const importText = ` - - a - - b - - c` - await newThought() - await paste([''], importText) - - await clickThought('b') - await newThought('d', { insertNewSubthought: true }) - - const editableNodeHandle = await waitForEditable('c') - await tap(editableNodeHandle, { y: 60, x: 20 }) - await waitUntil(async () => (await getEditingText()) === 'c') - - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe('c') - }) - - it.skip('Tap hidden root thought', async () => { - const importText = ` - - a - - b - - c - - d` - await newThought() - await paste([''], importText) - await clickThought('a') - await clickThought('b') - await clickThought('c') - - const editableNodeHandle = await waitForEditable('d') - await tap(editableNodeHandle, { y: 60, x: 20 }) - await waitUntil(async () => (await getEditingText()) !== 'c') - - const editingText = await getEditingText() - expect(editingText).toBe('b') - }) - - it('Tap hidden uncle', async () => { - const importText = ` - - a - - b - - c - - d` - await newThought() - await paste([''], importText) - await clickThought('a') - await clickThought('b') - await clickThought('c') - - const editableNodeHandle = await waitForEditable('d') - await tap(editableNodeHandle, { y: 60, x: 20 }) - - await waitUntil(async () => (await getEditingText()) === 'd') - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe('d') - }) - - it.skip('Tap empty content while keyboard up', async () => { - const importText = ` - - a - - b - - c - - d` - - await newThought() - await paste([''], importText) - await clickThought('b') - await clickThought('c') - - const editableNodeHandleD = await waitForEditable('d') - await tap(editableNodeHandleD, { x: 20, y: 200 }) - - // Wait until cursor change - await waitUntil(async () => (await getEditingText()) === 'b') - expect(await isKeyboardShown()).toBeTruthy() - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe('b') - }) - - it.skip('Tap empty content while keyboard down', async () => { - const importText = ` - - a - - b - - c - - d` - - await newThought() - await paste([''], importText) - await clickThought('b') - await clickThought('c') - await hideKeyboardByTappingDone() - - const editableNodeHandleD = await waitForEditable('d') - await tap(editableNodeHandleD, { x: 20, y: 200 }) - - // Wait until cursor change - await waitUntil(async () => (await getEditingText()) === 'b') - expect(await isKeyboardShown()).toBeFalsy() - }) - - it('Swipe over cursor', async () => { - await newThought('foo') - await hideKeyboardByTappingDone() - - const editableNodeHandle = await waitForEditable('foo') - const elementRect = await getElementRectByScreen(editableNodeHandle) - - // swipe right on thought - await gesture('r', { - xStart: elementRect.x + 5, - yStart: elementRect.y + elementRect.height / 2, - segmentLength: elementRect.width, - }) - - await tap(editableNodeHandle, { y: 60, x: 20 }) - - const editingText = await getEditingText() - expect(editingText).toBe('foo') - - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe(null) - }) - - it.skip('Swipe over hidden thought', async () => { - const importText = ` - - a - - x - - y - - b - - c - - d - - e - - f - - g - - h - - i` - - await newThought() - await paste([''], importText) - await waitForEditable('i') - await clickThought('a') - await clickThought('x') - await clickThought('y') - - const editableNodeHandle = await waitForEditable('y') - const elementRect = await getElementRectByScreen(editableNodeHandle) - - await gesture(gestures.newThought, { - xStart: elementRect.x + 5, - yStart: elementRect.y + elementRect.height + 10, - }) - await waitForEditable('') - - await editThought('this-is-new-thought') - const newThoughtEditable = await waitForEditable('this-is-new-thought') - - // get first child of parent thought - const previousSibling = await browser.execute((newThoughtEditable: HTMLElement) => { - const editable = (newThoughtEditable as unknown as HTMLElement) - .closest('ul.children') - ?.firstElementChild?.querySelector('[data-editable]') as HTMLElement - return editable?.innerText - }, newThoughtEditable) - - expect(previousSibling).toBe('y') - }) - - it.skip('Bump Thought Down on a thought that has children', async () => { - await newThought('foo') - await newThought('bar', { insertNewSubthought: true }) - await hideKeyboardByTappingDone() - - const editableNodeHandle = await getEditable('foo') - await tap(editableNodeHandle) - - await gesture(gestures.bumpThoughtDown) - const newThoughtEditable = await editThought('new') - const selectionTextContent = await getSelection().focusNode?.textContent - - const childrenTexts = await browser.execute((newThoughtEditable: HTMLElement) => { - const children = (newThoughtEditable as unknown as HTMLElement) - .closest('ul.children') - ?.firstElementChild?.getElementsByTagName('ul')[0] - ?.querySelectorAll('[data-editable]') as NodeListOf - return Array.from(children).map(x => (x as HTMLElement).innerText) - }, newThoughtEditable) - - expect(selectionTextContent).toBe('new') - expect(childrenTexts).toEqual(['foo', 'bar']) - }) -}) diff --git a/src/e2e/iOS/__tests__/color.ts b/src/e2e/iOS/__tests__/color.ts index 2aad6760dbf..fa505fbc18f 100644 --- a/src/e2e/iOS/__tests__/color.ts +++ b/src/e2e/iOS/__tests__/color.ts @@ -2,9 +2,6 @@ import click from '../helpers/click' import getEditingText from '../helpers/getEditingText' import paste from '../helpers/paste' -/** Retrieve the innerHTML of the first note on the page. Assumes that there will be only a single note. */ -const getFirstNoteText = () => browser.execute(() => document.querySelector('[aria-label="note-editable"]')?.innerHTML) - it('Can change the background color of a thought that already has the same background color applied to part of its text, then change the text color', async () => { await paste(`- some formatted text`) @@ -17,23 +14,3 @@ it('Can change the background color of a thought that already has the same backg const thought = await getEditingText() expect(thought).toBe('some formatted text') }) - -it('Can change the background color of a note that already has the same background color applied to part of its text, then change the text color', async () => { - await paste( - ` - - a - - =note - - Multi-word note - `, - ) - - await click('[data-testid="toolbar-icon"][aria-label="Note"]') - - // WebDriver clicks move focus off the note and clear Redux `noteFocus`; synthetic DOM clicks keep the note focused. - await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') //, { preserveActiveFocus: true }) - await click('[aria-label="background color swatches"] [aria-label="red"]') //, { preserveActiveFocus: true }) - await click('[aria-label="text color swatches"] [aria-label="red"]') //, { preserveActiveFocus: true }) - - const result = await getFirstNoteText() - expect(result).toBe('Multi-word note') -}) diff --git a/src/e2e/iOS/__tests__/home.ts b/src/e2e/iOS/__tests__/home.ts deleted file mode 100644 index d0e41d7dcfc..00000000000 --- a/src/e2e/iOS/__tests__/home.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * IOS Safari home navigation tests. - * Uses WDIO test runner with Mocha framework. - */ -import $ from '../helpers/$' -import clickThought from '../helpers/clickThought' -import paste from '../helpers/paste' -import waitForEditable from '../helpers/waitForEditable' - -// test succeeds individually, but fails when there are too many tests running in parallel -// https://github.com/cybersemics/em/issues/1475 -// https://github.com/cybersemics/em/issues/1523 - -describe('Home', () => { - it('click home link to set the cursor to null', async () => { - const text = ` - - a - - b` - await paste(text) - await waitForEditable('b') - await clickThought('b') // set cursor - await clickThought('b') // open keyboard - - const editingBefore = await $('[data-editing=true]') - expect(editingBefore.elementId).toBeTruthy() - - const homeLink = await $('[data-testid=home] a') - expect(homeLink).toBeTruthy() - await homeLink.click() - - const editingAfter = await $('[data-editing=true]') - expect(editingAfter.elementId).toBeFalsy() - }) -}) diff --git a/src/e2e/iOS/__tests__/split.ts b/src/e2e/iOS/__tests__/split.ts deleted file mode 100644 index 757dfeff27b..00000000000 --- a/src/e2e/iOS/__tests__/split.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * IOS Safari thought splitting tests. - * Uses WDIO test runner with Mocha framework. - */ -import clickThought from '../helpers/clickThought' -import getEditingText from '../helpers/getEditingText' -import getSelection from '../helpers/getSelection' -import paste from '../helpers/paste' -import tap from '../helpers/tap' -import tapReturnKey from '../helpers/tapReturnKey' -import waitForEditable from '../helpers/waitForEditable' - -describe('Split', () => { - it('split a thought when the caret is in the middle', async () => { - const importText = ` - - puppeteer - - web scraping - - insomnia - - rest api` - - await paste(importText) - - await waitForEditable('puppeteer') - await clickThought('puppeteer') - - const editableNodeHandle = await waitForEditable('web scraping') - await clickThought('web scraping') - - await tap(editableNodeHandle, { y: 60, x: 25 }) - await tap(editableNodeHandle, { y: 60, x: 25 }) - await tapReturnKey() - - const offset = await getSelection()?.focusOffset - expect(offset).toBe(0) - - const editingText = await getEditingText() - expect(editingText).toBe('scraping') - }) -}) From df3a0de92e3018ebe8f1aa31220c269f1bb01888 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 11 May 2026 13:36:39 -0700 Subject: [PATCH 44/50] Log tap handlers --- src/components/ToolbarButton.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/ToolbarButton.tsx b/src/components/ToolbarButton.tsx index f5beb715e6b..328da38a6a6 100644 --- a/src/components/ToolbarButton.tsx +++ b/src/components/ToolbarButton.tsx @@ -111,6 +111,7 @@ const ToolbarButton: FC = ({ /** Handles the onMouseUp/onTouchEnd event. Makes sure that we are actually clicking and not scrolling the toolbar. */ const tapUp = useCallback( (e: React.MouseEvent | React.TouchEvent) => { + console.info('tapUp tapUp') longPress.props[isTouch ? 'onTouchEnd' : 'onMouseUp']?.() const wasMouseDown = isMouseDownRef.current isMouseDownRef.current = false @@ -171,7 +172,7 @@ const ToolbarButton: FC = ({ const iconEl = e.target as HTMLElement const toolbarEl = iconEl.closest('#toolbar')! longPressTapDown?.(e) - + console.info('tapDown') lastScrollLeft.current = toolbarEl.scrollLeft if (!disabled) { From 0b498bc381c1e6d8595f8c674e0abf262f7149af Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 11 May 2026 13:46:57 -0700 Subject: [PATCH 45/50] Always assign tapDown and tapUp --- src/components/ToolbarButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ToolbarButton.tsx b/src/components/ToolbarButton.tsx index 328da38a6a6..5cd1dbd7eb3 100644 --- a/src/components/ToolbarButton.tsx +++ b/src/components/ToolbarButton.tsx @@ -248,8 +248,8 @@ const ToolbarButton: FC = ({ }} onMouseDown={isTouch ? undefined : tapDown} onClick={isTouch ? undefined : tapUp} - onTouchStart={isTouch ? tapDown : undefined} - onTouchEnd={isTouch ? tapUp : undefined} + onTouchStart={tapDown} + onTouchEnd={tapUp} > { // selected top dash From 97b482122d292f9fd3f6844356a45e1b27aae916 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 11 May 2026 13:57:40 -0700 Subject: [PATCH 46/50] Remove unnecessary actions, attach touch events using addEventListener --- .github/workflows/docs.yml | 40 -- .github/workflows/lint.yml | 38 -- .github/workflows/puppeteer.yml | 67 --- .github/workflows/tdd.yml | 596 --------------------------- .github/workflows/test.yml | 43 -- src/components/ThoughtPositioner.tsx | 2 +- src/components/ToolbarButton.tsx | 32 +- 7 files changed, 17 insertions(+), 801 deletions(-) delete mode 100644 .github/workflows/docs.yml delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/puppeteer.yml delete mode 100644 .github/workflows/tdd.yml delete mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 1e0cf5a4b7c..00000000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Docs - -# Temporarily disable the workflow except when manually triggered -# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch -on: workflow_dispatch - -# on: -# push: -# branches: -# - main - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - COPILOT_AGENT_FIREWALL_ALLOW_LIST_ADDITIONS: googlechromelabs.github.io,storage.googleapis.com - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout 🛎️ - uses: actions/checkout@v6 - with: - # For GitHub Pages deployment, persist-credentials: false is often required. - persist-credentials: false - - - name: Install npm dependencies 🛠 - run: yarn - - - name: Build 🔧 - run: yarn docs - - - name: NoJekyll 🎃 - run: touch docs/.nojekyll - - - name: Deploy 🚀 - uses: JamesIves/github-pages-deploy-action@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - branch: gh-pages - folder: docs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index b0b5f7afd54..00000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Lint - -on: - push: - branches: - - main - pull_request: - branches: - - '**' - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - CI: true - COPILOT_AGENT_FIREWALL_ALLOW_LIST_ADDITIONS: googlechromelabs.github.io,storage.googleapis.com - -jobs: - run: - name: Lint - runs-on: ubuntu-latest - if: github.event_name != 'pull_request' || github.event.pull_request.changed_files > 0 - - steps: - - name: Clone repository - uses: actions/checkout@v6 - - - name: Enable Corepack - run: corepack enable - - - name: Set Node.js version - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Install npm dependencies - run: yarn - - - name: Lint - run: yarn lint diff --git a/.github/workflows/puppeteer.yml b/.github/workflows/puppeteer.yml deleted file mode 100644 index f7a85c3da15..00000000000 --- a/.github/workflows/puppeteer.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Puppeteer - -on: - push: - branches: - - main - pull_request: - branches: - - '**' - workflow_dispatch: - inputs: - rerun_id: - description: 'Optional ID for tracking repeated runs' - required: false - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - CI: true - COPILOT_AGENT_FIREWALL_ALLOW_LIST_ADDITIONS: googlechromelabs.github.io,storage.googleapis.com - -jobs: - run: - name: Puppeteer - runs-on: ubuntu-latest - if: github.event_name != 'pull_request' || github.event.pull_request.changed_files > 0 - services: - browserless: - image: browserless/chrome:latest - ports: - - 7566:3000 - env: - CONNECTION_TIMEOUT: -1 - - steps: - - name: Clone repository - uses: actions/checkout@v6 - - - name: Enable Corepack - run: corepack enable - - - name: Set Node.js version - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Install npm dependencies - run: yarn - - - name: Start SSL proxy - run: docker run -d --rm --add-host=host.docker.internal:host-gateway -e "PORT=3000" -p 2552:443 esplo/docker-local-ssl-termination-proxy - - - name: Build - run: yarn build - - - name: Serve - uses: ./.github/actions/serve - - - name: Test - run: yarn test:puppeteer - - - name: Upload snapshot diff artifact - if: failure() - uses: actions/upload-artifact@v7 - with: - name: __diff_output__ - path: src/e2e/puppeteer/__tests__/__image_snapshots__/**/__diff_output__/ - if-no-files-found: ignore diff --git a/.github/workflows/tdd.yml b/.github/workflows/tdd.yml deleted file mode 100644 index 2f0dc11dc58..00000000000 --- a/.github/workflows/tdd.yml +++ /dev/null @@ -1,596 +0,0 @@ -name: TDD - -on: - pull_request: - branches: - - '**' - workflow_dispatch: - inputs: - rerun_id: - description: 'Optional ID for tracking repeated runs' - required: false - -permissions: - contents: read - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - CI: true - COPILOT_AGENT_FIREWALL_ALLOW_LIST_ADDITIONS: googlechromelabs.github.io,storage.googleapis.com - -jobs: - # --------------------------------------------------------------------------- - # Detect: classify changed files, decide which downstream jobs to run. - # --------------------------------------------------------------------------- - detect: - name: Detect changed tests - runs-on: ubuntu-latest - if: github.event_name != 'pull_request' || github.event.pull_request.changed_files > 0 - outputs: - has_unit: ${{ steps.classify.outputs.has_unit }} - has_puppeteer: ${{ steps.classify.outputs.has_puppeteer }} - has_ios: ${{ steps.classify.outputs.has_ios }} - has_skipped_unit: ${{ steps.classify.outputs.has_skipped_unit }} - has_skipped_puppeteer: ${{ steps.classify.outputs.has_skipped_puppeteer }} - unit_files: ${{ steps.classify.outputs.unit_files }} - puppeteer_files: ${{ steps.classify.outputs.puppeteer_files }} - ios_files: ${{ steps.classify.outputs.ios_files }} - skipped_unit_files: ${{ steps.classify.outputs.skipped_unit_files }} - skipped_puppeteer_files: ${{ steps.classify.outputs.skipped_puppeteer_files }} - merge_base: ${{ steps.classify.outputs.merge_base }} - tdd_commit: ${{ steps.tdd_commit.outputs.tdd_commit }} - skip: ${{ steps.skip.outputs.skip }} - skip_reason: ${{ steps.skip.outputs.reason }} - skip_unit: ${{ steps.skip.outputs.skip_unit }} - skip_puppeteer: ${{ steps.skip.outputs.skip_puppeteer }} - - steps: - - name: Clone repository - uses: actions/checkout@v6 - with: - fetch-depth: 999 - - - name: Classify changed files - id: classify - run: | - BASE_SHA="${{ github.event.pull_request.base.sha }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - - echo "Base SHA: $BASE_SHA" - echo "Head SHA: $HEAD_SHA" - - # Compute the merge base so we only diff files changed in this PR, - # not files from other PRs merged into the base branch since the PR - # was originally branched off. - MERGE_BASE=$(git merge-base "$BASE_SHA" "$HEAD_SHA") - echo "Merge base: $MERGE_BASE" - - # All changed files in the PR - CHANGED_FILES=$(git diff --name-only "$MERGE_BASE" "$HEAD_SHA" --) - - echo "=== All changed files ===" - echo "$CHANGED_FILES" - - # Classify test files into categories - # Unit tests: any __tests__/**/*.ts EXCLUDING e2e directories - UNIT_FILES=$(echo "$CHANGED_FILES" | grep -E '^src/.*/__tests__/.*\.ts$' | grep -v '^src/e2e/' || true) - - # Puppeteer tests: src/e2e/puppeteer/__tests__/*.ts - PUPPETEER_FILES=$(echo "$CHANGED_FILES" | grep -E '^src/e2e/puppeteer/__tests__/.*\.ts$' || true) - - # iOS tests: src/e2e/iOS/__tests__/**/*.ts - IOS_FILES=$(echo "$CHANGED_FILES" | grep -E '^src/e2e/iOS/__tests__/.*\.ts$' || true) - - # Filter a newline-separated list of test files to only those where the diff - # adds new test definitions (new it() or test() calls). This prevents triggering - # TDD for changes that only modify existing test internals (e.g. replacing - # sleep() with waitUntil()). Matches both it(...) and test(...) regardless of - # indentation. Usage: filter_new_tests "$FILES" "$MERGE_BASE" "$HEAD_SHA" - filter_new_tests() { - local files="$1" merge_base="$2" head_sha="$3" result="" - for f in $files; do - if git diff "$merge_base" "$head_sha" -- "$f" | grep -qE '^\+[ \t]*(it|test)\b'; then - result="$result"$'\n'"$f" - fi - done - echo "$result" | sed '/^$/d' - } - - # Filter a newline-separated list of test files to those where the diff adds - # new skipped test definitions (it.skip/test.skip). These need a special TDD - # flow that unskips them on base before execution. - # Usage: filter_added_skipped_tests "$FILES" "$MERGE_BASE" "$HEAD_SHA" - filter_added_skipped_tests() { - local files="$1" merge_base="$2" head_sha="$3" result="" - for f in $files; do - if git diff "$merge_base" "$head_sha" -- "$f" | grep -qE '^\+[ \t]*(it|test)\.skip\b'; then - result="$result"$'\n'"$f" - fi - done - echo "$result" | sed '/^$/d' - } - - UNIT_SKIPPED_FILES=$(filter_added_skipped_tests "$UNIT_FILES" "$MERGE_BASE" "$HEAD_SHA") - PUPPETEER_SKIPPED_FILES=$(filter_added_skipped_tests "$PUPPETEER_FILES" "$MERGE_BASE" "$HEAD_SHA") - - UNIT_FILES=$(filter_new_tests "$UNIT_FILES" "$MERGE_BASE" "$HEAD_SHA") - PUPPETEER_FILES=$(filter_new_tests "$PUPPETEER_FILES" "$MERGE_BASE" "$HEAD_SHA") - IOS_FILES=$(filter_new_tests "$IOS_FILES" "$MERGE_BASE" "$HEAD_SHA") - - # All test files (union of the above) - ALL_TEST_FILES=$(printf '%s\n' "$UNIT_FILES" "$PUPPETEER_FILES" "$IOS_FILES" | sed '/^$/d' | sort -u) - - # Non-test application files: TypeScript source files that are not test files, - # test helpers, or e2e infrastructure (helpers, config, setup). - # Excludes non-source files (markdown, yaml, json, js, css, html) to focus - # on actual application logic changes. - NON_TEST_FILES=$(echo "$CHANGED_FILES" \ - | grep -v -E '(/__tests__/|/test-helpers/|/e2e/.*/(helpers|config|setup)/)' \ - | grep -v -E '(\.md$|\.yml$|\.json$|\.js$|\.css$|\.html$)' \ - | grep -E '\.(ts|tsx)$' || true) - - echo "" - echo "=== Unit test files ===" - echo "$UNIT_FILES" - echo "" - echo "=== Puppeteer test files ===" - echo "$PUPPETEER_FILES" - echo "" - echo "=== iOS test files ===" - echo "$IOS_FILES" - echo "" - echo "=== Unit files with added skipped tests ===" - echo "$UNIT_SKIPPED_FILES" - echo "" - echo "=== Puppeteer files with added skipped tests ===" - echo "$PUPPETEER_SKIPPED_FILES" - echo "" - echo "=== Non-test application files ===" - echo "$NON_TEST_FILES" - - # Set outputs - HAS_UNIT="false" - HAS_PUPPETEER="false" - HAS_IOS="false" - HAS_SKIPPED_UNIT="false" - HAS_SKIPPED_PUPPETEER="false" - - if [ -n "$UNIT_FILES" ]; then HAS_UNIT="true"; fi - if [ -n "$PUPPETEER_FILES" ]; then HAS_PUPPETEER="true"; fi - if [ -n "$IOS_FILES" ]; then HAS_IOS="true"; fi - if [ -n "$UNIT_SKIPPED_FILES" ]; then HAS_SKIPPED_UNIT="true"; fi - if [ -n "$PUPPETEER_SKIPPED_FILES" ]; then HAS_SKIPPED_PUPPETEER="true"; fi - - echo "has_unit=$HAS_UNIT" >> "$GITHUB_OUTPUT" - echo "has_puppeteer=$HAS_PUPPETEER" >> "$GITHUB_OUTPUT" - echo "has_ios=$HAS_IOS" >> "$GITHUB_OUTPUT" - echo "has_skipped_unit=$HAS_SKIPPED_UNIT" >> "$GITHUB_OUTPUT" - echo "has_skipped_puppeteer=$HAS_SKIPPED_PUPPETEER" >> "$GITHUB_OUTPUT" - - # Store file lists (newline-separated) for downstream jobs - # Use base64 to safely pass through GITHUB_OUTPUT - echo "unit_files=$(echo "$UNIT_FILES" | base64 -w 0)" >> "$GITHUB_OUTPUT" - echo "puppeteer_files=$(echo "$PUPPETEER_FILES" | base64 -w 0)" >> "$GITHUB_OUTPUT" - echo "ios_files=$(echo "$IOS_FILES" | base64 -w 0)" >> "$GITHUB_OUTPUT" - echo "skipped_unit_files=$(echo "$UNIT_SKIPPED_FILES" | base64 -w 0)" >> "$GITHUB_OUTPUT" - echo "skipped_puppeteer_files=$(echo "$PUPPETEER_SKIPPED_FILES" | base64 -w 0)" >> "$GITHUB_OUTPUT" - echo "merge_base=$MERGE_BASE" >> "$GITHUB_OUTPUT" - - # Store whether any non-test app code changed - HAS_APP_CHANGES="false" - if [ -n "$NON_TEST_FILES" ]; then HAS_APP_CHANGES="true"; fi - echo "has_app_changes=$HAS_APP_CHANGES" >> "$GITHUB_OUTPUT" - - echo "" - echo "=== Summary ===" - echo "Has unit tests: $HAS_UNIT" - echo "Has puppeteer tests: $HAS_PUPPETEER" - echo "Has iOS tests: $HAS_IOS" - echo "Has unit skipped tests: $HAS_SKIPPED_UNIT" - echo "Has puppeteer skipped tests: $HAS_SKIPPED_PUPPETEER" - echo "Has app code changes: $HAS_APP_CHANGES" - - - name: Parse /tdd command from PR description - id: tdd_commit - env: - PR_BODY: ${{ github.event.pull_request.body }} - run: | - BASE_SHA="${{ github.event.pull_request.base.sha }}" - - # Look for /tdd at the beginning of a line (7–40 lowercase hex chars) - TDD_COMMIT=$(printf '%s' "$PR_BODY" | grep -oP '^/tdd\s+\K[a-f0-9]{7,40}' | head -1 || true) - - if [ -n "$TDD_COMMIT" ]; then - # Validate the commit exists in the repository - if git cat-file -t "$TDD_COMMIT" >/dev/null 2>&1; then - echo "Found /tdd command — using commit: $TDD_COMMIT" - echo "tdd_commit=$TDD_COMMIT" >> "$GITHUB_OUTPUT" - else - echo "::warning::/tdd commit '$TDD_COMMIT' not found in repository — falling back to base SHA: $BASE_SHA" - echo "tdd_commit=$BASE_SHA" >> "$GITHUB_OUTPUT" - fi - else - echo "No /tdd command found — using base SHA: $BASE_SHA" - echo "tdd_commit=$BASE_SHA" >> "$GITHUB_OUTPUT" - fi - - - name: Check skip conditions - id: skip - run: | - HAS_UNIT="${{ steps.classify.outputs.has_unit }}" - HAS_PUPPETEER="${{ steps.classify.outputs.has_puppeteer }}" - HAS_IOS="${{ steps.classify.outputs.has_ios }}" - HAS_APP_CHANGES="${{ steps.classify.outputs.has_app_changes }}" - HAS_SKIPPED_UNIT="${{ steps.classify.outputs.has_skipped_unit }}" - HAS_SKIPPED_PUPPETEER="${{ steps.classify.outputs.has_skipped_puppeteer }}" - BASE_SHA="${{ github.event.pull_request.base.sha }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - - SKIP="false" - REASON="" - SKIP_UNIT="false" - SKIP_PUPPETEER="false" - - # 1. Check for skip-tdd label - LABELS='${{ toJSON(github.event.pull_request.labels.*.name) }}' - if echo "$LABELS" | grep -q '"skip-tdd"'; then - SKIP="true" - REASON="PR has skip-tdd label" - fi - - # 1b. Check for granular skip labels - if echo "$LABELS" | grep -q '"skip-tdd-unit"'; then - SKIP_UNIT="true" - REASON="PR has skip-tdd-unit label" - fi - if echo "$LABELS" | grep -q '"skip-tdd-puppeteer"'; then - SKIP_PUPPETEER="true" - REASON="${REASON:+$REASON; }PR has skip-tdd-puppeteer label" - fi - - # 2. No changed tests at all - if [ "$SKIP" = "false" ] && [ "$HAS_UNIT" = "false" ] && [ "$HAS_PUPPETEER" = "false" ] && [ "$HAS_IOS" = "false" ]; then - SKIP="true" - REASON="No changed test files detected" - fi - - # 3. Only test files changed, no application code changed (coverage-only) - # Keep running when skipped tests were added so they can be unskipped and - # validated against base. - if [ "$SKIP" = "false" ] \ - && [ "$HAS_APP_CHANGES" = "false" ] \ - && [ "$HAS_SKIPPED_UNIT" = "false" ] \ - && [ "$HAS_SKIPPED_PUPPETEER" = "false" ]; then - SKIP="true" - REASON="Only test files changed (no application code) — likely coverage-only" - fi - - echo "skip=$SKIP" >> "$GITHUB_OUTPUT" - echo "reason=$REASON" >> "$GITHUB_OUTPUT" - echo "skip_unit=$SKIP_UNIT" >> "$GITHUB_OUTPUT" - echo "skip_puppeteer=$SKIP_PUPPETEER" >> "$GITHUB_OUTPUT" - - echo "=== Skip decision ===" - echo "Skip: $SKIP" - echo "Reason: $REASON" - echo "Skip unit: $SKIP_UNIT" - echo "Skip puppeteer: $SKIP_PUPPETEER" - - # --------------------------------------------------------------------------- - # Unit: apply changed unit test diff to base, run them, expect failure. - # --------------------------------------------------------------------------- - unit: - name: TDD — Unit tests - needs: detect - if: needs.detect.outputs.skip != 'true' && needs.detect.outputs.skip_unit != 'true' && needs.detect.outputs.has_unit == 'true' - runs-on: ubuntu-latest - - steps: - - name: Clone repository - uses: actions/checkout@v6 - with: - ref: ${{ needs.detect.outputs.tdd_commit }} - fetch-depth: 999 - - - name: Fetch PR head - run: git fetch origin ${{ github.event.pull_request.head.sha }} - - - name: Checkout test files from PR head - id: patch - run: | - # Decode the file list - UNIT_FILES=$(echo "${{ needs.detect.outputs.unit_files }}" | base64 -d) - - echo "=== Changed unit test files ===" - echo "$UNIT_FILES" - - if [ -z "$UNIT_FILES" ]; then - echo "No unit test files to process" - echo "empty=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Check out each test file directly from the PR head. This reliably handles - # both modified files and brand-new test files (including new directories), - # where git apply --3way can fail because there is no base blob to merge. - echo "=== Checking out test files from PR head ===" - for f in $UNIT_FILES; do - echo "Checking out: $f" - git checkout FETCH_HEAD -- "$f" - done - - echo "empty=false" >> "$GITHUB_OUTPUT" - - - name: Enable Corepack - if: steps.patch.outputs.empty != 'true' - run: corepack enable - - - name: Unskip added unit tests before running on base - if: steps.patch.outputs.empty != 'true' && needs.detect.outputs.has_skipped_unit == 'true' - uses: ./.github/actions/unskip-added-tests - with: - merge-base: ${{ needs.detect.outputs.merge_base }} - head-sha: ${{ github.event.pull_request.head.sha }} - skipped-files: ${{ needs.detect.outputs.skipped_unit_files }} - no-files-message: No unit skipped test files to unskip. - - - name: Set Node.js version - if: steps.patch.outputs.empty != 'true' - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Install npm dependencies - if: steps.patch.outputs.empty != 'true' - run: yarn - - - name: Run changed unit tests on base (expect failure) - if: steps.patch.outputs.empty != 'true' - id: run_tests - run: | - UNIT_FILES=$(echo "${{ needs.detect.outputs.unit_files }}" | base64 -d) - - echo "=== Running changed unit tests ===" - echo "$UNIT_FILES" - - # Run only the changed test files - # vitest accepts file paths as positional args - set +e - yarn vitest run --project unit $UNIT_FILES 2>&1 - TEST_EXIT=$? - set -e - - echo "test_exit=$TEST_EXIT" >> "$GITHUB_OUTPUT" - - if [ "$TEST_EXIT" -ne 0 ]; then - echo "" - echo "✅ TDD validated: changed tests FAIL on the base branch (exit code $TEST_EXIT)." - echo "This means the tests are genuinely covering new behavior." - else - echo "" - echo "❌ TDD flagged: changed tests PASS on the base branch." - echo "New tests that accompany bug fixes are expected to FAIL on the base branch and PASS on the PR, demonstrating that the issue has been fixed. Please review your tests and make sure they fail on the base branch." - echo "If you are extending the test coverage over previously working behavior, add the 'skip-tdd' or 'skip-tdd-unit' label to the PR to skip this workflow." - exit 1 - fi - - # --------------------------------------------------------------------------- - # Puppeteer: apply changed puppeteer test diff to base, run them, expect failure. - # --------------------------------------------------------------------------- - puppeteer: - name: TDD — Puppeteer tests - needs: detect - if: needs.detect.outputs.skip != 'true' && needs.detect.outputs.skip_puppeteer != 'true' && needs.detect.outputs.has_puppeteer == 'true' - runs-on: ubuntu-latest - services: - browserless: - image: browserless/chrome:latest - ports: - - 7566:3000 - env: - CONNECTION_TIMEOUT: -1 - - steps: - - name: Clone repository - uses: actions/checkout@v6 - with: - ref: ${{ needs.detect.outputs.tdd_commit }} - fetch-depth: 999 - - - name: Fetch PR head - run: git fetch origin ${{ github.event.pull_request.head.sha }} - - - name: Restore GitHub Actions files from PR HEAD - if: github.event_name == 'pull_request' - run: git checkout FETCH_HEAD -- .github/actions/ - - - name: Checkout test files from PR head - id: patch - run: | - PUPPETEER_FILES=$(echo "${{ needs.detect.outputs.puppeteer_files }}" | base64 -d) - - echo "=== Changed puppeteer test files ===" - echo "$PUPPETEER_FILES" - - if [ -z "$PUPPETEER_FILES" ]; then - echo "No puppeteer test files to process" - echo "empty=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Check out each test file directly from the PR head. This reliably handles - # both modified files and brand-new test files (including new directories), - # where git apply --3way can fail because there is no base blob to merge. - echo "=== Checking out puppeteer test files from PR head ===" - for f in $PUPPETEER_FILES; do - echo "Checking out: $f" - git checkout FETCH_HEAD -- "$f" - done - - echo "empty=false" >> "$GITHUB_OUTPUT" - - - name: Enable Corepack - if: steps.patch.outputs.empty != 'true' - run: corepack enable - - - name: Unskip added puppeteer tests before running on base - if: steps.patch.outputs.empty != 'true' && needs.detect.outputs.has_skipped_puppeteer == 'true' - uses: ./.github/actions/unskip-added-tests - with: - merge-base: ${{ needs.detect.outputs.merge_base }} - head-sha: ${{ github.event.pull_request.head.sha }} - skipped-files: ${{ needs.detect.outputs.skipped_puppeteer_files }} - no-files-message: No puppeteer skipped test files to unskip. - - - name: Set Node.js version - if: steps.patch.outputs.empty != 'true' - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Install npm dependencies - if: steps.patch.outputs.empty != 'true' - run: yarn - - - name: Start SSL proxy - if: steps.patch.outputs.empty != 'true' - run: docker run -d --rm --add-host=host.docker.internal:host-gateway -e "PORT=3000" -p 2552:443 esplo/docker-local-ssl-termination-proxy - - - name: Build - if: steps.patch.outputs.empty != 'true' - run: yarn build - - - name: Serve - if: steps.patch.outputs.empty != 'true' - uses: ./.github/actions/serve - - - name: Run changed puppeteer tests on base (expect failure) - if: steps.patch.outputs.empty != 'true' - id: run_tests - run: | - PUPPETEER_FILES=$(echo "${{ needs.detect.outputs.puppeteer_files }}" | base64 -d) - - echo "=== Running changed puppeteer tests ===" - echo "$PUPPETEER_FILES" - - set +e - yarn vitest run --project puppeteer-e2e $PUPPETEER_FILES 2>&1 - TEST_EXIT=$? - set -e - - echo "test_exit=$TEST_EXIT" >> "$GITHUB_OUTPUT" - - if [ "$TEST_EXIT" -ne 0 ]; then - echo "" - echo "✅ TDD validated: changed puppeteer tests FAIL on the base branch (exit code $TEST_EXIT)." - echo "This means the tests are genuinely covering new behavior." - else - echo "" - echo "❌ TDD flagged: changed puppeteer tests PASS on the base branch." - echo "New tests that accompany bug fixes are expected to FAIL on the base branch and PASS on the PR, demonstrating that the issue has been fixed. Please review your tests and make sure they fail on the base branch." - echo "If you are extending the test coverage over previously working behavior, add the 'skip-tdd' or 'skip-tdd-unit' label to the PR to skip this workflow." - exit 1 - fi - - # --------------------------------------------------------------------------- - # iOS BrowserStack: skipped in v1. - # - # BrowserStack iOS tests require BROWSERSTACK_USERNAME and - # BROWSERSTACK_ACCESS_KEY secrets, a running local server tunneled via - # BrowserStack Local, and real device time. Running these against the base - # branch just for TDD validation is expensive and complex. - # - # For now, if iOS tests change alongside app code, we note it but do not - # gate the PR. A future iteration can add this when cost/benefit is clearer. - # --------------------------------------------------------------------------- - ios: - name: TDD — iOS (skipped) - needs: detect - if: needs.detect.outputs.skip != 'true' && needs.detect.outputs.has_ios == 'true' - runs-on: ubuntu-latest - steps: - - name: iOS TDD check not yet implemented - run: | - echo "⚠️ Changed iOS test files detected, but BrowserStack TDD validation" - echo "is not yet implemented (requires secrets, device time, tunneling)." - echo "" - echo "Changed files:" - echo "${{ needs.detect.outputs.ios_files }}" | base64 -d - echo "" - echo "Skipping — this will be addressed in a future iteration." - - # --------------------------------------------------------------------------- - # Summary: single status check that downstream branch protection can require. - # --------------------------------------------------------------------------- - summary: - name: TDD - runs-on: ubuntu-latest - if: always() - needs: [detect, unit, puppeteer, ios] - steps: - - name: Evaluate results - run: | - DETECT_RESULT="${{ needs.detect.result }}" - SKIP="${{ needs.detect.outputs.skip }}" - SKIP_REASON="${{ needs.detect.outputs.skip_reason }}" - SKIP_UNIT="${{ needs.detect.outputs.skip_unit }}" - SKIP_PUPPETEER="${{ needs.detect.outputs.skip_puppeteer }}" - - echo "=== TDD Validation Summary ===" - - if [ "$DETECT_RESULT" = "skipped" ]; then - echo "⏭️ Skipped: No file changes" - exit 0 - fi - - if [ "$SKIP" = "true" ]; then - echo "⏭️ Skipped: $SKIP_REASON" - exit 0 - fi - - if [ -n "$SKIP_REASON" ]; then - echo "⏭️ Partial skip: $SKIP_REASON" - fi - - # Check each job result - UNIT_RESULT="${{ needs.unit.result }}" - PUPPETEER_RESULT="${{ needs.puppeteer.result }}" - IOS_RESULT="${{ needs.ios.result }}" - - echo "Unit: $UNIT_RESULT" - echo "Puppeteer: $PUPPETEER_RESULT" - echo "iOS: $IOS_RESULT" - echo "" - - FAILED="false" - - # A job result of "failure" means the tests passed on base (TDD violation) - # A job result of "success" means the tests failed on base (TDD validated) - # A job result of "skipped" means no changed tests of that type - - if [ "$UNIT_RESULT" = "failure" ]; then - echo "❌ Unit tests passed on base branch — TDD flagged" - FAILED="true" - fi - - if [ "$PUPPETEER_RESULT" = "failure" ]; then - echo "❌ Puppeteer tests passed on base branch — TDD flagged" - FAILED="true" - fi - - # iOS is informational only in v1 - if [ "$IOS_RESULT" = "failure" ]; then - echo "⚠️ iOS job had an issue (informational only)" - fi - - if [ "$FAILED" = "true" ]; then - echo "" - echo "TDD validation failed. Changed tests should fail on the base branch" - echo "to prove they cover new functionality. If this is intentional, add the" - echo "'skip-tdd' label to skip all TDD checks, or use 'skip-tdd-unit' /" - echo "'skip-tdd-puppeteer' to skip only the relevant job." - exit 1 - fi - - echo "" - echo "✅ TDD validation passed!" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 730cce11624..00000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Test - -on: - push: - branches: - - main - pull_request: - branches: - - '**' - workflow_dispatch: - inputs: - rerun_id: - description: 'Optional ID for tracking repeated runs' - required: false - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - CI: true - COPILOT_AGENT_FIREWALL_ALLOW_LIST_ADDITIONS: googlechromelabs.github.io,storage.googleapis.com - -jobs: - run: - name: Test - runs-on: ubuntu-latest - if: github.event_name != 'pull_request' || github.event.pull_request.changed_files > 0 - - steps: - - name: Clone repository - uses: actions/checkout@v6 - - - name: Enable Corepack - run: corepack enable - - - name: Set Node.js version - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Install npm dependencies - run: yarn - - - name: Test - run: yarn test diff --git a/src/components/ThoughtPositioner.tsx b/src/components/ThoughtPositioner.tsx index f79a4ad9177..4de61697784 100644 --- a/src/components/ThoughtPositioner.tsx +++ b/src/components/ThoughtPositioner.tsx @@ -25,7 +25,7 @@ const ThoughtPositioner = ({ className={css({ /* Use line-height to vertically center the text and bullet. We cannot use padding since it messes up the selection. This needs to be overwritten on multiline elements. See ".child .editable" below. */ /* must match value used in Editable useMultiline */ - lineHeight: '2', + lineHeight: '1.25', // ensure that ThoughtAnnotation is positioned correctly position: 'relative', ...(hideBullet ? { marginLeft: -12 } : null), diff --git a/src/components/ToolbarButton.tsx b/src/components/ToolbarButton.tsx index 5cd1dbd7eb3..0ddcc9d31ce 100644 --- a/src/components/ToolbarButton.tsx +++ b/src/components/ToolbarButton.tsx @@ -1,4 +1,4 @@ -import React, { FC, MutableRefObject, useCallback, useMemo, useRef, useState } from 'react' +import React, { FC, MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSelector } from 'react-redux' import { css, cx } from '../../styled-system/css' import { toolbarPointerEventsRecipe } from '../../styled-system/recipes' @@ -14,7 +14,6 @@ import useDragAndDropToolbarButton from '../hooks/useDragAndDropToolbarButton' import useLongPress from '../hooks/useLongPress' import store from '../stores/app' import commandStateStore from '../stores/commandStateStore' -import dndRef from '../util/dndRef' import getCursorSortDirection from '../util/getCursorSortDirection' import haptics from '../util/haptics' @@ -54,6 +53,8 @@ const ToolbarButton: FC = ({ /** Tracks if mousedown occurred on this button, independent of React's render cycle. This prevents a race condition where the React-prop isPressing (derived from the parent Toolbar's pressingToolbarId state) hasn't updated between mousedown and click events dispatched in rapid succession (e.g. by Puppeteer under CI load). */ const isMouseDownRef = useRef(false) + const buttonRef = useRef(null) + const command = commandById(commandId) if (!command) { console.error('Missing command: ' + commandId) @@ -75,7 +76,7 @@ const ToolbarButton: FC = ({ const buttonError = useSelector(state => (!customize && command.error ? command.error(state) : null)) const isButtonExecutable = useSelector(state => customize || !canExecute || canExecute(state)) - const { isDragging, dragSource, isHovering, dropTarget } = useDragAndDropToolbarButton({ + const { isDragging, isHovering } = useDragAndDropToolbarButton({ commandId, customize, }) @@ -110,7 +111,7 @@ const ToolbarButton: FC = ({ /** Handles the onMouseUp/onTouchEnd event. Makes sure that we are actually clicking and not scrolling the toolbar. */ const tapUp = useCallback( - (e: React.MouseEvent | React.TouchEvent) => { + (e: TouchEvent) => { console.info('tapUp tapUp') longPress.props[isTouch ? 'onTouchEnd' : 'onMouseUp']?.() const wasMouseDown = isMouseDownRef.current @@ -143,10 +144,6 @@ const ToolbarButton: FC = ({ } lastScrollLeft.current = toolbarEl.scrollLeft - - if (!disabled) { - onTapUp?.(commandId, e) - } }, // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -167,17 +164,15 @@ const ToolbarButton: FC = ({ /** Handles the onMouseDown/onTouchEnd event. Updates lastScrollPosition for tapUp. */ const tapDown = useCallback( - (e: React.MouseEvent | React.TouchEvent) => { + (e: TouchEvent) => { isMouseDownRef.current = true const iconEl = e.target as HTMLElement const toolbarEl = iconEl.closest('#toolbar')! - longPressTapDown?.(e) console.info('tapDown') lastScrollLeft.current = toolbarEl.scrollLeft if (!disabled) { haptics.medium() - onTapDown?.(commandId, e) } if (!customize && !isTouch) { @@ -202,12 +197,21 @@ const ToolbarButton: FC = ({ }), [buttonError, fontSize, isButtonActive, isButtonExecutable, isDragging], ) + + useEffect(() => { + buttonRef.current?.addEventListener('touchstart', tapDown) + buttonRef.current?.addEventListener('touchend', tapUp) + return () => { + buttonRef.current?.removeEventListener('touchstart', tapDown) + buttonRef.current?.removeEventListener('touchend', tapUp) + } + }, []) return (
dragSource(dropTarget(node)))} + ref={buttonRef} key={commandId} title={`${command.label}${(command.keyboard ?? command.overlay?.keyboard) ? ` (${formatKeyboardShortcut((command.keyboard ?? command.overlay?.keyboard)!)})` : ''}${buttonError ? '\nError: ' + buttonError : ''}`} className={cx( @@ -246,10 +250,6 @@ const ToolbarButton: FC = ({ isMouseDownRef.current = false onMouseLeave?.() }} - onMouseDown={isTouch ? undefined : tapDown} - onClick={isTouch ? undefined : tapUp} - onTouchStart={tapDown} - onTouchEnd={tapUp} > { // selected top dash From 21ba7e32c576f21755ab20f910179b54b3118c2c Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 11 May 2026 14:00:51 -0700 Subject: [PATCH 47/50] Fix lint errors --- src/components/ToolbarButton.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/ToolbarButton.tsx b/src/components/ToolbarButton.tsx index 0ddcc9d31ce..4e64185ca2b 100644 --- a/src/components/ToolbarButton.tsx +++ b/src/components/ToolbarButton.tsx @@ -199,13 +199,14 @@ const ToolbarButton: FC = ({ ) useEffect(() => { + const buttonEl = buttonRef.current buttonRef.current?.addEventListener('touchstart', tapDown) buttonRef.current?.addEventListener('touchend', tapUp) return () => { - buttonRef.current?.removeEventListener('touchstart', tapDown) - buttonRef.current?.removeEventListener('touchend', tapUp) + buttonEl?.removeEventListener('touchstart', tapDown) + buttonEl?.removeEventListener('touchend', tapUp) } - }, []) + }, [tapDown, tapUp]) return (
Date: Mon, 11 May 2026 14:12:45 -0700 Subject: [PATCH 48/50] Revert "Remove unnecessary iOS tests" This reverts commit 0748eaa749a5bb3df06ae8f1ccec403c038897b6. --- src/e2e/iOS/__tests__/caret.ts | 254 +++++++++++++++++++++++++++++++++ src/e2e/iOS/__tests__/color.ts | 23 +++ src/e2e/iOS/__tests__/home.ts | 34 +++++ src/e2e/iOS/__tests__/split.ts | 39 +++++ 4 files changed, 350 insertions(+) create mode 100644 src/e2e/iOS/__tests__/caret.ts create mode 100644 src/e2e/iOS/__tests__/home.ts create mode 100644 src/e2e/iOS/__tests__/split.ts diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts new file mode 100644 index 00000000000..29e773dc8f0 --- /dev/null +++ b/src/e2e/iOS/__tests__/caret.ts @@ -0,0 +1,254 @@ +/** + * IOS Safari caret positioning tests. + * Uses WDIO test runner with Mocha framework. + */ +import gestures from '../../../test-helpers/gestures' +import clickThought from '../helpers/clickThought' +import editThought from '../helpers/editThought' +import gesture from '../helpers/gesture' +import getEditable from '../helpers/getEditable' +import getEditingText from '../helpers/getEditingText' +import getElementRectByScreen from '../helpers/getElementRectByScreen' +import getSelection from '../helpers/getSelection' +import hideKeyboardByTappingDone from '../helpers/hideKeyboardByTappingDone' +import isKeyboardShown from '../helpers/isKeyboardShown' +import newThought from '../helpers/newThought' +import paste from '../helpers/paste' +import tap from '../helpers/tap' +import waitForEditable from '../helpers/waitForEditable' +import waitUntil from '../helpers/waitUntil' + +// tests succeeds individually, but fails when there are too many tests running in parallel +// https://github.com/cybersemics/em/issues/1475 +// https://github.com/cybersemics/em/issues/1523 + +describe('Caret', () => { + it('Enter edit mode', async () => { + await newThought('foo') + await hideKeyboardByTappingDone() + + const editableNodeHandle = await waitForEditable('foo') + await tap(editableNodeHandle, { y: 60, x: 20 }) + + await waitUntil(isKeyboardShown) + const selectionTextContent = await getSelection().focusNode?.textContent + expect(selectionTextContent).toBe('foo') + }) + + it('Preserve Editing: true', async () => { + await newThought('foo') + await newThought('bar', { insertNewSubthought: true }) + + const editableNodeHandle = await waitForEditable('foo') + await tap(editableNodeHandle, { y: 60, x: 20 }) + + await waitUntil(async () => (await getEditingText()) === 'foo') + const selectionTextContent = await getSelection().focusNode?.textContent + expect(selectionTextContent).toBe('foo') + }) + + it('Preserve Editing: false', async () => { + await newThought('foo') + await newThought('bar', { insertNewSubthought: true }) + await hideKeyboardByTappingDone() + + const editableNodeHandle = await waitForEditable('foo') + await tap(editableNodeHandle) + + const selectionTextContent = await getSelection().focusNode?.textContent + expect(selectionTextContent).toBe(null) + }) + + it('No uncle loop', async () => { + const importText = ` + - a + - b + - c` + await newThought() + await paste([''], importText) + + await clickThought('b') + await newThought('d', { insertNewSubthought: true }) + + const editableNodeHandle = await waitForEditable('c') + await tap(editableNodeHandle, { y: 60, x: 20 }) + await waitUntil(async () => (await getEditingText()) === 'c') + + const selectionTextContent = await getSelection().focusNode?.textContent + expect(selectionTextContent).toBe('c') + }) + + it.skip('Tap hidden root thought', async () => { + const importText = ` + - a + - b + - c + - d` + await newThought() + await paste([''], importText) + await clickThought('a') + await clickThought('b') + await clickThought('c') + + const editableNodeHandle = await waitForEditable('d') + await tap(editableNodeHandle, { y: 60, x: 20 }) + await waitUntil(async () => (await getEditingText()) !== 'c') + + const editingText = await getEditingText() + expect(editingText).toBe('b') + }) + + it('Tap hidden uncle', async () => { + const importText = ` + - a + - b + - c + - d` + await newThought() + await paste([''], importText) + await clickThought('a') + await clickThought('b') + await clickThought('c') + + const editableNodeHandle = await waitForEditable('d') + await tap(editableNodeHandle, { y: 60, x: 20 }) + + await waitUntil(async () => (await getEditingText()) === 'd') + const selectionTextContent = await getSelection().focusNode?.textContent + expect(selectionTextContent).toBe('d') + }) + + it.skip('Tap empty content while keyboard up', async () => { + const importText = ` + - a + - b + - c + - d` + + await newThought() + await paste([''], importText) + await clickThought('b') + await clickThought('c') + + const editableNodeHandleD = await waitForEditable('d') + await tap(editableNodeHandleD, { x: 20, y: 200 }) + + // Wait until cursor change + await waitUntil(async () => (await getEditingText()) === 'b') + expect(await isKeyboardShown()).toBeTruthy() + const selectionTextContent = await getSelection().focusNode?.textContent + expect(selectionTextContent).toBe('b') + }) + + it.skip('Tap empty content while keyboard down', async () => { + const importText = ` + - a + - b + - c + - d` + + await newThought() + await paste([''], importText) + await clickThought('b') + await clickThought('c') + await hideKeyboardByTappingDone() + + const editableNodeHandleD = await waitForEditable('d') + await tap(editableNodeHandleD, { x: 20, y: 200 }) + + // Wait until cursor change + await waitUntil(async () => (await getEditingText()) === 'b') + expect(await isKeyboardShown()).toBeFalsy() + }) + + it('Swipe over cursor', async () => { + await newThought('foo') + await hideKeyboardByTappingDone() + + const editableNodeHandle = await waitForEditable('foo') + const elementRect = await getElementRectByScreen(editableNodeHandle) + + // swipe right on thought + await gesture('r', { + xStart: elementRect.x + 5, + yStart: elementRect.y + elementRect.height / 2, + segmentLength: elementRect.width, + }) + + await tap(editableNodeHandle, { y: 60, x: 20 }) + + const editingText = await getEditingText() + expect(editingText).toBe('foo') + + const selectionTextContent = await getSelection().focusNode?.textContent + expect(selectionTextContent).toBe(null) + }) + + it.skip('Swipe over hidden thought', async () => { + const importText = ` + - a + - x + - y + - b + - c + - d + - e + - f + - g + - h + - i` + + await newThought() + await paste([''], importText) + await waitForEditable('i') + await clickThought('a') + await clickThought('x') + await clickThought('y') + + const editableNodeHandle = await waitForEditable('y') + const elementRect = await getElementRectByScreen(editableNodeHandle) + + await gesture(gestures.newThought, { + xStart: elementRect.x + 5, + yStart: elementRect.y + elementRect.height + 10, + }) + await waitForEditable('') + + await editThought('this-is-new-thought') + const newThoughtEditable = await waitForEditable('this-is-new-thought') + + // get first child of parent thought + const previousSibling = await browser.execute((newThoughtEditable: HTMLElement) => { + const editable = (newThoughtEditable as unknown as HTMLElement) + .closest('ul.children') + ?.firstElementChild?.querySelector('[data-editable]') as HTMLElement + return editable?.innerText + }, newThoughtEditable) + + expect(previousSibling).toBe('y') + }) + + it.skip('Bump Thought Down on a thought that has children', async () => { + await newThought('foo') + await newThought('bar', { insertNewSubthought: true }) + await hideKeyboardByTappingDone() + + const editableNodeHandle = await getEditable('foo') + await tap(editableNodeHandle) + + await gesture(gestures.bumpThoughtDown) + const newThoughtEditable = await editThought('new') + const selectionTextContent = await getSelection().focusNode?.textContent + + const childrenTexts = await browser.execute((newThoughtEditable: HTMLElement) => { + const children = (newThoughtEditable as unknown as HTMLElement) + .closest('ul.children') + ?.firstElementChild?.getElementsByTagName('ul')[0] + ?.querySelectorAll('[data-editable]') as NodeListOf + return Array.from(children).map(x => (x as HTMLElement).innerText) + }, newThoughtEditable) + + expect(selectionTextContent).toBe('new') + expect(childrenTexts).toEqual(['foo', 'bar']) + }) +}) diff --git a/src/e2e/iOS/__tests__/color.ts b/src/e2e/iOS/__tests__/color.ts index fa505fbc18f..2aad6760dbf 100644 --- a/src/e2e/iOS/__tests__/color.ts +++ b/src/e2e/iOS/__tests__/color.ts @@ -2,6 +2,9 @@ import click from '../helpers/click' import getEditingText from '../helpers/getEditingText' import paste from '../helpers/paste' +/** Retrieve the innerHTML of the first note on the page. Assumes that there will be only a single note. */ +const getFirstNoteText = () => browser.execute(() => document.querySelector('[aria-label="note-editable"]')?.innerHTML) + it('Can change the background color of a thought that already has the same background color applied to part of its text, then change the text color', async () => { await paste(`- some formatted text`) @@ -14,3 +17,23 @@ it('Can change the background color of a thought that already has the same backg const thought = await getEditingText() expect(thought).toBe('some formatted text') }) + +it('Can change the background color of a note that already has the same background color applied to part of its text, then change the text color', async () => { + await paste( + ` + - a + - =note + - Multi-word note + `, + ) + + await click('[data-testid="toolbar-icon"][aria-label="Note"]') + + // WebDriver clicks move focus off the note and clear Redux `noteFocus`; synthetic DOM clicks keep the note focused. + await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') //, { preserveActiveFocus: true }) + await click('[aria-label="background color swatches"] [aria-label="red"]') //, { preserveActiveFocus: true }) + await click('[aria-label="text color swatches"] [aria-label="red"]') //, { preserveActiveFocus: true }) + + const result = await getFirstNoteText() + expect(result).toBe('Multi-word note') +}) diff --git a/src/e2e/iOS/__tests__/home.ts b/src/e2e/iOS/__tests__/home.ts new file mode 100644 index 00000000000..d0e41d7dcfc --- /dev/null +++ b/src/e2e/iOS/__tests__/home.ts @@ -0,0 +1,34 @@ +/** + * IOS Safari home navigation tests. + * Uses WDIO test runner with Mocha framework. + */ +import $ from '../helpers/$' +import clickThought from '../helpers/clickThought' +import paste from '../helpers/paste' +import waitForEditable from '../helpers/waitForEditable' + +// test succeeds individually, but fails when there are too many tests running in parallel +// https://github.com/cybersemics/em/issues/1475 +// https://github.com/cybersemics/em/issues/1523 + +describe('Home', () => { + it('click home link to set the cursor to null', async () => { + const text = ` + - a + - b` + await paste(text) + await waitForEditable('b') + await clickThought('b') // set cursor + await clickThought('b') // open keyboard + + const editingBefore = await $('[data-editing=true]') + expect(editingBefore.elementId).toBeTruthy() + + const homeLink = await $('[data-testid=home] a') + expect(homeLink).toBeTruthy() + await homeLink.click() + + const editingAfter = await $('[data-editing=true]') + expect(editingAfter.elementId).toBeFalsy() + }) +}) diff --git a/src/e2e/iOS/__tests__/split.ts b/src/e2e/iOS/__tests__/split.ts new file mode 100644 index 00000000000..757dfeff27b --- /dev/null +++ b/src/e2e/iOS/__tests__/split.ts @@ -0,0 +1,39 @@ +/** + * IOS Safari thought splitting tests. + * Uses WDIO test runner with Mocha framework. + */ +import clickThought from '../helpers/clickThought' +import getEditingText from '../helpers/getEditingText' +import getSelection from '../helpers/getSelection' +import paste from '../helpers/paste' +import tap from '../helpers/tap' +import tapReturnKey from '../helpers/tapReturnKey' +import waitForEditable from '../helpers/waitForEditable' + +describe('Split', () => { + it('split a thought when the caret is in the middle', async () => { + const importText = ` + - puppeteer + - web scraping + - insomnia + - rest api` + + await paste(importText) + + await waitForEditable('puppeteer') + await clickThought('puppeteer') + + const editableNodeHandle = await waitForEditable('web scraping') + await clickThought('web scraping') + + await tap(editableNodeHandle, { y: 60, x: 25 }) + await tap(editableNodeHandle, { y: 60, x: 25 }) + await tapReturnKey() + + const offset = await getSelection()?.focusOffset + expect(offset).toBe(0) + + const editingText = await getEditingText() + expect(editingText).toBe('scraping') + }) +}) From 095df3476c62f3e4a30f44727e4fc2a070d4d689 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 11 May 2026 14:15:03 -0700 Subject: [PATCH 49/50] Switch to caret test --- src/e2e/iOS/__tests__/caret.ts | 225 --------------------------------- src/e2e/iOS/__tests__/color.ts | 39 ------ src/e2e/iOS/__tests__/home.ts | 34 ----- src/e2e/iOS/__tests__/split.ts | 39 ------ 4 files changed, 337 deletions(-) delete mode 100644 src/e2e/iOS/__tests__/color.ts delete mode 100644 src/e2e/iOS/__tests__/home.ts delete mode 100644 src/e2e/iOS/__tests__/split.ts diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts index 29e773dc8f0..dcaabcfd1d4 100644 --- a/src/e2e/iOS/__tests__/caret.ts +++ b/src/e2e/iOS/__tests__/caret.ts @@ -2,18 +2,10 @@ * IOS Safari caret positioning tests. * Uses WDIO test runner with Mocha framework. */ -import gestures from '../../../test-helpers/gestures' -import clickThought from '../helpers/clickThought' -import editThought from '../helpers/editThought' -import gesture from '../helpers/gesture' -import getEditable from '../helpers/getEditable' -import getEditingText from '../helpers/getEditingText' -import getElementRectByScreen from '../helpers/getElementRectByScreen' import getSelection from '../helpers/getSelection' import hideKeyboardByTappingDone from '../helpers/hideKeyboardByTappingDone' import isKeyboardShown from '../helpers/isKeyboardShown' import newThought from '../helpers/newThought' -import paste from '../helpers/paste' import tap from '../helpers/tap' import waitForEditable from '../helpers/waitForEditable' import waitUntil from '../helpers/waitUntil' @@ -34,221 +26,4 @@ describe('Caret', () => { const selectionTextContent = await getSelection().focusNode?.textContent expect(selectionTextContent).toBe('foo') }) - - it('Preserve Editing: true', async () => { - await newThought('foo') - await newThought('bar', { insertNewSubthought: true }) - - const editableNodeHandle = await waitForEditable('foo') - await tap(editableNodeHandle, { y: 60, x: 20 }) - - await waitUntil(async () => (await getEditingText()) === 'foo') - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe('foo') - }) - - it('Preserve Editing: false', async () => { - await newThought('foo') - await newThought('bar', { insertNewSubthought: true }) - await hideKeyboardByTappingDone() - - const editableNodeHandle = await waitForEditable('foo') - await tap(editableNodeHandle) - - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe(null) - }) - - it('No uncle loop', async () => { - const importText = ` - - a - - b - - c` - await newThought() - await paste([''], importText) - - await clickThought('b') - await newThought('d', { insertNewSubthought: true }) - - const editableNodeHandle = await waitForEditable('c') - await tap(editableNodeHandle, { y: 60, x: 20 }) - await waitUntil(async () => (await getEditingText()) === 'c') - - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe('c') - }) - - it.skip('Tap hidden root thought', async () => { - const importText = ` - - a - - b - - c - - d` - await newThought() - await paste([''], importText) - await clickThought('a') - await clickThought('b') - await clickThought('c') - - const editableNodeHandle = await waitForEditable('d') - await tap(editableNodeHandle, { y: 60, x: 20 }) - await waitUntil(async () => (await getEditingText()) !== 'c') - - const editingText = await getEditingText() - expect(editingText).toBe('b') - }) - - it('Tap hidden uncle', async () => { - const importText = ` - - a - - b - - c - - d` - await newThought() - await paste([''], importText) - await clickThought('a') - await clickThought('b') - await clickThought('c') - - const editableNodeHandle = await waitForEditable('d') - await tap(editableNodeHandle, { y: 60, x: 20 }) - - await waitUntil(async () => (await getEditingText()) === 'd') - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe('d') - }) - - it.skip('Tap empty content while keyboard up', async () => { - const importText = ` - - a - - b - - c - - d` - - await newThought() - await paste([''], importText) - await clickThought('b') - await clickThought('c') - - const editableNodeHandleD = await waitForEditable('d') - await tap(editableNodeHandleD, { x: 20, y: 200 }) - - // Wait until cursor change - await waitUntil(async () => (await getEditingText()) === 'b') - expect(await isKeyboardShown()).toBeTruthy() - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe('b') - }) - - it.skip('Tap empty content while keyboard down', async () => { - const importText = ` - - a - - b - - c - - d` - - await newThought() - await paste([''], importText) - await clickThought('b') - await clickThought('c') - await hideKeyboardByTappingDone() - - const editableNodeHandleD = await waitForEditable('d') - await tap(editableNodeHandleD, { x: 20, y: 200 }) - - // Wait until cursor change - await waitUntil(async () => (await getEditingText()) === 'b') - expect(await isKeyboardShown()).toBeFalsy() - }) - - it('Swipe over cursor', async () => { - await newThought('foo') - await hideKeyboardByTappingDone() - - const editableNodeHandle = await waitForEditable('foo') - const elementRect = await getElementRectByScreen(editableNodeHandle) - - // swipe right on thought - await gesture('r', { - xStart: elementRect.x + 5, - yStart: elementRect.y + elementRect.height / 2, - segmentLength: elementRect.width, - }) - - await tap(editableNodeHandle, { y: 60, x: 20 }) - - const editingText = await getEditingText() - expect(editingText).toBe('foo') - - const selectionTextContent = await getSelection().focusNode?.textContent - expect(selectionTextContent).toBe(null) - }) - - it.skip('Swipe over hidden thought', async () => { - const importText = ` - - a - - x - - y - - b - - c - - d - - e - - f - - g - - h - - i` - - await newThought() - await paste([''], importText) - await waitForEditable('i') - await clickThought('a') - await clickThought('x') - await clickThought('y') - - const editableNodeHandle = await waitForEditable('y') - const elementRect = await getElementRectByScreen(editableNodeHandle) - - await gesture(gestures.newThought, { - xStart: elementRect.x + 5, - yStart: elementRect.y + elementRect.height + 10, - }) - await waitForEditable('') - - await editThought('this-is-new-thought') - const newThoughtEditable = await waitForEditable('this-is-new-thought') - - // get first child of parent thought - const previousSibling = await browser.execute((newThoughtEditable: HTMLElement) => { - const editable = (newThoughtEditable as unknown as HTMLElement) - .closest('ul.children') - ?.firstElementChild?.querySelector('[data-editable]') as HTMLElement - return editable?.innerText - }, newThoughtEditable) - - expect(previousSibling).toBe('y') - }) - - it.skip('Bump Thought Down on a thought that has children', async () => { - await newThought('foo') - await newThought('bar', { insertNewSubthought: true }) - await hideKeyboardByTappingDone() - - const editableNodeHandle = await getEditable('foo') - await tap(editableNodeHandle) - - await gesture(gestures.bumpThoughtDown) - const newThoughtEditable = await editThought('new') - const selectionTextContent = await getSelection().focusNode?.textContent - - const childrenTexts = await browser.execute((newThoughtEditable: HTMLElement) => { - const children = (newThoughtEditable as unknown as HTMLElement) - .closest('ul.children') - ?.firstElementChild?.getElementsByTagName('ul')[0] - ?.querySelectorAll('[data-editable]') as NodeListOf - return Array.from(children).map(x => (x as HTMLElement).innerText) - }, newThoughtEditable) - - expect(selectionTextContent).toBe('new') - expect(childrenTexts).toEqual(['foo', 'bar']) - }) }) diff --git a/src/e2e/iOS/__tests__/color.ts b/src/e2e/iOS/__tests__/color.ts deleted file mode 100644 index 2aad6760dbf..00000000000 --- a/src/e2e/iOS/__tests__/color.ts +++ /dev/null @@ -1,39 +0,0 @@ -import click from '../helpers/click' -import getEditingText from '../helpers/getEditingText' -import paste from '../helpers/paste' - -/** Retrieve the innerHTML of the first note on the page. Assumes that there will be only a single note. */ -const getFirstNoteText = () => browser.execute(() => document.querySelector('[aria-label="note-editable"]')?.innerHTML) - -it('Can change the background color of a thought that already has the same background color applied to part of its text, then change the text color', async () => { - await paste(`- some formatted text`) - - await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') - await click('[aria-label="background color swatches"] [aria-label="red"]') - await click('[aria-label="text color swatches"] [aria-label="red"]') - - await browser.keys('Escape') - - const thought = await getEditingText() - expect(thought).toBe('some formatted text') -}) - -it('Can change the background color of a note that already has the same background color applied to part of its text, then change the text color', async () => { - await paste( - ` - - a - - =note - - Multi-word note - `, - ) - - await click('[data-testid="toolbar-icon"][aria-label="Note"]') - - // WebDriver clicks move focus off the note and clear Redux `noteFocus`; synthetic DOM clicks keep the note focused. - await click('[data-testid="toolbar-icon"][aria-label="Text Color"]') //, { preserveActiveFocus: true }) - await click('[aria-label="background color swatches"] [aria-label="red"]') //, { preserveActiveFocus: true }) - await click('[aria-label="text color swatches"] [aria-label="red"]') //, { preserveActiveFocus: true }) - - const result = await getFirstNoteText() - expect(result).toBe('Multi-word note') -}) diff --git a/src/e2e/iOS/__tests__/home.ts b/src/e2e/iOS/__tests__/home.ts deleted file mode 100644 index d0e41d7dcfc..00000000000 --- a/src/e2e/iOS/__tests__/home.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * IOS Safari home navigation tests. - * Uses WDIO test runner with Mocha framework. - */ -import $ from '../helpers/$' -import clickThought from '../helpers/clickThought' -import paste from '../helpers/paste' -import waitForEditable from '../helpers/waitForEditable' - -// test succeeds individually, but fails when there are too many tests running in parallel -// https://github.com/cybersemics/em/issues/1475 -// https://github.com/cybersemics/em/issues/1523 - -describe('Home', () => { - it('click home link to set the cursor to null', async () => { - const text = ` - - a - - b` - await paste(text) - await waitForEditable('b') - await clickThought('b') // set cursor - await clickThought('b') // open keyboard - - const editingBefore = await $('[data-editing=true]') - expect(editingBefore.elementId).toBeTruthy() - - const homeLink = await $('[data-testid=home] a') - expect(homeLink).toBeTruthy() - await homeLink.click() - - const editingAfter = await $('[data-editing=true]') - expect(editingAfter.elementId).toBeFalsy() - }) -}) diff --git a/src/e2e/iOS/__tests__/split.ts b/src/e2e/iOS/__tests__/split.ts deleted file mode 100644 index 757dfeff27b..00000000000 --- a/src/e2e/iOS/__tests__/split.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * IOS Safari thought splitting tests. - * Uses WDIO test runner with Mocha framework. - */ -import clickThought from '../helpers/clickThought' -import getEditingText from '../helpers/getEditingText' -import getSelection from '../helpers/getSelection' -import paste from '../helpers/paste' -import tap from '../helpers/tap' -import tapReturnKey from '../helpers/tapReturnKey' -import waitForEditable from '../helpers/waitForEditable' - -describe('Split', () => { - it('split a thought when the caret is in the middle', async () => { - const importText = ` - - puppeteer - - web scraping - - insomnia - - rest api` - - await paste(importText) - - await waitForEditable('puppeteer') - await clickThought('puppeteer') - - const editableNodeHandle = await waitForEditable('web scraping') - await clickThought('web scraping') - - await tap(editableNodeHandle, { y: 60, x: 25 }) - await tap(editableNodeHandle, { y: 60, x: 25 }) - await tapReturnKey() - - const offset = await getSelection()?.focusOffset - expect(offset).toBe(0) - - const editingText = await getEditingText() - expect(editingText).toBe('scraping') - }) -}) From 07c62f58a811d07038c31f57ce4f7dd8d6c9fa39 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 11 May 2026 14:17:35 -0700 Subject: [PATCH 50/50] Log touch events in and around Editable --- src/components/ContentEditable.tsx | 2 ++ src/components/Editable.tsx | 2 ++ src/components/StaticThought.tsx | 2 ++ 3 files changed, 6 insertions(+) diff --git a/src/components/ContentEditable.tsx b/src/components/ContentEditable.tsx index d86838ca368..0c7dc9eac91 100644 --- a/src/components/ContentEditable.tsx +++ b/src/components/ContentEditable.tsx @@ -106,6 +106,8 @@ const ContentEditable = React.memo( onKeyDown={(e: React.KeyboardEvent) => { if (props.onKeyDown) props.onKeyDown(e) }} + onTouchStart={() => console.info('ContentEditable onTouchStart')} + onTouchEnd={() => console.info('ContentEditable onTouchEnd')} /> ) }, diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index 27337a80967..1d60713c691 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -710,6 +710,8 @@ const Editable = ({ // unless it is given a hint that the element is some sort of form control role='button' style={style} + onTouchStart={() => console.info('Editable onTouchStart')} + onTouchEnd={() => console.info('Editable onTouchEnd')} /> ) } diff --git a/src/components/StaticThought.tsx b/src/components/StaticThought.tsx index 70e28db6d4c..c059ef27fb8 100644 --- a/src/components/StaticThought.tsx +++ b/src/components/StaticThought.tsx @@ -189,6 +189,8 @@ const StaticThought = ({ draggable={!!longPressProps && isSafari()} ref={isTouch ? dndRef(ref => dragSource(ref)) : undefined} style={{ minWidth: `${MIN_CONTENT_WIDTH_EM}em` }} + onTouchStart={() => console.info('StaticThought onTouchStart')} + onTouchEnd={() => console.info('StaticThought onTouchEnd')} > {homeContext ? ( // left, top are eyeballed for different font sizes