Skip to content

Commit 8b2769a

Browse files
committed
fix: render overlays via portals and preserve text color on export
1 parent 2bb832e commit 8b2769a

6 files changed

Lines changed: 334 additions & 115 deletions

File tree

src/components/Dropdown.tsx

Lines changed: 136 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import React, { useState, useRef, useEffect } from 'react';
1+
import React, { useState, useRef, useEffect, useCallback } from 'react';
2+
import { createPortal } from 'react-dom';
23
import { Icon } from './Icons';
34

45
interface DropdownProps {
@@ -22,11 +23,48 @@ export const Dropdown: React.FC<DropdownProps> = ({
2223
}) => {
2324
const [isOpen, setIsOpen] = useState(false);
2425
const [customColor, setCustomColor] = useState("#000000");
26+
const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
2527
const dropdownRef = useRef<HTMLDivElement>(null);
28+
const menuRef = useRef<HTMLDivElement>(null);
29+
const buttonRef = useRef<HTMLButtonElement>(null);
30+
31+
const updateMenuPosition = useCallback(() => {
32+
if (!buttonRef.current) return;
33+
const rect = buttonRef.current.getBoundingClientRect();
34+
const pad = 8;
35+
let top = rect.bottom + 4;
36+
let left = rect.left;
37+
38+
const menuEl = menuRef.current;
39+
if (menuEl) {
40+
const menuW = menuEl.offsetWidth;
41+
const menuH = menuEl.offsetHeight;
42+
if (left + menuW > window.innerWidth - pad) {
43+
left = window.innerWidth - menuW - pad;
44+
}
45+
if (left < pad) left = pad;
46+
if (top + menuH > window.innerHeight - pad) {
47+
top = rect.top - menuH - 4;
48+
}
49+
if (top < pad) top = pad;
50+
}
51+
52+
setMenuPos({ top, left });
53+
}, []);
54+
55+
useEffect(() => {
56+
if (!isOpen) return;
57+
updateMenuPosition();
58+
requestAnimationFrame(updateMenuPosition);
59+
}, [isOpen, updateMenuPosition]);
2660

2761
useEffect(() => {
2862
const handleClickOutside = (event: MouseEvent) => {
29-
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
63+
const target = event.target as Node;
64+
if (
65+
dropdownRef.current && !dropdownRef.current.contains(target) &&
66+
(!menuRef.current || !menuRef.current.contains(target))
67+
) {
3068
setIsOpen(false);
3169
}
3270
};
@@ -47,7 +85,6 @@ export const Dropdown: React.FC<DropdownProps> = ({
4785

4886
const currentOption = options.find(opt => opt.value === currentValue);
4987

50-
// Close on Escape key
5188
useEffect(() => {
5289
if (!isOpen) return;
5390
const handleKeyDown = (e: KeyboardEvent) => {
@@ -60,9 +97,104 @@ export const Dropdown: React.FC<DropdownProps> = ({
6097
return () => document.removeEventListener("keydown", handleKeyDown);
6198
}, [isOpen]);
6299

100+
const menuContent = isOpen ? (
101+
<div
102+
ref={menuRef}
103+
className="rte-dropdown-menu"
104+
role="listbox"
105+
aria-label={label}
106+
style={{
107+
position: 'fixed',
108+
top: menuPos.top,
109+
left: menuPos.left,
110+
}}
111+
onMouseDown={(e) => e.preventDefault()}
112+
>
113+
{options.map((option) => (
114+
<button
115+
key={option.value}
116+
type="button"
117+
role="option"
118+
aria-selected={currentValue === option.value}
119+
className={`rte-dropdown-item ${currentValue === option.value ? 'rte-dropdown-item-active' : ''}`}
120+
onClick={() => handleSelect(option.value)}
121+
>
122+
{option.color && (
123+
<span
124+
className={`rte-dropdown-color-preview ${currentValue === option.value ? 'active' : ''}`}
125+
style={{ backgroundColor: option.color }}
126+
/>
127+
)}
128+
{option.preview && !option.headingPreview && (
129+
<span
130+
className="rte-dropdown-fontsize-preview"
131+
style={{ fontSize: `${option.preview}px` }}
132+
>
133+
Aa
134+
</span>
135+
)}
136+
{option.headingPreview && (
137+
<span
138+
className={`rte-dropdown-heading-preview ${option.headingPreview}`}
139+
>
140+
{option.headingPreview === 'p' ? 'Normal' : option.headingPreview.toUpperCase()}
141+
</span>
142+
)}
143+
{option.icon && <Icon icon={option.icon} width={16} height={16} />}
144+
<span style={{ flex: 1, fontWeight: currentValue === option.value ? 600 : 400 }}>
145+
{option.label}
146+
</span>
147+
</button>
148+
))}
149+
{showCustomColorInput && (
150+
<div
151+
className="rte-color-custom-input"
152+
onMouseDown={(e) => e.stopPropagation()}
153+
>
154+
<input
155+
type="color"
156+
value={customColor}
157+
onChange={(e) => setCustomColor(e.target.value)}
158+
title="Pick a color"
159+
/>
160+
<input
161+
type="text"
162+
value={customColor}
163+
onChange={(e) => {
164+
const v = e.target.value;
165+
setCustomColor(v);
166+
}}
167+
placeholder="#000000"
168+
maxLength={7}
169+
onKeyDown={(e) => {
170+
if (e.key === "Enter") {
171+
e.preventDefault();
172+
if (/^#[0-9a-fA-F]{3,6}$/.test(customColor)) {
173+
handleSelect(customColor);
174+
}
175+
}
176+
}}
177+
/>
178+
<button
179+
type="button"
180+
className="rte-color-custom-apply"
181+
onClick={() => {
182+
if (/^#[0-9a-fA-F]{3,6}$/.test(customColor)) {
183+
handleSelect(customColor);
184+
}
185+
}}
186+
>
187+
Apply
188+
</button>
189+
</div>
190+
)}
191+
</div>
192+
) : null;
193+
63194
return (
64195
<div className="rte-dropdown" ref={dropdownRef} onMouseDown={(e) => e.preventDefault()}>
65196
<button
197+
ref={buttonRef}
66198
type="button"
67199
onClick={() => !disabled && setIsOpen(!isOpen)}
68200
disabled={disabled}
@@ -77,89 +209,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
77209
<span className="rte-dropdown-value">{currentOption.label}</span>
78210
)}
79211
</button>
80-
{isOpen && (
81-
<div className="rte-dropdown-menu" role="listbox" aria-label={label}>
82-
{options.map((option) => (
83-
<button
84-
key={option.value}
85-
type="button"
86-
role="option"
87-
aria-selected={currentValue === option.value}
88-
className={`rte-dropdown-item ${currentValue === option.value ? 'rte-dropdown-item-active' : ''}`}
89-
onClick={() => handleSelect(option.value)}
90-
>
91-
{option.color && (
92-
<span
93-
className={`rte-dropdown-color-preview ${currentValue === option.value ? 'active' : ''}`}
94-
style={{ backgroundColor: option.color }}
95-
/>
96-
)}
97-
{option.preview && !option.headingPreview && (
98-
<span
99-
className="rte-dropdown-fontsize-preview"
100-
style={{ fontSize: `${option.preview}px` }}
101-
>
102-
Aa
103-
</span>
104-
)}
105-
{option.headingPreview && (
106-
<span
107-
className={`rte-dropdown-heading-preview ${option.headingPreview}`}
108-
>
109-
{option.headingPreview === 'p' ? 'Normal' : option.headingPreview.toUpperCase()}
110-
</span>
111-
)}
112-
{option.icon && <Icon icon={option.icon} width={16} height={16} />}
113-
<span style={{ flex: 1, fontWeight: currentValue === option.value ? 600 : 400 }}>
114-
{option.label}
115-
</span>
116-
</button>
117-
))}
118-
{showCustomColorInput && (
119-
<div
120-
className="rte-color-custom-input"
121-
onMouseDown={(e) => e.stopPropagation()}
122-
>
123-
<input
124-
type="color"
125-
value={customColor}
126-
onChange={(e) => setCustomColor(e.target.value)}
127-
title="Pick a color"
128-
/>
129-
<input
130-
type="text"
131-
value={customColor}
132-
onChange={(e) => {
133-
const v = e.target.value;
134-
setCustomColor(v);
135-
}}
136-
placeholder="#000000"
137-
maxLength={7}
138-
onKeyDown={(e) => {
139-
if (e.key === "Enter") {
140-
e.preventDefault();
141-
if (/^#[0-9a-fA-F]{3,6}$/.test(customColor)) {
142-
handleSelect(customColor);
143-
}
144-
}
145-
}}
146-
/>
147-
<button
148-
type="button"
149-
className="rte-color-custom-apply"
150-
onClick={() => {
151-
if (/^#[0-9a-fA-F]{3,6}$/.test(customColor)) {
152-
handleSelect(customColor);
153-
}
154-
}}
155-
>
156-
Apply
157-
</button>
158-
</div>
159-
)}
160-
</div>
161-
)}
212+
{menuContent && createPortal(menuContent, document.body)}
162213
</div>
163214
);
164215
};
165-

src/components/FloatingToolbar.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useCallback, useEffect, useRef, useState } from "react";
2+
import { createPortal } from "react-dom";
23
import { ButtonProps, EditorAPI, Plugin } from "../types";
34

45
interface FloatingToolbarProps {
@@ -72,8 +73,8 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
7273

7374
// Center horizontally on the selection, clamp to viewport
7475
let left = rect.left + rect.width / 2 - toolbarW / 2;
75-
if (left < pad) left = pad;
7676
if (left + toolbarW > vw - pad) left = vw - toolbarW - pad;
77+
if (left < pad) left = pad;
7778

7879
setPos({ top, left, visible: true });
7980
}, [editorElement]);
@@ -126,7 +127,7 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
126127

127128
const isHidden = !pos.visible || leftPlugins.length === 0;
128129

129-
return (
130+
return createPortal(
130131
<div
131132
ref={toolbarRef}
132133
className="rte-floating-toolbar"
@@ -138,7 +139,6 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
138139
opacity: isHidden ? 0 : 1,
139140
pointerEvents: isHidden ? "none" : "auto",
140141
}}
141-
// Prevent selection loss when clicking toolbar buttons
142142
onMouseDown={(e) => e.preventDefault()}
143143
>
144144
<div className="rte-floating-toolbar-content">
@@ -195,7 +195,8 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
195195
</div>
196196
)}
197197
</div>
198-
</div>
198+
</div>,
199+
document.body
199200
);
200201
};
201202

