Last active
November 14, 2023 02:00
-
-
Save kalda341/7c618c2035d8700a0c05d7d389c9a308 to your computer and use it in GitHub Desktop.
Footnote Marks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function useForceUpdate() { | |
const [, setValue] = useState(0); | |
return () => setValue((value) => value + 1); | |
} | |
function useUpdatableDep() { | |
const [dep, setValue] = useState(0); | |
return [dep, () => setValue((value) => value + 1)] as const; | |
} | |
const useConstant = <T,>(init: () => T, deps: DependencyList = []): T => { | |
const depsRef = useRef(deps); | |
const ref = useRef<T | null>(null); | |
const depsEqual = | |
depsRef.current.length === deps.length && | |
depsRef.current.every((x, index) => x === deps[index]); | |
if (ref.current === null || !depsEqual) { | |
ref.current = init(); | |
depsRef.current = deps; | |
} | |
return ref.current; | |
}; | |
/** | |
* | |
* Very loosely based on: https://gist.github.com/ryanto/4a431d822a98770c4ca7905d9b7b07da | |
* We don't want the included useEditor as it will first render without an editor. | |
* The code this is based on (link above) creates a new editor every render (even though | |
* it isn't used), which is wasteful. | |
* It also causes the editor to scroll annoyingly while typing when autofocus is enabled. | |
* This appears to solve all the shortcomings. | |
*/ | |
const useEditor = ( | |
options: Partial<EditorOptions> = {}, | |
deps: DependencyList = [], | |
): Editor => { | |
const forceUpdate = useForceUpdate(); | |
// By having a dependency we control, we can force creation of a new editor | |
// when required. | |
const [editorDep, newEditor] = useUpdatableDep(); | |
const editor = useConstant(() => new Editor(options), [editorDep, ...deps]); | |
useEffect(() => { | |
let isMounted = true; | |
if (editor.isDestroyed) { | |
newEditor(); | |
} | |
editor.on('transaction', () => { | |
requestAnimationFrame(() => { | |
requestAnimationFrame(() => { | |
if (isMounted) { | |
forceUpdate(); | |
} | |
}); | |
}); | |
}); | |
// Override isActive so that we can handle our changes to marks. | |
// Otherwise, the implementation is identical. | |
editor.isActive = function ( | |
nameOrAttributes: string, | |
attributesOrUndefined?: {}, | |
) { | |
const name = | |
typeof nameOrAttributes === 'string' ? nameOrAttributes : null; | |
const attributes = | |
typeof nameOrAttributes === 'string' | |
? attributesOrUndefined | |
: nameOrAttributes; | |
return isActive(this.state, name, attributes); | |
}; | |
return () => { | |
editor.destroy(); | |
isMounted = false; | |
}; | |
// We don't need editorDep here as we have editor | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, [editor, ...deps]); | |
return editor; | |
}; | |
// Override isActive so that we can handle our changes to marks. | |
// Otherwise, the implementation is identical. | |
function isActive( | |
state: EditorState, | |
name: string | null, | |
attributes: Record<string, any> = {}, | |
): boolean { | |
if (!name) { | |
return ( | |
isNodeActive(state, null, attributes) || | |
isMarkActive(state, null, attributes) | |
); | |
} | |
const schemaType = getSchemaTypeNameByName(name, state.schema); | |
if (schemaType === 'node') { | |
return isNodeActive(state, name, attributes); | |
} | |
if (schemaType === 'mark') { | |
return isMarkActive(state, name, attributes); | |
} | |
return false; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Here we're overriding various TipTap mark functions. | |
* | |
* The only difference is that we've added a group called markBarrier, | |
* which prevents children of that node receiving marks. | |
* At the time of writing this, it also prevents the node from receiving | |
* marks, but we may change that in the future. | |
*/ | |
import { | |
Extension, | |
MarkRange, | |
RawCommands, | |
getMarkAttributes, | |
getMarkRange, | |
getMarkType, | |
isTextSelection, | |
objectIncludes, | |
} from '@tiptap/core'; | |
import { MarkType, Node, ResolvedPos } from '@tiptap/pm/model'; | |
import { EditorState, Transaction } from '@tiptap/pm/state'; | |
export const Marks = Extension.create({ | |
addCommands() { | |
return { | |
setMark: setMark, | |
unsetMark: unsetMark, | |
toggleMark: toggleMark, | |
}; | |
}, | |
}); | |
export function isMarkActive( | |
state: EditorState, | |
typeOrName: MarkType | string | null, | |
attributes: Record<string, any> = {}, | |
): boolean { | |
const { empty, ranges } = state.selection; | |
const type = typeOrName ? getMarkType(typeOrName, state.schema) : null; | |
if (empty) { | |
return !!(state.storedMarks || state.selection.$from.marks()) | |
.filter((mark) => { | |
if (!type) { | |
return true; | |
} | |
return type.name === mark.type.name; | |
}) | |
.find((mark) => | |
objectIncludes(mark.attrs, attributes, { strict: false }), | |
); | |
} | |
let selectionRange = 0; | |
const markRanges: MarkRange[] = []; | |
ranges.forEach(({ $from, $to }) => { | |
const from = $from.pos; | |
const to = $to.pos; | |
state.doc.nodesBetween(from, to, (node, pos) => { | |
// Added by Rico | |
if (markBarrier(node)) { | |
// Don't recurse | |
return false; | |
} | |
if (!node.isText && !node.marks.length) { | |
return; | |
} | |
const relativeFrom = Math.max(from, pos); | |
const relativeTo = Math.min(to, pos + node.nodeSize); | |
const range = relativeTo - relativeFrom; | |
selectionRange += range; | |
markRanges.push( | |
...node.marks.map((mark) => ({ | |
mark, | |
from: relativeFrom, | |
to: relativeTo, | |
})), | |
); | |
}); | |
}); | |
if (selectionRange === 0) { | |
return false; | |
} | |
// calculate range of matched mark | |
const matchedRange = markRanges | |
.filter((markRange) => { | |
if (!type) { | |
return true; | |
} | |
return type.name === markRange.mark.type.name; | |
}) | |
.filter((markRange) => | |
objectIncludes(markRange.mark.attrs, attributes, { strict: false }), | |
) | |
.reduce((sum, markRange) => sum + markRange.to - markRange.from, 0); | |
// calculate range of marks that excludes the searched mark | |
// for example `code` doesn’t allow any other marks | |
const excludedRange = markRanges | |
.filter((markRange) => { | |
if (!type) { | |
return true; | |
} | |
return markRange.mark.type !== type && markRange.mark.type.excludes(type); | |
}) | |
.reduce((sum, markRange) => sum + markRange.to - markRange.from, 0); | |
// we only include the result of `excludedRange` | |
// if there is a match at all | |
const range = matchedRange > 0 ? matchedRange + excludedRange : matchedRange; | |
return range >= selectionRange; | |
} | |
export const toggleMark: RawCommands['toggleMark'] = | |
(typeOrName, attributes = {}, options = {}) => | |
({ state, commands }) => { | |
const { extendEmptyMarkRange = false } = options; | |
const type = getMarkType(typeOrName, state.schema); | |
const isActive = isMarkActive(state, type, attributes); | |
if (isActive) { | |
return commands.unsetMark(type, { extendEmptyMarkRange }); | |
} | |
return commands.setMark(type, attributes); | |
}; | |
// Unchanged by Rico. | |
function canSetMark( | |
state: EditorState, | |
tr: Transaction, | |
newMarkType: MarkType, | |
) { | |
const { selection } = tr; | |
let cursor: ResolvedPos | null = null; | |
if (isTextSelection(selection)) { | |
cursor = selection.$cursor; | |
} | |
if (cursor) { | |
const currentMarks = state.storedMarks ?? cursor.marks(); | |
// There can be no current marks that exclude the new mark | |
return ( | |
!!newMarkType.isInSet(currentMarks) || | |
!currentMarks.some((mark) => mark.type.excludes(newMarkType)) | |
); | |
} | |
const { ranges } = selection; | |
return ranges.some(({ $from, $to }) => { | |
let someNodeSupportsMark = | |
$from.depth === 0 | |
? state.doc.inlineContent && state.doc.type.allowsMarkType(newMarkType) | |
: false; | |
state.doc.nodesBetween($from.pos, $to.pos, (node, _pos, parent) => { | |
// If we already found a mark that we can enable, return false to bypass the remaining search | |
if (someNodeSupportsMark) { | |
return false; | |
} | |
if (node.isInline) { | |
const parentAllowsMarkType = | |
!parent || parent.type.allowsMarkType(newMarkType); | |
const currentMarksAllowMarkType = | |
!!newMarkType.isInSet(node.marks) || | |
!node.marks.some((otherMark) => otherMark.type.excludes(newMarkType)); | |
someNodeSupportsMark = | |
parentAllowsMarkType && currentMarksAllowMarkType; | |
} | |
return !someNodeSupportsMark; | |
}); | |
return someNodeSupportsMark; | |
}); | |
} | |
const markBarrier = (node: Node): boolean => { | |
const groups = node.type.spec.group?.split(' ').filter((x) => x !== '') ?? []; | |
return groups.includes('markBarrier'); | |
}; | |
export const setMark: RawCommands['setMark'] = | |
(typeOrName, attributes = {}) => | |
({ tr, state, dispatch }) => { | |
const { selection } = tr; | |
const { empty, ranges } = selection; | |
const type = getMarkType(typeOrName, state.schema); | |
if (dispatch) { | |
if (empty) { | |
const oldAttributes = getMarkAttributes(state, type); | |
tr.addStoredMark( | |
type.create({ | |
...oldAttributes, | |
...attributes, | |
}), | |
); | |
} else { | |
ranges.forEach((range) => { | |
const from = range.$from.pos; | |
const to = range.$to.pos; | |
state.doc.nodesBetween(from, to, (node, pos) => { | |
// Added by Rico. | |
// This is a change from the TipTap implementation - it will | |
// perform the same behaviour on every node. | |
// The end result is the same anyway in their case, but for | |
// us we specifically want to avoid calling it on say document. | |
if (!node.type.isInline) { | |
return; | |
} | |
// Added by Rico. | |
// This is the main difference between the TipTap implementation. | |
if (markBarrier(node)) { | |
return false; | |
} | |
// All regular TipTap implementation from here. | |
const trimmedFrom = Math.max(pos, from); | |
const trimmedTo = Math.min(pos + node.nodeSize, to); | |
const someHasMark = node.marks.find((mark) => mark.type === type); | |
// if there is already a mark of this type | |
// we know that we have to merge its attributes | |
// otherwise we add a fresh new mark | |
if (someHasMark) { | |
node.marks.forEach((mark) => { | |
if (type === mark.type) { | |
tr.addMark( | |
trimmedFrom, | |
trimmedTo, | |
type.create({ | |
...mark.attrs, | |
...attributes, | |
}), | |
); | |
} | |
}); | |
} else { | |
tr.addMark(trimmedFrom, trimmedTo, type.create(attributes)); | |
} | |
}); | |
}); | |
} | |
} | |
return canSetMark(state, tr, type); | |
}; | |
export const unsetMark: RawCommands['unsetMark'] = | |
(typeOrName, options = {}) => | |
({ tr, state, dispatch }) => { | |
const { extendEmptyMarkRange = false } = options; | |
const { selection } = tr; | |
const type = getMarkType(typeOrName, state.schema); | |
const { $from, empty, ranges } = selection; | |
if (!dispatch) { | |
return true; | |
} | |
if (empty && extendEmptyMarkRange) { | |
let { from, to } = selection; | |
const attrs = $from.marks().find((mark) => mark.type === type)?.attrs; | |
const range = getMarkRange($from, type, attrs); | |
if (range) { | |
from = range.from; | |
to = range.to; | |
} | |
tr.removeMark(from, to, type); | |
} else { | |
// Changed by Rico to ignore node barriers | |
ranges.forEach((range) => { | |
const from = range.$from.pos; | |
const to = range.$to.pos; | |
state.doc.nodesBetween(from, to, (node, pos) => { | |
// Added by Rico. | |
// This is a change from the TipTap implementation - it will | |
// perform the same behaviour on every node. | |
// The end result is the same anyway in their case, but for | |
// us we specifically want to avoid calling it on say document. | |
if (!node.type.isInline) { | |
return; | |
} | |
// Added by Rico. | |
// This is the main difference between the TipTap implementation. | |
if (markBarrier(node)) { | |
return false; | |
} | |
const trimmedFrom = Math.max(pos, from); | |
const trimmedTo = Math.min(pos + node.nodeSize, to); | |
// Remove the mark specifically from what we care about. | |
tr.removeMark(trimmedFrom, trimmedTo, type); | |
}); | |
}); | |
} | |
tr.removeStoredMark(type); | |
return true; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment