From d91357f2fbe86d069e01d3322b9eed4d1ff2537f Mon Sep 17 00:00:00 2001 From: Per-Kristian Nordnes Date: Mon, 4 Sep 2023 14:12:05 +0200 Subject: [PATCH] fix(portable-text-editor): emit new selection on mark toggle (#4881) * fix(portable-text-editor): always emit new selection when toggling marks Always emit a new selection when toggling marks, even though the selection might still end up be the same. This happens when there is only one span node in the editor and you toggle a mark on it. It is very convenient for implementations to use selection as a dependency for updating toolbars etc, so we should force a new selection here to support that dependency. Note that the selection value is still the same, it's just emitted as a new object. * test(portable-text-editor): add test for new selection object after toggling marks This will both test the actual selection object and if the change event is emitted with the correct value. --- .../withPortableTextMarkModel.test.tsx | 764 ++++++++++++++++++ ...ortableTextMarkModelNormalization.test.tsx | 711 ---------------- .../createWithPortableTextMarkModel.ts | 35 +- .../src/editor/plugins/index.ts | 2 +- 4 files changed, 797 insertions(+), 715 deletions(-) create mode 100644 packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withPortableTextMarkModel.test.tsx delete mode 100644 packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withPortableTextMarkModelNormalization.test.tsx diff --git a/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withPortableTextMarkModel.test.tsx b/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withPortableTextMarkModel.test.tsx new file mode 100644 index 00000000000..8f30f9d7e10 --- /dev/null +++ b/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withPortableTextMarkModel.test.tsx @@ -0,0 +1,764 @@ +/* eslint-disable max-nested-callbacks */ +import {render, waitFor} from '@testing-library/react' + +import React from 'react' +import {PortableTextEditor} from '../../PortableTextEditor' +import {PortableTextEditorTester, schemaType} from '../../__tests__/PortableTextEditorTester' +import {EditorSelection} from '../../../types/editor' + +describe('plugin:withPortableTextMarksModel', () => { + describe('normalization', () => { + it('merges adjacent spans correctly when removing annotations', async () => { + const editorRef: React.RefObject = React.createRef() + const initialValue = [ + { + _key: '5fc57af23597', + _type: 'myTestBlockType', + children: [ + { + _key: 'be1c67c6971a', + _type: 'span', + marks: [], + text: 'This is a ', + }, + { + _key: '11c8c9f783a8', + _type: 'span', + marks: ['fde1fd54b544'], + text: 'link', + }, + { + _key: '576c748e0cd2', + _type: 'span', + marks: [], + text: ', this is ', + }, + { + _key: 'f3d73d3833bf', + _type: 'span', + marks: ['7b6d3d5de30c'], + text: 'another', + }, + { + _key: '73b01f13c2ec', + _type: 'span', + marks: [], + text: ', and this is ', + }, + { + _key: '13eb0d467c82', + _type: 'span', + marks: ['93a1d24eade0'], + text: 'a third', + }, + ], + markDefs: [ + { + _key: 'fde1fd54b544', + _type: 'link', + url: '1', + }, + { + _key: '7b6d3d5de30c', + _type: 'link', + url: '2', + }, + { + _key: '93a1d24eade0', + _type: 'link', + url: '3', + }, + ], + style: 'normal', + }, + ] + + const onChange = jest.fn() + await waitFor(() => { + render( + , + ) + }) + + await waitFor(() => { + if (editorRef.current) { + PortableTextEditor.focus(editorRef.current) + PortableTextEditor.select(editorRef.current, { + focus: {path: [{_key: '5fc57af23597'}, 'children', {_key: '11c8c9f783a8'}], offset: 4}, + anchor: {path: [{_key: '5fc57af23597'}, 'children', {_key: '11c8c9f783a8'}], offset: 0}, + }) + // eslint-disable-next-line max-nested-callbacks + const linkType = editorRef.current.schemaTypes.annotations.find((a) => a.name === 'link') + if (!linkType) { + throw new Error('No link type found') + } + PortableTextEditor.removeAnnotation(editorRef.current, linkType) + expect(PortableTextEditor.getValue(editorRef.current)).toEqual([ + { + _key: '5fc57af23597', + _type: 'myTestBlockType', + children: [ + { + _key: 'be1c67c6971a', + _type: 'span', + marks: [], + text: 'This is a link, this is ', + }, + { + _key: 'f3d73d3833bf', + _type: 'span', + marks: ['7b6d3d5de30c'], + text: 'another', + }, + { + _key: '73b01f13c2ec', + _type: 'span', + marks: [], + text: ', and this is ', + }, + { + _key: '13eb0d467c82', + _type: 'span', + marks: ['93a1d24eade0'], + text: 'a third', + }, + ], + markDefs: [ + { + _key: '7b6d3d5de30c', + _type: 'link', + url: '2', + }, + { + _key: '93a1d24eade0', + _type: 'link', + url: '3', + }, + ], + style: 'normal', + }, + ]) + } + }) + }) + + it('splits correctly when adding marks', async () => { + const editorRef: React.RefObject = React.createRef() + const initialValue = [ + { + _key: 'a', + _type: 'myTestBlockType', + children: [ + { + _key: 'a1', + _type: 'span', + marks: [], + text: '123', + }, + ], + markDefs: [], + style: 'normal', + }, + { + _key: 'b', + _type: 'myTestBlockType', + children: [ + { + _key: 'b1', + _type: 'span', + marks: [], + text: '123', + }, + ], + markDefs: [], + style: 'normal', + }, + ] + const onChange = jest.fn() + await waitFor(() => { + render( + , + ) + }) + await waitFor(() => { + if (editorRef.current) { + const editor = editorRef.current + PortableTextEditor.focus(editor) + PortableTextEditor.select(editor, { + focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 0}, + anchor: {path: [{_key: 'b'}, 'children', {_key: 'b1'}], offset: 1}, + }) + PortableTextEditor.toggleMark(editor, 'bold') + const value = PortableTextEditor.getValue(editor) + expect(value).toMatchInlineSnapshot(` + Array [ + Object { + "_key": "a", + "_type": "myTestBlockType", + "children": Array [ + Object { + "_key": "a1", + "_type": "span", + "marks": Array [ + "bold", + ], + "text": "123", + }, + ], + "markDefs": Array [], + "style": "normal", + }, + Object { + "_key": "b", + "_type": "myTestBlockType", + "children": Array [ + Object { + "_key": "b1", + "_type": "span", + "marks": Array [ + "bold", + ], + "text": "1", + }, + Object { + "_key": "1", + "_type": "span", + "marks": Array [], + "text": "23", + }, + ], + "markDefs": Array [], + "style": "normal", + }, + ] + `) + } + }) + }) + it('merges children correctly when toggling marks in various ranges', async () => { + const editorRef: React.RefObject = React.createRef() + const initialValue = [ + { + _key: 'a', + _type: 'myTestBlockType', + children: [ + { + _key: 'a1', + _type: 'span', + marks: [], + text: '1234', + }, + ], + markDefs: [], + style: 'normal', + }, + ] + const onChange = jest.fn() + await waitFor(() => { + render( + , + ) + }) + const editor = editorRef.current! + expect(editor).toBeDefined() + await waitFor(() => { + PortableTextEditor.focus(editor) + PortableTextEditor.select(editor, { + focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 0}, + anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 4}, + }) + PortableTextEditor.toggleMark(editor, 'bold') + expect(PortableTextEditor.getValue(editor)).toMatchInlineSnapshot(` + Array [ + Object { + "_key": "a", + "_type": "myTestBlockType", + "children": Array [ + Object { + "_key": "a1", + "_type": "span", + "marks": Array [ + "bold", + ], + "text": "1234", + }, + ], + "markDefs": Array [], + "style": "normal", + }, + ] + `) + }) + await waitFor(() => { + if (editorRef.current) { + PortableTextEditor.select(editorRef.current, { + focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 1}, + anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 3}, + }) + PortableTextEditor.toggleMark(editorRef.current, 'bold') + expect(PortableTextEditor.getValue(editorRef.current)).toMatchInlineSnapshot(` + Array [ + Object { + "_key": "a", + "_type": "myTestBlockType", + "children": Array [ + Object { + "_key": "a1", + "_type": "span", + "marks": Array [ + "bold", + ], + "text": "1", + }, + Object { + "_key": "2", + "_type": "span", + "marks": Array [], + "text": "23", + }, + Object { + "_key": "1", + "_type": "span", + "marks": Array [ + "bold", + ], + "text": "4", + }, + ], + "markDefs": Array [], + "style": "normal", + }, + ] + `) + } + }) + await waitFor(() => { + if (editor) { + PortableTextEditor.select(editor, { + focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 0}, + anchor: {path: [{_key: 'a'}, 'children', {_key: '1'}], offset: 1}, + }) + PortableTextEditor.toggleMark(editor, 'bold') + expect(PortableTextEditor.getValue(editor)).toMatchInlineSnapshot(` + Array [ + Object { + "_key": "a", + "_type": "myTestBlockType", + "children": Array [ + Object { + "_key": "a1", + "_type": "span", + "marks": Array [], + "text": "1234", + }, + ], + "markDefs": Array [], + "style": "normal", + }, + ] + `) + } + }) + }) + it('toggles marks on children with annotation marks correctly', async () => { + const editorRef: React.RefObject = React.createRef() + const initialValue = [ + { + _key: 'a', + _type: 'myTestBlockType', + children: [ + { + _key: 'a1', + _type: 'span', + marks: ['abc'], + text: 'A link', + }, + { + _key: 'a2', + _type: 'span', + marks: [], + text: ', not a link', + }, + ], + markDefs: [ + { + _type: 'link', + _key: 'abc', + href: 'http://www.link.com', + }, + ], + style: 'normal', + }, + ] + const onChange = jest.fn() + await waitFor(() => { + render( + , + ) + }) + const editor = editorRef.current! + expect(editor).toBeDefined() + + await waitFor(() => { + PortableTextEditor.focus(editor) + PortableTextEditor.select(editor, { + focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 0}, + anchor: {path: [{_key: 'a'}, 'children', {_key: 'b1'}], offset: 12}, + }) + PortableTextEditor.toggleMark(editor, 'bold') + expect(PortableTextEditor.getValue(editor)).toMatchInlineSnapshot(` + Array [ + Object { + "_key": "a", + "_type": "myTestBlockType", + "children": Array [ + Object { + "_key": "a1", + "_type": "span", + "marks": Array [ + "abc", + "bold", + ], + "text": "A link", + }, + Object { + "_key": "a2", + "_type": "span", + "marks": Array [ + "bold", + ], + "text": ", not a link", + }, + ], + "markDefs": Array [ + Object { + "_key": "abc", + "_type": "link", + "href": "http://www.link.com", + }, + ], + "style": "normal", + }, + ] + `) + }) + }) + + it('merges blocks correctly when containing links', async () => { + const editorRef: React.RefObject = React.createRef() + const initialValue = [ + { + _key: '5fc57af23597', + _type: 'myTestBlockType', + children: [ + { + _key: 'be1c67c6971a', + _type: 'span', + marks: [], + text: 'This is a ', + }, + { + _key: '11c8c9f783a8', + _type: 'span', + marks: ['fde1fd54b544'], + text: 'link', + }, + ], + markDefs: [ + { + _key: 'fde1fd54b544', + _type: 'link', + url: '1', + }, + ], + style: 'normal', + }, + { + _key: '7cd53af36712', + _type: 'myTestBlockType', + children: [ + { + _key: '576c748e0cd2', + _type: 'span', + marks: [], + text: 'This is ', + }, + { + _key: 'f3d73d3833bf', + _type: 'span', + marks: ['7b6d3d5de30c'], + text: 'another', + }, + ], + markDefs: [ + { + _key: '7b6d3d5de30c', + _type: 'link', + url: '2', + }, + ], + style: 'normal', + }, + ] + const sel: EditorSelection = { + focus: {path: [{_key: '5fc57af23597'}, 'children', {_key: '11c8c9f783a8'}], offset: 4}, + anchor: {path: [{_key: '7cd53af36712'}, 'children', {_key: '576c748e0cd2'}], offset: 0}, + } + const onChange = jest.fn() + await waitFor(() => { + render( + , + ) + }) + const editor = editorRef.current! + expect(editor).toBeDefined() + await waitFor(() => { + PortableTextEditor.select(editor, sel) + PortableTextEditor.delete(editor, sel) + expect(PortableTextEditor.getValue(editor)).toMatchInlineSnapshot(` + Array [ + Object { + "_key": "5fc57af23597", + "_type": "myTestBlockType", + "children": Array [ + Object { + "_key": "be1c67c6971a", + "_type": "span", + "marks": Array [], + "text": "This is a ", + }, + Object { + "_key": "11c8c9f783a8", + "_type": "span", + "marks": Array [ + "fde1fd54b544", + ], + "text": "link", + }, + Object { + "_key": "576c748e0cd2", + "_type": "span", + "marks": Array [], + "text": "This is ", + }, + Object { + "_key": "f3d73d3833bf", + "_type": "span", + "marks": Array [ + "7b6d3d5de30c", + ], + "text": "another", + }, + ], + "markDefs": Array [ + Object { + "_key": "fde1fd54b544", + "_type": "link", + "url": "1", + }, + Object { + "_key": "7b6d3d5de30c", + "_type": "link", + "url": "2", + }, + ], + "style": "normal", + }, + ] + `) + }) + }) + + it('resets markDefs when splitting a block in the beginning', async () => { + const editorRef: React.RefObject = React.createRef() + const initialValue = [ + { + _key: '1987f99da4a2', + _type: 'myTestBlockType', + children: [ + { + _key: '3693e789451c', + _type: 'span', + marks: [], + text: '1', + }, + ], + markDefs: [], + style: 'normal', + }, + { + _key: '2f55670a03bb', + _type: 'myTestBlockType', + children: [ + { + _key: '9f5ed7dee7ab', + _type: 'span', + marks: ['bab319ad3a9d'], + text: '2', + }, + ], + markDefs: [ + { + _key: 'bab319ad3a9d', + _type: 'link', + href: 'http://www.123.com', + }, + ], + style: 'normal', + }, + ] + const sel: EditorSelection = { + focus: {path: [{_key: '2f55670a03bb'}, 'children', {_key: '9f5ed7dee7ab'}], offset: 0}, + anchor: {path: [{_key: '2f55670a03bb'}, 'children', {_key: '9f5ed7dee7ab'}], offset: 0}, + } + const onChange = jest.fn() + await waitFor(() => { + render( + , + ) + }) + + const editor = editorRef.current! + expect(editor).toBeDefined() + + await waitFor(() => { + PortableTextEditor.select(editor, sel) + PortableTextEditor.focus(editor) + PortableTextEditor.insertBreak(editor) + expect(PortableTextEditor.getValue(editor)).toMatchInlineSnapshot(` + Array [ + Object { + "_key": "1987f99da4a2", + "_type": "myTestBlockType", + "children": Array [ + Object { + "_key": "3693e789451c", + "_type": "span", + "marks": Array [], + "text": "1", + }, + ], + "markDefs": Array [], + "style": "normal", + }, + Object { + "_key": "2f55670a03bb", + "_type": "myTestBlockType", + "children": Array [ + Object { + "_key": "9f5ed7dee7ab", + "_type": "span", + "marks": Array [], + "text": "", + }, + ], + "markDefs": Array [], + "style": "normal", + }, + Object { + "_key": "2", + "_type": "myTestBlockType", + "children": Array [ + Object { + "_key": "1", + "_type": "span", + "marks": Array [ + "bab319ad3a9d", + ], + "text": "2", + }, + ], + "markDefs": Array [ + Object { + "_key": "bab319ad3a9d", + "_type": "link", + "href": "http://www.123.com", + }, + ], + "style": "normal", + }, + ] + `) + }) + }) + }) + describe('selection', () => { + it('should emit a new selection object when toggling marks, even though the value is the same', async () => { + const editorRef: React.RefObject = React.createRef() + const initialValue = [ + { + _key: '1987f99da4a2', + _type: 'myTestBlockType', + children: [ + { + _key: '3693e789451c', + _type: 'span', + marks: [], + text: '', + }, + ], + markDefs: [], + style: 'normal', + }, + ] + const onChange = jest.fn() + + await waitFor(() => { + render( + , + ) + }) + + const editor = editorRef.current! + expect(editor).toBeDefined() + + await waitFor(() => { + PortableTextEditor.focus(editor) + }) + const currentSelectionObject = PortableTextEditor.getSelection(editor) + + await waitFor(() => { + PortableTextEditor.toggleMark(editor, 'strong') + }) + const nextSelectionObject = PortableTextEditor.getSelection(editor) + expect(currentSelectionObject).toEqual(nextSelectionObject) + expect(currentSelectionObject === nextSelectionObject).toBe(false) + expect(onChange).toHaveBeenCalledWith({type: 'selection', selection: nextSelectionObject}) + }) + }) +}) diff --git a/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withPortableTextMarkModelNormalization.test.tsx b/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withPortableTextMarkModelNormalization.test.tsx deleted file mode 100644 index aafb52e8f89..00000000000 --- a/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withPortableTextMarkModelNormalization.test.tsx +++ /dev/null @@ -1,711 +0,0 @@ -import {render, waitFor} from '@testing-library/react' - -import React from 'react' -import {PortableTextEditor} from '../../PortableTextEditor' -import { - PortableTextEditorTester, - schemaType, -} from '../../../editor/__tests__/PortableTextEditorTester' -import {EditorSelection} from '../../../types/editor' - -describe('plugin:withPortableTextMarksModel: normalization', () => { - it('merges adjacent spans correctly when removing annotations', async () => { - const editorRef: React.RefObject = React.createRef() - const initialValue = [ - { - _key: '5fc57af23597', - _type: 'myTestBlockType', - children: [ - { - _key: 'be1c67c6971a', - _type: 'span', - marks: [], - text: 'This is a ', - }, - { - _key: '11c8c9f783a8', - _type: 'span', - marks: ['fde1fd54b544'], - text: 'link', - }, - { - _key: '576c748e0cd2', - _type: 'span', - marks: [], - text: ', this is ', - }, - { - _key: 'f3d73d3833bf', - _type: 'span', - marks: ['7b6d3d5de30c'], - text: 'another', - }, - { - _key: '73b01f13c2ec', - _type: 'span', - marks: [], - text: ', and this is ', - }, - { - _key: '13eb0d467c82', - _type: 'span', - marks: ['93a1d24eade0'], - text: 'a third', - }, - ], - markDefs: [ - { - _key: 'fde1fd54b544', - _type: 'link', - url: '1', - }, - { - _key: '7b6d3d5de30c', - _type: 'link', - url: '2', - }, - { - _key: '93a1d24eade0', - _type: 'link', - url: '3', - }, - ], - style: 'normal', - }, - ] - const onChange = jest.fn() - render( - , - ) - await waitFor(() => { - if (editorRef.current) { - PortableTextEditor.focus(editorRef.current) - PortableTextEditor.select(editorRef.current, { - focus: {path: [{_key: '5fc57af23597'}, 'children', {_key: '11c8c9f783a8'}], offset: 4}, - anchor: {path: [{_key: '5fc57af23597'}, 'children', {_key: '11c8c9f783a8'}], offset: 0}, - }) - // eslint-disable-next-line max-nested-callbacks - const linkType = editorRef.current.schemaTypes.annotations.find((a) => a.name === 'link') - if (!linkType) { - throw new Error('No link type found') - } - PortableTextEditor.removeAnnotation(editorRef.current, linkType) - expect(PortableTextEditor.getValue(editorRef.current)).toEqual([ - { - _key: '5fc57af23597', - _type: 'myTestBlockType', - children: [ - { - _key: 'be1c67c6971a', - _type: 'span', - marks: [], - text: 'This is a link, this is ', - }, - { - _key: 'f3d73d3833bf', - _type: 'span', - marks: ['7b6d3d5de30c'], - text: 'another', - }, - { - _key: '73b01f13c2ec', - _type: 'span', - marks: [], - text: ', and this is ', - }, - { - _key: '13eb0d467c82', - _type: 'span', - marks: ['93a1d24eade0'], - text: 'a third', - }, - ], - markDefs: [ - { - _key: '7b6d3d5de30c', - _type: 'link', - url: '2', - }, - { - _key: '93a1d24eade0', - _type: 'link', - url: '3', - }, - ], - style: 'normal', - }, - ]) - } - }) - }) - - it('splits correctly when adding marks', async () => { - const editorRef: React.RefObject = React.createRef() - const initialValue = [ - { - _key: 'a', - _type: 'myTestBlockType', - children: [ - { - _key: 'a1', - _type: 'span', - marks: [], - text: '123', - }, - ], - markDefs: [], - style: 'normal', - }, - { - _key: 'b', - _type: 'myTestBlockType', - children: [ - { - _key: 'b1', - _type: 'span', - marks: [], - text: '123', - }, - ], - markDefs: [], - style: 'normal', - }, - ] - const onChange = jest.fn() - await waitFor(() => { - render( - , - ) - }) - await waitFor(() => { - if (editorRef.current) { - const editor = editorRef.current - PortableTextEditor.focus(editor) - PortableTextEditor.select(editor, { - focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 0}, - anchor: {path: [{_key: 'b'}, 'children', {_key: 'b1'}], offset: 1}, - }) - PortableTextEditor.toggleMark(editor, 'bold') - const value = PortableTextEditor.getValue(editor) - expect(value).toMatchInlineSnapshot(` - Array [ - Object { - "_key": "a", - "_type": "myTestBlockType", - "children": Array [ - Object { - "_key": "a1", - "_type": "span", - "marks": Array [ - "bold", - ], - "text": "123", - }, - ], - "markDefs": Array [], - "style": "normal", - }, - Object { - "_key": "b", - "_type": "myTestBlockType", - "children": Array [ - Object { - "_key": "b1", - "_type": "span", - "marks": Array [ - "bold", - ], - "text": "1", - }, - Object { - "_key": "1", - "_type": "span", - "marks": Array [], - "text": "23", - }, - ], - "markDefs": Array [], - "style": "normal", - }, - ] - `) - } - }) - }) - it('merges children correctly when toggling marks in various ranges', async () => { - const editorRef: React.RefObject = React.createRef() - const initialValue = [ - { - _key: 'a', - _type: 'myTestBlockType', - children: [ - { - _key: 'a1', - _type: 'span', - marks: [], - text: '1234', - }, - ], - markDefs: [], - style: 'normal', - }, - ] - const onChange = jest.fn() - await waitFor(() => { - render( - , - ) - }) - if (!editorRef.current) { - throw new Error('No editor') - } - await waitFor(() => { - if (editorRef.current) { - PortableTextEditor.focus(editorRef.current) - PortableTextEditor.select(editorRef.current, { - focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 0}, - anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 4}, - }) - PortableTextEditor.toggleMark(editorRef.current, 'bold') - expect(PortableTextEditor.getValue(editorRef.current)).toMatchInlineSnapshot(` - Array [ - Object { - "_key": "a", - "_type": "myTestBlockType", - "children": Array [ - Object { - "_key": "a1", - "_type": "span", - "marks": Array [ - "bold", - ], - "text": "1234", - }, - ], - "markDefs": Array [], - "style": "normal", - }, - ] - `) - } - }) - await waitFor(() => { - if (editorRef.current) { - PortableTextEditor.select(editorRef.current, { - focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 1}, - anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 3}, - }) - PortableTextEditor.toggleMark(editorRef.current, 'bold') - expect(PortableTextEditor.getValue(editorRef.current)).toMatchInlineSnapshot(` - Array [ - Object { - "_key": "a", - "_type": "myTestBlockType", - "children": Array [ - Object { - "_key": "a1", - "_type": "span", - "marks": Array [ - "bold", - ], - "text": "1", - }, - Object { - "_key": "2", - "_type": "span", - "marks": Array [], - "text": "23", - }, - Object { - "_key": "1", - "_type": "span", - "marks": Array [ - "bold", - ], - "text": "4", - }, - ], - "markDefs": Array [], - "style": "normal", - }, - ] - `) - } - }) - await waitFor(() => { - if (editorRef.current) { - PortableTextEditor.select(editorRef.current, { - focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 0}, - anchor: {path: [{_key: 'a'}, 'children', {_key: '1'}], offset: 1}, - }) - PortableTextEditor.toggleMark(editorRef.current, 'bold') - expect(PortableTextEditor.getValue(editorRef.current)).toMatchInlineSnapshot(` - Array [ - Object { - "_key": "a", - "_type": "myTestBlockType", - "children": Array [ - Object { - "_key": "a1", - "_type": "span", - "marks": Array [], - "text": "1234", - }, - ], - "markDefs": Array [], - "style": "normal", - }, - ] - `) - } - }) - }) - it('toggles marks on children with annotation marks correctly', async () => { - const editorRef: React.RefObject = React.createRef() - const initialValue = [ - { - _key: 'a', - _type: 'myTestBlockType', - children: [ - { - _key: 'a1', - _type: 'span', - marks: ['abc'], - text: 'A link', - }, - { - _key: 'a2', - _type: 'span', - marks: [], - text: ', not a link', - }, - ], - markDefs: [ - { - _type: 'link', - _key: 'abc', - href: 'http://www.link.com', - }, - ], - style: 'normal', - }, - ] - const onChange = jest.fn() - await waitFor(() => { - render( - , - ) - }) - await waitFor(() => { - if (editorRef.current) { - PortableTextEditor.focus(editorRef.current) - PortableTextEditor.select(editorRef.current, { - focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 0}, - anchor: {path: [{_key: 'a'}, 'children', {_key: 'b1'}], offset: 12}, - }) - PortableTextEditor.toggleMark(editorRef.current, 'bold') - expect(PortableTextEditor.getValue(editorRef.current)).toMatchInlineSnapshot(` - Array [ - Object { - "_key": "a", - "_type": "myTestBlockType", - "children": Array [ - Object { - "_key": "a1", - "_type": "span", - "marks": Array [ - "abc", - "bold", - ], - "text": "A link", - }, - Object { - "_key": "a2", - "_type": "span", - "marks": Array [ - "bold", - ], - "text": ", not a link", - }, - ], - "markDefs": Array [ - Object { - "_key": "abc", - "_type": "link", - "href": "http://www.link.com", - }, - ], - "style": "normal", - }, - ] - `) - } - }) - }) - - it('merges blocks correctly when containing links', async () => { - const editorRef: React.RefObject = React.createRef() - const initialValue = [ - { - _key: '5fc57af23597', - _type: 'myTestBlockType', - children: [ - { - _key: 'be1c67c6971a', - _type: 'span', - marks: [], - text: 'This is a ', - }, - { - _key: '11c8c9f783a8', - _type: 'span', - marks: ['fde1fd54b544'], - text: 'link', - }, - ], - markDefs: [ - { - _key: 'fde1fd54b544', - _type: 'link', - url: '1', - }, - ], - style: 'normal', - }, - { - _key: '7cd53af36712', - _type: 'myTestBlockType', - children: [ - { - _key: '576c748e0cd2', - _type: 'span', - marks: [], - text: 'This is ', - }, - { - _key: 'f3d73d3833bf', - _type: 'span', - marks: ['7b6d3d5de30c'], - text: 'another', - }, - ], - markDefs: [ - { - _key: '7b6d3d5de30c', - _type: 'link', - url: '2', - }, - ], - style: 'normal', - }, - ] - const sel: EditorSelection = { - focus: {path: [{_key: '5fc57af23597'}, 'children', {_key: '11c8c9f783a8'}], offset: 4}, - anchor: {path: [{_key: '7cd53af36712'}, 'children', {_key: '576c748e0cd2'}], offset: 0}, - } - const onChange = jest.fn() - await waitFor(() => { - render( - , - ) - }) - await waitFor(() => { - if (editorRef.current) { - PortableTextEditor.select(editorRef.current, sel) - PortableTextEditor.delete(editorRef.current, sel) - expect(PortableTextEditor.getValue(editorRef.current)).toMatchInlineSnapshot(` - Array [ - Object { - "_key": "5fc57af23597", - "_type": "myTestBlockType", - "children": Array [ - Object { - "_key": "be1c67c6971a", - "_type": "span", - "marks": Array [], - "text": "This is a ", - }, - Object { - "_key": "11c8c9f783a8", - "_type": "span", - "marks": Array [ - "fde1fd54b544", - ], - "text": "link", - }, - Object { - "_key": "576c748e0cd2", - "_type": "span", - "marks": Array [], - "text": "This is ", - }, - Object { - "_key": "f3d73d3833bf", - "_type": "span", - "marks": Array [ - "7b6d3d5de30c", - ], - "text": "another", - }, - ], - "markDefs": Array [ - Object { - "_key": "fde1fd54b544", - "_type": "link", - "url": "1", - }, - Object { - "_key": "7b6d3d5de30c", - "_type": "link", - "url": "2", - }, - ], - "style": "normal", - }, - ] - `) - } - }) - }) - - it('resets markDefs when splitting a block in the beginning', async () => { - const editorRef: React.RefObject = React.createRef() - const initialValue = [ - { - _key: '1987f99da4a2', - _type: 'myTestBlockType', - children: [ - { - _key: '3693e789451c', - _type: 'span', - marks: [], - text: '1', - }, - ], - markDefs: [], - style: 'normal', - }, - { - _key: '2f55670a03bb', - _type: 'myTestBlockType', - children: [ - { - _key: '9f5ed7dee7ab', - _type: 'span', - marks: ['bab319ad3a9d'], - text: '2', - }, - ], - markDefs: [ - { - _key: 'bab319ad3a9d', - _type: 'link', - href: 'http://www.123.com', - }, - ], - style: 'normal', - }, - ] - const sel: EditorSelection = { - focus: {path: [{_key: '2f55670a03bb'}, 'children', {_key: '9f5ed7dee7ab'}], offset: 0}, - anchor: {path: [{_key: '2f55670a03bb'}, 'children', {_key: '9f5ed7dee7ab'}], offset: 0}, - } - const onChange = jest.fn() - await waitFor(() => { - render( - , - ) - }) - await waitFor(() => { - if (editorRef.current) { - PortableTextEditor.select(editorRef.current, sel) - PortableTextEditor.focus(editorRef.current) - PortableTextEditor.insertBreak(editorRef.current) - expect(PortableTextEditor.getValue(editorRef.current)).toMatchInlineSnapshot(` - Array [ - Object { - "_key": "1987f99da4a2", - "_type": "myTestBlockType", - "children": Array [ - Object { - "_key": "3693e789451c", - "_type": "span", - "marks": Array [], - "text": "1", - }, - ], - "markDefs": Array [], - "style": "normal", - }, - Object { - "_key": "2f55670a03bb", - "_type": "myTestBlockType", - "children": Array [ - Object { - "_key": "9f5ed7dee7ab", - "_type": "span", - "marks": Array [], - "text": "", - }, - ], - "markDefs": Array [], - "style": "normal", - }, - Object { - "_key": "2", - "_type": "myTestBlockType", - "children": Array [ - Object { - "_key": "1", - "_type": "span", - "marks": Array [ - "bab319ad3a9d", - ], - "text": "2", - }, - ], - "markDefs": Array [ - Object { - "_key": "bab319ad3a9d", - "_type": "link", - "href": "http://www.123.com", - }, - ], - "style": "normal", - }, - ] - `) - } - }) - }) -}) diff --git a/packages/@sanity/portable-text-editor/src/editor/plugins/createWithPortableTextMarkModel.ts b/packages/@sanity/portable-text-editor/src/editor/plugins/createWithPortableTextMarkModel.ts index 8f81c576bcb..b05e03a47ff 100644 --- a/packages/@sanity/portable-text-editor/src/editor/plugins/createWithPortableTextMarkModel.ts +++ b/packages/@sanity/portable-text-editor/src/editor/plugins/createWithPortableTextMarkModel.ts @@ -8,19 +8,44 @@ import {isEqual, flatten, uniq} from 'lodash' import {Editor, Range, Transforms, Text, Path, NodeEntry, Element, Descendant} from 'slate' +import {Subject} from 'rxjs' import {debugWithName} from '../../utils/debug' -import {PortableTextMemberSchemaTypes, PortableTextSlateEditor} from '../../types/editor' -import {IS_PROCESSING_REMOTE_CHANGES} from '../../utils/weakMaps' +import { + EditorChange, + PortableTextMemberSchemaTypes, + PortableTextSlateEditor, +} from '../../types/editor' +import {toPortableTextRange} from '../../utils/ranges' const debug = debugWithName('plugin:withPortableTextMarkModel') export function createWithPortableTextMarkModel( types: PortableTextMemberSchemaTypes, + change$: Subject, ): (editor: PortableTextSlateEditor) => PortableTextSlateEditor { return function withPortableTextMarkModel(editor: PortableTextSlateEditor) { const {apply, normalizeNode} = editor const decorators = types.decorators.map((t) => t.value) + // Selections are normally emitted automatically via + // onChange, but they will keep the object reference if + // the selection is the same as the previous. + // When toggling marks however, it might not even + // result in a onChange event (for instance when nothing is selected), + // and if you toggle marks on a block with one single span, + // the selection would also stay the same. + // We should force a new selection object here when toggling marks, + // because toolbars and other things can very conveniently + // be memo'ed on the editor selection to update itself. + const forceNewSelection = () => { + if (editor.selection) { + Transforms.select(editor, {...editor.selection}) + editor.selection = {...editor.selection} // Ensure new object + } + const ptRange = toPortableTextRange(editor.children, editor.selection, types) + change$.next({type: 'selection', selection: ptRange}) + } + // Extend Slate's default normalization. Merge spans with same set of .marks when doing merge_node operations, and clean up markDefs / marks editor.normalizeNode = (nodeEntry) => { normalizeNode(nodeEntry) @@ -248,9 +273,11 @@ export function createWithPortableTextMarkModel( marks: [...existingMarks, mark], } editor.marks = marks as Text + forceNewSelection() return editor } editor.onChange() + forceNewSelection() } return editor } @@ -295,15 +322,17 @@ export function createWithPortableTextMarkModel( marks: existingMarks.filter((eMark) => eMark !== mark), } as Text editor.marks = {marks: marks.marks, _type: 'span'} as Text + forceNewSelection() return editor } editor.onChange() + forceNewSelection() } return editor } editor.pteIsMarkActive = (mark: string): boolean => { - if (!editor.selection || editor.selection.focus.path.length < 2) { + if (!editor.selection) { return false } let existingMarks = diff --git a/packages/@sanity/portable-text-editor/src/editor/plugins/index.ts b/packages/@sanity/portable-text-editor/src/editor/plugins/index.ts index 9716725dd60..afec06f8018 100644 --- a/packages/@sanity/portable-text-editor/src/editor/plugins/index.ts +++ b/packages/@sanity/portable-text-editor/src/editor/plugins/index.ts @@ -76,7 +76,7 @@ export const withPlugins = ( patches$, blockSchemaType: schemaTypes.block, }) - const withPortableTextMarkModel = createWithPortableTextMarkModel(schemaTypes) + const withPortableTextMarkModel = createWithPortableTextMarkModel(schemaTypes, change$) const withPortableTextBlockStyle = createWithPortableTextBlockStyle(schemaTypes) const withPlaceholderBlock = createWithPlaceholderBlock({