src/hooks/useEditorEvents.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,92 @@ export function useEditorEvents({
7171
return;
7272
}
7373

74+
// Exit code block: Enter on empty last line escapes <pre>
75+
if (
76+
e.key === "Enter" &&
77+
!e.shiftKey &&
78+
!isModifierPressed
79+
) {
80+
const sel = window.getSelection();
81+
if (sel && sel.rangeCount > 0 && sel.isCollapsed) {
82+
const range = sel.getRangeAt(0);
83+
const node = range.startContainer;
84+
const pre = (
85+
node instanceof HTMLElement
86+
? node
87+
: node.parentElement
88+
)?.closest("pre");
89+
90+
if (pre && pre.lastChild) {
91+
const lastChild = pre.lastChild;
92+
93+
const cursorInPre =
94+
node === pre &&
95+
range.startOffset === pre.childNodes.length;
96+
const cursorAtEndOfLastText =
97+
node.nodeType === Node.TEXT_NODE &&
98+
node === lastChild &&
99+
range.startOffset ===
100+
(node.textContent?.length ?? 0);
101+
const isAtEnd =
102+
cursorInPre || cursorAtEndOfLastText;
103+
104+
const lastIsBr =
105+
lastChild instanceof HTMLElement &&
106+
lastChild.tagName === "BR";
107+
const endsWithNewline =
108+
node.nodeType === Node.TEXT_NODE &&
109+
(node.textContent || "").endsWith("\n");
110+
111+
if (isAtEnd && (lastIsBr || endsWithNewline)) {
112+
e.preventDefault();
113+
114+
if (lastIsBr) {
115+
pre.removeChild(lastChild);
116+
} else if (
117+
node.nodeType === Node.TEXT_NODE &&
118+
node.textContent
119+
) {
120+
node.textContent =
121+
node.textContent.replace(/\n$/, "");
122+
}
123+
124+
if (
125+
!pre.textContent &&
126+
!pre.querySelector("br")
127+
) {
128+
pre.appendChild(
129+
document.createElement("br")
130+
);
131+
}
132+
133+
const p = document.createElement("p");
134+
p.appendChild(document.createElement("br"));
135+
pre.parentNode?.insertBefore(
136+
p,
137+
pre.nextSibling
138+
);
139+
140+
const newRange = document.createRange();
141+
newRange.setStart(p, 0);
142+
newRange.collapse(true);
143+
sel.removeAllRanges();
144+
sel.addRange(newRange);
145+
146+
const content = domToContent(editor);
147+
const serializedSel =
148+
serializeSelection(editor);
149+
historyRef.current.push(
150+
content,
151+
serializedSel
152+
);
153+
notifyChange(content);
154+
return;
155+
}
156+
}
157+
}
158+
}
159+
74160
// Auto-link: convert URLs to <a> tags on space/enter
75161
if (!isModifierPressed && (e.key === " " || e.key === "Enter")) {
76162
handleAutoLink(editor, e);

0 commit comments

Comments
 (0)