96 lines
3.4 KiB
TypeScript
96 lines
3.4 KiB
TypeScript
import { Extension } from '@tiptap/core';
|
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
|
import type { EditorState, Transaction } from '@tiptap/pm/state';
|
|
import type { Node as PmNode } from '@tiptap/pm/model';
|
|
|
|
const formattingMarksKey = new PluginKey<DecorationSet>('formattingMarks');
|
|
|
|
const FORCE_REBUILD_META = 'formattingMarks/forceRebuild';
|
|
|
|
function isEnabled(domRoot: HTMLElement | null): boolean {
|
|
return !!domRoot?.closest('.paper')?.classList.contains('show-formatting-marks');
|
|
}
|
|
|
|
function buildDecorations(doc: PmNode): DecorationSet {
|
|
const decos: Decoration[] = [];
|
|
doc.descendants((node, pos) => {
|
|
if (!node.isText) return;
|
|
const text = node.text ?? '';
|
|
for (let i = 0; i < text.length; i++) {
|
|
if (text.charCodeAt(i) === 32) {
|
|
const from = pos + i;
|
|
decos.push(Decoration.inline(from, from + 1, { class: 'pm-space-mark' }));
|
|
}
|
|
}
|
|
});
|
|
return decos.length ? DecorationSet.create(doc, decos) : DecorationSet.empty;
|
|
}
|
|
|
|
export const FormattingMarks = Extension.create({
|
|
name: 'formattingMarks',
|
|
|
|
addProseMirrorPlugins() {
|
|
return [
|
|
new Plugin<DecorationSet>({
|
|
key: formattingMarksKey,
|
|
state: {
|
|
init: () => DecorationSet.empty,
|
|
apply(tr: Transaction, old: DecorationSet, _oldState: EditorState, newState: EditorState) {
|
|
const force = tr.getMeta(FORCE_REBUILD_META) === true;
|
|
// Resolve enabled-state via the DOM class on .paper. The plugin's
|
|
// `view` hook below installs a MutationObserver that dispatches a
|
|
// FORCE_REBUILD meta whenever that class toggles, so this check
|
|
// always reflects the current UI state.
|
|
// During init (no view yet) we can't read DOM, so default to keeping
|
|
// the previous set mapped through.
|
|
if (typeof document === 'undefined') {
|
|
return tr.docChanged ? old.map(tr.mapping, tr.doc) : old;
|
|
}
|
|
const enabled = document.querySelector('.paper.show-formatting-marks') !== null;
|
|
if (!enabled) return DecorationSet.empty;
|
|
if (force || tr.docChanged) return buildDecorations(newState.doc);
|
|
return old;
|
|
},
|
|
},
|
|
props: {
|
|
decorations(state) {
|
|
return formattingMarksKey.getState(state);
|
|
},
|
|
},
|
|
view(editorView) {
|
|
const paper = editorView.dom.closest('.paper') as HTMLElement | null;
|
|
let observer: MutationObserver | null = null;
|
|
|
|
const triggerRebuild = () => {
|
|
editorView.dispatch(editorView.state.tr.setMeta(FORCE_REBUILD_META, true));
|
|
};
|
|
|
|
if (paper) {
|
|
observer = new MutationObserver((mutations) => {
|
|
for (const m of mutations) {
|
|
if (m.type === 'attributes' && m.attributeName === 'class') {
|
|
triggerRebuild();
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
observer.observe(paper, { attributes: true, attributeFilter: ['class'] });
|
|
}
|
|
|
|
// Initial pass: if the class is already on at mount-time, render now.
|
|
if (isEnabled(editorView.dom)) {
|
|
queueMicrotask(triggerRebuild);
|
|
}
|
|
|
|
return {
|
|
destroy() {
|
|
observer?.disconnect();
|
|
},
|
|
};
|
|
},
|
|
}),
|
|
];
|
|
},
|
|
});
|