InlineWidget.tsx129 lines · main
1import type { editor } from 'monaco-editor'
2import { PropsWithChildren, useEffect, useMemo, useRef } from 'react'
3import { createPortal } from 'react-dom'
4
5export interface InlineWidgetProps {
6 /**
7 * The Monaco editor instance.
8 */
9 editor: editor.IStandaloneCodeEditor | editor.IStandaloneDiffEditor
10
11 /**
12 * ID used by Monaco to reference this widget
13 */
14 id: string
15
16 /**
17 * The line number after or before which this zone should appear.
18 * Use 0 to place a view zone before the first line number.
19 */
20 afterLineNumber: number
21 beforeLineNumber?: number
22
23 /**
24 * The height in lines of the view zone.
25 * @default 1
26 */
27 heightInLines?: number
28}
29
30/**
31 * Adds an inline widget to a Monaco editor instance. Acts as a
32 * container for custom widgets that are rendered inline.
33 *
34 * Implemented using the same techniques VS Code uses for their inline
35 * widgets (such as "Go to References"), but built for React.
36 * Uses a combination of a view zone and overlay widget.
37 */
38const InlineWidget = ({
39 children,
40 editor,
41 id,
42 beforeLineNumber,
43 afterLineNumber = 0,
44 heightInLines = 1,
45}: PropsWithChildren<InlineWidgetProps>) => {
46 const lineNumber = beforeLineNumber ?? afterLineNumber
47 const key = `${id}-${lineNumber.toString()}`
48 const containerElement = useMemo(() => document.createElement('div'), [])
49 const zoneIdRef = useRef<string>(null)
50 const viewZoneRef = useRef<{
51 top: number
52 height: number
53 heightInLines: number
54 }>({ top: 0, height: 0, heightInLines: heightInLines })
55
56 // Get the appropriate editor instance for diff editor
57 const targetEditor = 'getModifiedEditor' in editor ? editor.getModifiedEditor() : editor
58
59 const recalculateLayout = () => {
60 const layoutInfo = targetEditor.getLayoutInfo()
61
62 if (!layoutInfo) {
63 return
64 }
65
66 containerElement.style.left = `${layoutInfo.contentLeft}px`
67 containerElement.style.top = `${viewZoneRef.current.top}px`
68 containerElement.style.width = `${layoutInfo.width - layoutInfo.contentLeft - 20}px`
69 containerElement.style.height = `${viewZoneRef.current.height}px`
70 }
71
72 const createViewZone = () => {
73 targetEditor.changeViewZones((accessor) => {
74 // Remove existing zone if it exists
75 if (zoneIdRef.current) {
76 accessor.removeZone(zoneIdRef.current)
77 }
78
79 // Create new zone with current height
80 zoneIdRef.current = accessor.addZone({
81 afterLineNumber: beforeLineNumber ?? afterLineNumber,
82 heightInLines: viewZoneRef.current.heightInLines,
83 domNode: document.createElement('div'),
84 onDomNodeTop: (top) => {
85 viewZoneRef.current.top = top
86 recalculateLayout()
87 },
88 onComputedHeight: (height) => {
89 viewZoneRef.current.height = height
90 recalculateLayout()
91 },
92 })
93 })
94 }
95
96 // Initial setup of view zone and overlay widget
97 useEffect(() => {
98 const overlayWidget: editor.IOverlayWidget = {
99 getId: () => id,
100 getDomNode: () => containerElement,
101 getPosition: () => null,
102 }
103
104 createViewZone()
105 targetEditor.addOverlayWidget(overlayWidget)
106
107 // Remove the view zone & overlay widget on unmount
108 return () => {
109 targetEditor.changeViewZones((accessor) => {
110 if (zoneIdRef.current) {
111 accessor.removeZone(zoneIdRef.current)
112 }
113 targetEditor.removeOverlayWidget(overlayWidget)
114 })
115 }
116 }, [targetEditor, id, beforeLineNumber, afterLineNumber]) // Note: heightInLines removed from deps
117
118 // Update view zone height when heightInLines changes
119 useEffect(() => {
120 if (heightInLines !== viewZoneRef.current.heightInLines) {
121 viewZoneRef.current.heightInLines = heightInLines
122 createViewZone()
123 }
124 }, [heightInLines])
125
126 return createPortal(children, containerElement, key)
127}
128
129export default InlineWidget