diff --git a/packages/@sanity/portable-text-editor/src/constants.ts b/packages/@sanity/portable-text-editor/src/constants.ts index 7c9e0414517..d986bd3ba9c 100644 --- a/packages/@sanity/portable-text-editor/src/constants.ts +++ b/packages/@sanity/portable-text-editor/src/constants.ts @@ -2,4 +2,4 @@ * Debounce time for flushing local patches (ms since user haven't produced a patch) * (lower time for tests to speed them up) */ -export const FLUSH_PATCHES_DEBOUNCE_MS = process.env.NODE_ENV === 'test' ? 50 : 1000 +export const FLUSH_PATCHES_DEBOUNCE_MS = process.env.NODE_ENV === 'test' ? 200 : 1000 diff --git a/packages/@sanity/portable-text-editor/test/__tests__/selectionAdjustment.collaborative.test.ts b/packages/@sanity/portable-text-editor/test/__tests__/selectionAdjustment.collaborative.test.ts index 5813cad2dd0..5faeae30d76 100644 --- a/packages/@sanity/portable-text-editor/test/__tests__/selectionAdjustment.collaborative.test.ts +++ b/packages/@sanity/portable-text-editor/test/__tests__/selectionAdjustment.collaborative.test.ts @@ -396,46 +396,63 @@ describe('selection adjustment', () => { }) }) - it('will keep A on same word if B merges marks within that line', async () => { + it('will keep A on same selection if B toggles marks on another block', async () => { await setDocumentValue([ { - _key: 'someKey', + _key: 'someKey1', _type: 'block', markDefs: [], style: 'normal', - children: [ - {_key: 'anotherKey1', _type: 'span', text: '1 ', marks: []}, - {_key: 'anotherKey2', _type: 'span', text: '22', marks: ['strong']}, - {_key: 'anotherKey3', _type: 'span', text: ' 333', marks: []}, - ], + children: [{_key: 'anotherKey1', _type: 'span', text: '1', marks: ['strong']}], + }, + { + _key: 'someKey2', + _type: 'block', + markDefs: [], + style: 'normal', + children: [{_key: 'anotherKey2', _type: 'span', text: '2', marks: []}], }, ]) const expectedSelectionA = { - anchor: {path: [{_key: 'someKey'}, 'children', {_key: 'anotherKey3'}], offset: 1}, - focus: {path: [{_key: 'someKey'}, 'children', {_key: 'anotherKey3'}], offset: 1}, + anchor: {path: [{_key: 'someKey1'}, 'children', {_key: 'anotherKey1'}], offset: 0}, + focus: {path: [{_key: 'someKey1'}, 'children', {_key: 'anotherKey1'}], offset: 1}, } - const [editorA, editorB] = await getEditors() - await editorA.setSelection(expectedSelectionA) - expect(await editorA.getSelection()).toEqual(expectedSelectionA) const expectedSelectionB = { - anchor: {path: [{_key: 'someKey'}, 'children', {_key: 'anotherKey2'}], offset: 0}, - focus: {path: [{_key: 'someKey'}, 'children', {_key: 'anotherKey2'}], offset: 2}, + anchor: {path: [{_key: 'someKey2'}, 'children', {_key: 'anotherKey2'}], offset: 0}, + focus: {path: [{_key: 'someKey2'}, 'children', {_key: 'anotherKey2'}], offset: 1}, } + const [editorA, editorB] = await getEditors() + await editorA.setSelection(expectedSelectionA) await editorB.setSelection(expectedSelectionB) + expect(await editorA.getSelection()).toEqual(expectedSelectionA) expect(await editorB.getSelection()).toEqual(expectedSelectionB) - await editorB.toggleMark() + await editorA.toggleMark() const valueB = await editorB.getValue() expect(valueB).toMatchInlineSnapshot(` Array [ Object { - "_key": "someKey", + "_key": "someKey1", "_type": "block", "children": Array [ Object { "_key": "anotherKey1", "_type": "span", "marks": Array [], - "text": "1 22 333", + "text": "1", + }, + ], + "markDefs": Array [], + "style": "normal", + }, + Object { + "_key": "someKey2", + "_type": "block", + "children": Array [ + Object { + "_key": "anotherKey2", + "_type": "span", + "marks": Array [], + "text": "2", }, ], "markDefs": Array [], @@ -446,8 +463,12 @@ describe('selection adjustment', () => { const valueA = await editorA.getValue() expect(valueA).toEqual(valueB) expect(await editorA.getSelection()).toEqual({ - anchor: {path: [{_key: 'someKey'}, 'children', {_key: 'anotherKey1'}], offset: 5}, - focus: {path: [{_key: 'someKey'}, 'children', {_key: 'anotherKey1'}], offset: 5}, + anchor: {path: [{_key: 'someKey1'}, 'children', {_key: 'anotherKey1'}], offset: 0}, + focus: {path: [{_key: 'someKey1'}, 'children', {_key: 'anotherKey1'}], offset: 1}, + }) + expect(await editorB.getSelection()).toEqual({ + anchor: {path: [{_key: 'someKey2'}, 'children', {_key: 'anotherKey2'}], offset: 0}, + focus: {path: [{_key: 'someKey2'}, 'children', {_key: 'anotherKey2'}], offset: 1}, }) }) }) diff --git a/packages/@sanity/portable-text-editor/test/__tests__/writingTogether.collaborative.test.ts b/packages/@sanity/portable-text-editor/test/__tests__/writingTogether.collaborative.test.ts index 56573954094..921dfc6aeb8 100644 --- a/packages/@sanity/portable-text-editor/test/__tests__/writingTogether.collaborative.test.ts +++ b/packages/@sanity/portable-text-editor/test/__tests__/writingTogether.collaborative.test.ts @@ -1,9 +1,9 @@ /** @jest-environment ./test/setup/collaborative.jest.env.ts */ import '../setup/globals.jest' -import type {PortableTextBlock} from '../../src' +import type {PortableTextBlock} from '@sanity/types' -const initialValue: PortableTextBlock[] | undefined = [ +const initialValue: PortableTextBlock[] = [ { _key: 'randomKey0', _type: 'block', diff --git a/packages/@sanity/portable-text-editor/test/schema.ts b/packages/@sanity/portable-text-editor/test/schema.ts index aaa2e2b7bdf..8c8814c1217 100644 --- a/packages/@sanity/portable-text-editor/test/schema.ts +++ b/packages/@sanity/portable-text-editor/test/schema.ts @@ -1,17 +1,17 @@ -import {RawType} from '../src/types/schema' +import {defineType} from '@sanity/types' -export const imageType: RawType = { +export const imageType = { type: 'image', name: 'blockImage', } -export const someObject: RawType = { +export const someObject = { type: 'object', name: 'someObject', fields: [{type: 'string', name: 'color'}], } -export const blockType: RawType = { +export const blockType = { type: 'block', name: 'block', styles: [ @@ -27,8 +27,8 @@ export const blockType: RawType = { of: [someObject], } -export const portableTextType: RawType = { +export const portableTextType = defineType({ type: 'array', name: 'body', of: [blockType, someObject], -} +}) diff --git a/packages/@sanity/portable-text-editor/test/setup/collaborative.jest.env.ts b/packages/@sanity/portable-text-editor/test/setup/collaborative.jest.env.ts index f4cc05d2532..0e3e50d1c85 100644 --- a/packages/@sanity/portable-text-editor/test/setup/collaborative.jest.env.ts +++ b/packages/@sanity/portable-text-editor/test/setup/collaborative.jest.env.ts @@ -2,10 +2,11 @@ import childProcess from 'child_process' import NodeEnvironment from 'jest-environment-node' import {isEqual} from 'lodash' import ipc from 'node-ipc' -import puppeteer, {ElementHandle, KeyInput} from 'puppeteer' +import {ElementHandle, KeyInput, Browser, Page, launch} from 'puppeteer' +import {PortableTextBlock} from '@sanity/types' import {FLUSH_PATCHES_DEBOUNCE_MS} from '../../src/constants' import {normalizeSelection} from '../../src/utils/selection' -import type {EditorSelection, PortableTextBlock} from '../../src' +import type {EditorSelection} from '../../src' ipc.config.id = 'collaborative-jest-environment-ipc-client' ipc.config.retry = 1500 @@ -19,10 +20,11 @@ const WEB_SERVER_ROOT_URL = 'http://localhost:3000' const DEBUG = process.env.DEBUG || false // Wait this long for selections and a new doc revision to appear in the clients. -const SELECTION_TIMEOUT_MS = 1500 +const SELECTION_TIMEOUT_MS = 300 // How long to wait for a new revision to come back to the client(s) when patched through the server. -const REVISION_TIMEOUT_MS = FLUSH_PATCHES_DEBOUNCE_MS + 1000 +// Wait for patch debounce time and some slack for selection adjustment time for everything to be ready +const REVISION_TIMEOUT_MS = FLUSH_PATCHES_DEBOUNCE_MS + SELECTION_TIMEOUT_MS // eslint-disable-next-line no-process-env const launchConfig = process.env.CI @@ -33,10 +35,6 @@ const launchConfig = process.env.CI } : {} -function generateRandomInteger(min: number, max: number) { - return Math.floor(min + Math.random() * (max - min + 1)) -} - export const delay = (time: number): Promise => { return new Promise((resolve) => { setTimeout(resolve, time) @@ -44,15 +42,15 @@ export const delay = (time: number): Promise => { } export default class CollaborationEnvironment extends NodeEnvironment { - private _browserA?: puppeteer.Browser - private _browserB?: puppeteer.Browser - private _pageA?: puppeteer.Page - private _pageB?: puppeteer.Page + private _browserA?: Browser + private _browserB?: Browser + private _pageA?: Page + private _pageB?: Page public async setup(): Promise { await super.setup() - this._browserA = await puppeteer.launch(launchConfig) - this._browserB = await puppeteer.launch(launchConfig) + this._browserA = await launch(launchConfig) + this._browserB = await launch(launchConfig) this._pageA = await this._browserA.newPage() this._pageB = await this._browserB.newPage() @@ -75,9 +73,11 @@ export default class CollaborationEnvironment extends NodeEnvironment { } this._pageA.on('pageerror', (err) => { console.error('Editor A crashed', err) + throw err }) this._pageB.on('pageerror', (err) => { console.error('Editor B crashed', err) + throw err }) await new Promise((resolve) => { ipc.connectToNet('socketServer', () => { @@ -107,6 +107,7 @@ export default class CollaborationEnvironment extends NodeEnvironment { value: PortableTextBlock[] | undefined ): Promise => { ipc.of.socketServer.emit('payload', JSON.stringify({type: 'value', value, testId})) + await delay(REVISION_TIMEOUT_MS) // Wait a little here for the payload to reach the clients const [valueHandleA, valueHandleB] = await Promise.all([ this._pageA?.waitForSelector('#pte-value'), this._pageB?.waitForSelector('#pte-value'), @@ -138,15 +139,12 @@ export default class CollaborationEnvironment extends NodeEnvironment { const isMac = /Mac|iPod|iPhone|iPad/.test(userAgent) const metaKey = isMac ? 'Meta' : 'Control' const editorId = `${['A', 'B'][index]}${testId}` - const [ - editableHandle, - selectionHandle, - valueHandle, - ]: (ElementHandle | null)[] = await Promise.all([ - page.waitForSelector('div[contentEditable="true"]'), - page.waitForSelector('#pte-selection'), - page.waitForSelector('#pte-value'), - ]) + const [editableHandle, selectionHandle, valueHandle]: (ElementHandle | null)[] = + await Promise.all([ + page.waitForSelector('div[contentEditable="true"]'), + page.waitForSelector('#pte-selection'), + page.waitForSelector('#pte-value'), + ]) if (!editableHandle || !selectionHandle || !valueHandle) { throw new Error('Failed to find required editor elements') @@ -164,7 +162,7 @@ export default class CollaborationEnvironment extends NodeEnvironment { } const getSelection = async (): Promise => { const selection = await selectionHandle.evaluate((node) => - node.innerText ? JSON.parse(node.innerText) : null + node instanceof HTMLElement && node.innerText ? JSON.parse(node.innerText) : null ) return selection } @@ -179,7 +177,7 @@ export default class CollaborationEnvironment extends NodeEnvironment { const waitForSelection = async (selection: EditorSelection) => { const value = await valueHandle.evaluate((node): PortableTextBlock[] | undefined => - node.innerText ? JSON.parse(node.innerText) : undefined + node instanceof HTMLElement && node.innerText ? JSON.parse(node.innerText) : undefined ) const normalized = normalizeSelection(selection, value) const dataVal = JSON.stringify(normalized) @@ -280,11 +278,14 @@ export default class CollaborationEnvironment extends NodeEnvironment { editorId, }) ) + await delay(REVISION_TIMEOUT_MS) // Wait a little here for the payload to reach the client await waitForSelection(selection) }, async getValue(): Promise { const value = await valueHandle.evaluate((node): PortableTextBlock[] | undefined => - node.innerText ? JSON.parse(node.innerText) : undefined + node instanceof HTMLElement && node.innerText + ? JSON.parse(node.innerText) + : undefined ) return value }, diff --git a/packages/@sanity/portable-text-editor/test/setup/globals.jest.ts b/packages/@sanity/portable-text-editor/test/setup/globals.jest.ts index 85f382eaac1..dc0ac0fc8e9 100644 --- a/packages/@sanity/portable-text-editor/test/setup/globals.jest.ts +++ b/packages/@sanity/portable-text-editor/test/setup/globals.jest.ts @@ -1,5 +1,6 @@ +import {PortableTextBlock} from '@sanity/types' import {KeyInput} from 'puppeteer' -import {EditorSelection, PortableTextBlock} from '../../src' +import {EditorSelection} from '../../src' export {} diff --git a/packages/@sanity/portable-text-editor/test/web-server/app.tsx b/packages/@sanity/portable-text-editor/test/web-server/app.tsx index 12637515de2..2130d0b88c6 100644 --- a/packages/@sanity/portable-text-editor/test/web-server/app.tsx +++ b/packages/@sanity/portable-text-editor/test/web-server/app.tsx @@ -1,7 +1,8 @@ +import {PortableTextBlock} from '@sanity/types' import {Box, Card, Stack, studioTheme, ThemeProvider} from '@sanity/ui' import React, {useCallback, useMemo, useState} from 'react' import {Subject} from 'rxjs' -import {EditorSelection, Patch, PortableTextBlock} from '../../src' +import {EditorSelection, Patch} from '../../src' import {Editor} from './components/Editor' import {Value} from './components/Value' @@ -73,7 +74,11 @@ export function App() { return ( - + + editorId: {editorId} + testId: {testId} + + - - - + diff --git a/packages/@sanity/portable-text-editor/test/web-server/components/Editor.tsx b/packages/@sanity/portable-text-editor/test/web-server/components/Editor.tsx index 8e002810a7c..8925cacd60a 100644 --- a/packages/@sanity/portable-text-editor/test/web-server/components/Editor.tsx +++ b/packages/@sanity/portable-text-editor/test/web-server/components/Editor.tsx @@ -2,32 +2,41 @@ import React, {useCallback, useMemo, useRef, useState, useEffect} from 'react' import {Text, Box, Card, Code} from '@sanity/ui' import styled from 'styled-components' import {Subject} from 'rxjs' +import {PortableTextBlock} from '@sanity/types' import { + BlockDecoratorRenderProps, + BlockRenderProps, PortableTextEditor, PortableTextEditable, - RenderDecoratorFunction, EditorChange, RenderBlockFunction, RenderChildFunction, - RenderAttributes, EditorSelection, - PortableTextBlock, Patch, + HotkeyOptions, + BlockListItemRenderProps, + BlockStyleRenderProps, } from '../../../src' import {createKeyGenerator} from '../keyGenerator' import {portableTextType} from '../../schema' -export const HOTKEYS = { +export const HOTKEYS: HotkeyOptions = { marks: { 'mod+b': 'strong', 'mod+i': 'em', }, + custom: { + 'mod+l': (e, editor) => { + e.preventDefault() + PortableTextEditor.toggleList(editor, 'number') + }, + }, } export const BlockObject = styled.div` - border: ${(props: RenderAttributes) => + border: ${(props: BlockRenderProps) => props.focused ? '1px solid blue' : '1px solid transparent'}; - background: ${(props: RenderAttributes) => (props.selected ? '#eeeeff' : 'transparent')}; + background: ${(props: BlockRenderProps) => (props.selected ? '#eeeeff' : 'transparent')}; padding: 2em; ` @@ -63,61 +72,64 @@ export const Editor = ({ const editor = useRef(null) const keyGenFn = useMemo(() => createKeyGenerator(editorId.substring(0, 1)), [editorId]) - const renderBlock: RenderBlockFunction = useCallback((block, type, attributes, defaultRender) => { + const renderBlock: RenderBlockFunction = useCallback((props) => { + const {value: block, type, children} = props if (editor.current) { - const textType = PortableTextEditor.getPortableTextFeatures(editor.current).types.block + const textType = editor.current.types.block // Text blocks if (type.name === textType.name) { return ( - {defaultRender(block)} + {children} ) } // Object blocks return ( - {JSON.stringify(block)} + + <>{JSON.stringify(block)} + ) } - return defaultRender(block) + return children }, []) - const renderChild: RenderChildFunction = useCallback( - (child, type, _attributes, defaultRender) => { - if (editor.current) { - const textType = PortableTextEditor.getPortableTextFeatures(editor.current).types.span - // Text spans - if (type.name === textType.name) { - return defaultRender(child) - } - // Inline objects + const renderChild: RenderChildFunction = useCallback((props) => { + const {type, children} = props + if (editor.current) { + const textType = editor.current.types.span + // Text spans + if (type.name === textType.name) { + return children } - return defaultRender(child) - }, - [] - ) + // Inline objects + } + return children + }, []) - const renderDecorator: RenderDecoratorFunction = useCallback( - (mark, _mType, _attributes, defaultRender) => { - switch (mark) { - case 'strong': - return {defaultRender()} - case 'em': - return {defaultRender()} - case 'code': - return {defaultRender()} - case 'underline': - return {defaultRender()} - case 'strike-through': - return {defaultRender()} - default: - return defaultRender() - } - }, - [] - ) + const renderDecorator = useCallback((props: BlockDecoratorRenderProps) => { + const {value: mark, children} = props + switch (mark) { + case 'strong': + return {children} + case 'em': + return {children} + case 'code': + return {children} + case 'underline': + return {children} + case 'strike-through': + return {children} + default: + return children + } + }, []) + + const renderStyle = useCallback((props: BlockStyleRenderProps) => { + return props.children + }, []) const handleChange = useCallback( (change: EditorChange): void => { @@ -144,6 +156,12 @@ export const Editor = ({ [onMutation] ) + const renderListItem = useCallback((props: BlockListItemRenderProps) => { + const {level, type, value: listType, children} = props + const listStyleType = type.value === 'number' ? 'decimal' : 'inherit' + return
  • {children}
  • + }, []) + const [readOnly, setReadOnly] = useState(false) const editable = useMemo( @@ -154,11 +172,13 @@ export const Editor = ({ renderBlock={renderBlock} renderDecorator={renderDecorator} renderChild={renderChild} + renderListItem={renderListItem} + renderStyle={renderStyle} selection={selection} spellCheck /> ), - [renderBlock, renderChild, renderDecorator, selection] + [renderBlock, renderChild, renderDecorator, renderListItem, renderStyle, selection] ) // Make sure that the test editor is focused and out of "readOnly mode". diff --git a/packages/@sanity/portable-text-editor/test/web-server/components/Value.tsx b/packages/@sanity/portable-text-editor/test/web-server/components/Value.tsx index 36de2a445b6..cf40ff05fac 100644 --- a/packages/@sanity/portable-text-editor/test/web-server/components/Value.tsx +++ b/packages/@sanity/portable-text-editor/test/web-server/components/Value.tsx @@ -1,6 +1,6 @@ import React from 'react' +import {PortableTextBlock} from '@sanity/types' import {Card, Heading, Code, Box} from '@sanity/ui' -import {PortableTextBlock} from '../../../src/index' type Props = {value: PortableTextBlock[] | undefined; revId: string} diff --git a/packages/@sanity/portable-text-editor/test/web-server/index.html b/packages/@sanity/portable-text-editor/test/web-server/index.html index 0b963ea27b2..6b6d50d85ce 100644 --- a/packages/@sanity/portable-text-editor/test/web-server/index.html +++ b/packages/@sanity/portable-text-editor/test/web-server/index.html @@ -3,7 +3,7 @@ - Test Page + Test Editor
    diff --git a/packages/@sanity/portable-text-editor/test/ws-server/index.ts b/packages/@sanity/portable-text-editor/test/ws-server/index.ts index bd0a334286c..d69d2b1a7af 100644 --- a/packages/@sanity/portable-text-editor/test/ws-server/index.ts +++ b/packages/@sanity/portable-text-editor/test/ws-server/index.ts @@ -3,8 +3,9 @@ import express from 'express' import expressWS from 'express-ws' import {Subject} from 'rxjs' import type {WebSocket} from 'ws' +import {PortableTextBlock} from '@sanity/types' import {applyAll} from '../../src/patch/applyPatch' -import {Patch, PortableTextBlock} from '../../src' +import {Patch} from '../../src' const expressApp = express() const {app} = expressWS(expressApp) @@ -81,21 +82,26 @@ app.ws('/', (s, req) => { } if (data.type === 'mutation' && testId) { const prevValue = valueMap[testId] - valueMap[testId] = applyAll(prevValue, data.patches) - messages.next( - JSON.stringify({ - type: 'value', - value: valueMap[testId], - testId, - revId: revisionMap[testId], - }) - ) - messages.next( - JSON.stringify({ - ...data, - snapshot: valueMap[testId], - }) - ) + try { + valueMap[testId] = applyAll(prevValue, data.patches) + messages.next( + JSON.stringify({ + type: 'value', + value: valueMap[testId], + testId, + revId: revisionMap[testId], + }) + ) + messages.next( + JSON.stringify({ + ...data, + snapshot: valueMap[testId], + }) + ) + } catch (err) { + console.error(err) + // Nothing + } } }) })