@portabletext/editor 1.5.6 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,10 +13,11 @@ import type {
13
13
  PortableTextSlateEditor,
14
14
  } from '../../types/editor'
15
15
  import {debugWithName} from '../../utils/debug'
16
- import {toPortableTextRange} from '../../utils/ranges'
17
16
  import {getNextSpan, getPreviousSpan} from '../../utils/sibling-utils'
18
17
  import {isChangingRemotely} from '../../utils/withChanges'
19
18
  import {isRedoing, isUndoing} from '../../utils/withUndoRedo'
19
+ import type {BehaviourActionImplementation} from '../behavior/behavior.actions'
20
+ import type {BehaviorAction, PickFromUnion} from '../behavior/behavior.types'
20
21
  import type {EditorActor} from '../editor-machine'
21
22
 
22
23
  const debug = debugWithName('plugin:withPortableTextMarkModel')
@@ -29,29 +30,6 @@ export function createWithPortableTextMarkModel(
29
30
  const {apply, normalizeNode} = editor
30
31
  const decorators = types.decorators.map((t) => t.value)
31
32
 
32
- // Selections are normally emitted automatically via
33
- // onChange, but they will keep the object reference if
34
- // the selection is the same as the previous.
35
- // When toggling marks however, it might not even
36
- // result in a onChange event (for instance when nothing is selected),
37
- // and if you toggle marks on a block with one single span,
38
- // the selection would also stay the same.
39
- // We should force a new selection object here when toggling marks,
40
- // because toolbars and other things can very conveniently
41
- // be memo'ed on the editor selection to update itself.
42
- const forceNewSelection = () => {
43
- if (editor.selection) {
44
- Transforms.select(editor, {...editor.selection})
45
- editor.selection = {...editor.selection} // Ensure new object
46
- }
47
- const ptRange = toPortableTextRange(
48
- editor.children,
49
- editor.selection,
50
- types,
51
- )
52
- editorActor.send({type: 'selection', selection: ptRange})
53
- }
54
-
55
33
  // Extend Slate's default normalization. Merge spans with same set of .marks when doing merge_node operations, and clean up markDefs / marks
56
34
  editor.normalizeNode = (nodeEntry) => {
57
35
  const [node, path] = nodeEntry
@@ -673,220 +651,237 @@ export function createWithPortableTextMarkModel(
673
651
  apply(op)
674
652
  }
675
653
 
676
- // Override built in addMark function
677
- editor.addMark = (mark: string) => {
678
- if (editor.selection) {
679
- if (Range.isExpanded(editor.selection)) {
680
- Editor.withoutNormalizing(editor, () => {
681
- // Split if needed
682
- Transforms.setNodes(
683
- editor,
684
- {},
685
- {match: Text.isText, split: true, hanging: true},
686
- )
687
- // Use new selection
688
- const splitTextNodes = Range.isRange(editor.selection)
689
- ? [
690
- ...Editor.nodes(editor, {
691
- at: editor.selection,
692
- match: Text.isText,
693
- }),
694
- ]
695
- : []
696
- const shouldRemoveMark =
697
- splitTextNodes.length > 1 &&
698
- splitTextNodes.every((node) => node[0].marks?.includes(mark))
699
-
700
- if (shouldRemoveMark) {
701
- editor.removeMark(mark)
702
- } else {
703
- splitTextNodes.forEach(([node, path]) => {
704
- const marks = [
705
- ...(Array.isArray(node.marks) ? node.marks : []).filter(
706
- (eMark: string) => eMark !== mark,
707
- ),
708
- mark,
709
- ]
710
- Transforms.setNodes(
711
- editor,
712
- {marks},
713
- {at: path, match: Text.isText, split: true, hanging: true},
714
- )
715
- })
716
- }
717
- })
718
- } else {
719
- const [block, blockPath] = Editor.node(editor, editor.selection, {
720
- depth: 1,
721
- })
722
- const lonelyEmptySpan =
723
- editor.isTextBlock(block) &&
724
- block.children.length === 1 &&
725
- editor.isTextSpan(block.children[0]) &&
726
- block.children[0].text === ''
727
- ? block.children[0]
728
- : undefined
729
-
730
- if (lonelyEmptySpan) {
731
- const existingMarks = lonelyEmptySpan.marks ?? []
732
- const existingMarksWithoutDecorator = existingMarks.filter(
733
- (existingMark) => existingMark !== mark,
734
- )
654
+ return editor
655
+ }
656
+ }
735
657
 
736
- Transforms.setNodes(
737
- editor,
738
- {
739
- marks:
740
- existingMarks.length === existingMarksWithoutDecorator.length
741
- ? [...existingMarks, mark]
742
- : existingMarksWithoutDecorator,
743
- },
744
- {
745
- at: blockPath,
746
- match: (node) => editor.isTextSpan(node),
747
- },
748
- )
749
- } else {
750
- const existingMarks: string[] =
751
- {
752
- ...(Editor.marks(editor) || {}),
753
- }.marks || []
754
- const marks = {
755
- ...(Editor.marks(editor) || {}),
756
- marks: [...existingMarks, mark],
757
- }
758
- editor.marks = marks as Text
759
- forceNewSelection()
760
- return editor
761
- }
658
+ export const addDecoratorActionImplementation: BehaviourActionImplementation<
659
+ PickFromUnion<BehaviorAction, 'type', 'decorator.add'>
660
+ > = ({action}) => {
661
+ const editor = action.editor
662
+ const mark = action.decorator
663
+
664
+ if (editor.selection) {
665
+ if (Range.isExpanded(editor.selection)) {
666
+ // Split if needed
667
+ Transforms.setNodes(
668
+ editor,
669
+ {},
670
+ {match: Text.isText, split: true, hanging: true},
671
+ )
672
+ // Use new selection
673
+ const splitTextNodes = Range.isRange(editor.selection)
674
+ ? [
675
+ ...Editor.nodes(editor, {
676
+ at: editor.selection,
677
+ match: Text.isText,
678
+ }),
679
+ ]
680
+ : []
681
+ const shouldRemoveMark =
682
+ splitTextNodes.length > 1 &&
683
+ splitTextNodes.every((node) => node[0].marks?.includes(mark))
684
+
685
+ if (shouldRemoveMark) {
686
+ editor.removeMark(mark)
687
+ } else {
688
+ splitTextNodes.forEach(([node, path]) => {
689
+ const marks = [
690
+ ...(Array.isArray(node.marks) ? node.marks : []).filter(
691
+ (eMark: string) => eMark !== mark,
692
+ ),
693
+ mark,
694
+ ]
695
+ Transforms.setNodes(
696
+ editor,
697
+ {marks},
698
+ {at: path, match: Text.isText, split: true, hanging: true},
699
+ )
700
+ })
701
+ }
702
+ } else {
703
+ const [block, blockPath] = Editor.node(editor, editor.selection, {
704
+ depth: 1,
705
+ })
706
+ const lonelyEmptySpan =
707
+ editor.isTextBlock(block) &&
708
+ block.children.length === 1 &&
709
+ editor.isTextSpan(block.children[0]) &&
710
+ block.children[0].text === ''
711
+ ? block.children[0]
712
+ : undefined
713
+
714
+ if (lonelyEmptySpan) {
715
+ const existingMarks = lonelyEmptySpan.marks ?? []
716
+ const existingMarksWithoutDecorator = existingMarks.filter(
717
+ (existingMark) => existingMark !== mark,
718
+ )
719
+
720
+ Transforms.setNodes(
721
+ editor,
722
+ {
723
+ marks:
724
+ existingMarks.length === existingMarksWithoutDecorator.length
725
+ ? [...existingMarks, mark]
726
+ : existingMarksWithoutDecorator,
727
+ },
728
+ {
729
+ at: blockPath,
730
+ match: (node) => editor.isTextSpan(node),
731
+ },
732
+ )
733
+ } else {
734
+ const existingMarks: string[] =
735
+ {
736
+ ...(Editor.marks(editor) || {}),
737
+ }.marks || []
738
+ const marks = {
739
+ ...(Editor.marks(editor) || {}),
740
+ marks: [...existingMarks, mark],
762
741
  }
763
- editor.onChange()
764
- forceNewSelection()
742
+ editor.marks = marks as Text
765
743
  }
766
- return editor
767
744
  }
745
+ editor.onChange()
746
+ }
747
+ }
768
748
 
769
- // Override built in removeMark function
770
- editor.removeMark = (mark: string) => {
771
- const {selection} = editor
772
- if (selection) {
773
- if (Range.isExpanded(selection)) {
774
- Editor.withoutNormalizing(editor, () => {
775
- // Split if needed
776
- Transforms.setNodes(
777
- editor,
778
- {},
779
- {match: Text.isText, split: true, hanging: true},
780
- )
781
- if (editor.selection) {
782
- const splitTextNodes = [
783
- ...Editor.nodes(editor, {
784
- at: editor.selection,
785
- match: Text.isText,
786
- }),
787
- ]
788
- splitTextNodes.forEach(([node, path]) => {
789
- const block = editor.children[path[0]]
790
- if (Element.isElement(block) && block.children.includes(node)) {
791
- Transforms.setNodes(
792
- editor,
793
- {
794
- marks: (Array.isArray(node.marks)
795
- ? node.marks
796
- : []
797
- ).filter((eMark: string) => eMark !== mark),
798
- _type: 'span',
799
- },
800
- {at: path},
801
- )
802
- }
803
- })
804
- }
805
- })
806
- Editor.normalize(editor)
807
- } else {
808
- const [block, blockPath] = Editor.node(editor, selection, {
809
- depth: 1,
810
- })
811
- const lonelyEmptySpan =
812
- editor.isTextBlock(block) &&
813
- block.children.length === 1 &&
814
- editor.isTextSpan(block.children[0]) &&
815
- block.children[0].text === ''
816
- ? block.children[0]
817
- : undefined
818
-
819
- if (lonelyEmptySpan) {
820
- const existingMarks = lonelyEmptySpan.marks ?? []
821
- const existingMarksWithoutDecorator = existingMarks.filter(
822
- (existingMark) => existingMark !== mark,
823
- )
824
-
749
+ export const removeDecoratorActionImplementation: BehaviourActionImplementation<
750
+ PickFromUnion<BehaviorAction, 'type', 'decorator.remove'>
751
+ > = ({action}) => {
752
+ const editor = action.editor
753
+ const mark = action.decorator
754
+ const {selection} = editor
755
+
756
+ if (selection) {
757
+ if (Range.isExpanded(selection)) {
758
+ // Split if needed
759
+ Transforms.setNodes(
760
+ editor,
761
+ {},
762
+ {match: Text.isText, split: true, hanging: true},
763
+ )
764
+ if (editor.selection) {
765
+ const splitTextNodes = [
766
+ ...Editor.nodes(editor, {
767
+ at: editor.selection,
768
+ match: Text.isText,
769
+ }),
770
+ ]
771
+ splitTextNodes.forEach(([node, path]) => {
772
+ const block = editor.children[path[0]]
773
+ if (Element.isElement(block) && block.children.includes(node)) {
825
774
  Transforms.setNodes(
826
775
  editor,
827
776
  {
828
- marks: existingMarksWithoutDecorator,
829
- },
830
- {
831
- at: blockPath,
832
- match: (node) => editor.isTextSpan(node),
777
+ marks: (Array.isArray(node.marks) ? node.marks : []).filter(
778
+ (eMark: string) => eMark !== mark,
779
+ ),
780
+ _type: 'span',
833
781
  },
782
+ {at: path},
834
783
  )
835
- } else {
836
- const existingMarks: string[] =
837
- {
838
- ...(Editor.marks(editor) || {}),
839
- }.marks || []
840
- const marks = {
841
- ...(Editor.marks(editor) || {}),
842
- marks: existingMarks.filter((eMark) => eMark !== mark),
843
- } as Text
844
- editor.marks = {marks: marks.marks, _type: 'span'} as Text
845
- forceNewSelection()
846
- return editor
847
784
  }
848
- }
849
- editor.onChange()
850
- forceNewSelection()
785
+ })
851
786
  }
852
- return editor
853
- }
787
+ } else {
788
+ const [block, blockPath] = Editor.node(editor, selection, {
789
+ depth: 1,
790
+ })
791
+ const lonelyEmptySpan =
792
+ editor.isTextBlock(block) &&
793
+ block.children.length === 1 &&
794
+ editor.isTextSpan(block.children[0]) &&
795
+ block.children[0].text === ''
796
+ ? block.children[0]
797
+ : undefined
798
+
799
+ if (lonelyEmptySpan) {
800
+ const existingMarks = lonelyEmptySpan.marks ?? []
801
+ const existingMarksWithoutDecorator = existingMarks.filter(
802
+ (existingMark) => existingMark !== mark,
803
+ )
854
804
 
855
- editor.pteIsMarkActive = (mark: string): boolean => {
856
- if (!editor.selection) {
857
- return false
805
+ Transforms.setNodes(
806
+ editor,
807
+ {
808
+ marks: existingMarksWithoutDecorator,
809
+ },
810
+ {
811
+ at: blockPath,
812
+ match: (node) => editor.isTextSpan(node),
813
+ },
814
+ )
815
+ } else {
816
+ const existingMarks: string[] =
817
+ {
818
+ ...(Editor.marks(editor) || {}),
819
+ }.marks || []
820
+ const marks = {
821
+ ...(Editor.marks(editor) || {}),
822
+ marks: existingMarks.filter((eMark) => eMark !== mark),
823
+ } as Text
824
+ editor.marks = {marks: marks.marks, _type: 'span'} as Text
858
825
  }
826
+ }
827
+ }
828
+ }
859
829
 
860
- const selectedNodes = Array.from(
861
- Editor.nodes(editor, {match: Text.isText, at: editor.selection}),
862
- )
830
+ export function isDecoratorActive({
831
+ editor,
832
+ decorator,
833
+ }: {
834
+ editor: PortableTextSlateEditor
835
+ decorator: string
836
+ }) {
837
+ if (!editor.selection) {
838
+ return false
839
+ }
863
840
 
864
- if (Range.isExpanded(editor.selection)) {
865
- return selectedNodes.every((n) => {
866
- const [node] = n
841
+ const selectedNodes = Array.from(
842
+ Editor.nodes(editor, {match: Text.isText, at: editor.selection}),
843
+ )
867
844
 
868
- return node.marks?.includes(mark)
869
- })
870
- }
845
+ if (Range.isExpanded(editor.selection)) {
846
+ return selectedNodes.every((n) => {
847
+ const [node] = n
871
848
 
872
- return (
873
- {
874
- ...(Editor.marks(editor) || {}),
875
- }.marks || []
876
- ).includes(mark)
877
- }
849
+ return node.marks?.includes(decorator)
850
+ })
851
+ }
878
852
 
879
- // Custom editor function to toggle a mark
880
- editor.pteToggleMark = (mark: string) => {
881
- const isActive = editor.pteIsMarkActive(mark)
882
- if (isActive) {
883
- debug(`Remove mark '${mark}'`)
884
- Editor.removeMark(editor, mark)
885
- } else {
886
- debug(`Add mark '${mark}'`)
887
- Editor.addMark(editor, mark, true)
888
- }
889
- }
890
- return editor
853
+ return (
854
+ {
855
+ ...(Editor.marks(editor) || {}),
856
+ }.marks || []
857
+ ).includes(decorator)
858
+ }
859
+
860
+ export const toggleDecoratorActionImplementation: BehaviourActionImplementation<
861
+ PickFromUnion<BehaviorAction, 'type', 'decorator.toggle'>
862
+ > = ({context, action}) => {
863
+ const isActive = isDecoratorActive({
864
+ editor: action.editor,
865
+ decorator: action.decorator,
866
+ })
867
+
868
+ if (isActive) {
869
+ removeDecoratorActionImplementation({
870
+ context,
871
+ action: {
872
+ type: 'decorator.remove',
873
+ editor: action.editor,
874
+ decorator: action.decorator,
875
+ },
876
+ })
877
+ } else {
878
+ addDecoratorActionImplementation({
879
+ context,
880
+ action: {
881
+ type: 'decorator.add',
882
+ editor: action.editor,
883
+ decorator: action.decorator,
884
+ },
885
+ })
891
886
  }
892
887
  }
@@ -171,20 +171,6 @@ export interface PortableTextSlateEditor extends ReactEditor {
171
171
  */
172
172
  pteEndList: () => boolean
173
173
 
174
- /**
175
- * Toggle marks in the selection
176
- *
177
- * @param mark - Mark to toggle on/off
178
- */
179
- pteToggleMark: (mark: string) => void
180
-
181
- /**
182
- * Test if a mark is active in the current selection
183
- *
184
- * @param mark - Mark to check whether or not is active
185
- */
186
- pteIsMarkActive: (mark: string) => boolean
187
-
188
174
  /**
189
175
  * Toggle the selected block style
190
176
  *