• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

keplergl / kepler.gl / 25830234009

13 May 2026 10:31PM UTC coverage: 57.668% (-1.0%) from 58.644%
25830234009

Pull #3434

github

web-flow
Merge b10a56eba into b4790f0f5
Pull Request #3434: feat: basic annotations

7143 of 14848 branches covered (48.11%)

Branch coverage included in aggregate %.

216 of 732 new or added lines in 25 files covered. (29.51%)

74 existing lines in 3 files now uncovered.

14551 of 22771 relevant lines covered (63.9%)

77.31 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

2.75
/src/components/src/annotations/annotation-text.tsx
1
// SPDX-License-Identifier: MIT
2
// Copyright contributors to the kepler.gl project
3

4
import React, {FC, useCallback, useEffect, useMemo} from 'react';
5
import styled from 'styled-components';
6
import {useDraggable} from '@dnd-kit/core';
7
import {Annotation, AnnotationWithArm} from '@kepler.gl/types';
8
import {AnnotationKind} from '@kepler.gl/constants';
9
import {makeMarker, isLeftOriented, MapViewport} from './annotation-utils';
10
import {LexicalRichTextEditor, LexicalToolbar} from './lexical-editor';
11
import {SerializedEditorState} from 'lexical';
12

13
type StyledAnnotationTextProps = {
14
  $isEditing?: boolean;
15
  $isSelected?: boolean;
16
  $isEditingText?: boolean;
17
  $textVerticalAlign?: string;
18
};
19

20
const StyledAnnotationText = styled.div<StyledAnnotationTextProps>`
7✔
21
  position: absolute;
22
  color: #fff;
23
  transition: border-color 0.2s;
24
  border: 2px solid transparent;
25
  padding: 4px 8px;
26
  white-space: pre-wrap;
27
  word-break: break-word;
28
  &:focus {
29
    outline: none;
30
  }
31
  ${props =>
NEW
32
    props.$isEditing
×
33
      ? `
34
        border-color: ${props.$isSelected ? 'rgba(255,255,255,0.8)' : 'rgba(255,255,255,0.2)'};
×
35
        &:hover {
36
          border-color: rgba(255,255,255,0.6);
37
        }
38
        cursor: move;
39
        pointer-events: all;
40
      `
41
      : `
42
        pointer-events: none;
43
      `}
44
  ${props =>
NEW
45
    props.$isEditingText
×
46
      ? `
47
    .editor-inner {
48
      cursor: text;
49
    }
50
  `
51
      : ''}
52
  .editor-inner {
53
    justify-content: ${props =>
NEW
54
      props.$textVerticalAlign === 'top'
×
55
        ? 'flex-start'
56
        : props.$textVerticalAlign === 'middle'
×
57
        ? 'center'
58
        : 'flex-end'};
59
  }
60
`;
61

62
const StyledToolbarWrapper = styled.div`
7✔
63
  position: absolute;
64
  bottom: 100%;
65
  left: 0;
66
  background-color: rgba(30, 33, 40, 0.95);
67
  border-radius: 5px;
68
  width: max-content;
69
  padding: 2px 5px;
70
  margin-bottom: 4px;
71
  z-index: 1;
72
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
73
`;
74

75
export type AnnotationTextProps = {
76
  annotation: Annotation;
77
  viewport: MapViewport;
78
  isEditing: boolean;
79
  isSelected: boolean;
80
  isEditingText: boolean;
81
  onSelect: (isEditingText: boolean) => void;
82
  onChangeText: (text: string, editorState?: SerializedEditorState) => void;
83
  onUpdateAnnotation: (config: Partial<Annotation>) => void;
84
};
85

