I finished it with some improvement on boundary check.
// the defined effect
const Effect = {
add: StateEffect.define<{ from: number; to: number }>(),
remove: StateEffect.define<{ from: number; to: number }>(),
};
// the filter part
if (e.is(Effect.remove)) {
underlines = underlines.update({
filter: (from, to, value) => {
let shouldRemove =
from === e.value.from &&
to === e.value.to &&
value.spec.class === 'cm-underline';
return !shouldRemove;
},
});
}
// the command
export function toggleUnderline(view: EditorView) {
const { state, dispatch } = view;
const decoSet = state.field(stateField, false),
effects: StateEffect<unknown>[] = [];
if (!decoSet) effects.push(StateEffect.appendConfig.of([stateField, theme]));
state.selection.ranges
.filter((r) => !r.empty)
.forEach(({ from, to }) => {
// whatever, add the Effect.add effect first
effects.push(Effect.add.of({ from, to }));
// iterate over decorations
decoSet?.between(from, to, (decoFrom, decoTo) => {
// for side decorations, do nothing
if (from === decoTo || to === decoFrom) return;
// for partly or fully contained decorations, do the actions below
effects.push(Effect.remove.of({ from, to }));
effects.push(Effect.remove.of({ from: decoFrom, to: decoTo }));
if (decoFrom < from) effects.push(Effect.add.of({ from: decoFrom, to: from }));
if (decoTo > to) effects.push(Effect.add.of({ from: to, to: decoTo }));
});
});
if (!effects.length) return false;
dispatch({ effects });
return true;
}