1- import { usePluginData } from "@docusaurus/useGlobalData" ;
21import type { Dispatch , JSX , ReactNode , SetStateAction } from "react" ;
32import { createContext , useContext , useMemo , useState } from "react" ;
43import type { DecorationItem } from "shiki" ;
54import type {
65 Position ,
6+ Range ,
77 Selection ,
88 TestCaseSnapshot ,
99} from "@cursorless/lib-common" ;
@@ -25,14 +25,15 @@ const RecordedTestVisualizerContext = createContext<
2525 RecordedTestVisualizerContextValue | undefined
2626> ( undefined ) ;
2727
28+ interface ProviderProps {
29+ children : ReactNode ;
30+ recordedTests : RecordedTest [ ] ;
31+ }
32+
2833export function RecordedTestVisualizerProvider ( {
2934 children,
30- } : {
31- children : ReactNode ;
32- } ) {
33- const recordedTests = usePluginData (
34- "recorded-tests-plugin" ,
35- ) as RecordedTest [ ] ;
35+ recordedTests,
36+ } : ProviderProps ) : JSX . Element {
3637 const [ renderWhitespace , setRenderWhitespace ] = useState ( true ) ;
3738 const fixtures = useMemo (
3839 ( ) =>
@@ -57,7 +58,7 @@ export function RecordedTestVisualizerProvider({
5758 ) ;
5859}
5960
60- export function RecordedTestVisualizerOptions ( ) {
61+ export function RecordedTestVisualizerOptions ( ) : JSX . Element {
6162 const { renderWhitespace, setRenderWhitespace } = useRecordedTestVisualizer ( ) ;
6263
6364 return (
@@ -99,7 +100,7 @@ export function RecordedTestVisualizer({
99100 // oxlint-disable-next-line react_perf/jsx-no-new-object-as-prop
100101 const link = {
101102 name : "GitHub" ,
102- url : `https://github.com/cursorless-dev/cursorless/blob/main/resources/fixtures/recorded/docs/ ${ path } ` ,
103+ url : `https://github.com/cursorless-dev/cursorless/blob/main/resources/fixtures/recorded/${ path } ` ,
103104 } ;
104105
105106 const renderHats =
@@ -109,7 +110,7 @@ export function RecordedTestVisualizer({
109110 return (
110111 < div className = "row" >
111112 < div className = "col" >
112- Input
113+ Before
113114 < CodeState
114115 renderWhitespace = { renderWhitespace }
115116 renderHats = { renderHats }
@@ -119,7 +120,7 @@ export function RecordedTestVisualizer({
119120 />
120121 </ div >
121122 < div className = "col" >
122- Output
123+ After
123124 < CodeState
124125 renderWhitespace = { renderWhitespace }
125126 renderHats = { renderHats }
@@ -204,16 +205,21 @@ function toDecorations(
204205 plainObjectToRange ( hatRange ) ,
205206 )
206207 : [ ] ;
208+ const markRanges = renderHats
209+ ? Object . values ( state . marks ?? { } ) . map ( plainObjectToRange )
210+ : [ ] ;
207211
208212 // Shiki rejects intersecting decorations. A zero-width cursor at the end of
209- // a hat range intersects the hat decoration, so render both on one wrapper.
210- // We only merge at the end because code-cursor-after recreates that exact
211- // boundary without competing with the hat's ::before pseudo-element.
213+ // a hat or mark range intersects that decoration, so render both on one
214+ // wrapper. We only merge at the end because code-cursor-after recreates that
215+ // exact boundary without competing with the hat's ::before pseudo-element.
212216 const mergedCursorPositions = selections
213217 . filter (
214218 ( selection ) =>
215219 selection . isEmpty &&
216- hatRanges . some ( ( { end } ) => end . isEqual ( selection . active ) ) ,
220+ [ ...hatRanges , ...markRanges ] . some ( ( { end } ) =>
221+ end . isEqual ( selection . active ) ,
222+ ) ,
217223 )
218224 . map ( ( { active } ) => active ) ;
219225
@@ -228,10 +234,46 @@ function toDecorations(
228234 ) ,
229235 )
230236 . map ( toDecoration ) ,
237+ ...toMarkDecorations ( markRanges , hatRanges , mergedCursorPositions ) ,
231238 ...toHatDecorations ( state , renderHats , mergedCursorPositions ) ,
232239 ] ;
233240}
234241
242+ function toMarkDecorations (
243+ markRanges : readonly Range [ ] ,
244+ hatRanges : readonly Range [ ] ,
245+ mergedCursorPositions : readonly Position [ ] ,
246+ ) : DecorationItem [ ] {
247+ return markRanges
248+ . filter (
249+ ( range , index ) =>
250+ markRanges . findIndex ( ( otherRange ) => otherRange . isRangeEqual ( range ) ) ===
251+ index ,
252+ )
253+ . map ( ( range ) => {
254+ const className = [ "code-mark" ] ;
255+
256+ // Prefer placing the cursor on the smaller hat wrapper when both the hat
257+ // and its containing mark end at the cursor position.
258+ if (
259+ mergedCursorPositions . some ( ( position ) => position . isEqual ( range . end ) ) &&
260+ ! hatRanges . some ( ( { end } ) => end . isEqual ( range . end ) )
261+ ) {
262+ className . push ( "code-cursor-after" ) ;
263+ }
264+
265+ return {
266+ start : range . start ,
267+ end : range . end ,
268+ alwaysWrap : true ,
269+ properties : {
270+ className,
271+ style : `--code-mark-color: ${ highlightColors . content . background } ;` ,
272+ } ,
273+ } ;
274+ } ) ;
275+ }
276+
235277function toHatDecorations (
236278 state : TestCaseSnapshot ,
237279 renderHats : boolean ,
@@ -241,23 +283,12 @@ function toHatDecorations(
241283 return [ ] ;
242284 }
243285
244- const markRanges = Object . values ( state . marks ?? { } ) . map ( plainObjectToRange ) ;
245-
246286 return state . hatTokenMap . map ( ( { hatStyle, hatRange } ) => {
247287 const range = plainObjectToRange ( hatRange ) ;
248288 const properties : DecorationItem [ "properties" ] = {
249289 className : [ "code-hat" , `code-hat-${ hatStyle } ` ] ,
250290 } ;
251291
252- const isReferenced = markRanges . some ( ( markRange ) =>
253- markRange . contains ( range ) ,
254- ) ;
255-
256- if ( isReferenced ) {
257- properties . className ?. push ( "code-hat-referenced" ) ;
258- properties . style = `--code-hat-referenced-color: ${ highlightColors . content . background } ;` ;
259- }
260-
261292 if ( mergedCursorPositions . some ( ( position ) => position . isEqual ( range . end ) ) ) {
262293 // The hat uses ::before and the cursor uses ::after, allowing both
263294 // visuals to share this wrapper without overlapping Shiki decorations.
0 commit comments