discourse-shared-edits/assets/javascripts/discourse/lib/caret-coordinates.js
Sam 50d9317272
FEATURE: yjs-based collaborative editing with state recovery and cursor sync
Major overhaul of the shared edits plugin to improve reliability, robustness,
and developer experience:

**Backend**
- Extract Revise service for cleaner controller orchestration
- Add StateValidator for base64/Yjs safety, corruption detection, and recovery
- Centralize protocol constants (Ruby + JS) to avoid hardcoded strings
- Add state hash sync verification and vector validation endpoints
- Harden security (guardian checks), error handling, and resource cleanup
- Resize shared edit columns migration; add state_hash column

**Frontend**
- Decompose shared-edit-manager into focused modules: yjs-document,
  markdown-sync, rich-mode-sync, network-manager, encoding-utils
- Add cursor overlay and caret coordinate tracking for selection sharing
- Add ProseMirror extension for rich-mode collaborative editing
- Cache-busted Yjs bundle loading via hashed filenames
- Fix scroll drift during sync

**Testing & Tooling**
- Extensive new specs: state_validator, revision_controller, model, revise service
- New Ember acceptance tests: cursor, lifecycle, sync flows
- Add support scripts: fake_writer (Playwright), state_corruptor, debug_recovery
- Add support/lint wrapper for full CI lint suite
- Update dependencies and rebuild Yjs/y-prosemirror bundles
2026-02-13 11:34:52 +11:00

114 lines
3.1 KiB
JavaScript

const properties = [
"direction",
"boxSizing",
"width",
"height",
"overflowX",
"overflowY",
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"borderStyle",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"fontStyle",
"fontVariant",
"fontWeight",
"fontStretch",
"fontSize",
"fontSizeAdjust",
"lineHeight",
"fontFamily",
"textAlign",
"textTransform",
"textIndent",
"textDecoration",
"letterSpacing",
"wordSpacing",
"tabSize",
"MozTabSize",
"whiteSpace",
"wordBreak",
"overflowWrap",
"wordWrap",
];
const isBrowser = typeof window !== "undefined";
export default function getCaretCoordinates(element, position) {
if (!isBrowser) {
throw new Error(
"getCaretCoordinates should only be called in a browser context"
);
}
const div = document.createElement("div");
div.id = "input-textarea-caret-position-mirror-div";
document.body.appendChild(div);
const style = div.style;
const computed = window.getComputedStyle(element);
properties.forEach((prop) => {
style[prop] = computed[prop];
});
style.whiteSpace = "pre-wrap";
style.overflowWrap = "break-word";
style.position = "absolute";
style.visibility = "hidden";
// Use getBoundingClientRect to get the precise fractional width of the element.
// This is critical because clientWidth rounds to an integer, which can cause
// incorrect line wrapping in the mirror div if the actual width is fractional
// (common on high-DPI displays or with percentage widths).
const rect = element.getBoundingClientRect();
style.width = `${rect.width}px`;
style.boxSizing = "border-box";
style.overflow = "hidden";
// Adjust padding-right to account for the scrollbar if it exists.
// The mirror div has overflow:hidden, so it won't have a scrollbar.
// We must reserve that space manually to match the text wrapping area.
// offsetWidth and clientWidth are integers, so the scrollbar width calculation is safe.
const borderLeft = parseFloat(computed.borderLeftWidth);
const borderRight = parseFloat(computed.borderRightWidth);
const scrollbarWidth =
element.offsetWidth - element.clientWidth - borderLeft - borderRight;
if (scrollbarWidth > 0) {
style.paddingRight = `${
parseFloat(computed.paddingRight) + scrollbarWidth
}px`;
}
let pos = parseInt(position, 10);
if (isNaN(pos)) {
pos = 0;
}
div.textContent = element.value.substring(0, pos);
const span = document.createElement("span");
// Use Zero Width Space to prevent the probe character from causing a wrap
// if the line is exactly full.
span.textContent = element.value.substring(pos) || "\u200b";
div.appendChild(span);
const coordinates = {
top: span.offsetTop + parseInt(computed.borderTopWidth, 10),
left: span.offsetLeft + parseInt(computed.borderLeftWidth, 10),
height: parseInt(computed.lineHeight, 10),
};
if (isNaN(coordinates.height)) {
coordinates.height = parseInt(computed.fontSize, 10) * 1.2; // Fallback
}
document.body.removeChild(div);
return coordinates;
}