mirror of
https://github.com/block/buzz.git
synced 2026-11-08 02:11:36 +00:00
fix(composer): avoid unmounted TipTap view crashes
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getEditorLinkRect,
|
||||
getEditorSelectionRect,
|
||||
} from "./composerEditorGeometry.ts";
|
||||
|
||||
function unmountedEditor() {
|
||||
return {
|
||||
get view() {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, key) {
|
||||
throw new Error(`editor view cannot access ${String(key)}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("selection geometry returns null instead of throwing after view teardown", () => {
|
||||
assert.equal(getEditorSelectionRect(unmountedEditor(), 1, 2), null);
|
||||
});
|
||||
|
||||
test("link geometry returns null instead of throwing after view teardown", () => {
|
||||
assert.equal(getEditorLinkRect(unmountedEditor(), 1, 2), null);
|
||||
});
|
||||
89
desktop/src/features/messages/lib/composerEditorGeometry.ts
Normal file
89
desktop/src/features/messages/lib/composerEditorGeometry.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
export type EditorRect = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
};
|
||||
|
||||
export function getEditorSelectionRect(
|
||||
editor: Editor,
|
||||
from: number,
|
||||
to: number,
|
||||
): EditorRect | null {
|
||||
try {
|
||||
const range = document.createRange();
|
||||
const start = editor.view.domAtPos(from);
|
||||
const end = editor.view.domAtPos(to);
|
||||
range.setStart(start.node, start.offset);
|
||||
range.setEnd(end.node, end.offset);
|
||||
|
||||
const clientRects = Array.from(range.getClientRects()).filter(
|
||||
(rect) => rect.width > 0 || rect.height > 0,
|
||||
);
|
||||
const rect = clientRects[0] ?? range.getBoundingClientRect();
|
||||
range.detach();
|
||||
if (rect.width > 0 || rect.height > 0) return rect;
|
||||
|
||||
const startCoords = editor.view.coordsAtPos(from);
|
||||
const endCoords = editor.view.coordsAtPos(to);
|
||||
const left = Math.min(startCoords.left, endCoords.left);
|
||||
const right = Math.max(startCoords.right, endCoords.right);
|
||||
const top = Math.min(startCoords.top, endCoords.top);
|
||||
const bottom = Math.max(startCoords.bottom, endCoords.bottom);
|
||||
|
||||
if (right <= left && bottom <= top) return null;
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width: Math.max(1, right - left),
|
||||
height: bottom - top,
|
||||
right,
|
||||
bottom,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getEditorLinkRect(
|
||||
editor: Editor,
|
||||
from: number,
|
||||
to: number,
|
||||
): EditorRect | null {
|
||||
try {
|
||||
const range = document.createRange();
|
||||
const start = editor.view.domAtPos(from);
|
||||
const end = editor.view.domAtPos(to);
|
||||
range.setStart(start.node, start.offset);
|
||||
range.setEnd(end.node, end.offset);
|
||||
|
||||
const rect = range.getBoundingClientRect();
|
||||
range.detach();
|
||||
if (rect.width > 0 || rect.height > 0) {
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
};
|
||||
}
|
||||
|
||||
const coords = editor.view.coordsAtPos(from);
|
||||
return {
|
||||
left: coords.left,
|
||||
top: coords.top,
|
||||
width: Math.max(1, coords.right - coords.left),
|
||||
height: Math.max(1, coords.bottom - coords.top),
|
||||
right: coords.right,
|
||||
bottom: coords.bottom,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { getMountedEditorDom } from "./selectionFormattingTrayEditorDom.ts";
|
||||
import { getMountedEditorDom } from "./composerEditorView.ts";
|
||||
|
||||
test("returns null while the TipTap editor view is not mounted", () => {
|
||||
const editor = {
|
||||
function unmountedEditor() {
|
||||
return {
|
||||
get view() {
|
||||
return new Proxy(
|
||||
{},
|
||||
@@ -16,13 +16,13 @@ test("returns null while the TipTap editor view is not mounted", () => {
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
assert.equal(getMountedEditorDom(editor), null);
|
||||
test("returns null while the TipTap editor view is not mounted", () => {
|
||||
assert.equal(getMountedEditorDom(unmountedEditor()), null);
|
||||
});
|
||||
|
||||
test("returns the editor DOM after the TipTap view mounts", () => {
|
||||
const dom = {};
|
||||
const editor = { view: { dom } };
|
||||
|
||||
assert.equal(getMountedEditorDom(editor), dom);
|
||||
assert.equal(getMountedEditorDom({ view: { dom } }), dom);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
import { mountSpoilerParticleCanvas } from "@/shared/ui/SpoilerParticles";
|
||||
import { getMountedEditorDom } from "./composerEditorView";
|
||||
|
||||
type ComposerSpoilerMount = {
|
||||
canvas: HTMLCanvasElement;
|
||||
@@ -23,10 +24,8 @@ export function useComposerSpoilerParticles(
|
||||
let teardown: (() => void) | undefined;
|
||||
|
||||
const setup = () => {
|
||||
let editorRoot: HTMLElement;
|
||||
try {
|
||||
editorRoot = editor.view.dom as HTMLElement;
|
||||
} catch {
|
||||
const editorRoot = getMountedEditorDom(editor);
|
||||
if (!editorRoot) {
|
||||
if (!cancelled) setupFrame = window.requestAnimationFrame(setup);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type LinkEditorInitialFocus,
|
||||
} from "./linkEditorFocus";
|
||||
import { openPopoverLink } from "./openPopoverLink";
|
||||
import { getEditorLinkRect } from "./composerEditorGeometry";
|
||||
|
||||
type DraftState = {
|
||||
text: string;
|
||||
@@ -80,35 +81,7 @@ export function useLinkEditor(richText: UseRichTextEditorResult) {
|
||||
(info: LinkSelectionInfo): LinkCardState["rect"] | null => {
|
||||
const editor = richText.editor;
|
||||
if (!editor) return null;
|
||||
|
||||
try {
|
||||
const range = document.createRange();
|
||||
const start = editor.view.domAtPos(info.from);
|
||||
const end = editor.view.domAtPos(info.to);
|
||||
range.setStart(start.node, start.offset);
|
||||
range.setEnd(end.node, end.offset);
|
||||
|
||||
const rect = range.getBoundingClientRect();
|
||||
range.detach();
|
||||
if (rect.width > 0 || rect.height > 0) {
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the caret coordinates below.
|
||||
}
|
||||
|
||||
const coords = editor.view.coordsAtPos(info.from);
|
||||
return {
|
||||
left: coords.left,
|
||||
top: coords.top,
|
||||
width: Math.max(1, coords.right - coords.left),
|
||||
height: Math.max(1, coords.bottom - coords.top),
|
||||
};
|
||||
return getEditorLinkRect(editor, info.from, info.to);
|
||||
},
|
||||
[richText.editor],
|
||||
);
|
||||
|
||||
@@ -756,7 +756,7 @@ export function useRichTextEditor({
|
||||
.setMeta("preventUpdate", true);
|
||||
tr.setSelection(TextSelection.atEnd(tr.doc));
|
||||
editor.view.dispatch(tr);
|
||||
editor.view.focus();
|
||||
editor.commands.focus();
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
@@ -852,7 +852,7 @@ export function useRichTextEditor({
|
||||
const cursorPM = afterNode + (text ? text.length : 0);
|
||||
tr = tr.setSelection(TextSelection.create(tr.doc, cursorPM));
|
||||
editor.view.dispatch(tr);
|
||||
editor.view.focus();
|
||||
editor.commands.focus();
|
||||
return;
|
||||
}
|
||||
// No node type (shouldn't happen) → fall through to literal text.
|
||||
@@ -874,7 +874,7 @@ export function useRichTextEditor({
|
||||
}
|
||||
settleAutocompleteMentionInsert(editor, tr, text, !preserveSelection);
|
||||
editor.view.dispatch(tr);
|
||||
editor.view.focus();
|
||||
editor.commands.focus();
|
||||
if (!preserveSelection) reassertMentionCaretAfterFocus(editor.view);
|
||||
},
|
||||
[editor, customEmojiWiring.resolveUrl],
|
||||
@@ -933,7 +933,7 @@ export function useRichTextEditor({
|
||||
const cursorPM = tr.mapping.map(end);
|
||||
tr.setSelection(TextSelection.create(tr.doc, cursorPM));
|
||||
editor.view.dispatch(tr);
|
||||
editor.view.focus();
|
||||
editor.commands.focus();
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { Editor } from "@tiptap/react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { FormattingToolbar } from "./FormattingToolbar";
|
||||
import { getMountedEditorDom } from "./selectionFormattingTrayEditorDom";
|
||||
import { getEditorSelectionRect } from "../lib/composerEditorGeometry";
|
||||
import { getMountedEditorDom } from "../lib/composerEditorView";
|
||||
|
||||
type SelectionFormattingTrayProps = {
|
||||
editor: Editor | null;
|
||||
@@ -26,38 +27,6 @@ function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function getSelectionRect(editor: Editor): DOMRect | null {
|
||||
const { from, to } = editor.state.selection;
|
||||
|
||||
try {
|
||||
const range = document.createRange();
|
||||
const start = editor.view.domAtPos(from);
|
||||
const end = editor.view.domAtPos(to);
|
||||
range.setStart(start.node, start.offset);
|
||||
range.setEnd(end.node, end.offset);
|
||||
|
||||
const clientRects = Array.from(range.getClientRects()).filter(
|
||||
(rect) => rect.width > 0 || rect.height > 0,
|
||||
);
|
||||
const rect = clientRects[0] ?? range.getBoundingClientRect();
|
||||
range.detach();
|
||||
|
||||
if (rect.width > 0 || rect.height > 0) return rect;
|
||||
} catch {
|
||||
// Fall back to the caret coordinates below.
|
||||
}
|
||||
|
||||
const startCoords = editor.view.coordsAtPos(from);
|
||||
const endCoords = editor.view.coordsAtPos(to);
|
||||
const left = Math.min(startCoords.left, endCoords.left);
|
||||
const right = Math.max(startCoords.right, endCoords.right);
|
||||
const top = Math.min(startCoords.top, endCoords.top);
|
||||
const bottom = Math.max(startCoords.bottom, endCoords.bottom);
|
||||
|
||||
if (right <= left && bottom <= top) return null;
|
||||
return new DOMRect(left, top, Math.max(1, right - left), bottom - top);
|
||||
}
|
||||
|
||||
function getTrayPosition(
|
||||
editor: Editor,
|
||||
trayWidth: number,
|
||||
@@ -73,7 +42,7 @@ function getTrayPosition(
|
||||
);
|
||||
if (selectedText.trim().length === 0) return null;
|
||||
|
||||
const rect = getSelectionRect(editor);
|
||||
const rect = getEditorSelectionRect(editor, selection.from, selection.to);
|
||||
if (!rect) return null;
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
Reference in New Issue
Block a user