a tool for shared writing and social publishing
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix a lot of little text-selection and toolbar issues

Namely switch to iterating over every position in a slection and
checking if there is a mark, and then updating the mark if neccessary
instead of toggling it.

Also add a specific case for handling links since they aren't inclusive
of the last position

+210 -21
+13 -2
components/TextBlock/index.tsx
··· 17 17 } from "src/replicache"; 18 18 import { isVisible } from "src/utils/isVisible"; 19 19 20 - import { EditorState } from "prosemirror-state"; 20 + import { EditorState, TextSelection } from "prosemirror-state"; 21 21 import { ySyncPlugin } from "y-prosemirror"; 22 22 import { Replicache } from "replicache"; 23 23 import { generateKeyBetween } from "fractional-indexing"; ··· 35 35 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 36 36 import { isIOS } from "@react-aria/utils"; 37 37 import { useIsMobile } from "src/hooks/isMobile"; 38 + import { setMark } from "src/utils/prosemirror/setMark"; 39 + import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; 38 40 39 41 export function TextBlock(props: BlockProps & { className: string }) { 40 42 let initialized = useInitialPageLoad(); ··· 429 431 function CommandHandler(props: { entityID: string }) { 430 432 let cb = useEditorEventCallback( 431 433 (view, args: { mark: MarkType; attrs?: any }) => { 432 - toggleMark(args.mark, args.attrs)(view.state, view.dispatch); 434 + let { to, from, $cursor, $to, $from } = view.state 435 + .selection as TextSelection; 436 + let mark = rangeHasMark(view.state, args.mark, from, to); 437 + if ( 438 + mark && 439 + (!args.attrs || 440 + JSON.stringify(args.attrs) === JSON.stringify(mark.attrs)) 441 + ) { 442 + toggleMark(args.mark, args.attrs)(view.state, view.dispatch); 443 + } else setMark(args.mark, args.attrs)(view.state, view.dispatch); 433 444 }, 434 445 ); 435 446 useAppEventListener(props.entityID, "toggleMark", cb, []);
+11 -7
components/Toolbar/HighlightButton.tsx
··· 28 28 import { useMemo, useState } from "react"; 29 29 import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 30 30 import { useParams } from "next/navigation"; 31 + import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; 31 32 32 33 export const HighlightColorButton = (props: { 33 34 color: "1" | "2" | "3"; ··· 40 41 let hasMark: boolean = false; 41 42 if (focusedEditor) { 42 43 let { to, from, $cursor } = focusedEditor.editor.selection as TextSelection; 44 + 45 + let mark = rangeHasMark( 46 + focusedEditor.editor, 47 + schema.marks.highlight, 48 + from, 49 + to, 50 + ); 43 51 if ($cursor) 44 52 hasMark = !!schema.marks.highlight.isInSet( 45 53 focusedEditor.editor.storedMarks || $cursor.marks(), 46 54 ); 47 - else 48 - hasMark = focusedEditor.editor.doc.rangeHasMark( 49 - from, 50 - to, 51 - schema.marks.highlight, 52 - ); 55 + else { 56 + hasMark = !!mark; 57 + } 53 58 } 54 59 return ( 55 60 <button ··· 58 63 toggleMarkInFocusedBlock(schema.marks.highlight, { 59 64 color: props.color, 60 65 }); 61 - schema.marks.highlight.create({ color: props.color }); 62 66 props.setLastUsedHightlight(props.color); 63 67 }} 64 68 >
+8 -9
components/Toolbar/LinkButton.tsx
··· 7 7 import { Separator } from "components/Layout"; 8 8 import { MarkType } from "prosemirror-model"; 9 9 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 10 + import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; 10 11 11 12 export function LinkButton(props: { setToolBarState: (s: "link") => void }) { 12 13 let focusedBlock = useUIState((s) => s.focusedBlock); ··· 17 18 if (focusedEditor) { 18 19 let { to, from, $cursor } = focusedEditor.editor.selection as TextSelection; 19 20 if ($cursor) isLink = !!schema.marks.link.isInSet($cursor.marks()); 20 - else { 21 - isLink = true; 22 - for (let pos = from + 1; pos < to - 1; pos++) { 23 - const $pos = focusedEditor.editor.doc.resolve(pos); 24 - if (!$pos.marks().find((mark) => mark.type === schema.marks.link)) { 25 - isLink = false; 26 - } 27 - } 28 - } 21 + if (to !== from) 22 + isLink = !!rangeHasMark( 23 + focusedEditor.editor, 24 + schema.marks.link, 25 + from, 26 + to, 27 + ); 29 28 } 30 29 return ( 31 30 <ToolbarButton
+10 -3
components/Toolbar/TextDecorationButton.tsx
··· 1 - import { MarkType } from "prosemirror-model"; 1 + import { Mark, MarkType } from "prosemirror-model"; 2 2 import { useUIState } from "src/useUIState"; 3 3 import { ToolbarButton } from "."; 4 4 import { toggleMark } from "prosemirror-commands"; 5 5 import { TextSelection } from "prosemirror-state"; 6 6 import { publishAppEvent } from "src/eventBus"; 7 7 import { useEditorStates } from "src/state/useEditorState"; 8 + import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; 8 9 9 10 export function TextDecorationButton(props: { 10 11 mark: MarkType; ··· 15 16 focusedBlock ? s.editorStates[focusedBlock.entityID] : null, 16 17 ); 17 18 let hasMark: boolean = false; 19 + let mark: Mark | null = null; 18 20 if (focusedEditor) { 19 - let { to, from, $cursor } = focusedEditor.editor.selection as TextSelection; 21 + let { to, from, $cursor, $to, $from } = focusedEditor.editor 22 + .selection as TextSelection; 23 + 24 + mark = rangeHasMark(focusedEditor.editor, props.mark, from, to); 20 25 if ($cursor) 21 26 hasMark = !!props.mark.isInSet( 22 27 focusedEditor.editor.storedMarks || $cursor.marks(), 23 28 ); 24 - else hasMark = focusedEditor.editor.doc.rangeHasMark(from, to, props.mark); 29 + else { 30 + hasMark = !!mark; 31 + } 25 32 } 26 33 27 34 return (
+30
src/utils/prosemirror/rangeHasMark.ts
··· 1 + import { schema } from "components/TextBlock/schema"; 2 + import { Mark, MarkType } from "prosemirror-model"; 3 + import { EditorState } from "prosemirror-state"; 4 + 5 + export function rangeHasMark( 6 + state: EditorState, 7 + type: MarkType, 8 + start: number, 9 + end: number, 10 + ) { 11 + let mark: Mark | null = null; 12 + let length = end - start; 13 + if (!type.spec.inclusive) length = length - 1; 14 + for (let i = 1; i <= length; i++) { 15 + let pos = state.doc.resolve(start + i); 16 + let markAtPos = pos.marks().find((f) => f.type === type); 17 + if (!mark) { 18 + if (!markAtPos) { 19 + return null; 20 + } 21 + mark = markAtPos; 22 + } else { 23 + if (!markAtPos) return null; 24 + if (mark !== markAtPos) { 25 + return null; 26 + } 27 + } 28 + } 29 + return mark; 30 + }
+138
src/utils/prosemirror/setMark.ts
··· 1 + // Taken from https://github.com/ueberdosis/tiptap/blob/dfacb3b987b57b3ab518bae87bc3d263ebfb60d0/packages/core/src/commands/setMark.ts#L66 2 + import { MarkType, ResolvedPos } from "@tiptap/pm/model"; 3 + import { EditorState, Transaction } from "@tiptap/pm/state"; 4 + 5 + import { TextSelection } from "prosemirror-state"; 6 + import { Mark, Schema } from "prosemirror-model"; 7 + 8 + function canSetMark( 9 + state: EditorState, 10 + tr: Transaction, 11 + newMarkType: MarkType, 12 + ) { 13 + const { selection } = tr; 14 + let cursor: ResolvedPos | null = null; 15 + 16 + if (selection instanceof TextSelection) { 17 + cursor = selection.$cursor; 18 + } 19 + 20 + if (cursor) { 21 + const currentMarks = state.storedMarks ?? cursor.marks(); 22 + 23 + // There can be no current marks that exclude the new mark 24 + return ( 25 + !!newMarkType.isInSet(currentMarks) || 26 + !currentMarks.some((mark) => mark.type.excludes(newMarkType)) 27 + ); 28 + } 29 + 30 + const { ranges } = selection; 31 + 32 + return ranges.some(({ $from, $to }) => { 33 + let someNodeSupportsMark = 34 + $from.depth === 0 35 + ? state.doc.inlineContent && state.doc.type.allowsMarkType(newMarkType) 36 + : false; 37 + 38 + state.doc.nodesBetween($from.pos, $to.pos, (node, _pos, parent) => { 39 + // If we already found a mark that we can enable, return false to bypass the remaining search 40 + if (someNodeSupportsMark) { 41 + return false; 42 + } 43 + 44 + if (node.isInline) { 45 + const parentAllowsMarkType = 46 + !parent || parent.type.allowsMarkType(newMarkType); 47 + const currentMarksAllowMarkType = 48 + !!newMarkType.isInSet(node.marks) || 49 + !node.marks.some((otherMark) => otherMark.type.excludes(newMarkType)); 50 + 51 + someNodeSupportsMark = 52 + parentAllowsMarkType && currentMarksAllowMarkType; 53 + } 54 + return !someNodeSupportsMark; 55 + }); 56 + 57 + return someNodeSupportsMark; 58 + }); 59 + } 60 + export const setMark = 61 + (type: MarkType, attributes = {}) => 62 + (state: EditorState, dispatch: (tr: Transaction) => void) => { 63 + const { selection } = state; 64 + const { empty, ranges } = selection; 65 + 66 + let tr = state.tr; 67 + if (empty) { 68 + const oldAttributes = getMarkAttributes(state, type); 69 + 70 + tr.addStoredMark( 71 + type.create({ 72 + ...oldAttributes, 73 + ...attributes, 74 + }), 75 + ); 76 + } else { 77 + ranges.forEach((range) => { 78 + const from = range.$from.pos; 79 + const to = range.$to.pos; 80 + 81 + state.doc.nodesBetween(from, to, (node, pos) => { 82 + const trimmedFrom = Math.max(pos, from); 83 + const trimmedTo = Math.min(pos + node.nodeSize, to); 84 + const someHasMark = node.marks.find((mark) => mark.type === type); 85 + 86 + // if there is already a mark of this type 87 + // we know that we have to merge its attributes 88 + // otherwise we add a fresh new mark 89 + if (someHasMark) { 90 + node.marks.forEach((mark) => { 91 + if (type === mark.type) { 92 + tr.addMark( 93 + trimmedFrom, 94 + trimmedTo, 95 + type.create({ 96 + ...mark.attrs, 97 + ...attributes, 98 + }), 99 + ); 100 + } 101 + }); 102 + } else { 103 + tr.addMark(trimmedFrom, trimmedTo, type.create(attributes)); 104 + } 105 + }); 106 + }); 107 + } 108 + 109 + dispatch(tr); 110 + }; 111 + 112 + function getMarkAttributes( 113 + state: EditorState, 114 + type: MarkType, 115 + ): Record<string, any> { 116 + const { from, to, empty } = state.selection; 117 + const marks: Mark[] = []; 118 + 119 + if (empty) { 120 + if (state.storedMarks) { 121 + marks.push(...state.storedMarks); 122 + } 123 + 124 + marks.push(...state.selection.$head.marks()); 125 + } else { 126 + state.doc.nodesBetween(from, to, (node) => { 127 + marks.push(...node.marks); 128 + }); 129 + } 130 + 131 + const mark = marks.find((markItem) => markItem.type.name === type.name); 132 + 133 + if (!mark) { 134 + return {}; 135 + } 136 + 137 + return { ...mark.attrs }; 138 + }