import * as array from "@heydovetail/array"; import { colorUtils, fsm2 } from "@heydovetail/ui-components"; import memoize from "lodash.memoize"; import { EditorState, Plugin, Transaction } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import { cssRule, forceRenderStyles } from "typestyle"; import { style } from "typestyle-react"; import { LOADING_HIGHLIGHT_COLOR } from "../constants"; import { RangeCreateStep } from "../range/RangeCreateStep"; import { RangeDeleteStep } from "../range/RangeDeleteStep"; import { getPluginStateOrThrow as getRangePluginStateOrThrow } from "../range/RangePlugin"; import { RangeSetAttrsStep } from "../range/RangeSetAttrsStep"; import { HighlightRange, RangeType } from "../range/types"; import { PluginDescriptor } from "../util/PluginDescriptor"; import { walk } from "../util/ranges"; import { getPluginState as getHighlightWidgetPluginState, getPluginStateOrThrow as getHighlightWidgetPluginStateOrThrow } from "./HighlightWidgetPlugin"; export interface ITag { id: string; title: string; color: string; count: number | null; } export interface GlossWithRange { rangeId: string; tagId: string; from: number; to: number; color: string; decoration: Decoration; } export interface PluginState { readonly type: "default"; readonly focusedRangeId: string | null; readonly strongRangeId: string | null; readonly selectionRangeId: string | null; readonly sortedGlosses: ReadonlyArray; readonly decorationSet: DecorationSet; } export const transition = fsm2.createTransition()({ default: { with: ( prev, options: { focusedRangeId?: string | null; strongRangeId?: string | null; doc: EditorState["doc"]; } ) => { const decorationsToAdd: Decoration[] = []; const decorationsToRemove: Decoration[] = []; let newSortedGlosses = prev.sortedGlosses; let ds = prev.decorationSet; const updated = { focusedRangeId: options.focusedRangeId !== undefined ? options.focusedRangeId : prev.focusedRangeId, strongRangeId: options.strongRangeId !== undefined ? options.strongRangeId : prev.strongRangeId }; newSortedGlosses = newSortedGlosses.map(gloss => { if ( gloss.rangeId === prev.focusedRangeId || gloss.rangeId === prev.strongRangeId || gloss.rangeId === prev.selectionRangeId || gloss.rangeId === updated.focusedRangeId || gloss.rangeId === updated.strongRangeId ) { decorationsToRemove.push(gloss.decoration); const decoration = Decoration.inline( gloss.from, gloss.to, { class: styleClass({ color: gloss.color, type: updated.focusedRangeId === gloss.rangeId ? GlossWeight.Focus : updated.strongRangeId === gloss.rangeId || prev.selectionRangeId === gloss.rangeId ? GlossWeight.Strong : GlossWeight.Subtle }) }, { rangeId: gloss.rangeId, tagId: gloss.tagId } as Object ); decorationsToAdd.push(decoration); return { ...gloss, decoration }; } else { return gloss; } }); ds = ds.remove(decorationsToRemove); ds = ds.add(options.doc, decorationsToAdd); // Update any overlap class names //Walk the array and create combination styles walk(newSortedGlosses, (_, ranges) => { // combined glosses for the overlapping range segments const combinedGlosses = ranges.reduce( (accum, range) => { return [ ...accum, { color: range.color, type: updated.focusedRangeId === range.rangeId ? GlossWeight.Focus : updated.strongRangeId === range.rangeId || prev.selectionRangeId === range.rangeId ? GlossWeight.Strong : GlossWeight.Subtle } ].filter(array.filterUnique(gloss => gloss.color + gloss.type)); }, [] as Gloss[] ); if (combinedGlosses.length > 1) { glossCombineAndCreateRule(combinedGlosses); } }); return { ...prev, ...updated, sortedGlosses: newSortedGlosses, decorationSet: ds }; }, tr: ( prev, { tr, tagLookup, updateTagColors }: { tr: Transaction; tagLookup: Map; updateTagColors: boolean } ) => { let newSortedGlosses = prev.sortedGlosses; let needsWalk = updateTagColors; let decorationsToAdd: Decoration[] = []; let decorationsToRemove: Decoration[] = []; // Handle changes due to steps for (let i = 0; i < tr.steps.length; i++) { const step = tr.steps[i]; const stepMapping = tr.mapping.maps[i]; if (step instanceof RangeCreateStep && step.range.type === RangeType.HIGHLIGHT) { needsWalk = true; // Highlight Added or Moved decorationsToRemove.push( ...newSortedGlosses.filter(gloss => gloss.rangeId === step.range.id).map(gloss => gloss.decoration) ); newSortedGlosses = newSortedGlosses.filter(gloss => gloss.rangeId !== step.range.id); step.range.attrs.tagIds.forEach(tagId => { const color = tagLookup.has(tagId) ? tagLookup.get(tagId)!.color : LOADING_HIGHLIGHT_COLOR; const decoration = Decoration.inline( step.range.from, step.range.to, { class: styleClass({ color, type: GlossWeight.Subtle }) }, { rangeId: step.range.id, tagId: tagId } as Object ); decorationsToAdd.push(decoration); newSortedGlosses = array.sortedInsert( newSortedGlosses, { rangeId: step.range.id, tagId: tagId, from: step.range.from, to: step.range.to, decoration, color }, array.sortComparatorAsc(gloss => gloss.from) ); }); } else if (step instanceof RangeDeleteStep && step.range.type === RangeType.HIGHLIGHT) { needsWalk = true; //Highlight Removed decorationsToRemove.push( ...newSortedGlosses.filter(gloss => gloss.rangeId === step.range.id).map(gloss => gloss.decoration) ); newSortedGlosses = newSortedGlosses.filter(gloss => gloss.rangeId !== step.range.id); } else if (step instanceof RangeSetAttrsStep) { needsWalk = true; //Highlight TagIds Updated const existing = newSortedGlosses.find(gloss => gloss.rangeId === step.id); if (existing !== undefined) { decorationsToRemove.push( ...newSortedGlosses.filter(gloss => gloss.rangeId === step.id).map(gloss => gloss.decoration) ); newSortedGlosses = newSortedGlosses.filter(gloss => gloss.rangeId !== step.id); if ("tagIds" in step.curr) { step.curr.tagIds.forEach(tagId => { const color = tagLookup.has(tagId) ? tagLookup.get(tagId)!.color : LOADING_HIGHLIGHT_COLOR; const decoration = Decoration.inline( existing.from, existing.to, { class: styleClass({ color, type: GlossWeight.Subtle }) }, { rangeId: existing.rangeId, tagId: tagId } as Object ); decorationsToAdd.push(decoration); newSortedGlosses = array.sortedInsert( newSortedGlosses, { rangeId: step.id, tagId: tagId, from: existing.from, to: existing.to, decoration, color }, array.sortComparatorAsc(gloss => gloss.from) ); }); } } } else { newSortedGlosses = newSortedGlosses.map(gloss => ({ ...gloss, from: stepMapping.map(gloss.from, 1), to: stepMapping.map(gloss.to, -1) })); } } let ds = prev.decorationSet.map(tr.mapping, tr.doc); ds = ds.remove(decorationsToRemove); ds = ds.add(tr.doc, decorationsToAdd); // Handle changes due to new color map if (updateTagColors) { decorationsToRemove = []; decorationsToAdd = []; newSortedGlosses = newSortedGlosses.map(gloss => { const color = tagLookup.has(gloss.tagId) ? tagLookup.get(gloss.tagId)!.color : LOADING_HIGHLIGHT_COLOR; if (color !== gloss.color) { decorationsToRemove.push(gloss.decoration); const decoration = Decoration.inline( gloss.from, gloss.to, { class: styleClass({ color, type: GlossWeight.Subtle }) }, { rangeId: gloss.rangeId, tagId: gloss.tagId } as Object ); decorationsToAdd.push(decoration); return { ...gloss, decoration, color }; } else { return gloss; } }); ds = ds.remove(decorationsToRemove); ds = ds.add(tr.doc, decorationsToAdd); } // Handle changes due to current selection or strongRangeId or focusRangeId decorationsToRemove = []; decorationsToAdd = []; let newSelectionRangeId: string | null = null; if (tr.selection.empty) { const match = newSortedGlosses.find(range => range.from <= tr.selection.from && tr.selection.to <= range.to); if (match !== undefined) { newSelectionRangeId = match.rangeId; } } if (prev.focusedRangeId !== null || prev.strongRangeId !== null || prev.selectionRangeId !== newSelectionRangeId) { needsWalk = true; newSortedGlosses = newSortedGlosses.map(gloss => { if ( gloss.rangeId === prev.focusedRangeId || gloss.rangeId === prev.strongRangeId || gloss.rangeId === prev.selectionRangeId || gloss.rangeId === newSelectionRangeId ) { decorationsToRemove.push(gloss.decoration); const decoration = Decoration.inline( gloss.from, gloss.to, { class: styleClass({ color: gloss.color, type: prev.focusedRangeId === gloss.rangeId ? GlossWeight.Focus : prev.strongRangeId === gloss.rangeId || newSelectionRangeId === gloss.rangeId ? GlossWeight.Strong : GlossWeight.Subtle }) }, { rangeId: gloss.rangeId, tagId: gloss.tagId } as Object ); decorationsToAdd.push(decoration); return { ...gloss, decoration }; } else { return gloss; } }); ds = ds.remove(decorationsToRemove); ds = ds.add(tr.doc, decorationsToAdd); } // Update any overlap class names if (needsWalk) { //Walk the array and create combination styles walk(newSortedGlosses, (_, ranges) => { // combined glosses for the overlapping range segments const combinedGlosses = ranges.reduce( (accum, range) => { return [ ...accum, { color: range.color, type: prev.focusedRangeId === range.rangeId ? GlossWeight.Focus : prev.strongRangeId === range.rangeId || newSelectionRangeId === range.rangeId ? GlossWeight.Strong : GlossWeight.Subtle } ].filter(array.filterUnique(gloss => gloss.color + gloss.type)); }, [] as Gloss[] ); if (combinedGlosses.length > 1) { glossCombineAndCreateRule(combinedGlosses); } }); } return { ...prev, sortedGlosses: newSortedGlosses, selectionRangeId: newSelectionRangeId, decorationSet: ds }; } } }); const { key, setPluginState, getPluginState, getPluginStateOrThrow } = new PluginDescriptor( "HighlightDecorationPlugin" ); export { setPluginState, getPluginStateOrThrow }; export class HighlightDecorationPlugin extends Plugin { constructor(options: { defaultFocusedRangeId?: string }) { super({ key, state: { init: (_, initialState: EditorState) => { const { sortedRanges } = getRangePluginStateOrThrow(initialState); const { tagLookup } = getHighlightWidgetPluginStateOrThrow(initialState); const focusedRangeId = options.defaultFocusedRangeId !== undefined ? options.defaultFocusedRangeId : null; let sortedGlosses: ReadonlyArray = []; const decorationsToAdd: Decoration[] = []; sortedRanges.filter((r): r is HighlightRange => r.type === RangeType.HIGHLIGHT).forEach(range => { range.attrs.tagIds.forEach(tagId => { const color = tagLookup.has(tagId) ? tagLookup.get(tagId)!.color : LOADING_HIGHLIGHT_COLOR; const decoration = Decoration.inline( range.from, range.to, { class: styleClass({ color, type: focusedRangeId === range.id ? GlossWeight.Focus : GlossWeight.Subtle }) }, { rangeId: range.id, tagId: tagId } as Object ); decorationsToAdd.push(decoration); sortedGlosses = array.sortedInsert( sortedGlosses, { rangeId: range.id, tagId: tagId, from: range.from, to: range.to, decoration, color }, array.sortComparatorAsc(gloss => gloss.from) ); }); }); //Walk the array and create combination styles walk(sortedGlosses, (_, ranges) => { // combined glosses for the overlapping range segments const combinedGlosses = ranges.reduce( (accum, range) => { return [ ...accum, { color: range.color, type: focusedRangeId === range.rangeId ? GlossWeight.Focus : GlossWeight.Subtle } ].filter(array.filterUnique(gloss => gloss.color + gloss.type)); }, [] as Gloss[] ); if (combinedGlosses.length > 1) { glossCombineAndCreateRule(combinedGlosses); } }); return { type: "default", focusedRangeId, strongRangeId: null, selectionRangeId: null, sortedGlosses, decorationSet: DecorationSet.create(initialState.doc, decorationsToAdd) }; }, apply: (tr, curr: PluginState, oldEditorState: EditorState): PluginState => { const nextPluginState = getPluginState(tr); const newWidgetPluginState = getHighlightWidgetPluginState(tr); const oldWidgetPluginState = getHighlightWidgetPluginStateOrThrow(oldEditorState); return transition(nextPluginState !== null ? nextPluginState : curr).tr({ tr, tagLookup: newWidgetPluginState !== null ? newWidgetPluginState.tagLookup : oldWidgetPluginState.tagLookup, updateTagColors: newWidgetPluginState !== null }); } }, props: { decorations: (editorState: EditorState) => { const pluginState = getPluginStateOrThrow(editorState); return pluginState.decorationSet; } } }); } } const styleClass = memoize((gloss: Gloss) => style({ backgroundColor: colorUtils.highlightColor(gloss.color, gloss.type === GlossWeight.Subtle ? 0.1 : 0.15), padding: "5px 0" }) ); // Order of values is significant, larger the value the heavier the effect is. // Heavier effects override lighter effects when resolving multiple glosses. export enum GlossWeight { // underline only Subtle, // underline and background Strong, // same as strong, but is never blended Focus } export interface Gloss { color: string; type: GlossWeight; } const glossCombineAndCreateRule = memoize((glosses: ReadonlyArray) => combineAndCreateRule(glosses)); const combineAndCreateRule: (glosses: ReadonlyArray) => void = ([head, ...tail]) => { const { type, colors } = tail.reduce( (accum, curr) => // Higher-weight glosses replace lower-weight curr.type > accum.type ? { type: curr.type, colors: [curr.color] } : // Equal-weight glosses are combined curr.type === accum.type ? { type: accum.type, colors: [...accum.colors, curr.color] } : // Lower-weight glosses are ignored accum, { type: head.type, colors: [head.color] } ); const classNames = [head, ...tail].map(g => styleClass(g)); const newGloss = { color: colorUtils.mixedHumanNoteColors(colors), type }; cssRule(`span.${classNames.join(".")}`, { backgroundColor: colorUtils.highlightColor(newGloss.color, newGloss.type === GlossWeight.Subtle ? 0.1 : 0.15), padding: "5px 0" }); forceRenderStyles(); };