Skip to content

Instantly share code, notes, and snippets.

@kalda341
Last active November 14, 2023 02:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kalda341/7c618c2035d8700a0c05d7d389c9a308 to your computer and use it in GitHub Desktop.
Save kalda341/7c618c2035d8700a0c05d7d389c9a308 to your computer and use it in GitHub Desktop.
Footnote Marks
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;
}
/**
* 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