86
const AnnotationText: FC<AnnotationTextProps> = ({
7✔
87
  annotation,
88
  viewport,
89
  isEditing,
90
  isSelected,
91
  isEditingText,
92
  onSelect,
93
  onChangeText,
94
  onUpdateAnnotation
95
}) => {
NEW
96
  const {attributes, listeners, setNodeRef} = useDraggable({
×
97
    id: `${annotation.id}:MOVE_TEXT`
98
  });
99

100
  const {textWidth, textHeight, lineColor, lineWidth, textVerticalAlign, autoSize, kind} =
NEW
101
    annotation;
×
NEW
102
  const {x, y, tx, ty} = makeMarker(annotation, viewport);
×
103

NEW
104
  const isArm = 'armLength' in annotation;
×
NEW
105
  const isLeft = isArm && isLeftOriented((annotation as AnnotationWithArm).angle);
×
106

NEW
107
  const style = useMemo(
×
NEW
108
    () => ({
×
109
      ...(autoSize ? {minWidth: 80} : {width: textWidth || 120}),
×
110
      bottom: viewport.height - (y + ty),
111
      borderBottom:
×
112
        kind !== AnnotationKind.TEXT ? `${lineWidth}px solid ${lineColor}` : undefined,
×
113
      ...(kind === AnnotationKind.TEXT
×
114
        ? {left: x + tx - (textWidth || 80) / 2}
×
115
        : isLeft
116
        ? {right: viewport.width - x - tx}
117
        : {left: x + tx})
118
    }),
119
    [autoSize, kind, textWidth, tx, ty, viewport.width, viewport.height, x, y, isLeft, lineWidth, lineColor]
120
  );
121

122
  const handleEditorChange = useCallback(
123
    (change: {text: string; editorState: SerializedEditorState}) => {
124
      onChangeText(change.text, change.editorState);
125
    },
126
    [onChangeText]
127
  );
128

129
  useEffect(() => {
130
    if (!isEditingText || !isSelected || !isEditing) {
131
      window.getSelection()?.removeAllRanges();
132
    }
133
  }, [isEditingText, isSelected, isEditing]);
NEW
134

×
135
  return (
NEW
136
    <StyledAnnotationText
×
137
      ref={setNodeRef}
138
      $isEditing={isEditing}
139
      $isEditingText={isEditingText}
140
      $isSelected={isSelected}
NEW
141
      $textVerticalAlign={textVerticalAlign}
×
NEW
142
      style={style}
×
NEW
143
      {...(isEditing
×
144
        ? {
145
            ...attributes,
146
            ...listeners,
NEW
147
            onPointerDown: (evt: React.PointerEvent) => {
×
148
              const {target} = evt;
149
              if (target instanceof Element && target.closest('.lexical-toolbar')) {
150
                return;
151
              }
152
              if (isEditingText) {
153
                if (target instanceof Element && target.closest('.editor-inner')) {
154
                  return;
155
                }
×
156
                listeners?.onPointerDown?.(evt as any);
157
                return;
158
              }
159
              listeners?.onPointerDown?.(evt as any);
NEW
160
              if (!isSelected) {
×
NEW
161
                onSelect(false);
×
NEW
162
              }
×
163
            }
NEW
164
          }
×
NEW
165
        : {})}
×
NEW
166
      onClick={evt => {
×
167
        if (!isEditing) return;
NEW
168
        const target = evt.target;
×
NEW
169
        if (target instanceof Element && target.closest('.lexical-toolbar')) {
×
170
          return;
NEW
171
        }
×
NEW
172
        evt.stopPropagation();
×
NEW
173
        if (!isEditingText) {
×
174
          onSelect(true);
175
        }
176
      }}
177
      onDoubleClick={() => {
178
        if (!isEditing) {
NEW
179
          onSelect(true);
×
NEW
180
        }
×
NEW
181
      }}
×
NEW
182
    >
×
183
      <LexicalRichTextEditor
NEW
184
        {...(isEditingText && annotation.autoSize ? {} : {width: textWidth || undefined})}
×
NEW
185
        {...(isEditingText && annotation.autoSizeY ? {} : {height: textHeight || undefined})}
×
NEW
186
        isEditable={isEditingText}
×
187
        editorState={annotation.editorState as SerializedEditorState | undefined}
188
        initialText={annotation.label}
189
        onChange={handleEditorChange}
NEW
190
      >
×
NEW
191
        {isEditing && isSelected ? (
×
192
          <StyledToolbarWrapper>
193
            <LexicalToolbar
194
              isEditingText={isEditingText}
195
              textVerticalAlign={textVerticalAlign}
196
              onChangeTextVerticalAlign={value => {
×
197
                onUpdateAnnotation({textVerticalAlign: value});
×
198
              }}
199
            />
200
          </StyledToolbarWrapper>
201
        ) : null}
202
      </LexicalRichTextEditor>
203
    </StyledAnnotationText>
×
204
  );
205
};
206

207
export default AnnotationText;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc